diff --git a/API.rst b/API.rst
new file mode 100644
--- /dev/null
+++ b/API.rst
@@ -0,0 +1,97 @@
+API
+==================================================
+
+This document roughly describes the API that is used between the
+doc-review Web server and the user's browser. This specification is
+not complete. I wrote it while attempting to reverse-engineer the API
+used by book.realworldhaskell.org.
+
+General notes
+--------------------------------------------------
+
+If there is more than one page that is being commented on per server,
+the id namespace is the same between documents.
+
+jQuery and jQuery form need to be loaded before this script
+
+HTTP
+--------------------------------------------------
+
+Files
+..................................................
+
+``/support/icons/throbber.gif``
+
+Load comment
+..................................................
+
+Request::
+  GET /comments/single/<id>/ HTTP/1.1
+
+Response::
+  HTTP/1.1 200 OK
+  Content-type: text/html
+
+  <HTML fragment to display for a single comment>
+
+There should be a span.comment_error present in order to do
+validation. The new comment form has to have controls with
+``name="name"`` and ``name="comment"`` fields in order for the
+validation to pass. The submission button should have
+``name="submit"``.
+
+Load comment counts
+..................................................
+
+Request::
+  GET /comments/chapter/<chapter id>/count/
+
+Response::
+  HTTP/1.1 200 OK
+  Content-type: text/json
+
+  <{<comment id>:<count>} :: {String:Integer}>
+
+Add new comment
+..................................................
+
+Request::
+  POST /comments/submit/<id>/
+  Content-type: x-www-urlencoded
+
+  <Required fields: id, comment, name>
+
+Response::
+  HTTP/1.1 200 OK
+  Content-type: text/html
+
+  <the new comment HTML. Should be the same as
+  /comments/single/<id>>
+
+HTML
+--------------------------------------------------
+
+Adds a "Load all comments" link following ``div.toc>p``
+
+Adds the chapter id to ``a#chapterfeed[@href]``
+
+Looks for the first existing one of the following elements to find
+the chapter id:
+
+* ``div.preface``
+* ``div.chapter``
+* ``div.appendix``
+* ``div.bibliography``
+
+Adds comment nodes for each of:
+
+* ``.chapter p[@id]``
+* ``.chapter table[@id].equation``
+* ``.chapter pre[@id]``
+
+A comment node is identified by ``#comments_<comment id>``
+
+Loads the comment counts for each comment and replaces the loading
+message with the comment count. Any ``span.commenttoggle`` elements
+that do not have a count returned get the text "No comments". ids that
+are not found in the page are ignored.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2010, Josh Hoyt
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Josh Hoyt nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.rst b/README.rst
new file mode 100644
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,220 @@
+Collaborative Document Review Web Application README
+####################################################
+
+This package provides a Web application for soliciting
+paragraph-by-paragraph comments on a document. The application was
+inspired by and re-uses the user interface and much of the JavaScript
+code from the excellent <http://book.realworldhaskell.org/>.
+
+The original Web application was part of a Docbook-based toolchain
+that was used to produce Real World Haskell, and was written in Python
+using Django. This implementation is intended to be as independent as
+possible from the authoring system that is used to produce a document,
+and to run in a wide variety of environments.
+
+If you have feature requests, bug reports or other feedback, please
+let me know!
+
+Quick Start
+--------------------------------------------------
+
+For most users, you can be up and running by following these steps:
+
+1. Prepare your documents:
+
+   * Add a ``<div>`` tag with ``class="chapter"`` around the content that you
+     will want comments on.
+   * Add an ``id`` attribute to all of the paragraphs that you wish to
+     enable comments on.
+2. Build and install using ``cabal install`` to obtain a doc-review
+   executable::
+
+    $ cabal install doc-review
+3. Test your documents::
+
+    $ doc-review run --content-dir=$PATH_TO_YOUR_DOCUMENTS
+
+   Comments you leave when testing will not be saved when the server
+   is restarted.
+4. Select your backend. Right now, that probably means the SQLite
+   backend::
+
+    $ doc-review run --content-dir=$PATH_TO_YOUR_DOCUMENTS
+       --store=sqlite:comments.db
+
+   This command will create ``comments.db`` if necessary, and store all
+   comments in that SQLite database.
+
+5. Decide how you are going to run the server. Running this program as
+   a daemon and configuring your Web server to use reverse proxying is
+   the most straightforward solution.
+
+Marking up documents
+--------------------------------------------------
+
+``doc-review`` will recursively traverse the directory specified by the
+``--content-dir`` parameter looking for files with the extension
+``.html``, ``.htm`` or ``.xhtml``. It will parse those files as HTML,
+looking for paragraphs marked as commentable, and will store those
+chapter definitions in the data store.
+
+In order for a document to be commentable, you must load the comment
+JavaScript into the document, by adding the following lines to the
+``<head>`` of the document::
+
+ <script type="text/javascript"
+         src="/support/javascript/jquery.js"></script>
+ <script type="text/javascript"
+         src="/support/javascript/form.js"></script>
+ <script type="text/javascript"
+         src="/support/javascript/hsbook.js"></script>
+
+You will likely want to (but by no means have to) reuse the CSS for
+the comments::
+
+ <link rel="stylesheet" type="text/css" href="/comments.css"/>
+
+To mark a paragraph as commentable, it must be inside of a ``<div>``
+with ``class="chapter"``. The choice of ``class="chapter"`` is for
+compatibility with the Real World Haskell implementation.
+
+The current implementation (like the Real World Haskell
+implementation) depends on the ``id`` being unique across the full set
+of documents that you want comments on. That means that if you have
+two documents with paragraphs with ``id="sauteed-spinach"``, then
+comments left on either of those paragraphs will be visible in both
+documents. This can be useful if you have duplicated content, but if
+the content is different, user (or author!) confusion can result.
+
+Running the server
+--------------------------------------------------
+
+The server is a plain HTTP server that serves three kinds of files:
+
+1. URLs under ``/comments/``, which serve the comments API. This is
+what is accessed by the JavaScript in order to insert the comments to
+the document and to save user comments. These are the only dynamic
+URLs that will be served.
+
+2. The URL will be checked against the files in the directory
+specified by ``--content-dir=``. That static file will be served if
+there is a match.
+
+3. If no matching file was found, it will look for a matching file
+in the directory specified by ``--static-dir=`` and serve that.
+
+It is likely that you will be integrating this server into a larger
+Web site. In that case, you will likely want to reverse proxy requests
+from your main Web server to this server (using
+e.g. <http://httpd.apache.org/docs/2.2/mod/mod_proxy.html>). If you
+are running this server proxied by another Web server, you can serve
+the content and other static files from that server to no ill effect
+(those files are served by default for convenience).
+
+Storage options
+--------------------------------------------------
+
+There are three kinds of storage backend implemented at this time:
+
+In-memory
+ Store the comments in the server process' memory (no persistent
+ storage). This means that all comments will be lost when the server
+ is restarted. This is useful for testing and as a reference
+ implementation of the storage API.
+
+Flat file
+ Store the comments in flat files in the filesystem. The comments and
+ other data are stored in a custom binary format. This backend is
+ known to have race conditions and other non-ACID properties. It is
+ not recommended that you use this store.
+
+SQLite
+ Store the comments in a SQLite database. This is the best option for
+ production use at this time. That being said, this is not really a
+ production-ready solution, because database errors (e.g. a
+ SQLITE_BUSY timeout) will result in a 500 error being returned to the
+ client.
+
+The output to ``doc-review --help`` will indicate how to specify each
+store type.
+
+The store API is well defined and easy to implement and test. Patches
+for data storage improvements are welcome!
+
+Binary logging
+..................................................
+
+In addition to the storage options specified above, there is an
+experimental binary logging option that will append a binary log
+record of each store operation to a file in addition to applying it to
+the store. This was implemented as a backup mechanism should the
+primary store be corrupted, as replaying the operations from the log
+should restore the contents of the data store.
+
+Note that this option is not well tested, and may disappear in future
+releases.
+
+Implementation details
+--------------------------------------------------
+
+This section discusses some implementation details that may be useful
+for examining the data in the database or implementing your own
+storage backend. As always, the code is the best reference, but this
+discussion should help you get started and serve as a rough
+specification for what the code ought to do when it's not inherently
+clear.
+
+User sessions
+..................................................
+
+This server stores a session cookie for each browsing session that is
+renewed on each request. The session cookie is used to look up the
+user information to prefill when showing the add comment form. It is
+also stored in the database so that the author/administrator can see
+which comments came from which browser. It is a rather imprecise
+mechanism, and easy to spoof (just send whatever session cookie you
+want), but it is helpful for the user not to have to re-fill the form
+fields. The session cookie expires after 11 days without visiting any
+page on the site.
+
+Test suite
+..................................................
+
+There is a test suite, which will be build when the parameter
+``--flags=test`` is supplied to cabal-install. *The test suite only
+tests the storage backends. The remainder of the code currently has no
+automated tests.* The backends are tested using randomized testing for
+consistency with each other as well as some relatively trivial, but
+critical behavior.
+
+The tests do not test concurrent access to the stores. There is no
+specification of the behavior of the stores under concurrent
+access. The SQLite and in-memory stores serialize access to the
+backend between threads, so concurrency should not be an issue, but
+the file-based backend may cause data loss under concurrent use. Tests
+welcome.
+
+To test the stores for consistency, the test suite creates two empty
+stores of different types and then randomly generates store
+operations. The store operations are performed to each store in turn,
+checking that the operation returns the same result for both
+stores. This does not show that the stores behave correctly, but it
+does provide evidence that the implementations are consistent with
+each other.
+
+There are not many tests for correctness, but there are a few tests
+that perform an operation with a specified effect on the backend and
+then make observations that the desired effect has occurred. These
+tests are run with each store in an empty state, and then a sequence
+of randomized operations that perturb the store's state are
+performed. The properties are once again checked. This process is
+repeated. This should provide evidence that the specified properties
+hold for the store without depending on it being in a particular state.
+
+Future plans
+--------------------------------------------------
+
+As usual, there are a whole list of features and changes that I'd like
+to make to this program. See TODO for this list. If a feature is
+important to you, or if you have an idea for a new feature, please let
+me know. The best way is to submit a patch!
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/TODO.rst b/TODO.rst
new file mode 100644
--- /dev/null
+++ b/TODO.rst
@@ -0,0 +1,34 @@
+* Packaging the API as a library so that it can be integrated with
+  other Haskell Web applications
+
+* Features:
+
+  * Add a link field to the comment form and backend storage so that
+    commenters can provide a link to their blog or home page.
+
+  * Better comment viewing tools:
+
+    * View one user's annotations at a time
+
+    * Add a widget that shows an overview of the comments on a
+      document
+
+    * Show where in the document the comments are
+
+  * Have the server add the ``<script>`` and ``<link>`` tags to
+    documents in the content directory automatically
+
+  * Add a PostgreSQL storage backend
+
+  * Add a MySQL storage backend
+
+  * Make building SQLite support optional (so you do not need to build
+    it unless you are using that backend)
+
+  * Add support for handing proxy host headers so that URLs get
+    generated properly (e.g. X-Forwarded-Host)
+
+  * Remove the code for dropping OS privilege (it's not really
+    trustworthy, so it's a little bit of a misfeature)
+
+  * Add markup processing for the comments (e.g. Markdown in the user comments)
diff --git a/doc-review.cabal b/doc-review.cabal
new file mode 100644
--- /dev/null
+++ b/doc-review.cabal
@@ -0,0 +1,108 @@
+Name:                doc-review
+Version:             0.7
+Synopsis:            Document review Web application, like http://book.realworldhaskell.org/
+
+Description: A standalone Web application that provides for
+             paragraph-by-paragraph commentary by document reviewers,
+             based on the user interface from
+             <http://book.realworldhaskell.org/>. Any Web page can be
+             configured to work with the service by adding just a
+             little bit of Javascript that makes it load the comment
+             Web UI. See the README or the homepage for more information.
+
+Author:              Josh Hoyt
+Maintainer:          josh.hoyt@galois.com
+Stability:           Experimental
+Category:            Web
+Build-type:          Simple
+Cabal-version:       >=1.6
+License:             BSD3
+License-File:        LICENSE
+Tested-with:         GHC == 6.12.3
+Homepage:            https://github.com/j3h/doc-review
+
+data-files:
+      static/*.css,
+      static/support/javascript/*.js,
+      static/support/icons/*.gif
+
+extra-source-files:
+      README.rst,
+      API.rst,
+      TODO.rst
+
+Flag test
+  Description: Build the test executable (tests the store implementations)
+  Manual: True
+  Default: False
+
+Executable doc-review
+  hs-source-dirs: src
+  main-is: Main.hs
+
+  other-modules:
+    Analyze,
+    Config,
+    Config.Types,
+    Config.Format,
+    Config.Store,
+    Config.Command.Run,
+    Config.Command.Scan,
+    Config.Command.Report,
+    Config.GetOpt,
+    Feed,
+    Privilege,
+    Report,
+    Server,
+    State.Disk,
+    State.Logger,
+    State.Mem,
+    State.SQLite,
+    State.Types
+
+  Build-depends:
+    base >= 4 && < 5,
+    base64-bytestring == 0.1.*,
+    binary == 0.5.*,
+    bytestring >= 0.9.1 && <0.10,
+    containers < 0.5,
+    directory < 1.2,
+    feed == 0.3.*,
+    filepath >= 1.1 && <1.3,
+    haskell98,
+    heist >= 0.2.2 && <0.3,
+    hexpat >= 0.18.2 && <0.20,
+    json == 0.4.*,
+    MonadCatchIO-transformers,
+    MonadRandom == 0.1.*,
+    monads-fd >= 0.1 && <0.2,
+    network == 2.2.*,
+    old-locale == 1.0.*,
+    snap-core >= 0.2 && <0.3,
+    snap-server >= 0.2 && <0.3,
+    sqlite == 0.5.*,
+    tagchup == 0.4.*,
+    text >= 0.7 && < 0.11,
+    time == 1.1.*,
+    transformers == 0.2.*,
+    unix < 2.5,
+    xhtml-combinators < 0.3,
+    xml == 1.3.*,
+    xml-basic == 0.1.*
+
+  if impl(ghc >= 6.12.0)
+    ghc-options: -threaded -Wall -fwarn-tabs -funbox-strict-fields
+                 -fno-warn-unused-do-bind
+  else
+    ghc-options: -threaded -Wall -fwarn-tabs -funbox-strict-fields
+
+Executable doc-review-test
+  HS-Source-Dirs: test, src
+  Main-is: Main.hs
+  ghc-options: -Wall
+  if flag(test)
+    Build-Depends: MonadRandom < 0.2,
+                   array < 0.4,
+                   text >= 0.7 && < 0.11
+  else
+    Buildable: False
diff --git a/src/Analyze.hs b/src/Analyze.hs
new file mode 100644
--- /dev/null
+++ b/src/Analyze.hs
@@ -0,0 +1,104 @@
+module Analyze
+    ( htmlIds
+    , shouldAnalyzeFile
+    , analyzeFile
+    , analyze
+    )
+where
+import qualified Text.HTML.Tagchup.Parser as T ( runSoup )
+import qualified Text.HTML.Tagchup.Tag.Match as M
+import qualified Text.HTML.Tagchup.Tag as T
+import qualified Text.XML.Basic.Name.LowerCase as N
+import qualified Text.XML.Basic.Attribute as A
+import Data.Char ( toLower )
+import Data.Maybe ( mapMaybe, listToMaybe )
+import Control.Arrow ( second, first )
+import Control.Monad ( forM )
+import System.Directory ( getDirectoryContents )
+import System.FilePath ( (</>), takeExtension )
+import qualified Data.Text as Txt
+import State.Types ( ChapterId, mkChapterId, CommentId, mkCommentId )
+
+shouldAnalyzeFile :: FilePath -> Bool
+shouldAnalyzeFile fn = ext `elem` goodExts
+    where
+      ext = map toLower $ takeExtension fn
+      goodExts = [".html", ".htm", ".xhtml"]
+
+analyze :: FilePath -> IO [(FilePath, [(Maybe ChapterId, [CommentId])])]
+analyze fn = do
+  let ignoreEntry = (`elem` [".", ".."])
+
+      processEntry n
+          | shouldAnalyzeFile n = processFile n `catch` \_ ->
+                                  processDir n
+          | otherwise           = processDir n
+
+      processFile n = (\xs -> [(n, xs)]) `fmap` analyzeFile (fn </> n)
+      processDir n = map (first (n </>)) `fmap` analyze (fn </> n)
+
+  fs <- filter (not . ignoreEntry) `fmap` getDirectoryContents fn
+
+  results <- forM fs $ \n ->
+             processEntry n `catch` \_ -> return []
+
+  return $ concat results
+
+analyzeFile :: FilePath -> IO [(Maybe ChapterId, [CommentId])]
+analyzeFile = fmap htmlIds . readFile
+
+-- Extract the paragraph ids from the chapter divs
+htmlIds :: String -> [(Maybe ChapterId, [CommentId])]
+htmlIds = map (first (mkChapterId . Txt.pack =<<)) .
+          map (second (mapMaybe (mkCommentId . Txt.pack) . findIds)) .
+          findChapterContent .
+          T.runSoup
+
+findIds :: [T.T N.T String] -> [String]
+findIds = concatMap getId . concatMap snd . mapMaybe T.maybeOpen
+
+getId :: A.T N.T String -> [String]
+getId (A.Cons (A.Name (N.Cons "id")) i) = [i]
+getId _                        = []
+
+findChapterContent :: [T.T N.T String] -> [(Maybe String, [T.T N.T String])]
+findChapterContent ts =
+    case dropWhile (not . divChapter) ts of
+      (t:ts') -> let chId = listToMaybe (chapId t)
+                 in case findClose "div" ts' of
+                      Nothing ->
+                          -- No close tag, so no content, but check
+                          -- for well-formed tags further on
+                          (chId, []):findChapterContent ts'
+                      Just (f, ts'') ->
+                          -- Well-formed tag, so take its content
+                          (chId, f []):findChapterContent ts''
+      _ -> []
+    where
+      chapId (T.Open _ attrs) = concatMap getId attrs
+      chapId _ = []
+
+divChapter :: T.T N.T String -> Bool
+divChapter (T.Open (T.Name (N.Cons "div")) attrs) = any hasChapterClass attrs
+divChapter _ = False
+
+hasChapterClass :: A.T N.T String -> Bool
+hasChapterClass (A.Cons (A.Name (N.Cons "class")) s) = "chapter" `elem` words s
+hasChapterClass _ = False
+
+findClose :: String -> [T.T N.T String]
+          -> Maybe ([T.T N.T String] -> [T.T N.T String], [T.T N.T String])
+findClose _ [] = Nothing
+findClose n (t:ts)
+    | M.openNameLit n t =
+        do -- Find the close of this inner tag
+           (inside, ts') <- findClose n ts
+           -- Resume finiding the close of the outer tag
+           (inside', ts'') <- findClose n ts'
+           -- Put the content of the inner tag
+           let inner = (t:) . inside . (T.Close (T.Name (N.Cons n)):)
+           return $ (inner . inside', ts'')
+    | M.closeLit n t    = Just (id, ts)
+    | otherwise         =
+        do (inside, ts') <- findClose n ts
+           return ((t:) . inside, ts')
diff --git a/src/Config.hs b/src/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/Config.hs
@@ -0,0 +1,85 @@
+{-| Configuration and command-line arguments -}
+module Config
+    ( Action(..)
+    , parseArgs
+    , usage
+    , Usage
+    , unUsage
+    )
+where
+
+import Data.Monoid ( Monoid(..) )
+
+import Config.Store ( ParseStore, psConst, psArgStr
+                    , storeDocumentation )
+import Config.GetOpt ( optSpec, noArgs )
+import Config.Types ( Usage(..), strUsage, ActionSpec(..)
+                    , parseArgsG, CommandResult(..) )
+import Config.Format ( twoColumn, indent )
+import qualified Config.Command.Scan as Scan
+import qualified Config.Command.Report as Report
+import qualified Report as Report
+import qualified Config.Command.Run as Run
+import State.Types ( State )
+import qualified State.Mem ( new )
+import qualified State.Disk ( new )
+import qualified State.SQLite ( new )
+
+data Action = Help Usage
+            | RunServer Run.Config
+            | Report Report.ReportConfig
+            | Scan Scan.Config
+
+parseArgs :: [String] -> Either Usage Action
+parseArgs args = case parseArgsG commands args of
+                   BadCommand u    -> Left $ usage `mappend` u
+                   HelpRequest u   -> Right $ Help u
+                   CommandFailed u -> Left u
+                   CommandOk x     -> Right $ x
+
+-- |Add the preamble to the usage information and turn it into a String
+unUsage :: Int -> Usage -> String
+unUsage n u = let (Usage f) = strUsage preamble `mappend` u in unlines $ f n
+    where
+      preamble =
+          "Web-based document review system, based on the system used for \
+          \Real World Haskell <http://book.realworldhaskell.org/>"
+
+usage :: Usage
+usage =
+    mconcat
+    [ Usage cmds
+    , strUsage "Use <command> --help for help on any individual command."
+    , Usage $ storeDocumentation stores
+    ]
+    where
+      cmds n = "Commands:":indent 2 (twoColumn (n - 2) $ map showCmd $ commands)
+      showCmd as = (asName as, asDescr as)
+
+commands :: [ActionSpec Action]
+commands =
+    [ optSpec "run" RunServer (Run.opts stores) Run.mkCfg
+      "Run the HTTP server for Web-based document review, that can serve \
+      \the static content, the AJAX API for adding and displaying comments, \
+      \and the Atom feed and HTML comment viewing API"
+    , optSpec "scan" Scan (Scan.opts stores) Scan.mkCfg
+      "Scan a set of documents for commentable items and (optionally) update \
+      \a store"
+    , optSpec "report" Report Report.opts Report.mkCfg
+      "Process a set of binary log files and produce a usage report"
+    , optSpec "help" Help [] (noArgs usage)
+      "Show this help text"
+    ]
+
+stores :: [ ParseStore (IO State) ]
+stores = [ psConst "mem" State.Mem.new
+           "Use an ephemeral in-memory store (data will be lost when \
+           \the process ends)"
+         , psArgStr "fs" "DIRECTORY" State.Disk.new
+           "Store data in flat files in the specified directory. This \
+           \store is not ACID (has known race conditions and updates \
+           \are not atomic)"
+         , psArgStr "sqlite" "DBNAME" State.SQLite.new
+           "Store data in a SQLite database specified by DBNAME. Uses \
+           \the syntax of the SQLite API"
+         ]
diff --git a/src/Config/Command/Report.hs b/src/Config/Command/Report.hs
new file mode 100644
--- /dev/null
+++ b/src/Config/Command/Report.hs
@@ -0,0 +1,14 @@
+module Config.Command.Report
+    ( mkCfg
+    , opts
+    )
+where
+
+import Config.GetOpt ( MkCfg, Opts )
+import Report ( ReportConfig(..) )
+
+mkCfg :: MkCfg ReportConfig
+mkCfg = return . ReportConfig
+
+opts :: Opts ReportConfig
+opts = []
diff --git a/src/Config/Command/Run.hs b/src/Config/Command/Run.hs
new file mode 100644
--- /dev/null
+++ b/src/Config/Command/Run.hs
@@ -0,0 +1,100 @@
+module Config.Command.Run
+    ( Config(..)
+    , mkCfg
+    , opts
+    )
+where
+
+import State.Types ( State )
+import Network.URI ( parseRelativeReference, URI )
+import Config.GetOpt ( MkCfg, Opts, noArgs, Err, contentDirDesc )
+import Config.Store ( storeOptDescr, ParseStore )
+import System.Console.GetOpt
+import qualified State.Mem ( new )
+
+-- |The configurable settings for an instance of the comments server
+data Config =
+    Config
+    { cfgStore       :: !(IO State) -- ^How state is stored
+    , cfgPort        :: !Int        -- ^What TCP port to listen on
+    , cfgLogDir      :: !FilePath   -- ^Where to put the log files
+    , cfgHostName    :: !String     -- ^The hostname used by Snap
+    , cfgContentDir  :: !FilePath   -- ^Where to look for the
+                                    -- documents to index and content
+                                    -- to serve. This is mapped to the
+                                    -- root of the Web server.
+    , cfgScanOnStart :: !Bool       -- ^Whether to scan for updated
+                                    -- ids in the content directory
+    , cfgStaticDir   :: !(Maybe FilePath) -- ^Other static content to serve
+    , cfgDefaultPage :: !(Maybe URI)
+    -- ^Where to redirect to if no URL is requested
+    , cfgRunAs       :: !(Maybe String)
+    , cfgLogTo       :: !(Maybe FilePath)
+    }
+
+mkCfg :: MkCfg Config
+mkCfg =
+    noArgs $
+    Config
+    { cfgStore       = State.Mem.new
+    , cfgPort        = 3000
+    , cfgLogDir      = "."
+    , cfgHostName    = "localhost"
+    , cfgContentDir  = "."
+    , cfgStaticDir   = Nothing
+    , cfgScanOnStart = True
+    , cfgDefaultPage = Nothing
+    , cfgRunAs       = Nothing
+    , cfgLogTo       = Nothing
+    }
+
+-- All of the defined options
+opts :: [ParseStore (IO State)] -> Opts Config
+opts stores =
+    [ storeOptDescr stores $ \st cfg -> cfg { cfgStore = st }
+    , Option "" ["no-scan"] (NoArg (soptScan False))
+      "Do not update the chapter database at startup"
+    , Option "" ["scan"] (NoArg (soptScan True))
+      "Scan for updated content at server startup (default)"
+    , Option "" ["static-dir"] (ReqArg soptStaticDir "DIR")
+      "Serve static files from this directory"
+    , contentDirDesc soptContentDir
+    , Option "p" ["port"] (ReqArg soptPort "PORT")
+      "Listen on this port"
+    , Option "" ["log-dir"] (ReqArg soptLogDir "DIR")
+      "Store the error and access logs in this directory"
+    , Option "" ["host"] (ReqArg soptHost "HOSTNAME")
+      "Use this as the Web server host (not for binding address)"
+    , Option "" ["default-page"] (ReqArg soptDefaultPage "URL")
+      "Where the server should redirect when the URL / is requested"
+    , Option "" ["run-as"] (ReqArg soptRunAs "USERNAME")
+      "If started as root, attempt to drop privileges by \n\
+      \changing to this user once the port is bound"
+    , Option "" ["log-to"] (ReqArg soptLogTo "LOGFILE")
+      "Write a binary log file as a backup in case of primary\n\
+      \store failure"
+    ]
+
+type Opt = Err (Config -> Config)
+
+-- Turn an option string into something that updates the configuration
+soptPort, soptLogDir, soptHost, soptContentDir, soptStaticDir,
+ soptDefaultPage, soptRunAs, soptLogTo :: String -> Opt
+soptLogDir str      = return $ \cfg -> cfg { cfgLogDir     = str }
+soptHost str        = return $ \cfg -> cfg { cfgHostName   = str }
+soptContentDir str  = return $ \cfg -> cfg { cfgContentDir = str }
+soptStaticDir str   = return $ \cfg -> cfg { cfgStaticDir  = Just str }
+soptRunAs str       = return $ \cfg -> cfg { cfgRunAs      = Just str }
+soptLogTo str       = return $ \cfg -> cfg { cfgLogTo      = Just str }
+soptPort str        =
+    case reads str of
+      [(x, [])] -> return $ \cfg -> cfg { cfgPort = x }
+      _         -> fail $ "Bad port: " ++ show str
+soptDefaultPage str =
+    case parseRelativeReference str of
+      Nothing -> fail $ "Not a valid URI: " ++ show str
+      Just u  -> return $ \cfg ->
+                 cfg { cfgDefaultPage = Just u }
+
+soptScan :: Bool -> Opt
+soptScan st = return $ \cfg -> cfg { cfgScanOnStart = st }
diff --git a/src/Config/Command/Scan.hs b/src/Config/Command/Scan.hs
new file mode 100644
--- /dev/null
+++ b/src/Config/Command/Scan.hs
@@ -0,0 +1,26 @@
+module Config.Command.Scan
+    ( Config(..)
+    , opts
+    , mkCfg
+    )
+where
+
+import Config.Store ( storeOptDescr, ParseStore )
+import Config.GetOpt ( Opts, MkCfg, noArgs, contentDirDesc )
+import State.Types ( State )
+
+data Config =
+    Config
+    { contentDir :: !(FilePath)
+    , store      :: !(Maybe (IO State))
+    -- ^If this is Nothing, then just print out what we find.
+    }
+
+opts :: [ParseStore (IO State)] -> Opts Config
+opts ss =
+    [ contentDirDesc $ \p -> return $ \cfg -> cfg { contentDir = p }
+    , storeOptDescr ss $ \st cfg -> cfg { store = Just st }
+    ]
+
+mkCfg :: MkCfg Config
+mkCfg = noArgs $ Config "." Nothing
diff --git a/src/Config/Format.hs b/src/Config/Format.hs
new file mode 100644
--- /dev/null
+++ b/src/Config/Format.hs
@@ -0,0 +1,94 @@
+module Config.Format
+    ( twoColumn
+    , wrap
+    , findBreak
+    , indent
+    )
+where
+
+import Control.Monad ( mplus )
+import Data.Char ( isAlphaNum, isSpace )
+import Data.Maybe ( fromMaybe )
+
+--------------------------------------------------
+-- Laying out documentation
+
+-- XXX: This should be generalized so that you can pass it a list of
+-- rows of arbitrary numbers of columns and get a table layout.
+
+-- |Lay out pairs of strings in two columns, under the specified line length
+twoColumn :: Int -> [(String, String)] -> [String]
+twoColumn w ps = concatMap fmt1 ps
+    where
+      firstColN = min (maximum $ map (length . fst) ps) 40
+
+      secondColN = w - firstColN - 2
+
+      fmt1 (a, b) = hcat firstColN secondColN a b
+
+      hcat n1 n2 = go
+          where
+            go [] [] = []
+            go xs ys =
+                let (c1, xs') = findBreak n1 xs
+                    (c2, ys') = findBreak n2 ys
+                    l = fillCol n1 c1 ++ "  " ++ fillCol n2 c2
+                in l : go xs' ys'
+      fillCol n xs = take n $ xs ++ repeat ' '
+
+-- |Wrap a string at a certain line length, attempting to break at
+-- word boundaries
+wrap :: Int -> String -> [String]
+wrap n = go
+    where
+      go s = case findBreak n s of
+               ([], []) -> []
+               (l,  s') -> l:go s'
+
+-- |Attempt to break a string at a word boundary. As a last resort,
+-- arbitrarily break at the specified line limit
+findBreak :: Int -> String -> (String, String)
+findBreak n s = let (l, s') = fromMaybe (splitAt n s) $ go 0 id s
+                in (l, dropWhile isSpace s')
+    where
+      -- Look for the last word boundary in the first n characters of
+      -- the String, and if there is a word boundary, return the
+      -- String up to that last boundary and the remainder of the
+      -- String
+      go :: Int    -- ^Which character we are examining now
+         -> ShowS  -- ^The String up to this point
+         -> String -- ^The remainder of the String that we have to examine
+         -> Maybe (String, String)
+            -- ^If we found a breaking point before getting to the
+            -- limit, this is the String up to that breaking point and
+            -- then the remainder
+      go l acc xs =
+          case xs of
+            -- If we get to the end and we haven't violated the length
+            -- constraint, then return what we've got.
+            []                    -> ok
+            (c:cs)
+                -- This violates the length constraint, so fail.
+                | l >= n       -> Nothing
+
+                -- An alphanumeric character is not allowed to be a
+                -- break point, so try to find a breaking point later
+                -- on or fail.
+                | isAlphaNum c -> next c cs
+
+                -- This is a valid breaking point, so try to see if
+                -- there is one further on in the string and if there
+                -- is not, then this one is the longest.
+                | otherwise    -> next c cs `mplus` ok
+          where
+            -- acc [] is what we have processed before examining this
+            -- character
+            ok = return (acc [], xs)
+
+            -- next looks for a breaking point further on in the
+            -- String
+            next c cs = go (l + 1) (acc . (c:)) cs
+
+-- |Prepend the specified number of spaces to each String
+indent :: Int -> [String] -> [String]
+indent n = map $ showString $ replicate n ' '
diff --git a/src/Config/GetOpt.hs b/src/Config/GetOpt.hs
new file mode 100644
--- /dev/null
+++ b/src/Config/GetOpt.hs
@@ -0,0 +1,90 @@
+module Config.GetOpt
+    ( Opts
+    , MkCfg
+    , optSpec
+    , noArgs
+    , contentDirDesc
+    , Err
+    )
+where
+
+import Data.Monoid ( mappend )
+import Control.Monad ( liftM, ap, guard )
+import Config.Types ( ActionSpec(..), preUsage, CommandResult(..)
+                    , isHelpRequest )
+import Config.Format ( wrap, indent )
+import Data.Char ( isSpace )
+import System.Console.GetOpt
+
+type Opts cfg = [OptDescr (Err (cfg -> cfg))]
+type MkCfg cfg = [String] -> Err cfg
+
+contentDirDesc :: (FilePath -> a) -> OptDescr a
+contentDirDesc f = Option "" ["content-dir"] (ReqArg f "DIR")
+                   "Where to find the marked up HTML files. Defaults to \
+                   \the current working directory."
+
+optSpec :: String -> (cfg -> a) -> Opts cfg -> MkCfg cfg -> String
+        -> ActionSpec a
+optSpec name mkAction osOpts osCfg desc =
+    ActionSpec
+    { asName = name
+    , asDescr = desc
+    , asParse = parseOptSpec
+    }
+    where
+      actUsage = preUsage [usageInfo (unlines $ wrap 78 $ desc) osOpts]
+
+      strip = reverse . dropWhile isSpace . reverse
+
+      parseOptSpec args =
+          let (os, args', es) = getOpt Permute osOpts args
+              flgs = if null es
+                     then sequenceE os
+                     else Err $ map strip es ++ toErrors (sequenceE os)
+              result xs = case (flgs, osCfg xs) of
+                            (Err es1, Err es2) -> Err $ es1 ++ es2
+                            (_, cfg)           ->
+                                foldr ($) `liftM` cfg `ap` flgs
+              handle (Err msgs)  =
+                  CommandFailed $ actUsage `mappend` preUsage
+                                    ("Errors parsing arguments:":indent 2 msgs)
+              handle (Val cfg) =
+                  CommandOk $ mkAction cfg
+              accepted actArgs =
+                  if isHelpRequest actArgs
+                  then HelpRequest actUsage
+                  else handle $ result actArgs
+          in case args' of
+               (k:actArgs) -> guard (k == name) >> return (accepted actArgs)
+               _                       -> Nothing
+
+
+noArgs :: a -> [String] -> Err a
+noArgs x [] = return x
+noArgs _ args = fail $ "Unexpected arguments: " ++ show args
+
+-- Error monad (like either)
+data Err a = Err [String] | Val a
+instance Monad Err where
+    return        = Val
+    fail          = Err . return
+    (Err s) >>= _ = Err s
+    (Val x) >>= f = f x
+
+sequenceE :: [Err a] -> Err [a]
+sequenceE xs = case errs xs of
+                 ([], as) -> Val as
+                 (es, _)  -> Err es
+
+-- Partition errors and successes
+errs :: [Err a] -> ([String], [a])
+errs (x:xs) = let (es, as) = errs xs
+              in case x of
+                   Err e -> (e ++ es, as)
+                   Val x' -> (es, x':as)
+errs [] = ([], [])
+
+toErrors :: Err a -> [String]
+toErrors (Err es) = es
+toErrors _        = []
diff --git a/src/Config/Store.hs b/src/Config/Store.hs
new file mode 100644
--- /dev/null
+++ b/src/Config/Store.hs
@@ -0,0 +1,54 @@
+module Config.Store
+    ( storeOptDescr
+    , ParseStore
+    , psConst
+    , psArg
+    , psArgStr
+    , storeDocumentation
+    )
+where
+
+import System.Console.GetOpt
+import Control.Monad ( msum, guard )
+import Config.Format ( twoColumn, indent, wrap )
+
+storeOptDescr :: Monad m => [ParseStore b] -> (b -> a -> a)
+              -> OptDescr (m (a -> a))
+storeOptDescr stores set
+    = Option "s" ["store"] (ReqArg f "STORE") "The state storage type."
+    where f sType =
+              case parseStore stores sType of
+                Nothing -> fail $ "Unknown store type: " ++ show sType
+                Just st -> return $ set st
+
+parseStore :: [ParseStore a] -> String -> Maybe a
+parseStore stores sType = msum $ map (`psParse` sType) stores
+
+data ParseStore a = PS { psParse :: String -> Maybe a
+                       , psKey   :: String
+                       , psDescr :: String
+                       }
+
+psConst :: String -> a -> String -> ParseStore a
+psConst k v = PS f k
+    where
+      f sType = guard (sType == k) >> return v
+
+psArg :: String -> String -> (String -> Maybe a) -> String -> ParseStore a
+psArg pfx argName p = PS f (pfx ++ ':':argName)
+    where
+      f sType = case break (== ':') sType of
+                  (k, (':':rest)) | k == pfx -> p rest
+                  _                          -> Nothing
+
+psArgStr :: String -> String -> (String -> a) -> String -> ParseStore a
+psArgStr pfx argName f = psArg pfx argName (Just . f)
+
+storeDocumentation :: [ParseStore a] -> Int -> [String]
+storeDocumentation stores lineLength =
+    wrap lineLength "Specifying data storage:" ++ showStores
+    where
+      showStores = indent 2 $
+                   twoColumn (lineLength - 2) $
+                   map showStore stores
+      showStore ps = (psKey ps, psDescr ps)
diff --git a/src/Config/Types.hs b/src/Config/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Config/Types.hs
@@ -0,0 +1,48 @@
+module Config.Types
+    ( Usage(..)
+    , preUsage
+    , strUsage
+    , ActionSpec(..)
+    , isHelpRequest
+    , parseArgsG
+    , CommandResult(..)
+    )
+where
+
+import Data.Monoid ( Monoid(..) )
+import Data.Maybe ( fromMaybe )
+import Control.Monad ( msum )
+import Config.Format ( wrap )
+
+newtype Usage = Usage (Int -> [String])
+
+instance Monoid Usage where
+    mempty = Usage $ const []
+    mappend (Usage f1) (Usage f2) = Usage $ \n -> f1 n ++ ([]:f2 n)
+
+strUsage :: String -> Usage
+strUsage = Usage . flip wrap
+
+preUsage :: [String] -> Usage
+preUsage = Usage . const
+
+data ActionSpec a
+    = ActionSpec { asName  :: String
+                 , asDescr :: String
+                 , asParse :: [String] -> Maybe (CommandResult a)
+                 }
+
+data CommandResult a = BadCommand Usage
+                     | HelpRequest Usage
+                     | CommandFailed Usage
+                     | CommandOk a
+
+parseArgsG :: [ActionSpec a] -> [String] -> CommandResult a
+parseArgsG _ [] = BadCommand $ strUsage "Please specify a command"
+parseArgsG acts args =
+    fromMaybe (BadCommand unknown) $ msum $ map (`asParse` args) acts
+    where
+      unknown = strUsage ("Unknown command: " ++ show args)
+
+isHelpRequest :: [String] -> Bool
+isHelpRequest args = any (`elem` args) ["-h", "--help"]
diff --git a/src/Feed.hs b/src/Feed.hs
new file mode 100644
--- /dev/null
+++ b/src/Feed.hs
@@ -0,0 +1,57 @@
+module Feed
+    ( commentFeed
+    )
+where
+
+import State.Types ( ChapterId, chapterId, CommentId, commentId, Comment(..) )
+
+import Data.Maybe ( listToMaybe )
+import Data.Time ( formatTime )
+import Data.Time.Clock.POSIX ( posixSecondsToUTCTime )
+import System.Locale ( defaultTimeLocale )
+import Network.URI ( URI(uriFragment) )
+import qualified Data.Text as T
+import qualified Text.Atom.Feed as Atom
+
+-- XXX: This is inefficient if there are a lot of comments, because
+-- the results are all loaded from storage and then trimmed down here.
+commentFeed :: ChapterId -> [(CommentId, Comment)] -> URI
+            -> Atom.Feed
+commentFeed chId cs docURI =
+    (Atom.nullFeed (show chapterURI) (Atom.TextString feedTitle) feedDate)
+    { Atom.feedEntries = map mkItem cs
+    , Atom.feedLinks   = [ alternateHtml $ chapterURI ]
+    }
+    where
+      alternateHtml u = (Atom.nullLink u)
+                        { Atom.linkRel = Just $ Left "alternate"
+                        , Atom.linkType = Just "text/html"
+                        }
+      chapterURI      = docFragText $ chapterId chId
+      itemURI cId     = docFragText $ commentId cId
+      docFragText t   = show $ docURI { uriFragment = '#':T.unpack t }
+      commentDateStr  = formatTime defaultTimeLocale "%c" .
+                         posixSecondsToUTCTime . cDate
+      feedDate        = maybe "no-date" (commentDateStr . snd) $ listToMaybe cs
+      feedTitle       = "Comments on " ++ (T.unpack $ chapterId chId)
+
+      mkItem (cId, c) =
+          let -- XXX: this identifier should have a comment id to make
+              -- this unique per comment instead of unique per
+              -- commentable node
+              iid    = itemURI cId
+
+              ititle = Atom.TextString $
+                       "Comment on " ++ (T.unpack $ commentId cId)
+
+              idate  = commentDateStr c
+
+              author = Atom.nullPerson
+                       { Atom.personName = T.unpack $ cName c }
+
+          in (Atom.nullEntry iid ititle idate)
+                 { Atom.entryLinks   = [ alternateHtml $ itemURI cId ]
+                 , Atom.entryAuthors = [ author ]
+                 , Atom.entryContent =
+                     Just $ Atom.TextContent $ T.unpack $ cComment c
+                 }
diff --git a/src/Main.hs b/src/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Main.hs
@@ -0,0 +1,449 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Main where
+
+import           Control.Applicative          ( (<$>), (<*>), (<|>) )
+import           Control.Arrow                ( first )
+import           Control.Monad                ( replicateM, guard, when )
+import           Control.Monad.IO.Class       ( liftIO )
+import           Control.Monad.Random         ( getRandomR )
+import           Data.Char                    ( toLower )
+import           Data.Foldable                ( mapM_, forM_ )
+import           Data.Maybe                   ( fromJust, fromMaybe )
+import           Data.Time.Clock              ( getCurrentTime, addUTCTime )
+import           Data.Time.Clock.POSIX        ( getPOSIXTime
+                                              , posixSecondsToUTCTime )
+import           Data.Time.Format             ( formatTime )
+import           Data.Function                ( on )
+import           Data.List                    ( sortBy, groupBy )
+import           Network.URI                  ( URI(uriFragment)
+                                              , relativeTo
+                                              , parseURI
+                                              , parseRelativeReference
+                                              )
+import           Prelude hiding               ( mapM_ )
+import           Snap.Iteratee                ( enumBS )
+import           Snap.Types
+import           Snap.Util.FileServe          ( fileServe )
+import           System.Environment           ( getArgs )
+import           System.Exit                  ( exitFailure, exitSuccess )
+import           System.FilePath              ( (</>) )
+import           System.Locale                ( defaultTimeLocale )
+import           Text.XHtmlCombinators
+import           Text.XHtmlCombinators.Escape ( escape, escapeAttr )
+import qualified Data.ByteString.Char8            as B
+import qualified Data.Text                        as T
+import qualified Data.Text.Encoding               as E
+import qualified Text.JSON                        as JSON
+import qualified Text.XHtmlCombinators.Attributes as A
+import qualified Text.XML.Light.Output            as XML
+import qualified Text.Atom.Feed.Export            as Atom
+
+import qualified Report as Report
+import           Config           ( parseArgs, unUsage, Usage, Action(..) )
+import qualified Config.Command.Run as Run
+import qualified Config.Command.Scan as Scan
+import           Analyze          ( analyze )
+import           Privilege        ( tryDropPrivilege )
+import           Server
+import           State.Types
+import qualified State.Logger as L
+import           Paths_doc_review ( getDataFileName )
+import           Feed ( commentFeed )
+
+showUsage :: Usage -> IO ()
+showUsage = putStr . unUsage 78
+
+main :: IO ()
+main = do
+  args <- getArgs
+  case parseArgs args of
+    Left usg ->
+        do showUsage usg
+           exitFailure
+
+    Right (Help usg) ->
+        do showUsage usg
+           exitSuccess
+
+    Right (Report cfg) ->
+        putStr . Report.genReport cfg =<< Report.analyzeFiles cfg
+
+    Right (RunServer cfg) ->
+        do st <- maybe return L.wrap (Run.cfgLogTo cfg) =<< Run.cfgStore cfg
+           when (Run.cfgScanOnStart cfg) $ scanDir cfg st
+           runServer cfg st
+
+    Right (Scan cfg) ->
+        let cDir = Scan.contentDir cfg
+
+            showChapter (mChId, cIds) =
+                maybe "(unnamed)" (showString "Chapter: " . T.unpack . chapterId) mChId:
+                map (showString "  " . showCommentId) cIds
+
+            showCommentId = (T.unpack . commentId)
+
+            showFile (fn, chs) = [ ""
+                                 , replicate 50 '='
+                                 , "Analysis of " ++ show fn
+                                 , replicate 50 '-'
+                                 , ""
+                                 ] ++ concatMap showChapter chs
+
+            dupes =
+                  filter ((> 1) . length) .
+                  groupBy ((==) `on` getCId) .
+                  sortBy (compare `on` getCId) .
+                  recs
+                where
+                  recs fs = do
+                        (fn, chs) <- fs
+                        (mChId, cIds) <- chs
+                        cId <- cIds
+                        [(fn, mChId, cId)]
+
+            getCId (_, _, cId) = cId
+
+            showDupes files = case map (map getCId) $ dupes files of
+                                [] -> ["No duplicate comment ids"]
+                                ds -> "Duplicate comment ids found:":
+                                      concatMap showDupeSet ds
+                where
+                  showDupeSet = map (showString "  " . show)
+
+        in case Scan.store cfg of
+             Just mk -> loadChapters cDir =<< mk
+             Nothing ->
+                 do files <- analyze cDir
+                    putStr $ unlines $ concatMap showFile files
+                    putStr $ unlines $ []:showDupes files
+
+-- |Scan the content directory for chapter HTML files and update the
+-- database to include all of the chapter mappings
+loadChapters :: FilePath -> State -> IO ()
+loadChapters chapterDir st = do
+  chapters <- analyze chapterDir
+  storeChapters chapters st
+
+storeChapters :: [(String, [(Maybe ChapterId, [CommentId])])]
+              -> State -> IO ()
+storeChapters files st = do
+  forM_ files $ \(fn, chapters) -> do
+      let uri = parseRelativeReference fn
+      forM_ chapters $ \(mChId, cIds) ->
+          maybe (return ()) (\chId -> addChapter st chId cIds uri) mChId
+
+runServer :: Run.Config -> State -> IO ()
+runServer cfg st = do
+  static <- case Run.cfgStaticDir cfg of
+              Nothing -> getDataFileName "static"
+              Just s  -> return s
+
+  let hostBS = E.encodeUtf8 $ T.pack $ Run.cfgHostName cfg
+      sCfg = emptyServerConfig
+             { hostname = hostBS
+             , accessLog = Just $ Run.cfgLogDir cfg </> "access.log"
+             , errorLog = Just $ Run.cfgLogDir cfg </> "error.log"
+             , port = Run.cfgPort cfg
+             }
+
+  dropPriv <- maybe (return (return ())) tryDropPrivilege $ Run.cfgRunAs cfg
+  let dropPrivAct = liftIO dropPriv >> pass
+  server sCfg $ dropPrivAct <|> trackSession (app static cfg st)
+
+scanDir :: Run.Config -> State -> IO ()
+scanDir = loadChapters . Run.cfgContentDir
+
+app :: FilePath -> Run.Config -> State -> SessionId -> Snap ()
+app static cfg st sessionId =
+    dir "comments"
+            (route [ ("single/:id", getCommentHandler st sessionId)
+                   , ("chapter/:chapid/count/", getCountsHandler st)
+                   , ("chapter/:chapid/feed", getChapterFeedHandler sessionId st)
+                   , ("submit/:id", submitHandler st sessionId)
+                   ]) <|>
+    fileServe (Run.cfgContentDir cfg) <|>
+    fileServe static <|>
+    maybe pass (ifTop . relativeRedirect) (Run.cfgDefaultPage cfg)
+
+-- |Redirect to the supplied URI (relative to the current request's
+-- URI)
+relativeRedirect :: URI -> Snap ()
+relativeRedirect d = do
+  u <- baseURL
+  -- relativeTo's implementation always returns Just
+  redirect $ B.pack $ show $ fromJust $ d `relativeTo` u
+
+-- |The base URL for this request (taking into account the protocol,
+-- the host, and the port)
+baseURL :: Snap URI
+baseURL =
+    withRequest $ \req -> do
+      let host = B.unpack $ rqServerName req
+
+          rport = rqServerPort req
+
+          defaultPort | rqIsSecure req = 443
+                      | otherwise      = 80
+
+          proto | rqIsSecure req = "https"
+                | otherwise = "http"
+
+          authStr | rport == defaultPort || rport == 0 = host
+                  | otherwise = host ++ ':':show rport
+
+      case parseURI $ proto ++ "://" ++ authStr of
+
+        Nothing -> finishWith $ badRequest $
+                   T.concat ["Bad host header: ", T.pack host]
+
+        Just u  -> return u
+
+newSessionId :: IO SessionId
+newSessionId = SessionId . B.pack <$> replicateM 24 selectRandomSessionChar
+    where
+      selectRandomSessionChar =
+          B.index sessionChars <$> getRandomR (0, B.length sessionChars - 1)
+
+sessionChars :: B.ByteString
+sessionChars = B.pack $ ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9'] ++ "."
+
+trackSession :: (SessionId -> Snap a) -> Snap a
+trackSession act = do
+  let sessionCookieName = "doc-review-session"
+  cookies <- rqCookies <$> getRequest
+  sessionId <-
+      case [c | c <- cookies, cookieName c == sessionCookieName ] of
+        []    -> liftIO newSessionId
+        (c:_) -> return $ SessionId $ cookieValue c
+
+  res <- act sessionId
+
+  -- Expires in 1000000 seconds
+  expiry <- addUTCTime 1000000 <$> liftIO getCurrentTime
+
+  let newCookie =
+          Cookie { cookieName = sessionCookieName
+                 , cookieValue = let SessionId sid = sessionId
+                                 in sid
+                 , cookieExpires = Just expiry
+                 , cookieDomain = Nothing
+                 , cookiePath = Just "/"
+                 }
+
+  modifyResponse $ addCookie newCookie
+
+  return res
+
+--------------------------------------------------
+-- Handlers
+
+getChapterFeedHandler :: SessionId -> State -> Snap ()
+getChapterFeedHandler sId st = do
+  Just chapId <- mkChapterId <$> requireParam "chapid"
+  makeAbsolute <- flip relativeTo <$> baseURL
+  chapURL <- liftIO $ getChapterURI st chapId
+  case chapURL >>= makeAbsolute of
+    Nothing -> do
+      modifyResponse $ setResponseStatus 404 "Not Found"
+                     . addHeader "content-type" "text/plain"
+      mapM_ writeBS [ "Chapter not found: "
+                    , E.encodeUtf8 $ chapterId chapId
+                    ]
+      finishWith =<< getResponse
+    Just u -> do
+      limit <- fromMaybe 10 <$> getLimitParam "limit"
+      cs <- liftIO $ take limit <$> getChapterComments st chapId
+      fmt <- fromMaybe AtomFeed <$> getFormatParam "format"
+      case fmt of
+        AtomFeed ->
+            do modifyResponse $ addHeader "content-type" "application/atom+xml"
+               writeText $ T.pack $ XML.showTopElement $
+                         Atom.xmlFeed $ commentFeed chapId cs u
+        WebPage ->
+            do modifyResponse $ addHeader "content-type" "text/html"
+               writeText $ render $ chronoView sId chapId cs u
+
+getParamM :: (B.ByteString -> Maybe a) -> B.ByteString -> Snap (Maybe a)
+getParamM f name = (f =<<) <$> getParam name
+
+getLimitParam :: B.ByteString -- ^Parameter name
+              -> Snap (Maybe Int)
+getLimitParam = getParamM $ \lim ->
+                case reads $ B.unpack lim of
+                  [(n, [])] -> Just n
+                  _         -> Nothing -- log something here
+                                       -- because of the bad parse?
+
+data Format = AtomFeed | WebPage
+
+getFormatParam :: B.ByteString -> Snap (Maybe Format)
+getFormatParam = getParamM $ (`lookup` fs) . B.map toLower
+    where fs = [ ("atom", AtomFeed), ("html", WebPage) ]
+
+getCountsHandler :: State -> Snap ()
+getCountsHandler st = do
+  chapId <- mkChapterId <$> requireParam "chapid"
+  counts <- liftIO $ getCounts st chapId
+  modifyResponse $ addHeader "content-type" "text/json"
+  writeText $ T.pack $ JSON.encode $ countsJSON counts
+
+countsJSON :: [(CommentId, Int)] -> JSON.JSValue
+countsJSON = JSON.showJSON . JSON.toJSObject .
+             map (first (T.unpack . commentId))
+
+getCommentHandler :: State -> SessionId -> Snap ()
+getCommentHandler st sessionId = do
+  mcid <- mkCommentId <$> requireParam "id"
+  case mcid of
+    Nothing -> finishWith $ badRequest "Bad comment id"
+    Just cid -> respondComments cid st sessionId
+
+submitHandler :: State -> SessionId -> Snap ()
+submitHandler st sessionId = do
+  mcid <- mkCommentId <$> requireParam "id"
+  case mcid of
+    Nothing -> finishWith $ badRequest "Bad comment id"
+    Just cid ->
+        do comment <- Comment <$> requireParam "name"
+                              <*> requireParam "comment"
+                              <*> getParamUtf8 "email"
+                              <*> liftIO getPOSIXTime
+                              <*> return sessionId
+           chap <- (mkChapterId =<<) <$> getParamUtf8 "chapid"
+           liftIO $ addComment st cid chap comment
+           respondComments cid st sessionId
+
+--------------------------------------------------
+-- Snap helpers
+
+respondComments :: CommentId -> State -> SessionId -> Snap ()
+respondComments cid st sessionId = do
+  cs <- liftIO $ findComments st cid
+  si <- liftIO $ getLastInfo st sessionId
+  modifyResponse $ addHeader "content-type" "text/html"
+  writeText $ render $ (getMarkup cid cs si sessionId :: XHtml FlowContent)
+
+badRequest :: T.Text -> Response
+badRequest msg = setResponseBody (enumBS $ E.encodeUtf8 msg) $
+                 setResponseStatus 400 "Bad request" $
+                 emptyResponse
+
+requireParam :: T.Text -> Snap T.Text
+requireParam argName =
+    getParamUtf8 argName >>=
+    maybe (finishWith $ badRequest missing) return
+    where
+      missing = T.concat ["Missing parameter ", argName]
+
+getParamUtf8 :: T.Text -> Snap (Maybe T.Text)
+getParamUtf8 argName = fmap E.decodeUtf8 <$> getParam (E.encodeUtf8 argName)
+
+--------------------------------------------------
+-- Rendering comments
+
+chronoView :: SessionId -> ChapterId -> [(CommentId, Comment)] -> URI -> XHtml Page
+chronoView sId chId cs u =
+    html True $ do
+      head_ $ do
+        title $ pageTitle
+        link' [ A.type_ "text/css"
+              , A.href $ escape "/styles.css"
+              , A.rel "stylesheet" ]
+      body $ div' [ A.id_ "content" ] $
+           do div' [ A.class_ "navheader" ] $ h1 $ text pageTitle
+              div' [ A.class_ "chapter" ] $ do
+                  p $ do
+                    text $ escape $ T.concat
+                             [ "This is a chronological listing of comments on "
+                             , chapterId chId
+                             , ". Click on each comment's link to view the "
+                             , "comment in context. You can also view them "
+                             , "inline using the links "
+                             ]
+                    a' [ A.href $ escapeAttr $ T.pack $ show u ] $ text $
+                       escape $ "within the document itself"
+                    text $ escape "."
+                  forM_ cs $ \(cId, c) ->
+                      commentMarkup sId cId (Just u) c
+    where
+      pageTitle = escape $ T.concat [ "Chronological view of comments on "
+                                    , chapterId chId
+                                    ]
+
+getMarkup :: Block a => CommentId -> [Comment] -> Maybe SessionInfo
+          -> SessionId -> XHtml a
+getMarkup cId cs si sid = do
+  div' [ A.id_ $ T.concat ["toggle_", commentId cId], A.class_ "toggle" ] $
+       a' [ A.class_ "commenttoggle"
+          , A.attr "onclick" $
+            T.concat ["return toggleComment('", commentId cId, "')"]
+          , A.href "show/hide comments"
+          ] $ text $ case length cs of
+                       0 -> "No comments"
+                       1 -> "1 comment"
+                       n -> T.concat [T.pack (show n), " comments"]
+  case cs of
+    []  -> do
+      div' [A.class_ "comment"] $
+           text "Be the first to comment on this paragraph!"
+      return ()
+    _   -> mapM_ (commentMarkup sid cId Nothing) cs
+  newCommentForm cId si
+  div_ $ span' [ A.class_ "comment_error" ] $ empty
+
+newCommentForm :: Block a => CommentId -> Maybe SessionInfo -> XHtml a
+newCommentForm cId msi =
+    form' (addId "/comments/submit/") [ idAttr "form_"
+                                      , A.class_ "comment"
+                                      , A.method "post"
+                                      ] $
+    table $ tbody $ do
+      tr $ do
+        th $ label' [forAttr "comment_"] $ text "Comment:"
+        td $ textarea' 10 40 [A.name "comment", idAttr "comment_"] ""
+      tr $ do
+        th $ label' [forAttr "name_"] $ text "Name:"
+        td $ input' [ A.name "name"
+                    , A.type_ "text"
+                    , idAttr "name_"
+                    , nameValue
+                    ]
+      tr $ do
+        th $ label' [forAttr "email_"] $ text "E-Mail Address: "
+        td $ do
+             input' [ A.name "email"
+                    , A.type_ "text"
+                    , idAttr "email_"
+                    , emailValue
+                    ]
+             text " (optional, will not be displayed)"
+      tr $ do
+        td empty
+        td $ input' [A.name "submit", A.type_ "submit"]
+    where
+      nameValue = A.value $ maybe "" (escapeAttr . siName) msi
+      emailValue = A.value $ maybe "" escapeAttr $ siEmail =<< msi
+      forAttr t = A.for (addId t)
+      idAttr t = A.id_ (addId t)
+      addId t = T.concat [t, commentId cId]
+
+commentMarkup :: Block a => SessionId -> CommentId -> Maybe URI -> Comment
+              -> XHtml a
+commentMarkup sid cId mURI c =
+    div' [A.class_ topCls] $ do
+      div' [A.class_ "username"] $ maybeLink $
+           do text $ escape $ name
+              text " "
+              span' [A.class_ "date"] $ text $ escape $ fmtTime $ cDate c
+      div' [A.class_ "comment-text"] $ text $ escape $ cComment c
+    where
+      maybeLink = maybe span_ (toAnchor . addFrag) mURI
+      toAnchor u = a' [ A.href $ escapeAttr $ T.pack $ show u ]
+      addFrag u = u { uriFragment = '#':T.unpack (commentId cId) }
+      isMine = sid == cSession c
+      name | isMine = T.concat [ cName c, " (you)" ]
+           | otherwise = cName c
+      topCls = T.unwords $ ["comment"] ++ (guard isMine >> return "mine")
+      fmtTime = T.pack .
+                formatTime defaultTimeLocale "%Y-%m-%d %H:%M UTC" .
+                posixSecondsToUTCTime
diff --git a/src/Privilege.hs b/src/Privilege.hs
new file mode 100644
--- /dev/null
+++ b/src/Privilege.hs
@@ -0,0 +1,75 @@
+module Privilege
+    ( tryDropPrivilege
+    )
+where
+
+import Control.Applicative ( (<$>) )
+import Data.IORef ( newIORef, readIORef , writeIORef )
+import System.Posix.User ( getUserEntryForName, setUserID
+                         , userID, getRealUserID )
+import Control.Monad ( join, unless )
+
+-- Obtain an action that will attempt to drop privileges if the
+-- environment and configuration allow it. Ideally, we would be able
+-- to drop privileges after binding to the port and before doing
+-- anything else. This is a compromise that provides a modicum of
+-- safety if the request can co-opt the server, but no protection from
+-- any local attacks. In particular, all of the server start-up stuff
+-- happened before this, including creating the database. This could
+-- potentially cause permission errors.
+tryDropPrivilege :: String -> IO (IO ())
+tryDropPrivilege username = do
+  -- Figure out what user id we need to switch to (if any)
+  uid <- getRealUserID
+  targetUID <-
+      do t <- userID <$> getUserEntryForName username
+         case uid of
+           -- Root:
+           0            ->
+               return $ Just t
+
+           -- We already are the target user:
+           _ | t == uid ->
+                 return Nothing
+
+           -- Anyone else:
+           _            ->
+               error $ "Only root can change users \
+                       \(trying to run as " ++ username ++ ")"
+
+  case targetUID of
+    Nothing     ->
+        -- If we have no target UID, then just return a no-op
+        return $ return ()
+
+    Just target ->
+        -- Build and return an action that will ensure that we are
+        -- running as the target user before proceeding
+        do
+          -- The variable that holds the action that ensures that we
+          -- have dropped privileges
+          startRef <- newIORef $ return ()
+
+          -- Put the privilege dropping action in
+          writeIORef startRef $
+            do putStrLn $
+                 "Dropping privileges: switching to user " ++ show target
+
+               setUserID target `catch` \e ->
+                   -- Check that we didn't lose a race
+                   -- trying to drop privileges. If we lost,
+                   -- then everything's OK because the
+                   -- privileges are already dropped.
+                   do newUID <- getRealUserID
+
+                      -- If it wasn't losing the race,
+                      -- raise it again here
+                      unless (newUID == target) $ ioError e
+
+               finalUID <- getRealUserID
+               if finalUID == target
+                 -- replace the action with a no-op
+                 then writeIORef startRef $ return ()
+                 else error "Failed to drop privileges"
+
+          return $ join $ readIORef startRef
diff --git a/src/Report.hs b/src/Report.hs
new file mode 100644
--- /dev/null
+++ b/src/Report.hs
@@ -0,0 +1,75 @@
+module Report
+    ( genReport
+    , analyzeFiles
+    , ReportConfig(..)
+    )
+where
+
+import Control.Applicative ( (<$>) )
+import Control.Monad       ( foldM, (>=>) )
+import Data.Monoid         ( Monoid(..) )
+import System.IO           ( IOMode(ReadMode), withBinaryFile )
+import qualified Data.ByteString.Lazy as B
+import qualified Data.Map as Map
+import qualified Data.Set as Set
+import qualified Data.Text as T
+
+import State.Logger ( foldMapComments )
+import State.Types
+
+data CommentStats =
+    CommentStats
+    { csTopicCount :: !(Map.Map CommentId Int)
+    , csUsers      :: !(Map.Map SessionId Int)
+    , csEmails     :: !(Set.Set T.Text)
+    }
+
+instance Monoid CommentStats where
+    mempty = CommentStats mempty mempty mempty
+    mappend stats1 stats2 =
+        CommentStats (hist csTopicCount) (hist csUsers) (comb mappend csEmails)
+            where
+              hist :: Ord a => (CommentStats -> Map.Map a Int) -> Map.Map a Int
+              hist = comb $ Map.unionWith (+)
+              comb f field = f (field stats1) (field stats2)
+
+stats :: CommentId -> a -> Comment -> CommentStats
+stats cid _ c = CommentStats
+                (Map.singleton cid 1)
+                (Map.singleton (cSession c) 1)
+                (maybe Set.empty Set.singleton $ cEmail c)
+
+getStats :: FilePath -> IO CommentStats
+getStats = withContents $ foldMapComments stats
+
+withContents :: (B.ByteString -> IO a) -> FilePath -> IO a
+withContents f fn = withBinaryFile fn ReadMode $ B.hGetContents >=> f
+
+data ReportConfig = ReportConfig [FilePath]
+
+analyzeFiles :: ReportConfig -> IO CommentStats
+analyzeFiles (ReportConfig filenames) =
+  foldM (\s fn -> s `seq` (mappend s <$> getStats fn)) mempty filenames
+
+genReport :: ReportConfig -> CommentStats -> String
+genReport (ReportConfig args) allStats =
+    unlines $
+    [ "Usage statistics for: " ++ unwords args
+    , shows total " comments"
+    , ""
+    , "Comment counts:"
+    ] ++ commentLines ++
+    [ ""
+    , case Map.size $ csUsers allStats of
+        1 -> "1 user contributing"
+        n -> shows n " users contributing"
+    , ""
+    , "E-mail addresses:"
+    ] ++ emailAddrs
+    where
+      total = sum $ Map.elems $ csTopicCount allStats
+      showCommentStat (cid, n) =
+          concat ["  ", T.unpack $ commentId cid, " ", show n]
+      commentLines = map showCommentStat $ Map.toList $ csTopicCount allStats
+      indent = showString "  "
+      emailAddrs = map (indent . T.unpack) $ Set.toList $ csEmails allStats
diff --git a/src/Server.hs b/src/Server.hs
new file mode 100644
--- /dev/null
+++ b/src/Server.hs
@@ -0,0 +1,111 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Server
+    ( ServerConfig(..)
+    , emptyServerConfig
+    , commandLineConfig
+    , server
+    , quickServer
+    ) where
+import qualified Data.ByteString.Char8 as B
+import           Data.ByteString.Char8 (ByteString)
+import           Data.Char
+import           Control.Concurrent
+import           Control.Exception (SomeException)
+import           Control.Monad.CatchIO
+import qualified Data.Text as T
+import           Prelude hiding (catch)
+import           Snap.Http.Server
+import           Snap.Types
+import           Snap.Util.GZip
+import           System hiding (getEnv)
+import           System.Posix.Env
+import qualified Text.XHtmlCombinators.Escape as XH
+
+
+data ServerConfig = ServerConfig
+    { locale          :: String
+    , interface       :: ByteString
+    , port            :: Int
+    , hostname        :: ByteString
+    , accessLog       :: Maybe FilePath
+    , errorLog        :: Maybe FilePath
+    , compression     :: Bool
+    , error500Handler :: SomeException -> Snap ()
+    }
+
+
+emptyServerConfig :: ServerConfig
+emptyServerConfig = ServerConfig
+    { locale          = "en_US"
+    , interface       = "0.0.0.0"
+    , port            = 8000
+    , hostname        = "myserver"
+    , accessLog       = Just "access.log"
+    , errorLog        = Just "error.log"
+    , compression     = True
+    , error500Handler = \e -> do
+        let t = T.pack $ show e
+            r = setContentType "text/html; charset=utf-8" $
+                setResponseStatus 500 "Internal Server Error" emptyResponse
+        putResponse r
+        writeBS "<html><head><title>Internal Server Error</title></head>"
+        writeBS "<body><h1>Internal Server Error</h1>"
+        writeBS "<p>A web handler threw an exception. Details:</p>"
+        writeBS "<pre>\n"
+        writeText $ XH.escape t
+        writeBS "\n</pre></body></html>"
+    }
+
+
+commandLineConfig :: IO ServerConfig
+commandLineConfig = do
+    args <- getArgs
+    let conf = case args of
+         []        -> emptyServerConfig
+         (port':_) -> emptyServerConfig { port = read port' }
+    locale' <- getEnv "LANG"
+    return $ case locale' of
+        Nothing -> conf
+        Just l  -> conf {locale = takeWhile (\c -> isAlpha c || c == '_') l}
+
+server :: ServerConfig -> Snap () -> IO ()
+server config handler = do
+    putStrLn $ "Listening on " ++ (B.unpack $ interface config)
+             ++ ":" ++ show (port config)
+    setUTF8Locale (locale config)
+    try $ httpServe
+             (interface config)
+             (port      config)
+             (hostname  config)
+             (accessLog config)
+             (errorLog  config)
+             (catch500 $ compress $ handler)
+             :: IO (Either SomeException ())
+    threadDelay 1000000
+    putStrLn "Shutting down"
+  where
+    catch500 = (`catch` (error500Handler config))
+    compress = if compression config then withCompression else id
+
+
+quickServer :: Snap () -> IO ()
+quickServer = (commandLineConfig >>=) . flip server
+
+
+setUTF8Locale :: String -> IO ()
+setUTF8Locale locale' = do
+    mapM_ (\k -> setEnv k (locale' ++ ".UTF-8") True)
+          [ "LANG"
+          , "LC_CTYPE"
+          , "LC_NUMERIC"
+          , "LC_TIME"
+          , "LC_COLLATE"
+          , "LC_MONETARY"
+          , "LC_MESSAGES"
+          , "LC_PAPER"
+          , "LC_NAME"
+          , "LC_ADDRESS"
+          , "LC_TELEPHONE"
+          , "LC_MEASUREMENT"
+          , "LC_IDENTIFICATION"
+          , "LC_ALL" ]
diff --git a/src/State/Disk.hs b/src/State/Disk.hs
new file mode 100644
--- /dev/null
+++ b/src/State/Disk.hs
@@ -0,0 +1,193 @@
+{- WARNING: this store implementation has race-conditions for
+   multi-threaded access! Its operations are also not atomic. Beware!
+ -}
+
+module State.Disk
+    ( new
+    )
+where
+
+import Control.Applicative   ( (<$>) )
+import Control.Arrow         ( second )
+import Control.Monad         ( when )
+import Data.Binary           ( encode, decode )
+import Data.Bits             ( (.&.), shiftR )
+import Data.Char             ( chr )
+import Data.Function         ( on )
+import Data.List             ( nub, sortBy )
+import Data.Maybe            ( fromMaybe, mapMaybe )
+import Data.Time.Clock.POSIX ( POSIXTime )
+import Data.Word             ( Word8 )
+import Numeric               ( readHex )
+import System.Directory      ( getDirectoryContents, createDirectoryIfMissing )
+import System.FilePath       ( (</>), (<.>) )
+import System.IO             ( withBinaryFile, IOMode(ReadMode, WriteMode) )
+import Network.URI           ( parseRelativeReference, URI )
+import qualified Data.ByteString      as BS
+import qualified Data.ByteString.Lazy as B
+import qualified Data.Text            as T
+import qualified Data.Text.Encoding   as E
+import qualified Data.Text.IO         as T
+
+import State.Types         ( State(..), CommentId, commentId , ChapterId
+                           , chapterId, mkCommentId, Comment(..)
+                           , SessionId(..), SessionInfo(..) )
+
+safe :: T.Text -> FilePath
+safe = safeBS . E.encodeUtf8
+
+-- Encode arbitrary text as a filename that is safe to use on a POSIX
+-- filesystem (no special characters)
+safeBS :: BS.ByteString -> FilePath
+safeBS = concatMap makeSafe . BS.unpack
+    where
+      -- Conservative set of characters that are allowed unescaped in
+      -- a filename
+      unescaped w = if or [ '0' |-| '9', 'a' |-| 'z', 'A' |-| 'Z' ]
+                    then Just c
+                    else Nothing
+          where
+            c = chr $ fromIntegral w
+            lo |-| hi = c >= lo && c <= hi
+
+      -- Keep characters that are allowed unescaped, and use '-' as an
+      -- escape sequence, followed by two hex digits for octets that
+      -- are out of range.
+      makeSafe :: Word8 -> [Char]
+      makeSafe c = maybe ('-':hex c) return $ unescaped c
+
+      -- Convert the Word8 to a pair of hex digits
+      hex c = map hDig [c `shiftR` 4, c .&. 0x0f]
+
+      -- Convert the four-bit value into a hex digit
+      hDig = ((['0'..'9'] ++ ['a'..'f']) !!) . fromIntegral
+
+decodeFileName :: FilePath -> T.Text
+decodeFileName = T.pack . go
+    where
+      go ('-':x:y:cs) = case readHex [x, y] of
+                          [(n, [])] -> chr n:go cs
+                          _ -> '-':go (x:y:cs)
+      go (c:cs) = c:go cs
+      go [] = []
+
+new :: FilePath -> IO State
+new storeDir =
+    do createDirectoryIfMissing True commentsDir
+       createDirectoryIfMissing True chaptersDir
+       createDirectoryIfMissing True sessionsDir
+       return $  State { findComments = findComments'
+
+                       , getCounts =
+                         \mChId -> do
+                           cIds <- case mChId of
+                                     Nothing -> getAllCommentIds
+                                     Just chId -> maybe [] id <$> readChapterFile chId
+                           cs <- readComments cIds
+                           return $ filter ((> 0) . snd) $ map (second length) cs
+
+                       , addComment =
+                         \cId chId c -> do
+                           case chId of
+                             Nothing -> return ()
+                             Just chap -> addChapter' chap [cId] (Nothing :: Maybe URI)
+                           cs <- findComments' cId
+                           writeCommentsFile cId (cs ++ [c])
+                           writeSession (cSession c) (cName c)
+                                            (cEmail c) (cDate c)
+
+                       , addChapter = addChapter'
+
+
+                       , getLastInfo =
+                         \sId -> fmap (\(n, e, _) -> SessionInfo n e)
+                                 `fmap` readSession sId
+
+                       , getChapterComments =
+                         \chId -> do
+                           mCIds <- readChapterFile chId
+                           let fixUp (cId, cs) = map ((,) cId) cs
+                               sortDateDesc = sortBy $ flip $
+                                              (compare `on` (cDate . snd))
+                           case mCIds of
+                             Nothing -> return []
+                             Just cIds -> sortDateDesc . concatMap fixUp <$>
+                                          readComments cIds
+
+                       , getChapterURI = getChapterURI'
+                       }
+    where
+      commentsDir = storeDir </> "comments"
+
+      commentPath cId = commentsDir </> safe (commentId cId)
+
+      readComments :: [CommentId] -> IO [(CommentId, [Comment])]
+      readComments cIds = zip cIds `fmap` mapM findComments' cIds
+
+      writeCommentsFile cId = B.writeFile (commentPath cId) . encode
+
+      findComments' cId = tryRead `catch` \_ -> return []
+          where
+            tryRead = withBinaryFile (commentPath cId) ReadMode $ \h ->
+                      do cs <- decode <$> B.hGetContents h
+                         length cs `seq` return cs
+
+      getAllCommentIds =
+          mapMaybe (mkCommentId . decodeFileName) . filter okId
+                       <$> getDirectoryContents commentsDir
+              where
+                okId = not . (`elem` [".", ".."])
+
+      chaptersDir = storeDir </> "chapters"
+
+      chapterPath chId = chaptersDir </> safe (chapterId chId)
+      chapterURIPath chId = chapterPath chId <.> "uri"
+
+      addChapter' chId cIds mURI = do
+        cIds' <- fromMaybe [] <$> readChapterFile chId
+        writeChapterFile chId (nub $ cIds ++ cIds')
+        case mURI of
+          Nothing -> return ()
+          Just u  -> T.writeFile (chapterURIPath chId) $ T.pack $ show u
+
+      getChapterURI' chId = tryRead `catch` \_ -> return Nothing
+          where
+            tryRead =
+                parseRelativeReference . T.unpack <$>
+                T.readFile (chapterURIPath chId)
+
+      readChapterFile chId = (Just `fmap` tryRead) `catch` \_ -> return Nothing
+          where
+            tryRead = do
+              content <- T.readFile $ chapterPath chId
+              return $ mapMaybe mkCommentId $ T.lines content
+
+      writeChapterFile chId =
+          T.writeFile (chapterPath chId) . T.unlines . map commentId
+
+      sessionsDir = storeDir </> "sessions"
+
+      sessionPath sid = sessionsDir </> safeBS (sidBS sid)
+
+      writeSession sid n e d = do
+        exists <- readSession sid
+        let overwrite = case exists of
+                          Nothing -> True
+                          Just (_, _, d') -> d > d'
+        when overwrite $
+             withBinaryFile (sessionPath sid) WriteMode $ \h ->
+                 B.hPut h $ encode ( E.encodeUtf8 n
+                                   , fmap E.encodeUtf8 e
+                                   , realToFrac d :: Double
+                                   )
+
+      readSession :: SessionId -> IO (Maybe (T.Text, Maybe T.Text, POSIXTime))
+      readSession sid = (Just `fmap` readOK) `catch` \_ -> return Nothing
+          where
+            readOK =
+                withBinaryFile (sessionPath sid) ReadMode $ \h -> do
+                  (nBS, eBS, dD) <- decode <$> B.hGetContents h
+                  return ( E.decodeUtf8 nBS
+                         , fmap E.decodeUtf8 eBS
+                         , realToFrac (dD :: Double)
+                         )
diff --git a/src/State/Logger.hs b/src/State/Logger.hs
new file mode 100644
--- /dev/null
+++ b/src/State/Logger.hs
@@ -0,0 +1,163 @@
+{-|
+  Module that logs all state modifying actions to a file so that they
+  can be reprocessed.
+ -}
+module State.Logger
+    ( wrap
+    , replay
+    , foldLogFile
+    , foldLogFile_
+    , foldComments
+    , foldMapComments
+    )
+where
+
+import Control.Monad ( forever )
+import System.IO.Error ( try )
+import System.IO ( stderr, openBinaryFile, hPutStrLn, hClose, hFlush
+                 , IOMode(AppendMode), Handle )
+import Data.ByteString.Lazy as B
+import Data.Binary.Get ( runGetState )
+import Data.Binary ( Binary(..), getWord8, putWord8, encode )
+import Control.Applicative ( (<$>), (<*>), pure )
+import Control.Concurrent.MVar ( MVar, newMVar, modifyMVar )
+import Control.Concurrent ( forkIO, threadDelay )
+import Data.Monoid ( mempty, mappend, Monoid )
+import Network.URI ( URI, parseRelativeReference )
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+
+import State.Types
+
+data Action = AddComment CommentId (Maybe ChapterId) Comment
+            | AddChapter ChapterId [CommentId] (Maybe URI)
+              deriving (Eq, Show)
+
+instance Binary Action where
+    get = do
+      ty <- getWord8
+      case ty of
+        0xfe -> AddComment <$> get <*> get <*> get
+        0xff -> AddChapter <$> get <*> get <*> pure Nothing
+        0xfd -> AddChapter <$> get <*> get <*> (parseRelativeReference <$> get)
+        _    -> error $ "Bad type code: " ++ show ty
+
+    put (AddComment cId mChId c) =
+        do putWord8 0xfe
+           put cId
+           put mChId
+           put c
+    put (AddChapter chId cs Nothing) =
+        do putWord8 0xff
+           put chId
+           put cs
+    put (AddChapter chId cs (Just uri)) =
+        do putWord8 0xfd
+           put chId
+           put cs
+           put $ T.encodeUtf8 $ T.pack $ show uri
+
+-- Attempt to replay a log file, skipping corrupted sections of the file
+foldLogFile :: Binary act => (act -> a -> IO a) -> a -> B.ByteString -> IO a
+foldLogFile processAction initial = go initial
+    where
+      go x bs | B.null bs = return x
+      go x bs = uncurry go =<< (step x bs `catch` \_ -> return (x, B.drop 1 bs))
+
+      step x bs = do
+        let (act, bs', _) = runGetState get bs 0
+        x' <- processAction act x
+        return (x', bs')
+
+foldLogFile_ :: Binary act => (act -> IO ()) -> B.ByteString -> IO ()
+foldLogFile_ f = foldLogFile (const . f) ()
+
+replay :: State -> B.ByteString -> IO ()
+replay st =
+    foldLogFile_ $ \act ->
+    case act of
+      AddComment cId mChId c  -> addComment st cId mChId c
+      AddChapter chId cs mURI -> addChapter st chId cs mURI
+
+-- | Strict fold over the comments in the log file
+foldComments :: (CommentId -> Maybe ChapterId -> Comment -> b -> b) -> b
+             -> B.ByteString -> IO b
+foldComments f =
+    foldLogFile $ \act ->
+    case act of
+      AddChapter _ _ _       -> return
+      AddComment cId mChId c -> \x -> let x' = f cId mChId c x
+                                      in  x' `seq` return x'
+
+foldMapComments :: Monoid b => (CommentId -> Maybe ChapterId -> Comment -> b)
+                -> B.ByteString -> IO b
+foldMapComments f =
+    foldComments (\cId mChId c -> (f cId mChId c `mappend`)) mempty
+
+-- |Wrap a store so that all of its modifying operations are logged to
+-- a file. The file can later be replayed to restore the state.
+wrap :: FilePath -> State -> IO State
+wrap logFileName st = do
+  ref <- newMVar Nothing
+  forkIO $ rotateLog ref
+  return st { addComment = \cId mChId c -> do
+                             writeLog logFileName ref $ AddComment cId mChId c
+                             addComment st cId mChId c
+
+            , addChapter = \chId cs mURI -> do
+                             writeLog logFileName ref $ AddChapter chId cs mURI
+                             addChapter st chId cs mURI
+            }
+
+-- |Attempt to close the log file every five minutes so that we can
+-- open new files and make sure that the entries are being written to
+-- a mapped disk file
+rotateLog :: MVar (Maybe Handle) -> IO ()
+rotateLog v = forever $ do
+                modifyMVar v $ \mh -> do
+                          hPutStrLn stderr "Rotating"
+                          maybe (return ()) closeLog mh
+                          return (Nothing, ())
+
+                -- reopen the log file every five minutes
+                threadDelay $ 1000000 * 600
+
+    where
+      closeLog h = hClose h `catch` putErr "Failed to close log handle"
+
+-- Write any errors to stderr so that we have a record of them
+-- somewhere
+putErr :: String -> IOError -> IO ()
+putErr msg e = hPutStrLn stderr $ msg ++ ": " ++ show e
+
+-- |Write a log entry to a file, opening the file for append if
+-- necessary
+writeLog :: FilePath -> MVar (Maybe Handle) -> Action -> IO ()
+writeLog fn v act =
+    modifyMVar v $ \mh -> do
+      mh' <- getHandle mh >>= writeEntry
+      return (mh', ())
+
+    where
+      -- If we don't have a handle, try to open one
+      getHandle (Just h) = return $ Just h
+      getHandle Nothing  = do
+        res <- try $ openBinaryFile fn AppendMode
+        case res of
+          Right h -> return $ Just h
+          Left e  -> do putErr "Failed to open log file" e
+                        return Nothing
+
+      -- Try to write the log entry, but don't raise an exception if
+      -- we can't, so that we have the best chance of actually saving
+      -- the data
+      writeEntry Nothing  = return Nothing
+      writeEntry (Just h) = do
+        res <- try $ do
+                 B.hPut h (encode act)
+                 hFlush h
+        case res of
+          Right () -> return $ Just h
+          Left e   -> do putErr "Failed to write log entry" e
+                         hClose h `catch` putErr "Failed to close log file"
+                         return Nothing
diff --git a/src/State/Mem.hs b/src/State/Mem.hs
new file mode 100644
--- /dev/null
+++ b/src/State/Mem.hs
@@ -0,0 +1,118 @@
+module State.Mem
+    ( new
+    )
+where
+
+import State.Types
+
+import Data.Maybe ( listToMaybe, fromMaybe )
+import Data.Function ( on )
+import Data.List ( sortBy )
+import Control.Monad ( mplus )
+import qualified Data.Sequence as S
+import qualified Data.Set as Set
+import qualified Data.Map as Map
+import Data.Foldable ( toList )
+import Control.Concurrent.MVar ( newMVar, withMVar, modifyMVar_ )
+import Control.Arrow ( second, (***) )
+import Network.URI ( URI )
+
+--------------------------------------------------
+-- Stateful
+
+new :: IO State
+new = do
+  v <- newMVar emptyMemState
+  return $ State
+             { findComments = \cId        ->
+                              withMVar v $
+                              return . maybe [] toList . Map.lookup cId . cms
+
+             , getCounts    = \chId       ->
+                              withMVar v $ return . getCounts' chId
+
+             , addComment   = \cId chId c ->
+                              modifyMVar_ v $ return . addComment' cId chId c
+
+             , addChapter   = \chId cIds mURI ->
+                              modifyMVar_ v $ return . addChapter' chId cIds mURI
+
+             , getLastInfo  = \sid ->
+                              withMVar v $ return . getInfo' sid
+
+             , getChapterComments =
+               \chId -> withMVar v $ return . getChapterComments' chId
+
+             , getChapterURI = \chId -> withMVar v $ return . chapterURI chId
+             }
+
+--------------------------------------------------
+-- Pure
+
+data MemState = MemState { chs :: Map.Map ChapterId (Maybe URI, Set.Set CommentId)
+                         , cms :: Map.Map CommentId (S.Seq Comment)
+                         }
+
+chapterComments :: ChapterId -> MemState -> Set.Set CommentId
+chapterComments chId = maybe Set.empty snd . Map.lookup chId . chs
+
+chapterURI :: ChapterId -> MemState -> Maybe URI
+chapterURI chId st = fst =<< Map.lookup chId (chs st)
+
+withChapterComments :: (Set.Set CommentId -> Set.Set CommentId)
+                    -> ChapterId -> MemState -> MemState
+withChapterComments = withChapter . second
+
+withChapter :: ((Maybe URI, Set.Set CommentId) -> (Maybe URI, Set.Set CommentId))
+            -> ChapterId -> MemState -> MemState
+withChapter f chId st =
+    let upd x = case f $ fromMaybe (Nothing, Set.empty) x of
+                  (Nothing, s) | Set.null s -> Nothing
+                  p                         -> Just p
+    in st { chs = Map.alter upd chId $ chs st }
+
+emptyMemState :: MemState
+emptyMemState = MemState Map.empty Map.empty
+
+getInfo' :: SessionId -> MemState -> Maybe SessionInfo
+getInfo' sId =
+    fmap toInfo .
+    listToMaybe .
+    take 1 .
+    reverse .
+    sortBy (compare `on` cDate) .
+    filter ((== sId) . cSession) .
+    concatMap toList .
+    Map.elems .
+    cms
+    where
+      toInfo c = SessionInfo (cName c) (cEmail c)
+
+getCounts' :: Maybe ChapterId -> MemState -> [(CommentId, Int)]
+getCounts' mChId st =
+    map (second S.length) $ Map.toList $
+    case mChId of
+      Nothing   -> cms st
+      Just chId ->
+          let cIds = chapterComments chId st
+              p k _ =  k `Set.member` cIds
+          in Map.filterWithKey p (cms st)
+
+addChapter' :: ChapterId -> [CommentId] -> Maybe URI -> MemState -> MemState
+addChapter' chId cIds mURI =
+    withChapter ((mURI `mplus`) *** (Set.union $ Set.fromList cIds)) chId
+
+addComment' :: CommentId -> Maybe ChapterId -> Comment -> MemState -> MemState
+addComment' cId mChId c st =
+    maybe id (withChapterComments (Set.insert cId)) mChId $
+    st { cms = Map.alter (addSeq c) cId $ cms st }
+    where
+      addSeq x = return . (S.|> x) . maybe S.empty id
+
+getChapterComments' :: ChapterId -> MemState -> [(CommentId, Comment)]
+getChapterComments' chId st =
+    let sortDateDesc       = sortBy $ flip (compare `on` (cDate . snd))
+        lookupComments cId = maybe [] (map ((,) cId) . toList) $
+                             Map.lookup cId $ cms st
+    in sortDateDesc $ concatMap lookupComments $
+       toList $ chapterComments chId st
diff --git a/src/State/SQLite.hs b/src/State/SQLite.hs
new file mode 100644
--- /dev/null
+++ b/src/State/SQLite.hs
@@ -0,0 +1,291 @@
+{-| Comment storage in SQLite -}
+module State.SQLite
+    ( new
+    )
+where
+
+import Network.URI ( URI, parseRelativeReference )
+import Data.Maybe ( listToMaybe, maybeToList )
+import Data.List ( intercalate )
+import System.IO ( hPutStrLn, stderr )
+import qualified Data.Text.Encoding as E
+import qualified Data.Text as T
+import Database.SQLite ( openConnection
+                       , SQLiteHandle
+                       , execParamStatement
+                       , execStatement_
+                       , execParamStatement_
+                       , execStatement
+                       , defineTableOpt
+                       )
+import Control.Exception ( onException )
+import qualified Database.SQLite as Q
+import State.Types         ( State(..), CommentId, commentId , ChapterId
+                           , chapterId, mkCommentId, Comment(..)
+                           , SessionId(..), SessionInfo(..) )
+import Control.Monad ( forM_, when, (<=<) )
+
+-- |Open a new SQLite comment store
+new :: FilePath -> IO State
+new dbName = do
+  hdl <- openConnection dbName
+  checkSchema hdl
+  return $ State { findComments = findComments' hdl
+                 , getCounts    = getCounts' hdl
+                 , addComment   = addComment' hdl
+                 , addChapter   = addChapter' hdl
+                 , getLastInfo  = getLastInfo' hdl
+                 , getChapterComments = getChapterComments' hdl
+                 , getChapterURI = getChapterURI' hdl
+                 }
+
+handleDefault :: b -> String -> Either String a -> (a -> IO b) -> IO b
+handleDefault d msg (Left err) _ = do
+  hPutStrLn stderr $ "Error " ++ msg ++ ": " ++ err
+  return d
+handleDefault _ _ (Right x) f = f x
+
+getLastInfo' :: SQLiteHandle -> SessionId -> IO (Maybe SessionInfo)
+getLastInfo' hdl sid = do
+  let sql = "SELECT name, email \
+            \FROM comments \
+            \WHERE session_id = :session_id \
+            \ORDER BY date DESC \
+            \LIMIT 1"
+      binder = [(":session_id", Q.Blob $ sidBS sid)]
+  res <- execParamStatement hdl sql binder
+  let toInfo [(_, Q.Blob n), (_, ef)] =
+          SessionInfo (E.decodeUtf8 n) `fmap` loadEmail ef
+      toInfo _ = []
+  handleDefault Nothing "loading info" res $
+                return . listToMaybe . concatMap toInfo . concat
+
+loadEmail :: Q.Value -> [Maybe T.Text]
+loadEmail Q.Null     = return Nothing
+loadEmail (Q.Blob s) = return $ Just $ E.decodeUtf8 s
+loadEmail _          = []
+
+-- Load all of the comments for a given comment id
+findComments' :: SQLiteHandle -> CommentId -> IO [Comment]
+findComments' hdl cId = do
+  let commentIdBlob = txtBlob $ commentId cId
+      sql = "SELECT name, comment, email, date, session_id \
+            \FROM comments WHERE comment_id = :comment_id"
+      binder = [(":comment_id", commentIdBlob)]
+  res <- execParamStatement hdl sql binder
+  let toComment [(_, Q.Blob n), (_, Q.Blob c), (_, ef), (_, Q.Double i), (_, Q.Blob sid)] =
+          do e <- loadEmail ef
+             let d = realToFrac i
+             [Comment (E.decodeUtf8 n) (E.decodeUtf8 c) e d (SessionId sid)]
+      toComment _ = []
+  handleDefault [] "loading comments" res $
+                return . concatMap toComment . concat
+
+-- Load the comment counts for the current chapter, or all counts if
+-- no chapter is supplied
+getCounts' :: SQLiteHandle -> Maybe ChapterId -> IO [(CommentId, Int)]
+getCounts' hdl mChId = do
+  res <- case mChId of
+           Nothing ->
+               let sql = "SELECT comment_id, COUNT(comment_id) \
+                         \FROM comments GROUP BY comment_id"
+               in execStatement hdl sql
+           Just chId ->
+               let sql = "SELECT comments.comment_id, \
+                         \       COUNT(comments.comment_id) \
+                         \FROM comments JOIN chapters \
+                         \ON comments.comment_id == chapters.comment_id \
+                         \WHERE chapters.chapter_id = :chapter_id \
+                         \      AND chapters.chapter_id IS NOT NULL \
+                         \GROUP BY comments.comment_id"
+                   chIdBlob = txtBlob $ chapterId chId
+                   binder = [(":chapter_id", chIdBlob)]
+               in execParamStatement hdl sql binder
+
+  let convertRow [(_, Q.Blob cIdBS), (_, Q.Int cnt)] =
+          case mkCommentId $ E.decodeUtf8 cIdBS of
+            Just cId | cnt > 0 -> [(cId, fromIntegral cnt)]
+            _ -> []
+      convertRow _ = []
+  handleDefault [] "getting counts" res $
+                return . concatMap convertRow . concat
+
+-- Add the comment, possibly associating it with a chapter
+addComment' :: SQLiteHandle -> CommentId -> Maybe ChapterId -> Comment -> IO ()
+addComment' hdl cId mChId c =
+    txn hdl $ do
+      case mChId of
+        Nothing   -> return ()
+        Just chId -> insertChapterComment cId chId hdl
+
+      insertComment cId c hdl
+
+-- Insert a row that adds a comment for the given comment id
+insertComment :: CommentId -> Comment -> SQLiteHandle -> IO ()
+insertComment cId c hdl = do
+  maybeErr =<< insertRowVal hdl "comments"
+             [ ("comment_id", txtBlob $ commentId cId)
+             , ("name", txtBlob $ cName c)
+             , ("comment", txtBlob $ cComment c)
+             , ("email", maybe Q.Null txtBlob $ cEmail c)
+             , ("date", Q.Double $ realToFrac $ cDate c)
+             , ("session_id", Q.Blob $ sidBS $ cSession c)
+             ]
+
+-- Insert a row that maps a comment id with a chapter
+insertChapterComment :: CommentId -> ChapterId -> SQLiteHandle -> IO ()
+insertChapterComment cId chId hdl = do
+  let sql = "SELECT 1 FROM chapters \
+            \WHERE chapter_id = :chId AND comment_id = :cId"
+      binder = [ (":chId", txtBlob $ chapterId chId)
+               , (":cId", txtBlob $ commentId cId)
+               ]
+  res <- execParamStatement hdl sql binder
+  case (res :: Either String [[Q.Row ()]]) of
+    Left err -> error err
+    Right rs -> when (null $ concat rs) $
+                maybeErr =<< insertRowVal hdl "chapters"
+                [ ("chapter_id", txtBlob $ chapterId chId)
+                , ("comment_id", txtBlob $ commentId cId)
+                ]
+
+addChapter' :: SQLiteHandle -> ChapterId -> [CommentId] -> Maybe URI -> IO ()
+addChapter' hdl chId cIds mURI =
+    txn hdl $ do
+      forM_ cIds $ \cId -> insertChapterComment cId chId hdl
+      case mURI of
+        Nothing -> return ()
+        Just uri ->
+            do let sql = "INSERT OR REPLACE INTO \
+                         \    chapter_info (chapter_id, url) \
+                         \VALUES (:chId, :url)"
+                   binder = [ (":chId", txtBlob $ chapterId chId)
+                            , (":url", txtBlob $ T.pack $ show uri)
+                            ]
+               maybeErr =<< execParamStatement_ hdl sql binder
+
+getChapterURI' :: SQLiteHandle -> ChapterId -> IO (Maybe URI)
+getChapterURI' hdl chId = do
+  let sql = "SELECT url FROM chapter_info WHERE chapter_id = :chId"
+      binder = [(":chId", txtBlob $ chapterId chId)]
+      convertRow [(_, Q.Blob u)] =
+          maybeToList $ parseRelativeReference $ T.unpack $ E.decodeUtf8 u
+      convertRow _ = []
+  res <- execParamStatement hdl sql binder
+  handleDefault Nothing "getting URI" res $
+                return . listToMaybe . concatMap convertRow . concat
+
+-- If the tables we expect are not there, add them (to deal with the
+-- case that this is a new database)
+--
+-- This can cause problems if the schema changes, since it does not
+-- actually check to see that the schema matches what we expect.
+checkSchema :: SQLiteHandle -> IO ()
+checkSchema hdl =
+    txn hdl $ do
+      mapM_ (maybeErr <=< defineTableOpt hdl True)
+                [ t "comments"
+                        [ tCol "comment_id"
+                        , tCol "name"
+                        , tCol "comment"
+                        , tColN "email" []
+                        , Q.Column "date" real [notNull]
+                        , tCol "session_id"
+                        ]
+                , t "chapters" [ tCol "chapter_id"
+                               , tCol "comment_id"
+                               ]
+                , t "chapter_info" [ tColN "chapter_id" [ notNull
+                                                        , Q.PrimaryKey False
+                                                        ]
+                                   , tColN "url" []
+                                   ]
+                ]
+      mapM_ (maybeErr <=< execStatement_ hdl)
+                [ "CREATE INDEX IF NOT EXISTS comments_comment_id_idx \
+                  \ON comments(comment_id)"
+                , "CREATE INDEX IF NOT EXISTS chapters_comment_id_idx \
+                  \ON chapters(comment_id)"
+                , "CREATE INDEX IF NOT EXISTS chapters_chapter_id_idx \
+                  \ON chapters(chapter_id)"
+                , "CREATE INDEX IF NOT EXISTS comments_date_idx \
+                  \ON comments(date)"
+                , "CREATE INDEX IF NOT EXISTS comments_session_id \
+                  \ON comments(session_id)"
+                , "CREATE INDEX IF NOT EXISTS chapter_info_chapter_id \
+                  \ON chapters(chapter_id)"
+                ]
+    where
+      real = Q.SQLFloat Nothing Nothing
+      tColN c cs = Q.Column c txt cs
+      notNull = Q.IsNullable False
+      tCol c = tColN c [notNull]
+      t n cs = Q.Table n cs []
+      txt = Q.SQLBlob $ Q.NormalBlob Nothing
+
+getChapterComments' :: SQLiteHandle -> ChapterId -> IO [(CommentId, Comment)]
+getChapterComments' hdl chId = do
+  let sql = "SELECT comments.comment_id, name, comment, \
+            \       email, date, session_id \
+            \FROM comments JOIN chapters \
+            \              ON comments.comment_id = chapters.comment_id \
+            \WHERE chapters.chapter_id = :chapter_id \
+            \ORDER BY comments.date DESC"
+      binder = [(":chapter_id", txtBlob $ chapterId chId)]
+  res <- execParamStatement hdl sql binder
+  let toResult [ (_, Q.Blob cIdB)
+               , (_, Q.Blob n)
+               , (_, Q.Blob c)
+               , (_, ef)
+               , (_, Q.Double i)
+               , (_, Q.Blob sid)
+               ] = do
+        e <- loadEmail ef
+        cId <- maybeToList $ mkCommentId $ E.decodeUtf8 cIdB
+        let d = realToFrac i
+            name = E.decodeUtf8 n
+            commentTxt = E.decodeUtf8 c
+            sId = SessionId sid
+            comment = Comment name commentTxt e d sId
+        [(cId, comment)]
+      toResult _ = []
+  handleDefault [] "loading comments for chapter" res $
+                return . concatMap toResult . concat
+
+--------------------------------------------------
+-- Helpers
+
+-- Convert a Data.Text.Text value to a UTF-8-encoded Blob. We use
+-- Blobs to avoid dealing with database encodings (the application
+-- does all of the encoding and decoding)
+txtBlob :: T.Text -> Q.Value
+txtBlob = Q.Blob . E.encodeUtf8
+
+-- If there is a string in the value, throw an error. Otherwise, do
+-- nothing.
+maybeErr :: Maybe String -> IO ()
+maybeErr Nothing = return ()
+maybeErr (Just e) = error e
+
+-- Perform the supplied action inside of a database transaction
+txn :: SQLiteHandle -> IO () -> IO ()
+txn hdl act = do
+  let execSql sql = maybeErr =<< execStatement_ hdl sql
+  execSql "BEGIN"
+  act `onException` (execSql "ROLLBACK" >> return ())
+  execSql "COMMIT"
+  return ()
+
+-- Insert a row into a table, using type Value for the values
+insertRowVal :: SQLiteHandle -> Q.TableName -> Q.Row Q.Value
+             -> IO (Maybe String)
+insertRowVal hdl tbl r = execParamStatement_ hdl sql row
+    where
+      sql = "INSERT INTO " ++ tbl ++ cols ++ " VALUES " ++ vals
+      tupled l = '(':(intercalate "," l ++ ")")
+      cols = tupled $ map fst r
+      vals = tupled valNames
+      valNames = take (length r) $
+                 map (showString ":val") $
+                 map show [(1::Integer)..]
+      row = zip valNames $ map snd r
diff --git a/src/State/Types.hs b/src/State/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/State/Types.hs
@@ -0,0 +1,91 @@
+{-# LANGUAGE OverloadedStrings #-}
+module State.Types
+    ( Comment(..)
+
+    , ChapterId
+    , chapterId
+    , mkChapterId
+
+    , CommentId
+    , commentId
+    , mkCommentId
+
+    , State(..)
+    , SessionId(..)
+    , SessionInfo(..)
+    )
+where
+
+import Control.Applicative ( (<$>), (<*>) )
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as E
+import Data.Binary ( Binary(..), Get )
+import Data.Time.Clock.POSIX ( POSIXTime )
+import qualified Data.ByteString as B
+import Network.URI ( URI )
+
+newtype CommentId = CommentId { commentId :: T.Text } deriving (Ord, Eq, Show)
+instance Binary CommentId where
+    get = CommentId . E.decodeUtf8 <$> get
+    put = put . E.encodeUtf8 . commentId
+
+newtype ChapterId = ChapterId { chapterId :: T.Text } deriving (Ord, Eq, Show)
+instance Binary ChapterId where
+    get = ChapterId . E.decodeUtf8 <$> get
+    put = put . E.encodeUtf8 . chapterId
+
+data SessionInfo = SessionInfo { siName :: !T.Text
+                               , siEmail :: !(Maybe T.Text)
+                               } deriving (Eq, Show)
+
+data State =
+    State
+    { findComments       :: CommentId -> IO [Comment]
+    , getCounts          :: Maybe ChapterId -> IO [(CommentId, Int)]
+    , getLastInfo        :: SessionId -> IO (Maybe SessionInfo)
+    , getChapterComments :: ChapterId -> IO [(CommentId, Comment)]
+    , getChapterURI      :: ChapterId -> IO (Maybe URI)
+
+    , addComment         :: CommentId -> Maybe ChapterId -> Comment -> IO ()
+    , addChapter         :: ChapterId -> [CommentId] -> Maybe URI -> IO ()
+    }
+
+data Comment =
+    Comment
+    { cName    :: !T.Text
+    , cComment :: !T.Text
+    , cEmail   :: Maybe T.Text
+    , cDate    :: !POSIXTime
+    , cSession :: !SessionId
+    } deriving (Show, Eq)
+
+newtype SessionId =
+    SessionId { sidBS :: B.ByteString }
+    deriving ( Eq, Show, Ord )
+
+instance Binary Comment where
+    put (Comment name comment email date sid) =
+        do putU8 name
+           putU8 comment
+           put (E.encodeUtf8 <$> email)
+           put (realToFrac date :: Double)
+           put $ sidBS sid
+        where putU8 = put . E.encodeUtf8
+
+    get = Comment
+          <$> getU8
+          <*> getU8
+          <*> (fmap E.decodeUtf8 <$> get)
+          <*> fmap realToFrac (get :: Get Double)
+          <*> fmap SessionId get
+        where getU8 = E.decodeUtf8 <$> get
+
+-- These functions allow any text content as comment and chapter ids,
+-- but we may want to be able to restrict these to allow only things
+-- that could appear in the wild (or at least some length
+-- restrictions).
+mkCommentId :: T.Text -> Maybe CommentId
+mkCommentId = Just . CommentId
+
+mkChapterId :: T.Text -> Maybe ChapterId
+mkChapterId = Just . ChapterId
diff --git a/static/comments.css b/static/comments.css
new file mode 100644
--- /dev/null
+++ b/static/comments.css
@@ -0,0 +1,38 @@
+div.toggle { display: inline; }
+div.comment {
+    border: 1px solid #eef;
+    background-color: #eef;
+    padding: 0.25em;
+    margin-bottom: 0.25em;
+    margin-left: 1em;
+}
+div.comment.mine {
+    border: 2px solid #33f;
+    background-color: #eef;
+}
+div.comment.mine .username {
+    font-style: bold;
+}
+div.comment-text {
+    white-space: pre-line;
+}
+form.comment {
+    margin-left: 1em;
+}
+div.username {
+    margin-bottom: 0.5em;
+}
+.mine div.username {
+    font-weight: bold;
+}
+span.comment_error {
+    color: red;
+}
+span.date {
+    font-size: smaller;
+    color: #999;
+}
+.comment th {
+    text-align: right;
+    vertical-align: top;
+}
diff --git a/static/default.css b/static/default.css
new file mode 100644
--- /dev/null
+++ b/static/default.css
@@ -0,0 +1,12 @@
+div.metadata {
+    font-size: smaller;
+}
+div.metadata p {
+    margin: 0;
+}
+div.disclaimer {
+    font-size: smaller;
+    margin-top: 5em;
+    font-style: italic;
+    border-top: 2px solid #bbb;
+}
diff --git a/static/support/icons/throbber.gif b/static/support/icons/throbber.gif
new file mode 100644
Binary files /dev/null and b/static/support/icons/throbber.gif differ
diff --git a/static/support/javascript/form.js b/static/support/javascript/form.js
new file mode 100644
--- /dev/null
+++ b/static/support/javascript/form.js
@@ -0,0 +1,819 @@
+/*
+ * jQuery Form Plugin
+ * @requires jQuery v1.1 or later
+ *
+ * Examples at: http://malsup.com/jquery/form/
+ * Dual licensed under the MIT and GPL licenses:
+ *   http://www.opensource.org/licenses/mit-license.php
+ *   http://www.gnu.org/licenses/gpl.html
+ *
+ * Revision: $Id$
+ */
+ (function($) {
+/**
+ * ajaxSubmit() provides a mechanism for submitting an HTML form using AJAX.
+ *
+ * ajaxSubmit accepts a single argument which can be either a success callback function
+ * or an options Object.  If a function is provided it will be invoked upon successful
+ * completion of the submit and will be passed the response from the server.
+ * If an options Object is provided, the following attributes are supported:
+ *
+ *  target:   Identifies the element(s) in the page to be updated with the server response.
+ *            This value may be specified as a jQuery selection string, a jQuery object,
+ *            or a DOM element.
+ *            default value: null
+ *
+ *  url:      URL to which the form data will be submitted.
+ *            default value: value of form's 'action' attribute
+ *
+ *  type:     The method in which the form data should be submitted, 'GET' or 'POST'.
+ *            default value: value of form's 'method' attribute (or 'GET' if none found)
+ *
+ *  data:     Additional data to add to the request, specified as key/value pairs (see $.ajax).
+ *
+ *  beforeSubmit:  Callback method to be invoked before the form is submitted.
+ *            default value: null
+ *
+ *  success:  Callback method to be invoked after the form has been successfully submitted
+ *            and the response has been returned from the server
+ *            default value: null
+ *
+ *  dataType: Expected dataType of the response.  One of: null, 'xml', 'script', or 'json'
+ *            default value: null
+ *
+ *  semantic: Boolean flag indicating whether data must be submitted in semantic order (slower).
+ *            default value: false
+ *
+ *  resetForm: Boolean flag indicating whether the form should be reset if the submit is successful
+ *
+ *  clearForm: Boolean flag indicating whether the form should be cleared if the submit is successful
+ *
+ *
+ * The 'beforeSubmit' callback can be provided as a hook for running pre-submit logic or for
+ * validating the form data.  If the 'beforeSubmit' callback returns false then the form will
+ * not be submitted. The 'beforeSubmit' callback is invoked with three arguments: the form data
+ * in array format, the jQuery object, and the options object passed into ajaxSubmit.
+ * The form data array takes the following form:
+ *
+ *     [ { name: 'username', value: 'jresig' }, { name: 'password', value: 'secret' } ]
+ *
+ * If a 'success' callback method is provided it is invoked after the response has been returned
+ * from the server.  It is passed the responseText or responseXML value (depending on dataType).
+ * See jQuery.ajax for further details.
+ *
+ *
+ * The dataType option provides a means for specifying how the server response should be handled.
+ * This maps directly to the jQuery.httpData method.  The following values are supported:
+ *
+ *      'xml':    if dataType == 'xml' the server response is treated as XML and the 'success'
+ *                   callback method, if specified, will be passed the responseXML value
+ *      'json':   if dataType == 'json' the server response will be evaluted and passed to
+ *                   the 'success' callback, if specified
+ *      'script': if dataType == 'script' the server response is evaluated in the global context
+ *
+ *
+ * Note that it does not make sense to use both the 'target' and 'dataType' options.  If both
+ * are provided the target will be ignored.
+ *
+ * The semantic argument can be used to force form serialization in semantic order.
+ * This is normally true anyway, unless the form contains input elements of type='image'.
+ * If your form must be submitted with name/value pairs in semantic order and your form
+ * contains an input of type='image" then pass true for this arg, otherwise pass false
+ * (or nothing) to avoid the overhead for this logic.
+ *
+ *
+ * When used on its own, ajaxSubmit() is typically bound to a form's submit event like this:
+ *
+ * $("#form-id").submit(function() {
+ *     $(this).ajaxSubmit(options);
+ *     return false; // cancel conventional submit
+ * });
+ *
+ * When using ajaxForm(), however, this is done for you.
+ *
+ * @example
+ * $('#myForm').ajaxSubmit(function(data) {
+ *     alert('Form submit succeeded! Server returned: ' + data);
+ * });
+ * @desc Submit form and alert server response
+ *
+ *
+ * @example
+ * var options = {
+ *     target: '#myTargetDiv'
+ * };
+ * $('#myForm').ajaxSubmit(options);
+ * @desc Submit form and update page element with server response
+ *
+ *
+ * @example
+ * var options = {
+ *     success: function(responseText) {
+ *         alert(responseText);
+ *     }
+ * };
+ * $('#myForm').ajaxSubmit(options);
+ * @desc Submit form and alert the server response
+ *
+ *
+ * @example
+ * var options = {
+ *     beforeSubmit: function(formArray, jqForm) {
+ *         if (formArray.length == 0) {
+ *             alert('Please enter data.');
+ *             return false;
+ *         }
+ *     }
+ * };
+ * $('#myForm').ajaxSubmit(options);
+ * @desc Pre-submit validation which aborts the submit operation if form data is empty
+ *
+ *
+ * @example
+ * var options = {
+ *     url: myJsonUrl.php,
+ *     dataType: 'json',
+ *     success: function(data) {
+ *        // 'data' is an object representing the the evaluated json data
+ *     }
+ * };
+ * $('#myForm').ajaxSubmit(options);
+ * @desc json data returned and evaluated
+ *
+ *
+ * @example
+ * var options = {
+ *     url: myXmlUrl.php,
+ *     dataType: 'xml',
+ *     success: function(responseXML) {
+ *        // responseXML is XML document object
+ *        var data = $('myElement', responseXML).text();
+ *     }
+ * };
+ * $('#myForm').ajaxSubmit(options);
+ * @desc XML data returned from server
+ *
+ *
+ * @example
+ * var options = {
+ *     resetForm: true
+ * };
+ * $('#myForm').ajaxSubmit(options);
+ * @desc submit form and reset it if successful
+ *
+ * @example
+ * $('#myForm).submit(function() {
+ *    $(this).ajaxSubmit();
+ *    return false;
+ * });
+ * @desc Bind form's submit event to use ajaxSubmit
+ *
+ *
+ * @name ajaxSubmit
+ * @type jQuery
+ * @param options  object literal containing options which control the form submission process
+ * @cat Plugins/Form
+ * @return jQuery
+ */
+$.fn.ajaxSubmit = function(options) {
+    if (typeof options == 'function')
+        options = { success: options };
+
+    options = $.extend({
+        url:  this.attr('action') || window.location,
+        type: this.attr('method') || 'GET'
+    }, options || {});
+
+    // hook for manipulating the form data before it is extracted;
+    // convenient for use with rich editors like tinyMCE or FCKEditor
+    var veto = {};
+    $.event.trigger('form.pre.serialize', [this, options, veto]);
+    if (veto.veto) return this;
+
+    var a = this.formToArray(options.semantic);
+	if (options.data) {
+	    for (var n in options.data)
+	        a.push( { name: n, value: options.data[n] } );
+	}
+
+    // give pre-submit callback an opportunity to abort the submit
+    if (options.beforeSubmit && options.beforeSubmit(a, this, options) === false) return this;
+
+    // fire vetoable 'validate' event
+    $.event.trigger('form.submit.validate', [a, this, options, veto]);
+    if (veto.veto) return this;
+
+    var q = $.param(a);//.replace(/%20/g,'+');
+
+    if (options.type.toUpperCase() == 'GET') {
+        options.url += (options.url.indexOf('?') >= 0 ? '&' : '?') + q;
+        options.data = null;  // data is null for 'get'
+    }
+    else
+        options.data = q; // data is the query string for 'post'
+
+    var $form = this, callbacks = [];
+    if (options.resetForm) callbacks.push(function() { $form.resetForm(); });
+    if (options.clearForm) callbacks.push(function() { $form.clearForm(); });
+
+    // perform a load on the target only if dataType is not provided
+    if (!options.dataType && options.target) {
+        var oldSuccess = options.success || function(){};
+        callbacks.push(function(data) {
+            if (this.evalScripts)
+                $(options.target).attr("innerHTML", data).evalScripts().each(oldSuccess, arguments);
+            else // jQuery v1.1.4
+                $(options.target).html(data).each(oldSuccess, arguments);
+        });
+    }
+    else if (options.success)
+        callbacks.push(options.success);
+
+    options.success = function(data, status) {
+        for (var i=0, max=callbacks.length; i < max; i++)
+            callbacks[i](data, status, $form);
+    };
+
+    // are there files to upload?
+    var files = $('input:file', this).fieldValue();
+    var found = false;
+    for (var j=0; j < files.length; j++)
+        if (files[j])
+            found = true;
+
+    if (options.iframe || found) // options.iframe allows user to force iframe mode
+        fileUpload();
+    else
+        $.ajax(options);
+
+    // fire 'notify' event
+    $.event.trigger('form.submit.notify', [this, options]);
+    return this;
+
+
+    // private function for handling file uploads (hat tip to YAHOO!)
+    function fileUpload() {
+        var form = $form[0];
+        var opts = $.extend({}, $.ajaxSettings, options);
+
+        var id = 'jqFormIO' + $.fn.ajaxSubmit.counter++;
+        var $io = $('<iframe id="' + id + '" name="' + id + '" />');
+        var io = $io[0];
+        var op8 = $.browser.opera && window.opera.version() < 9;
+        if ($.browser.msie || op8) io.src = 'javascript:false;document.write("");';
+        $io.css({ position: 'absolute', top: '-1000px', left: '-1000px' });
+
+        var xhr = { // mock object
+            responseText: null,
+            responseXML: null,
+            status: 0,
+            statusText: 'n/a',
+            getAllResponseHeaders: function() {},
+            getResponseHeader: function() {},
+            setRequestHeader: function() {}
+        };
+
+        var g = opts.global;
+        // trigger ajax global events so that activity/block indicators work like normal
+        if (g && ! $.active++) $.event.trigger("ajaxStart");
+        if (g) $.event.trigger("ajaxSend", [xhr, opts]);
+
+        var cbInvoked = 0;
+        var timedOut = 0;
+
+        // take a breath so that pending repaints get some cpu time before the upload starts
+        setTimeout(function() {
+            $io.appendTo('body');
+            // jQuery's event binding doesn't work for iframe events in IE
+            io.attachEvent ? io.attachEvent('onload', cb) : io.addEventListener('load', cb, false);
+
+            // make sure form attrs are set
+            var encAttr = form.encoding ? 'encoding' : 'enctype';
+            var t = $form.attr('target');
+            $form.attr({
+                target:   id,
+                method:  'POST',
+                action:   opts.url
+            });
+            form[encAttr] = 'multipart/form-data';
+
+            // support timout
+            if (opts.timeout)
+                setTimeout(function() { timedOut = true; cb(); }, opts.timeout);
+
+            form.submit();
+            $form.attr('target', t); // reset target
+        }, 10);
+
+        function cb() {
+            if (cbInvoked++) return;
+
+            io.detachEvent ? io.detachEvent('onload', cb) : io.removeEventListener('load', cb, false);
+
+            var ok = true;
+            try {
+                if (timedOut) throw 'timeout';
+                // extract the server response from the iframe
+                var data, doc;
+                doc = io.contentWindow ? io.contentWindow.document : io.contentDocument ? io.contentDocument : io.document;
+                xhr.responseText = doc.body ? doc.body.innerHTML : null;
+                xhr.responseXML = doc.XMLDocument ? doc.XMLDocument : doc;
+
+                if (opts.dataType == 'json' || opts.dataType == 'script') {
+                    var ta = doc.getElementsByTagName('textarea')[0];
+                    data = ta ? ta.value : xhr.responseText;
+                    if (opts.dataType == 'json')
+                        eval("data = " + data);
+                    else
+                        $.globalEval(data);
+                }
+                else if (opts.dataType == 'xml') {
+                    data = xhr.responseXML;
+                    if (!data && xhr.responseText != null)
+                        data = toXml(xhr.responseText);
+                }
+                else {
+                    data = xhr.responseText;
+                }
+            }
+            catch(e){
+                ok = false;
+                $.handleError(opts, xhr, 'error', e);
+            }
+
+            // ordering of these callbacks/triggers is odd, but that's how $.ajax does it
+            if (ok) {
+                opts.success(data, 'success');
+                if (g) $.event.trigger("ajaxSuccess", [xhr, opts]);
+            }
+            if (g) $.event.trigger("ajaxComplete", [xhr, opts]);
+            if (g && ! --$.active) $.event.trigger("ajaxStop");
+            if (opts.complete) opts.complete(xhr, ok ? 'success' : 'error');
+
+            // clean up
+            setTimeout(function() {
+                $io.remove();
+                xhr.responseXML = null;
+            }, 100);
+        };
+
+        function toXml(s, doc) {
+            if (window.ActiveXObject) {
+                doc = new ActiveXObject('Microsoft.XMLDOM');
+                doc.async = 'false';
+                doc.loadXML(s);
+            }
+            else
+                doc = (new DOMParser()).parseFromString(s, 'text/xml');
+            return (doc && doc.documentElement && doc.documentElement.tagName != 'parsererror') ? doc : null;
+        };
+    };
+};
+$.fn.ajaxSubmit.counter = 0; // used to create unique iframe ids
+
+/**
+ * ajaxForm() provides a mechanism for fully automating form submission.
+ *
+ * The advantages of using this method instead of ajaxSubmit() are:
+ *
+ * 1: This method will include coordinates for <input type="image" /> elements (if the element
+ *    is used to submit the form).
+ * 2. This method will include the submit element's name/value data (for the element that was
+ *    used to submit the form).
+ * 3. This method binds the submit() method to the form for you.
+ *
+ * Note that for accurate x/y coordinates of image submit elements in all browsers
+ * you need to also use the "dimensions" plugin (this method will auto-detect its presence).
+ *
+ * The options argument for ajaxForm works exactly as it does for ajaxSubmit.  ajaxForm merely
+ * passes the options argument along after properly binding events for submit elements and
+ * the form itself.  See ajaxSubmit for a full description of the options argument.
+ *
+ *
+ * @example
+ * var options = {
+ *     target: '#myTargetDiv'
+ * };
+ * $('#myForm').ajaxSForm(options);
+ * @desc Bind form's submit event so that 'myTargetDiv' is updated with the server response
+ *       when the form is submitted.
+ *
+ *
+ * @example
+ * var options = {
+ *     success: function(responseText) {
+ *         alert(responseText);
+ *     }
+ * };
+ * $('#myForm').ajaxSubmit(options);
+ * @desc Bind form's submit event so that server response is alerted after the form is submitted.
+ *
+ *
+ * @example
+ * var options = {
+ *     beforeSubmit: function(formArray, jqForm) {
+ *         if (formArray.length == 0) {
+ *             alert('Please enter data.');
+ *             return false;
+ *         }
+ *     }
+ * };
+ * $('#myForm').ajaxSubmit(options);
+ * @desc Bind form's submit event so that pre-submit callback is invoked before the form
+ *       is submitted.
+ *
+ *
+ * @name   ajaxForm
+ * @param  options  object literal containing options which control the form submission process
+ * @return jQuery
+ * @cat    Plugins/Form
+ * @type   jQuery
+ */
+$.fn.ajaxForm = function(options) {
+    return this.ajaxFormUnbind().submit(submitHandler).each(function() {
+        // store options in hash
+        this.formPluginId = $.fn.ajaxForm.counter++;
+        $.fn.ajaxForm.optionHash[this.formPluginId] = options;
+        $(":submit,input:image", this).click(clickHandler);
+    });
+};
+
+$.fn.ajaxForm.counter = 1;
+$.fn.ajaxForm.optionHash = {};
+
+function clickHandler(e) {
+    var $form = this.form;
+    $form.clk = this;
+    if (this.type == 'image') {
+        if (e.offsetX != undefined) {
+            $form.clk_x = e.offsetX;
+            $form.clk_y = e.offsetY;
+        } else if (typeof $.fn.offset == 'function') { // try to use dimensions plugin
+            var offset = $(this).offset();
+            $form.clk_x = e.pageX - offset.left;
+            $form.clk_y = e.pageY - offset.top;
+        } else {
+            $form.clk_x = e.pageX - this.offsetLeft;
+            $form.clk_y = e.pageY - this.offsetTop;
+        }
+    }
+    // clear form vars
+    setTimeout(function() { $form.clk = $form.clk_x = $form.clk_y = null; }, 10);
+};
+
+function submitHandler() {
+    // retrieve options from hash
+    var id = this.formPluginId;
+    var options = $.fn.ajaxForm.optionHash[id];
+    $(this).ajaxSubmit(options);
+    return false;
+};
+
+/**
+ * ajaxFormUnbind unbinds the event handlers that were bound by ajaxForm
+ *
+ * @name   ajaxFormUnbind
+ * @return jQuery
+ * @cat    Plugins/Form
+ * @type   jQuery
+ */
+$.fn.ajaxFormUnbind = function() {
+    this.unbind('submit', submitHandler);
+    return this.each(function() {
+        $(":submit,input:image", this).unbind('click', clickHandler);
+    });
+
+};
+
+/**
+ * formToArray() gathers form element data into an array of objects that can
+ * be passed to any of the following ajax functions: $.get, $.post, or load.
+ * Each object in the array has both a 'name' and 'value' property.  An example of
+ * an array for a simple login form might be:
+ *
+ * [ { name: 'username', value: 'jresig' }, { name: 'password', value: 'secret' } ]
+ *
+ * It is this array that is passed to pre-submit callback functions provided to the
+ * ajaxSubmit() and ajaxForm() methods.
+ *
+ * The semantic argument can be used to force form serialization in semantic order.
+ * This is normally true anyway, unless the form contains input elements of type='image'.
+ * If your form must be submitted with name/value pairs in semantic order and your form
+ * contains an input of type='image" then pass true for this arg, otherwise pass false
+ * (or nothing) to avoid the overhead for this logic.
+ *
+ * @example var data = $("#myForm").formToArray();
+ * $.post( "myscript.cgi", data );
+ * @desc Collect all the data from a form and submit it to the server.
+ *
+ * @name formToArray
+ * @param semantic true if serialization must maintain strict semantic ordering of elements (slower)
+ * @type Array<Object>
+ * @cat Plugins/Form
+ */
+$.fn.formToArray = function(semantic) {
+    var a = [];
+    if (this.length == 0) return a;
+
+    var form = this[0];
+    var els = semantic ? form.getElementsByTagName('*') : form.elements;
+    if (!els) return a;
+    for(var i=0, max=els.length; i < max; i++) {
+        var el = els[i];
+        var n = el.name;
+        if (!n) continue;
+
+        if (semantic && form.clk && el.type == "image") {
+            // handle image inputs on the fly when semantic == true
+            if(!el.disabled && form.clk == el)
+                a.push({name: n+'.x', value: form.clk_x}, {name: n+'.y', value: form.clk_y});
+            continue;
+        }
+
+        var v = $.fieldValue(el, true);
+        if (v && v.constructor == Array) {
+            for(var j=0, jmax=v.length; j < jmax; j++)
+                a.push({name: n, value: v[j]});
+        }
+        else if (v !== null && typeof v != 'undefined')
+            a.push({name: n, value: v});
+    }
+
+    if (!semantic && form.clk) {
+        // input type=='image' are not found in elements array! handle them here
+        var inputs = form.getElementsByTagName("input");
+        for(var i=0, max=inputs.length; i < max; i++) {
+            var input = inputs[i];
+            var n = input.name;
+            if(n && !input.disabled && input.type == "image" && form.clk == input)
+                a.push({name: n+'.x', value: form.clk_x}, {name: n+'.y', value: form.clk_y});
+        }
+    }
+    return a;
+};
+
+
+/**
+ * Serializes form data into a 'submittable' string. This method will return a string
+ * in the format: name1=value1&amp;name2=value2
+ *
+ * The semantic argument can be used to force form serialization in semantic order.
+ * If your form must be submitted with name/value pairs in semantic order then pass
+ * true for this arg, otherwise pass false (or nothing) to avoid the overhead for
+ * this logic (which can be significant for very large forms).
+ *
+ * @example var data = $("#myForm").formSerialize();
+ * $.ajax('POST', "myscript.cgi", data);
+ * @desc Collect all the data from a form into a single string
+ *
+ * @name formSerialize
+ * @param semantic true if serialization must maintain strict semantic ordering of elements (slower)
+ * @type String
+ * @cat Plugins/Form
+ */
+$.fn.formSerialize = function(semantic) {
+    //hand off to jQuery.param for proper encoding
+    return $.param(this.formToArray(semantic));
+};
+
+
+/**
+ * Serializes all field elements in the jQuery object into a query string.
+ * This method will return a string in the format: name1=value1&amp;name2=value2
+ *
+ * The successful argument controls whether or not serialization is limited to
+ * 'successful' controls (per http://www.w3.org/TR/html4/interact/forms.html#successful-controls).
+ * The default value of the successful argument is true.
+ *
+ * @example var data = $("input").formSerialize();
+ * @desc Collect the data from all successful input elements into a query string
+ *
+ * @example var data = $(":radio").formSerialize();
+ * @desc Collect the data from all successful radio input elements into a query string
+ *
+ * @example var data = $("#myForm :checkbox").formSerialize();
+ * @desc Collect the data from all successful checkbox input elements in myForm into a query string
+ *
+ * @example var data = $("#myForm :checkbox").formSerialize(false);
+ * @desc Collect the data from all checkbox elements in myForm (even the unchecked ones) into a query string
+ *
+ * @example var data = $(":input").formSerialize();
+ * @desc Collect the data from all successful input, select, textarea and button elements into a query string
+ *
+ * @name fieldSerialize
+ * @param successful true if only successful controls should be serialized (default is true)
+ * @type String
+ * @cat Plugins/Form
+ */
+$.fn.fieldSerialize = function(successful) {
+    var a = [];
+    this.each(function() {
+        var n = this.name;
+        if (!n) return;
+        var v = $.fieldValue(this, successful);
+        if (v && v.constructor == Array) {
+            for (var i=0,max=v.length; i < max; i++)
+                a.push({name: n, value: v[i]});
+        }
+        else if (v !== null && typeof v != 'undefined')
+            a.push({name: this.name, value: v});
+    });
+    //hand off to jQuery.param for proper encoding
+    return $.param(a);
+};
+
+
+/**
+ * Returns the value(s) of the element in the matched set.  For example, consider the following form:
+ *
+ *  <form><fieldset>
+ *      <input name="A" type="text" />
+ *      <input name="A" type="text" />
+ *      <input name="B" type="checkbox" value="B1" />
+ *      <input name="B" type="checkbox" value="B2"/>
+ *      <input name="C" type="radio" value="C1" />
+ *      <input name="C" type="radio" value="C2" />
+ *  </fieldset></form>
+ *
+ *  var v = $(':text').fieldValue();
+ *  // if no values are entered into the text inputs
+ *  v == ['','']
+ *  // if values entered into the text inputs are 'foo' and 'bar'
+ *  v == ['foo','bar']
+ *
+ *  var v = $(':checkbox').fieldValue();
+ *  // if neither checkbox is checked
+ *  v === undefined
+ *  // if both checkboxes are checked
+ *  v == ['B1', 'B2']
+ *
+ *  var v = $(':radio').fieldValue();
+ *  // if neither radio is checked
+ *  v === undefined
+ *  // if first radio is checked
+ *  v == ['C1']
+ *
+ * The successful argument controls whether or not the field element must be 'successful'
+ * (per http://www.w3.org/TR/html4/interact/forms.html#successful-controls).
+ * The default value of the successful argument is true.  If this value is false the value(s)
+ * for each element is returned.
+ *
+ * Note: This method *always* returns an array.  If no valid value can be determined the
+ *       array will be empty, otherwise it will contain one or more values.
+ *
+ * @example var data = $("#myPasswordElement").fieldValue();
+ * alert(data[0]);
+ * @desc Alerts the current value of the myPasswordElement element
+ *
+ * @example var data = $("#myForm :input").fieldValue();
+ * @desc Get the value(s) of the form elements in myForm
+ *
+ * @example var data = $("#myForm :checkbox").fieldValue();
+ * @desc Get the value(s) for the successful checkbox element(s) in the jQuery object.
+ *
+ * @example var data = $("#mySingleSelect").fieldValue();
+ * @desc Get the value(s) of the select control
+ *
+ * @example var data = $(':text').fieldValue();
+ * @desc Get the value(s) of the text input or textarea elements
+ *
+ * @example var data = $("#myMultiSelect").fieldValue();
+ * @desc Get the values for the select-multiple control
+ *
+ * @name fieldValue
+ * @param Boolean successful true if only the values for successful controls should be returned (default is true)
+ * @type Array<String>
+ * @cat Plugins/Form
+ */
+$.fn.fieldValue = function(successful) {
+    for (var val=[], i=0, max=this.length; i < max; i++) {
+        var el = this[i];
+        var v = $.fieldValue(el, successful);
+        if (v === null || typeof v == 'undefined' || (v.constructor == Array && !v.length))
+            continue;
+        v.constructor == Array ? $.merge(val, v) : val.push(v);
+    }
+    return val;
+};
+
+/**
+ * Returns the value of the field element.
+ *
+ * The successful argument controls whether or not the field element must be 'successful'
+ * (per http://www.w3.org/TR/html4/interact/forms.html#successful-controls).
+ * The default value of the successful argument is true.  If the given element is not
+ * successful and the successful arg is not false then the returned value will be null.
+ *
+ * Note: If the successful flag is true (default) but the element is not successful, the return will be null
+ * Note: The value returned for a successful select-multiple element will always be an array.
+ * Note: If the element has no value the return value will be undefined.
+ *
+ * @example var data = jQuery.fieldValue($("#myPasswordElement")[0]);
+ * @desc Gets the current value of the myPasswordElement element
+ *
+ * @name fieldValue
+ * @param Element el The DOM element for which the value will be returned
+ * @param Boolean successful true if value returned must be for a successful controls (default is true)
+ * @type String or Array<String> or null or undefined
+ * @cat Plugins/Form
+ */
+$.fieldValue = function(el, successful) {
+    var n = el.name, t = el.type, tag = el.tagName.toLowerCase();
+    if (typeof successful == 'undefined') successful = true;
+
+    if (successful && (!n || el.disabled || t == 'reset' || t == 'button' ||
+        (t == 'checkbox' || t == 'radio') && !el.checked ||
+        (t == 'submit' || t == 'image') && el.form && el.form.clk != el ||
+        tag == 'select' && el.selectedIndex == -1))
+            return null;
+
+    if (tag == 'select') {
+        var index = el.selectedIndex;
+        if (index < 0) return null;
+        var a = [], ops = el.options;
+        var one = (t == 'select-one');
+        var max = (one ? index+1 : ops.length);
+        for(var i=(one ? index : 0); i < max; i++) {
+            var op = ops[i];
+            if (op.selected) {
+                // extra pain for IE...
+                var v = $.browser.msie && !(op.attributes['value'].specified) ? op.text : op.value;
+                if (one) return v;
+                a.push(v);
+            }
+        }
+        return a;
+    }
+    return el.value;
+};
+
+
+/**
+ * Clears the form data.  Takes the following actions on the form's input fields:
+ *  - input text fields will have their 'value' property set to the empty string
+ *  - select elements will have their 'selectedIndex' property set to -1
+ *  - checkbox and radio inputs will have their 'checked' property set to false
+ *  - inputs of type submit, button, reset, and hidden will *not* be effected
+ *  - button elements will *not* be effected
+ *
+ * @example $('form').clearForm();
+ * @desc Clears all forms on the page.
+ *
+ * @name clearForm
+ * @type jQuery
+ * @cat Plugins/Form
+ */
+$.fn.clearForm = function() {
+    return this.each(function() {
+        $('input,select,textarea', this).clearFields();
+    });
+};
+
+/**
+ * Clears the selected form elements.  Takes the following actions on the matched elements:
+ *  - input text fields will have their 'value' property set to the empty string
+ *  - select elements will have their 'selectedIndex' property set to -1
+ *  - checkbox and radio inputs will have their 'checked' property set to false
+ *  - inputs of type submit, button, reset, and hidden will *not* be effected
+ *  - button elements will *not* be effected
+ *
+ * @example $('.myInputs').clearFields();
+ * @desc Clears all inputs with class myInputs
+ *
+ * @name clearFields
+ * @type jQuery
+ * @cat Plugins/Form
+ */
+$.fn.clearFields = $.fn.clearInputs = function() {
+    return this.each(function() {
+        var t = this.type, tag = this.tagName.toLowerCase();
+        if (t == 'text' || t == 'password' || tag == 'textarea')
+            this.value = '';
+        else if (t == 'checkbox' || t == 'radio')
+            this.checked = false;
+        else if (tag == 'select')
+            this.selectedIndex = -1;
+    });
+};
+
+
+/**
+ * Resets the form data.  Causes all form elements to be reset to their original value.
+ *
+ * @example $('form').resetForm();
+ * @desc Resets all forms on the page.
+ *
+ * @name resetForm
+ * @type jQuery
+ * @cat Plugins/Form
+ */
+$.fn.resetForm = function() {
+    return this.each(function() {
+        // guard against an input with the name of 'reset'
+        // note that IE reports the reset function as an 'object'
+        if (typeof this.reset == 'function' || (typeof this.reset == 'object' && !this.reset.nodeType))
+            this.reset();
+    });
+};
+
+})(jQuery);
diff --git a/static/support/javascript/hsbook.js b/static/support/javascript/hsbook.js
new file mode 100644
--- /dev/null
+++ b/static/support/javascript/hsbook.js
@@ -0,0 +1,141 @@
+function qid(id) {
+  return id.replace(/([.:])/g, "\\$1");
+}
+
+function beforeComment(formData, jqForm, options) {
+    var form = jqForm[0];
+    clearStatus(options.target);
+    if (!form.comment.value) {
+        setError(options.target, "Your comment is empty");
+        return false;
+    }
+    if (!form.name.value) {
+        setError(options.target, "Please provide a name");
+        return false;
+    }
+    $(options.target + " span.comment_error").after(
+        "<img src=\"/support/icons/throbber.gif\" style=\"vertical-align: middle\"/>");
+    $(options.target + " input[@name=submit]").attr("disabled", true);
+    return true;
+}
+
+function clearStatus(target) {
+    $(target + " input[@name=submit]").attr("disabled", false);
+    $(target + " span.comment_error").empty().nextAll().remove();
+}
+
+function setError(target, errStr) {
+    $(target + " span.comment_error").append(errStr);
+}
+
+function ajaxifyForm(id) {
+  var q = qid(id);
+  var target = "#comments_" + q;
+  $("#form_" + q).ajaxForm({
+      beforeSubmit: beforeComment,
+
+      success: function() { ajaxifyForm(id); },
+
+      error: function (_req, typ, exc) {
+          clearStatus(target);
+          switch (typ) {
+          case 'error':
+              setError(target, "Server error. Try again soon.");
+              break;
+          case 'timeout':
+              setError(target, 'Request timed out. Try again in a few seconds.');
+              break;
+          default:
+              setError(target, 'An unknown error occurred.');
+              break;
+          }
+          return false;
+      },
+
+      target: target
+  });
+}
+
+function toggleComment(id) {
+  $("#toggle_" + qid(id)).nextAll().toggle();
+  return false;
+}
+
+function loadComments(id) {
+  $("#comments_" + qid(id)).load(location.protocol + "//" + location.host +
+				 "/comments/single/" + id + "/", function() {
+    ajaxifyForm(id);
+  });
+  return false;
+}
+
+function loadAllComments() {
+  $("a.commenttoggle").each(function() {
+    var id = $(this).attr("pid");
+    if (id) {
+      loadComments(id);
+    }
+  });
+}
+
+function getChapterId() {
+    return $("div.preface, div.chapter, div.appendix, div.bibliography").attr("id");
+}
+
+$(document).ready(function() {
+  function loading(id) {
+    return " <span id=\"comments_" + id + "\" class=\"comment\">" +
+      "<span pid=\"" + id + "\" class=\"commenttoggle\">Loading..." +
+      "</span></span>";
+  }
+  $("div.toc>p")
+    .after("<p style='display: none;'><a onclick='return loadAllComments()'>" +
+	   "Load all comments (<b>slow</b>)</a></p>")
+    .toggle(function() { $(this).nextAll().show("normal"); },
+	    function() { $(this).nextAll().hide("normal"); })
+    .hover(function() { $(this).fadeTo("normal", 0.8); },
+	   function() { $(this).fadeTo("normal", 0.35); });
+  $(".chapter p[@id]").each(function() {
+    $(this).append(loading($(this).attr("id")));
+  });
+  $(".chapter table[@id].equation").each(function() {
+    id = $(this).attr("id");
+    $("#" + id + " tr").after('<tr><td colspan="4">' + loading($(this).attr("id")) + '</td></tr>');
+  });
+  $(".chapter pre[@id]").each(function() {
+    $(this).after(loading($(this).attr("id")));
+  });
+  var chapid = getChapterId();
+  $("#chapterfeed").attr("href",
+			 $("#chapterfeed").attr("href") + chapid + "/");
+  $.getJSON(location.protocol + "//" + location.host + "/comments/chapter/" +
+	    chapid + "/count/", function(data) {
+    $.each(data, function(id, item) {
+      $("#comments_" + qid(id) + " span.commenttoggle").replaceWith(
+          commentLink(id, item);
+      );
+    });
+    $("span.commenttoggle").each(function() {
+      var id = $(this).attr("pid");
+      $(this).replaceWith(commentLink(id, 0));
+    });
+  });
+});
+
+function commentLink(commentId, numComments) {
+    var desc, act;
+    if (numComments == 0) {
+        desc = "No comments";
+        act = "comment: add";
+    } else if (numComments == 1) {
+        desc = "1 comment";
+        act = "comments: show/hide";
+    } else {
+        desc = numComments + " comments";
+        act = "comments: show/hide";
+    }
+    return "<a class='commenttoggle' id='toggle_" + commentId + "' " +
+	"pid='" + commentId + "' " +
+	"onclick='return loadComments(\"" + commentId + "\")' " +
+	"href='" + act + "'>" + desc + "</a>";
+}
diff --git a/static/support/javascript/jquery.js b/static/support/javascript/jquery.js
new file mode 100644
--- /dev/null
+++ b/static/support/javascript/jquery.js
@@ -0,0 +1,2992 @@
+(function(){
+/*
+ * jQuery 1.2.1 - New Wave Javascript
+ *
+ * Copyright (c) 2007 John Resig (jquery.com)
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ *
+ * $Date: 2007-09-16 23:42:06 -0400 (Sun, 16 Sep 2007) $
+ * $Rev: 3353 $
+ */
+
+// Map over jQuery in case of overwrite
+if ( typeof jQuery != "undefined" )
+	var _jQuery = jQuery;
+
+var jQuery = window.jQuery = function(selector, context) {
+	// If the context is a namespace object, return a new object
+	return this instanceof jQuery ?
+		this.init(selector, context) :
+		new jQuery(selector, context);
+};
+
+// Map over the $ in case of overwrite
+if ( typeof $ != "undefined" )
+	var _$ = $;
+	
+// Map the jQuery namespace to the '$' one
+window.$ = jQuery;
+
+var quickExpr = /^[^<]*(<(.|\s)+>)[^>]*$|^#(\w+)$/;
+
+jQuery.fn = jQuery.prototype = {
+	init: function(selector, context) {
+		// Make sure that a selection was provided
+		selector = selector || document;
+
+		// Handle HTML strings
+		if ( typeof selector  == "string" ) {
+			var m = quickExpr.exec(selector);
+			if ( m && (m[1] || !context) ) {
+				// HANDLE: $(html) -> $(array)
+				if ( m[1] )
+					selector = jQuery.clean( [ m[1] ], context );
+
+				// HANDLE: $("#id")
+				else {
+					var tmp = document.getElementById( m[3] );
+					if ( tmp )
+						// Handle the case where IE and Opera return items
+						// by name instead of ID
+						if ( tmp.id != m[3] )
+							return jQuery().find( selector );
+						else {
+							this[0] = tmp;
+							this.length = 1;
+							return this;
+						}
+					else
+						selector = [];
+				}
+
+			// HANDLE: $(expr)
+			} else
+				return new jQuery( context ).find( selector );
+
+		// HANDLE: $(function)
+		// Shortcut for document ready
+		} else if ( jQuery.isFunction(selector) )
+			return new jQuery(document)[ jQuery.fn.ready ? "ready" : "load" ]( selector );
+
+		return this.setArray(
+			// HANDLE: $(array)
+			selector.constructor == Array && selector ||
+
+			// HANDLE: $(arraylike)
+			// Watch for when an array-like object is passed as the selector
+			(selector.jquery || selector.length && selector != window && !selector.nodeType && selector[0] != undefined && selector[0].nodeType) && jQuery.makeArray( selector ) ||
+
+			// HANDLE: $(*)
+			[ selector ] );
+	},
+	
+	jquery: "1.2.1",
+
+	size: function() {
+		return this.length;
+	},
+	
+	length: 0,
+
+	get: function( num ) {
+		return num == undefined ?
+
+			// Return a 'clean' array
+			jQuery.makeArray( this ) :
+
+			// Return just the object
+			this[num];
+	},
+	
+	pushStack: function( a ) {
+		var ret = jQuery(a);
+		ret.prevObject = this;
+		return ret;
+	},
+	
+	setArray: function( a ) {
+		this.length = 0;
+		Array.prototype.push.apply( this, a );
+		return this;
+	},
+
+	each: function( fn, args ) {
+		return jQuery.each( this, fn, args );
+	},
+
+	index: function( obj ) {
+		var pos = -1;
+		this.each(function(i){
+			if ( this == obj ) pos = i;
+		});
+		return pos;
+	},
+
+	attr: function( key, value, type ) {
+		var obj = key;
+		
+		// Look for the case where we're accessing a style value
+		if ( key.constructor == String )
+			if ( value == undefined )
+				return this.length && jQuery[ type || "attr" ]( this[0], key ) || undefined;
+			else {
+				obj = {};
+				obj[ key ] = value;
+			}
+		
+		// Check to see if we're setting style values
+		return this.each(function(index){
+			// Set all the styles
+			for ( var prop in obj )
+				jQuery.attr(
+					type ? this.style : this,
+					prop, jQuery.prop(this, obj[prop], type, index, prop)
+				);
+		});
+	},
+
+	css: function( key, value ) {
+		return this.attr( key, value, "curCSS" );
+	},
+
+	text: function(e) {
+		if ( typeof e != "object" && e != null )
+			return this.empty().append( document.createTextNode( e ) );
+
+		var t = "";
+		jQuery.each( e || this, function(){
+			jQuery.each( this.childNodes, function(){
+				if ( this.nodeType != 8 )
+					t += this.nodeType != 1 ?
+						this.nodeValue : jQuery.fn.text([ this ]);
+			});
+		});
+		return t;
+	},
+
+	wrapAll: function(html) {
+		if ( this[0] )
+			// The elements to wrap the target around
+			jQuery(html, this[0].ownerDocument)
+				.clone()
+				.insertBefore(this[0])
+				.map(function(){
+					var elem = this;
+					while ( elem.firstChild )
+						elem = elem.firstChild;
+					return elem;
+				})
+				.append(this);
+
+		return this;
+	},
+
+	wrapInner: function(html) {
+		return this.each(function(){
+			jQuery(this).contents().wrapAll(html);
+		});
+	},
+
+	wrap: function(html) {
+		return this.each(function(){
+			jQuery(this).wrapAll(html);
+		});
+	},
+
+	append: function() {
+		return this.domManip(arguments, true, 1, function(a){
+			this.appendChild( a );
+		});
+	},
+
+	prepend: function() {
+		return this.domManip(arguments, true, -1, function(a){
+			this.insertBefore( a, this.firstChild );
+		});
+	},
+	
+	before: function() {
+		return this.domManip(arguments, false, 1, function(a){
+			this.parentNode.insertBefore( a, this );
+		});
+	},
+
+	after: function() {
+		return this.domManip(arguments, false, -1, function(a){
+			this.parentNode.insertBefore( a, this.nextSibling );
+		});
+	},
+
+	end: function() {
+		return this.prevObject || jQuery([]);
+	},
+
+	find: function(t) {
+		var data = jQuery.map(this, function(a){ return jQuery.find(t,a); });
+		return this.pushStack( /[^+>] [^+>]/.test( t ) || t.indexOf("..") > -1 ?
+			jQuery.unique( data ) : data );
+	},
+
+	clone: function(events) {
+		// Do the clone
+		var ret = this.map(function(){
+			return this.outerHTML ? jQuery(this.outerHTML)[0] : this.cloneNode(true);
+		});
+
+		// Need to set the expando to null on the cloned set if it exists
+		// removeData doesn't work here, IE removes it from the original as well
+		// this is primarily for IE but the data expando shouldn't be copied over in any browser
+		var clone = ret.find("*").andSelf().each(function(){
+			if ( this[ expando ] != undefined )
+				this[ expando ] = null;
+		});
+		
+		// Copy the events from the original to the clone
+		if (events === true)
+			this.find("*").andSelf().each(function(i) {
+				var events = jQuery.data(this, "events");
+				for ( var type in events )
+					for ( var handler in events[type] )
+						jQuery.event.add(clone[i], type, events[type][handler], events[type][handler].data);
+			});
+
+		// Return the cloned set
+		return ret;
+	},
+
+	filter: function(t) {
+		return this.pushStack(
+			jQuery.isFunction( t ) &&
+			jQuery.grep(this, function(el, index){
+				return t.apply(el, [index]);
+			}) ||
+
+			jQuery.multiFilter(t,this) );
+	},
+
+	not: function(t) {
+		return this.pushStack(
+			t.constructor == String &&
+			jQuery.multiFilter(t, this, true) ||
+
+			jQuery.grep(this, function(a) {
+				return ( t.constructor == Array || t.jquery )
+					? jQuery.inArray( a, t ) < 0
+					: a != t;
+			})
+		);
+	},
+
+	add: function(t) {
+		return this.pushStack( jQuery.merge(
+			this.get(),
+			t.constructor == String ?
+				jQuery(t).get() :
+				t.length != undefined && (!t.nodeName || jQuery.nodeName(t, "form")) ?
+					t : [t] )
+		);
+	},
+
+	is: function(expr) {
+		return expr ? jQuery.multiFilter(expr,this).length > 0 : false;
+	},
+
+	hasClass: function(expr) {
+		return this.is("." + expr);
+	},
+	
+	val: function( val ) {
+		if ( val == undefined ) {
+			if ( this.length ) {
+				var elem = this[0];
+		    	
+				// We need to handle select boxes special
+				if ( jQuery.nodeName(elem, "select") ) {
+					var index = elem.selectedIndex,
+						a = [],
+						options = elem.options,
+						one = elem.type == "select-one";
+					
+					// Nothing was selected
+					if ( index < 0 )
+						return null;
+
+					// Loop through all the selected options
+					for ( var i = one ? index : 0, max = one ? index + 1 : options.length; i < max; i++ ) {
+						var option = options[i];
+						if ( option.selected ) {
+							// Get the specifc value for the option
+							var val = jQuery.browser.msie && !option.attributes["value"].specified ? option.text : option.value;
+							
+							// We don't need an array for one selects
+							if ( one )
+								return val;
+							
+							// Multi-Selects return an array
+							a.push(val);
+						}
+					}
+					
+					return a;
+					
+				// Everything else, we just grab the value
+				} else
+					return this[0].value.replace(/\r/g, "");
+			}
+		} else
+			return this.each(function(){
+				if ( val.constructor == Array && /radio|checkbox/.test(this.type) )
+					this.checked = (jQuery.inArray(this.value, val) >= 0 ||
+						jQuery.inArray(this.name, val) >= 0);
+				else if ( jQuery.nodeName(this, "select") ) {
+					var tmp = val.constructor == Array ? val : [val];
+
+					jQuery("option", this).each(function(){
+						this.selected = (jQuery.inArray(this.value, tmp) >= 0 ||
+						jQuery.inArray(this.text, tmp) >= 0);
+					});
+
+					if ( !tmp.length )
+						this.selectedIndex = -1;
+				} else
+					this.value = val;
+			});
+	},
+	
+	html: function( val ) {
+		return val == undefined ?
+			( this.length ? this[0].innerHTML : null ) :
+			this.empty().append( val );
+	},
+
+	replaceWith: function( val ) {
+		return this.after( val ).remove();
+	},
+
+	eq: function(i){
+		return this.slice(i, i+1);
+	},
+
+	slice: function() {
+		return this.pushStack( Array.prototype.slice.apply( this, arguments ) );
+	},
+
+	map: function(fn) {
+		return this.pushStack(jQuery.map( this, function(elem,i){
+			return fn.call( elem, i, elem );
+		}));
+	},
+
+	andSelf: function() {
+		return this.add( this.prevObject );
+	},
+	
+	domManip: function(args, table, dir, fn) {
+		var clone = this.length > 1, a; 
+
+		return this.each(function(){
+			if ( !a ) {
+				a = jQuery.clean(args, this.ownerDocument);
+				if ( dir < 0 )
+					a.reverse();
+			}
+
+			var obj = this;
+
+			if ( table && jQuery.nodeName(this, "table") && jQuery.nodeName(a[0], "tr") )
+				obj = this.getElementsByTagName("tbody")[0] || this.appendChild(document.createElement("tbody"));
+
+			jQuery.each( a, function(){
+				var elem = clone ? this.cloneNode(true) : this;
+				if ( !evalScript(0, elem) )
+					fn.call( obj, elem );
+			});
+		});
+	}
+};
+
+function evalScript(i, elem){
+	var script = jQuery.nodeName(elem, "script");
+
+	if ( script ) {
+		if ( elem.src )
+			jQuery.ajax({ url: elem.src, async: false, dataType: "script" });
+		else
+			jQuery.globalEval( elem.text || elem.textContent || elem.innerHTML || "" );
+	
+		if ( elem.parentNode )
+			elem.parentNode.removeChild(elem);
+
+	} else if ( elem.nodeType == 1 )
+    jQuery("script", elem).each(evalScript);
+
+	return script;
+}
+
+jQuery.extend = jQuery.fn.extend = function() {
+	// copy reference to target object
+	var target = arguments[0] || {}, a = 1, al = arguments.length, deep = false;
+
+	// Handle a deep copy situation
+	if ( target.constructor == Boolean ) {
+		deep = target;
+		target = arguments[1] || {};
+	}
+
+	// extend jQuery itself if only one argument is passed
+	if ( al == 1 ) {
+		target = this;
+		a = 0;
+	}
+
+	var prop;
+
+	for ( ; a < al; a++ )
+		// Only deal with non-null/undefined values
+		if ( (prop = arguments[a]) != null )
+			// Extend the base object
+			for ( var i in prop ) {
+				// Prevent never-ending loop
+				if ( target == prop[i] )
+					continue;
+
+				// Recurse if we're merging object values
+				if ( deep && typeof prop[i] == 'object' && target[i] )
+					jQuery.extend( target[i], prop[i] );
+
+				// Don't bring in undefined values
+				else if ( prop[i] != undefined )
+					target[i] = prop[i];
+			}
+
+	// Return the modified object
+	return target;
+};
+
+var expando = "jQuery" + (new Date()).getTime(), uuid = 0, win = {};
+
+jQuery.extend({
+	noConflict: function(deep) {
+		window.$ = _$;
+		if ( deep )
+			window.jQuery = _jQuery;
+		return jQuery;
+	},
+
+	// This may seem like some crazy code, but trust me when I say that this
+	// is the only cross-browser way to do this. --John
+	isFunction: function( fn ) {
+		return !!fn && typeof fn != "string" && !fn.nodeName && 
+			fn.constructor != Array && /function/i.test( fn + "" );
+	},
+	
+	// check if an element is in a XML document
+	isXMLDoc: function(elem) {
+		return elem.documentElement && !elem.body ||
+			elem.tagName && elem.ownerDocument && !elem.ownerDocument.body;
+	},
+
+	// Evalulates a script in a global context
+	// Evaluates Async. in Safari 2 :-(
+	globalEval: function( data ) {
+		data = jQuery.trim( data );
+		if ( data ) {
+			if ( window.execScript )
+				window.execScript( data );
+			else if ( jQuery.browser.safari )
+				// safari doesn't provide a synchronous global eval
+				window.setTimeout( data, 0 );
+			else
+				eval.call( window, data );
+		}
+	},
+
+	nodeName: function( elem, name ) {
+		return elem.nodeName && elem.nodeName.toUpperCase() == name.toUpperCase();
+	},
+	
+	cache: {},
+	
+	data: function( elem, name, data ) {
+		elem = elem == window ? win : elem;
+
+		var id = elem[ expando ];
+
+		// Compute a unique ID for the element
+		if ( !id ) 
+			id = elem[ expando ] = ++uuid;
+
+		// Only generate the data cache if we're
+		// trying to access or manipulate it
+		if ( name && !jQuery.cache[ id ] )
+			jQuery.cache[ id ] = {};
+		
+		// Prevent overriding the named cache with undefined values
+		if ( data != undefined )
+			jQuery.cache[ id ][ name ] = data;
+		
+		// Return the named cache data, or the ID for the element	
+		return name ? jQuery.cache[ id ][ name ] : id;
+	},
+	
+	removeData: function( elem, name ) {
+		elem = elem == window ? win : elem;
+
+		var id = elem[ expando ];
+
+		// If we want to remove a specific section of the element's data
+		if ( name ) {
+			if ( jQuery.cache[ id ] ) {
+				// Remove the section of cache data
+				delete jQuery.cache[ id ][ name ];
+
+				// If we've removed all the data, remove the element's cache
+				name = "";
+				for ( name in jQuery.cache[ id ] ) break;
+				if ( !name )
+					jQuery.removeData( elem );
+			}
+
+		// Otherwise, we want to remove all of the element's data
+		} else {
+			// Clean up the element expando
+			try {
+				delete elem[ expando ];
+			} catch(e){
+				// IE has trouble directly removing the expando
+				// but it's ok with using removeAttribute
+				if ( elem.removeAttribute )
+					elem.removeAttribute( expando );
+			}
+
+			// Completely remove the data cache
+			delete jQuery.cache[ id ];
+		}
+	},
+
+	// args is for internal usage only
+	each: function( obj, fn, args ) {
+		if ( args ) {
+			if ( obj.length == undefined )
+				for ( var i in obj )
+					fn.apply( obj[i], args );
+			else
+				for ( var i = 0, ol = obj.length; i < ol; i++ )
+					if ( fn.apply( obj[i], args ) === false ) break;
+
+		// A special, fast, case for the most common use of each
+		} else {
+			if ( obj.length == undefined )
+				for ( var i in obj )
+					fn.call( obj[i], i, obj[i] );
+			else
+				for ( var i = 0, ol = obj.length, val = obj[0]; 
+					i < ol && fn.call(val,i,val) !== false; val = obj[++i] ){}
+		}
+
+		return obj;
+	},
+	
+	prop: function(elem, value, type, index, prop){
+			// Handle executable functions
+			if ( jQuery.isFunction( value ) )
+				value = value.call( elem, [index] );
+				
+			// exclude the following css properties to add px
+			var exclude = /z-?index|font-?weight|opacity|zoom|line-?height/i;
+
+			// Handle passing in a number to a CSS property
+			return value && value.constructor == Number && type == "curCSS" && !exclude.test(prop) ?
+				value + "px" :
+				value;
+	},
+
+	className: {
+		// internal only, use addClass("class")
+		add: function( elem, c ){
+			jQuery.each( (c || "").split(/\s+/), function(i, cur){
+				if ( !jQuery.className.has( elem.className, cur ) )
+					elem.className += ( elem.className ? " " : "" ) + cur;
+			});
+		},
+
+		// internal only, use removeClass("class")
+		remove: function( elem, c ){
+			elem.className = c != undefined ?
+				jQuery.grep( elem.className.split(/\s+/), function(cur){
+					return !jQuery.className.has( c, cur );	
+				}).join(" ") : "";
+		},
+
+		// internal only, use is(".class")
+		has: function( t, c ) {
+			return jQuery.inArray( c, (t.className || t).toString().split(/\s+/) ) > -1;
+		}
+	},
+
+	swap: function(e,o,f) {
+		for ( var i in o ) {
+			e.style["old"+i] = e.style[i];
+			e.style[i] = o[i];
+		}
+		f.apply( e, [] );
+		for ( var i in o )
+			e.style[i] = e.style["old"+i];
+	},
+
+	css: function(e,p) {
+		if ( p == "height" || p == "width" ) {
+			var old = {}, oHeight, oWidth, d = ["Top","Bottom","Right","Left"];
+
+			jQuery.each( d, function(){
+				old["padding" + this] = 0;
+				old["border" + this + "Width"] = 0;
+			});
+
+			jQuery.swap( e, old, function() {
+				if ( jQuery(e).is(':visible') ) {
+					oHeight = e.offsetHeight;
+					oWidth = e.offsetWidth;
+				} else {
+					e = jQuery(e.cloneNode(true))
+						.find(":radio").removeAttr("checked").end()
+						.css({
+							visibility: "hidden", position: "absolute", display: "block", right: "0", left: "0"
+						}).appendTo(e.parentNode)[0];
+
+					var parPos = jQuery.css(e.parentNode,"position") || "static";
+					if ( parPos == "static" )
+						e.parentNode.style.position = "relative";
+
+					oHeight = e.clientHeight;
+					oWidth = e.clientWidth;
+
+					if ( parPos == "static" )
+						e.parentNode.style.position = "static";
+
+					e.parentNode.removeChild(e);
+				}
+			});
+
+			return p == "height" ? oHeight : oWidth;
+		}
+
+		return jQuery.curCSS( e, p );
+	},
+
+	curCSS: function(elem, prop, force) {
+		var ret, stack = [], swap = [];
+
+		// A helper method for determining if an element's values are broken
+		function color(a){
+			if ( !jQuery.browser.safari )
+				return false;
+
+			var ret = document.defaultView.getComputedStyle(a,null);
+			return !ret || ret.getPropertyValue("color") == "";
+		}
+
+		if (prop == "opacity" && jQuery.browser.msie) {
+			ret = jQuery.attr(elem.style, "opacity");
+			return ret == "" ? "1" : ret;
+		}
+		
+		if (prop.match(/float/i))
+			prop = styleFloat;
+
+		if (!force && elem.style[prop])
+			ret = elem.style[prop];
+
+		else if (document.defaultView && document.defaultView.getComputedStyle) {
+
+			if (prop.match(/float/i))
+				prop = "float";
+
+			prop = prop.replace(/([A-Z])/g,"-$1").toLowerCase();
+			var cur = document.defaultView.getComputedStyle(elem, null);
+
+			if ( cur && !color(elem) )
+				ret = cur.getPropertyValue(prop);
+
+			// If the element isn't reporting its values properly in Safari
+			// then some display: none elements are involved
+			else {
+				// Locate all of the parent display: none elements
+				for ( var a = elem; a && color(a); a = a.parentNode )
+					stack.unshift(a);
+
+				// Go through and make them visible, but in reverse
+				// (It would be better if we knew the exact display type that they had)
+				for ( a = 0; a < stack.length; a++ )
+					if ( color(stack[a]) ) {
+						swap[a] = stack[a].style.display;
+						stack[a].style.display = "block";
+					}
+
+				// Since we flip the display style, we have to handle that
+				// one special, otherwise get the value
+				ret = prop == "display" && swap[stack.length-1] != null ?
+					"none" :
+					document.defaultView.getComputedStyle(elem,null).getPropertyValue(prop) || "";
+
+				// Finally, revert the display styles back
+				for ( a = 0; a < swap.length; a++ )
+					if ( swap[a] != null )
+						stack[a].style.display = swap[a];
+			}
+
+			if ( prop == "opacity" && ret == "" )
+				ret = "1";
+
+		} else if (elem.currentStyle) {
+			var newProp = prop.replace(/\-(\w)/g,function(m,c){return c.toUpperCase();});
+			ret = elem.currentStyle[prop] || elem.currentStyle[newProp];
+
+			// From the awesome hack by Dean Edwards
+			// http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291
+
+			// If we're not dealing with a regular pixel number
+			// but a number that has a weird ending, we need to convert it to pixels
+			if ( !/^\d+(px)?$/i.test(ret) && /^\d/.test(ret) ) {
+				var style = elem.style.left;
+				var runtimeStyle = elem.runtimeStyle.left;
+				elem.runtimeStyle.left = elem.currentStyle.left;
+				elem.style.left = ret || 0;
+				ret = elem.style.pixelLeft + "px";
+				elem.style.left = style;
+				elem.runtimeStyle.left = runtimeStyle;
+			}
+		}
+
+		return ret;
+	},
+	
+	clean: function(a, doc) {
+		var r = [];
+		doc = doc || document;
+
+		jQuery.each( a, function(i,arg){
+			if ( !arg ) return;
+
+			if ( arg.constructor == Number )
+				arg = arg.toString();
+			
+			// Convert html string into DOM nodes
+			if ( typeof arg == "string" ) {
+				// Fix "XHTML"-style tags in all browsers
+				arg = arg.replace(/(<(\w+)[^>]*?)\/>/g, function(m, all, tag){
+					return tag.match(/^(abbr|br|col|img|input|link|meta|param|hr|area)$/i)? m : all+"></"+tag+">";
+				});
+
+				// Trim whitespace, otherwise indexOf won't work as expected
+				var s = jQuery.trim(arg).toLowerCase(), div = doc.createElement("div"), tb = [];
+
+				var wrap =
+					// option or optgroup
+					!s.indexOf("<opt") &&
+					[1, "<select>", "</select>"] ||
+					
+					!s.indexOf("<leg") &&
+					[1, "<fieldset>", "</fieldset>"] ||
+					
+					s.match(/^<(thead|tbody|tfoot|colg|cap)/) &&
+					[1, "<table>", "</table>"] ||
+					
+					!s.indexOf("<tr") &&
+					[2, "<table><tbody>", "</tbody></table>"] ||
+					
+				 	// <thead> matched above
+					(!s.indexOf("<td") || !s.indexOf("<th")) &&
+					[3, "<table><tbody><tr>", "</tr></tbody></table>"] ||
+					
+					!s.indexOf("<col") &&
+					[2, "<table><tbody></tbody><colgroup>", "</colgroup></table>"] ||
+
+					// IE can't serialize <link> and <script> tags normally
+					jQuery.browser.msie &&
+					[1, "div<div>", "</div>"] ||
+					
+					[0,"",""];
+
+				// Go to html and back, then peel off extra wrappers
+				div.innerHTML = wrap[1] + arg + wrap[2];
+				
+				// Move to the right depth
+				while ( wrap[0]-- )
+					div = div.lastChild;
+				
+				// Remove IE's autoinserted <tbody> from table fragments
+				if ( jQuery.browser.msie ) {
+					
+					// String was a <table>, *may* have spurious <tbody>
+					if ( !s.indexOf("<table") && s.indexOf("<tbody") < 0 ) 
+						tb = div.firstChild && div.firstChild.childNodes;
+						
+					// String was a bare <thead> or <tfoot>
+					else if ( wrap[1] == "<table>" && s.indexOf("<tbody") < 0 )
+						tb = div.childNodes;
+
+					for ( var n = tb.length-1; n >= 0 ; --n )
+						if ( jQuery.nodeName(tb[n], "tbody") && !tb[n].childNodes.length )
+							tb[n].parentNode.removeChild(tb[n]);
+	
+					// IE completely kills leading whitespace when innerHTML is used	
+					if ( /^\s/.test(arg) )	
+						div.insertBefore( doc.createTextNode( arg.match(/^\s*/)[0] ), div.firstChild );
+
+				}
+				
+				arg = jQuery.makeArray( div.childNodes );
+			}
+
+			if ( 0 === arg.length && (!jQuery.nodeName(arg, "form") && !jQuery.nodeName(arg, "select")) )
+				return;
+
+			if ( arg[0] == undefined || jQuery.nodeName(arg, "form") || arg.options )
+				r.push( arg );
+			else
+				r = jQuery.merge( r, arg );
+
+		});
+
+		return r;
+	},
+	
+	attr: function(elem, name, value){
+		var fix = jQuery.isXMLDoc(elem) ? {} : jQuery.props;
+
+		// Safari mis-reports the default selected property of a hidden option
+		// Accessing the parent's selectedIndex property fixes it
+		if ( name == "selected" && jQuery.browser.safari )
+			elem.parentNode.selectedIndex;
+		
+		// Certain attributes only work when accessed via the old DOM 0 way
+		if ( fix[name] ) {
+			if ( value != undefined ) elem[fix[name]] = value;
+			return elem[fix[name]];
+		} else if ( jQuery.browser.msie && name == "style" )
+			return jQuery.attr( elem.style, "cssText", value );
+
+		else if ( value == undefined && jQuery.browser.msie && jQuery.nodeName(elem, "form") && (name == "action" || name == "method") )
+			return elem.getAttributeNode(name).nodeValue;
+
+		// IE elem.getAttribute passes even for style
+		else if ( elem.tagName ) {
+
+			if ( value != undefined ) {
+				if ( name == "type" && jQuery.nodeName(elem,"input") && elem.parentNode )
+					throw "type property can't be changed";
+				elem.setAttribute( name, value );
+			}
+
+			if ( jQuery.browser.msie && /href|src/.test(name) && !jQuery.isXMLDoc(elem) ) 
+				return elem.getAttribute( name, 2 );
+
+			return elem.getAttribute( name );
+
+		// elem is actually elem.style ... set the style
+		} else {
+			// IE actually uses filters for opacity
+			if ( name == "opacity" && jQuery.browser.msie ) {
+				if ( value != undefined ) {
+					// IE has trouble with opacity if it does not have layout
+					// Force it by setting the zoom level
+					elem.zoom = 1; 
+	
+					// Set the alpha filter to set the opacity
+					elem.filter = (elem.filter || "").replace(/alpha\([^)]*\)/,"") +
+						(parseFloat(value).toString() == "NaN" ? "" : "alpha(opacity=" + value * 100 + ")");
+				}
+	
+				return elem.filter ? 
+					(parseFloat( elem.filter.match(/opacity=([^)]*)/)[1] ) / 100).toString() : "";
+			}
+			name = name.replace(/-([a-z])/ig,function(z,b){return b.toUpperCase();});
+			if ( value != undefined ) elem[name] = value;
+			return elem[name];
+		}
+	},
+	
+	trim: function(t){
+		return (t||"").replace(/^\s+|\s+$/g, "");
+	},
+
+	makeArray: function( a ) {
+		var r = [];
+
+		// Need to use typeof to fight Safari childNodes crashes
+		if ( typeof a != "array" )
+			for ( var i = 0, al = a.length; i < al; i++ )
+				r.push( a[i] );
+		else
+			r = a.slice( 0 );
+
+		return r;
+	},
+
+	inArray: function( b, a ) {
+		for ( var i = 0, al = a.length; i < al; i++ )
+			if ( a[i] == b )
+				return i;
+		return -1;
+	},
+
+	merge: function(first, second) {
+		// We have to loop this way because IE & Opera overwrite the length
+		// expando of getElementsByTagName
+
+		// Also, we need to make sure that the correct elements are being returned
+		// (IE returns comment nodes in a '*' query)
+		if ( jQuery.browser.msie ) {
+			for ( var i = 0; second[i]; i++ )
+				if ( second[i].nodeType != 8 )
+					first.push(second[i]);
+		} else
+			for ( var i = 0; second[i]; i++ )
+				first.push(second[i]);
+
+		return first;
+	},
+
+	unique: function(first) {
+		var r = [], done = {};
+
+		try {
+			for ( var i = 0, fl = first.length; i < fl; i++ ) {
+				var id = jQuery.data(first[i]);
+				if ( !done[id] ) {
+					done[id] = true;
+					r.push(first[i]);
+				}
+			}
+		} catch(e) {
+			r = first;
+		}
+
+		return r;
+	},
+
+	grep: function(elems, fn, inv) {
+		// If a string is passed in for the function, make a function
+		// for it (a handy shortcut)
+		if ( typeof fn == "string" )
+			fn = eval("false||function(a,i){return " + fn + "}");
+
+		var result = [];
+
+		// Go through the array, only saving the items
+		// that pass the validator function
+		for ( var i = 0, el = elems.length; i < el; i++ )
+			if ( !inv && fn(elems[i],i) || inv && !fn(elems[i],i) )
+				result.push( elems[i] );
+
+		return result;
+	},
+
+	map: function(elems, fn) {
+		// If a string is passed in for the function, make a function
+		// for it (a handy shortcut)
+		if ( typeof fn == "string" )
+			fn = eval("false||function(a){return " + fn + "}");
+
+		var result = [];
+
+		// Go through the array, translating each of the items to their
+		// new value (or values).
+		for ( var i = 0, el = elems.length; i < el; i++ ) {
+			var val = fn(elems[i],i);
+
+			if ( val !== null && val != undefined ) {
+				if ( val.constructor != Array ) val = [val];
+				result = result.concat( val );
+			}
+		}
+
+		return result;
+	}
+});
+
+var userAgent = navigator.userAgent.toLowerCase();
+
+// Figure out what browser is being used
+jQuery.browser = {
+	version: (userAgent.match(/.+(?:rv|it|ra|ie)[\/: ]([\d.]+)/) || [])[1],
+	safari: /webkit/.test(userAgent),
+	opera: /opera/.test(userAgent),
+	msie: /msie/.test(userAgent) && !/opera/.test(userAgent),
+	mozilla: /mozilla/.test(userAgent) && !/(compatible|webkit)/.test(userAgent)
+};
+
+var styleFloat = jQuery.browser.msie ? "styleFloat" : "cssFloat";
+	
+jQuery.extend({
+	// Check to see if the W3C box model is being used
+	boxModel: !jQuery.browser.msie || document.compatMode == "CSS1Compat",
+	
+	styleFloat: jQuery.browser.msie ? "styleFloat" : "cssFloat",
+	
+	props: {
+		"for": "htmlFor",
+		"class": "className",
+		"float": styleFloat,
+		cssFloat: styleFloat,
+		styleFloat: styleFloat,
+		innerHTML: "innerHTML",
+		className: "className",
+		value: "value",
+		disabled: "disabled",
+		checked: "checked",
+		readonly: "readOnly",
+		selected: "selected",
+		maxlength: "maxLength"
+	}
+});
+
+jQuery.each({
+	parent: "a.parentNode",
+	parents: "jQuery.dir(a,'parentNode')",
+	next: "jQuery.nth(a,2,'nextSibling')",
+	prev: "jQuery.nth(a,2,'previousSibling')",
+	nextAll: "jQuery.dir(a,'nextSibling')",
+	prevAll: "jQuery.dir(a,'previousSibling')",
+	siblings: "jQuery.sibling(a.parentNode.firstChild,a)",
+	children: "jQuery.sibling(a.firstChild)",
+	contents: "jQuery.nodeName(a,'iframe')?a.contentDocument||a.contentWindow.document:jQuery.makeArray(a.childNodes)"
+}, function(i,n){
+	jQuery.fn[ i ] = function(a) {
+		var ret = jQuery.map(this,n);
+		if ( a && typeof a == "string" )
+			ret = jQuery.multiFilter(a,ret);
+		return this.pushStack( jQuery.unique(ret) );
+	};
+});
+
+jQuery.each({
+	appendTo: "append",
+	prependTo: "prepend",
+	insertBefore: "before",
+	insertAfter: "after",
+	replaceAll: "replaceWith"
+}, function(i,n){
+	jQuery.fn[ i ] = function(){
+		var a = arguments;
+		return this.each(function(){
+			for ( var j = 0, al = a.length; j < al; j++ )
+				jQuery(a[j])[n]( this );
+		});
+	};
+});
+
+jQuery.each( {
+	removeAttr: function( key ) {
+		jQuery.attr( this, key, "" );
+		this.removeAttribute( key );
+	},
+	addClass: function(c){
+		jQuery.className.add(this,c);
+	},
+	removeClass: function(c){
+		jQuery.className.remove(this,c);
+	},
+	toggleClass: function( c ){
+		jQuery.className[ jQuery.className.has(this,c) ? "remove" : "add" ](this, c);
+	},
+	remove: function(a){
+		if ( !a || jQuery.filter( a, [this] ).r.length ) {
+			jQuery.removeData( this );
+			this.parentNode.removeChild( this );
+		}
+	},
+	empty: function() {
+		// Clean up the cache
+		jQuery("*", this).each(function(){ jQuery.removeData(this); });
+
+		while ( this.firstChild )
+			this.removeChild( this.firstChild );
+	}
+}, function(i,n){
+	jQuery.fn[ i ] = function() {
+		return this.each( n, arguments );
+	};
+});
+
+jQuery.each( [ "Height", "Width" ], function(i,name){
+	var n = name.toLowerCase();
+	
+	jQuery.fn[ n ] = function(h) {
+		return this[0] == window ?
+			jQuery.browser.safari && self["inner" + name] ||
+			jQuery.boxModel && Math.max(document.documentElement["client" + name], document.body["client" + name]) ||
+			document.body["client" + name] :
+		
+			this[0] == document ?
+				Math.max( document.body["scroll" + name], document.body["offset" + name] ) :
+        
+				h == undefined ?
+					( this.length ? jQuery.css( this[0], n ) : null ) :
+					this.css( n, h.constructor == String ? h : h + "px" );
+	};
+});
+
+var chars = jQuery.browser.safari && parseInt(jQuery.browser.version) < 417 ?
+		"(?:[\\w*_-]|\\\\.)" :
+		"(?:[\\w\u0128-\uFFFF*_-]|\\\\.)",
+	quickChild = new RegExp("^>\\s*(" + chars + "+)"),
+	quickID = new RegExp("^(" + chars + "+)(#)(" + chars + "+)"),
+	quickClass = new RegExp("^([#.]?)(" + chars + "*)");
+
+jQuery.extend({
+	expr: {
+		"": "m[2]=='*'||jQuery.nodeName(a,m[2])",
+		"#": "a.getAttribute('id')==m[2]",
+		":": {
+			// Position Checks
+			lt: "i<m[3]-0",
+			gt: "i>m[3]-0",
+			nth: "m[3]-0==i",
+			eq: "m[3]-0==i",
+			first: "i==0",
+			last: "i==r.length-1",
+			even: "i%2==0",
+			odd: "i%2",
+
+			// Child Checks
+			"first-child": "a.parentNode.getElementsByTagName('*')[0]==a",
+			"last-child": "jQuery.nth(a.parentNode.lastChild,1,'previousSibling')==a",
+			"only-child": "!jQuery.nth(a.parentNode.lastChild,2,'previousSibling')",
+
+			// Parent Checks
+			parent: "a.firstChild",
+			empty: "!a.firstChild",
+
+			// Text Check
+			contains: "(a.textContent||a.innerText||jQuery(a).text()||'').indexOf(m[3])>=0",
+
+			// Visibility
+			visible: '"hidden"!=a.type&&jQuery.css(a,"display")!="none"&&jQuery.css(a,"visibility")!="hidden"',
+			hidden: '"hidden"==a.type||jQuery.css(a,"display")=="none"||jQuery.css(a,"visibility")=="hidden"',
+
+			// Form attributes
+			enabled: "!a.disabled",
+			disabled: "a.disabled",
+			checked: "a.checked",
+			selected: "a.selected||jQuery.attr(a,'selected')",
+
+			// Form elements
+			text: "'text'==a.type",
+			radio: "'radio'==a.type",
+			checkbox: "'checkbox'==a.type",
+			file: "'file'==a.type",
+			password: "'password'==a.type",
+			submit: "'submit'==a.type",
+			image: "'image'==a.type",
+			reset: "'reset'==a.type",
+			button: '"button"==a.type||jQuery.nodeName(a,"button")',
+			input: "/input|select|textarea|button/i.test(a.nodeName)",
+
+			// :has()
+			has: "jQuery.find(m[3],a).length",
+
+			// :header
+			header: "/h\\d/i.test(a.nodeName)",
+
+			// :animated
+			animated: "jQuery.grep(jQuery.timers,function(fn){return a==fn.elem;}).length"
+		}
+	},
+	
+	// The regular expressions that power the parsing engine
+	parse: [
+		// Match: [@value='test'], [@foo]
+		/^(\[) *@?([\w-]+) *([!*$^~=]*) *('?"?)(.*?)\4 *\]/,
+
+		// Match: :contains('foo')
+		/^(:)([\w-]+)\("?'?(.*?(\(.*?\))?[^(]*?)"?'?\)/,
+
+		// Match: :even, :last-chlid, #id, .class
+		new RegExp("^([:.#]*)(" + chars + "+)")
+	],
+
+	multiFilter: function( expr, elems, not ) {
+		var old, cur = [];
+
+		while ( expr && expr != old ) {
+			old = expr;
+			var f = jQuery.filter( expr, elems, not );
+			expr = f.t.replace(/^\s*,\s*/, "" );
+			cur = not ? elems = f.r : jQuery.merge( cur, f.r );
+		}
+
+		return cur;
+	},
+
+	find: function( t, context ) {
+		// Quickly handle non-string expressions
+		if ( typeof t != "string" )
+			return [ t ];
+
+		// Make sure that the context is a DOM Element
+		if ( context && !context.nodeType )
+			context = null;
+
+		// Set the correct context (if none is provided)
+		context = context || document;
+
+		// Initialize the search
+		var ret = [context], done = [], last;
+
+		// Continue while a selector expression exists, and while
+		// we're no longer looping upon ourselves
+		while ( t && last != t ) {
+			var r = [];
+			last = t;
+
+			t = jQuery.trim(t);
+
+			var foundToken = false;
+
+			// An attempt at speeding up child selectors that
+			// point to a specific element tag
+			var re = quickChild;
+			var m = re.exec(t);
+
+			if ( m ) {
+				var nodeName = m[1].toUpperCase();
+
+				// Perform our own iteration and filter
+				for ( var i = 0; ret[i]; i++ )
+					for ( var c = ret[i].firstChild; c; c = c.nextSibling )
+						if ( c.nodeType == 1 && (nodeName == "*" || c.nodeName.toUpperCase() == nodeName.toUpperCase()) )
+							r.push( c );
+
+				ret = r;
+				t = t.replace( re, "" );
+				if ( t.indexOf(" ") == 0 ) continue;
+				foundToken = true;
+			} else {
+				re = /^([>+~])\s*(\w*)/i;
+
+				if ( (m = re.exec(t)) != null ) {
+					r = [];
+
+					var nodeName = m[2], merge = {};
+					m = m[1];
+
+					for ( var j = 0, rl = ret.length; j < rl; j++ ) {
+						var n = m == "~" || m == "+" ? ret[j].nextSibling : ret[j].firstChild;
+						for ( ; n; n = n.nextSibling )
+							if ( n.nodeType == 1 ) {
+								var id = jQuery.data(n);
+
+								if ( m == "~" && merge[id] ) break;
+								
+								if (!nodeName || n.nodeName.toUpperCase() == nodeName.toUpperCase() ) {
+									if ( m == "~" ) merge[id] = true;
+									r.push( n );
+								}
+								
+								if ( m == "+" ) break;
+							}
+					}
+
+					ret = r;
+
+					// And remove the token
+					t = jQuery.trim( t.replace( re, "" ) );
+					foundToken = true;
+				}
+			}
+
+			// See if there's still an expression, and that we haven't already
+			// matched a token
+			if ( t && !foundToken ) {
+				// Handle multiple expressions
+				if ( !t.indexOf(",") ) {
+					// Clean the result set
+					if ( context == ret[0] ) ret.shift();
+
+					// Merge the result sets
+					done = jQuery.merge( done, ret );
+
+					// Reset the context
+					r = ret = [context];
+
+					// Touch up the selector string
+					t = " " + t.substr(1,t.length);
+
+				} else {
+					// Optimize for the case nodeName#idName
+					var re2 = quickID;
+					var m = re2.exec(t);
+					
+					// Re-organize the results, so that they're consistent
+					if ( m ) {
+					   m = [ 0, m[2], m[3], m[1] ];
+
+					} else {
+						// Otherwise, do a traditional filter check for
+						// ID, class, and element selectors
+						re2 = quickClass;
+						m = re2.exec(t);
+					}
+
+					m[2] = m[2].replace(/\\/g, "");
+
+					var elem = ret[ret.length-1];
+
+					// Try to do a global search by ID, where we can
+					if ( m[1] == "#" && elem && elem.getElementById && !jQuery.isXMLDoc(elem) ) {
+						// Optimization for HTML document case
+						var oid = elem.getElementById(m[2]);
+						
+						// Do a quick check for the existence of the actual ID attribute
+						// to avoid selecting by the name attribute in IE
+						// also check to insure id is a string to avoid selecting an element with the name of 'id' inside a form
+						if ( (jQuery.browser.msie||jQuery.browser.opera) && oid && typeof oid.id == "string" && oid.id != m[2] )
+							oid = jQuery('[@id="'+m[2]+'"]', elem)[0];
+
+						// Do a quick check for node name (where applicable) so
+						// that div#foo searches will be really fast
+						ret = r = oid && (!m[3] || jQuery.nodeName(oid, m[3])) ? [oid] : [];
+					} else {
+						// We need to find all descendant elements
+						for ( var i = 0; ret[i]; i++ ) {
+							// Grab the tag name being searched for
+							var tag = m[1] == "#" && m[3] ? m[3] : m[1] != "" || m[0] == "" ? "*" : m[2];
+
+							// Handle IE7 being really dumb about <object>s
+							if ( tag == "*" && ret[i].nodeName.toLowerCase() == "object" )
+								tag = "param";
+
+							r = jQuery.merge( r, ret[i].getElementsByTagName( tag ));
+						}
+
+						// It's faster to filter by class and be done with it
+						if ( m[1] == "." )
+							r = jQuery.classFilter( r, m[2] );
+
+						// Same with ID filtering
+						if ( m[1] == "#" ) {
+							var tmp = [];
+
+							// Try to find the element with the ID
+							for ( var i = 0; r[i]; i++ )
+								if ( r[i].getAttribute("id") == m[2] ) {
+									tmp = [ r[i] ];
+									break;
+								}
+
+							r = tmp;
+						}
+
+						ret = r;
+					}
+
+					t = t.replace( re2, "" );
+				}
+
+			}
+
+			// If a selector string still exists
+			if ( t ) {
+				// Attempt to filter it
+				var val = jQuery.filter(t,r);
+				ret = r = val.r;
+				t = jQuery.trim(val.t);
+			}
+		}
+
+		// An error occurred with the selector;
+		// just return an empty set instead
+		if ( t )
+			ret = [];
+
+		// Remove the root context
+		if ( ret && context == ret[0] )
+			ret.shift();
+
+		// And combine the results
+		done = jQuery.merge( done, ret );
+
+		return done;
+	},
+
+	classFilter: function(r,m,not){
+		m = " " + m + " ";
+		var tmp = [];
+		for ( var i = 0; r[i]; i++ ) {
+			var pass = (" " + r[i].className + " ").indexOf( m ) >= 0;
+			if ( !not && pass || not && !pass )
+				tmp.push( r[i] );
+		}
+		return tmp;
+	},
+
+	filter: function(t,r,not) {
+		var last;
+
+		// Look for common filter expressions
+		while ( t  && t != last ) {
+			last = t;
+
+			var p = jQuery.parse, m;
+
+			for ( var i = 0; p[i]; i++ ) {
+				m = p[i].exec( t );
+
+				if ( m ) {
+					// Remove what we just matched
+					t = t.substring( m[0].length );
+
+					m[2] = m[2].replace(/\\/g, "");
+					break;
+				}
+			}
+
+			if ( !m )
+				break;
+
+			// :not() is a special case that can be optimized by
+			// keeping it out of the expression list
+			if ( m[1] == ":" && m[2] == "not" )
+				r = jQuery.filter(m[3], r, true).r;
+
+			// We can get a big speed boost by filtering by class here
+			else if ( m[1] == "." )
+				r = jQuery.classFilter(r, m[2], not);
+
+			else if ( m[1] == "[" ) {
+				var tmp = [], type = m[3];
+				
+				for ( var i = 0, rl = r.length; i < rl; i++ ) {
+					var a = r[i], z = a[ jQuery.props[m[2]] || m[2] ];
+					
+					if ( z == null || /href|src|selected/.test(m[2]) )
+						z = jQuery.attr(a,m[2]) || '';
+
+					if ( (type == "" && !!z ||
+						 type == "=" && z == m[5] ||
+						 type == "!=" && z != m[5] ||
+						 type == "^=" && z && !z.indexOf(m[5]) ||
+						 type == "$=" && z.substr(z.length - m[5].length) == m[5] ||
+						 (type == "*=" || type == "~=") && z.indexOf(m[5]) >= 0) ^ not )
+							tmp.push( a );
+				}
+				
+				r = tmp;
+
+			// We can get a speed boost by handling nth-child here
+			} else if ( m[1] == ":" && m[2] == "nth-child" ) {
+				var merge = {}, tmp = [],
+					test = /(\d*)n\+?(\d*)/.exec(
+						m[3] == "even" && "2n" || m[3] == "odd" && "2n+1" ||
+						!/\D/.test(m[3]) && "n+" + m[3] || m[3]),
+					first = (test[1] || 1) - 0, last = test[2] - 0;
+
+				for ( var i = 0, rl = r.length; i < rl; i++ ) {
+					var node = r[i], parentNode = node.parentNode, id = jQuery.data(parentNode);
+
+					if ( !merge[id] ) {
+						var c = 1;
+
+						for ( var n = parentNode.firstChild; n; n = n.nextSibling )
+							if ( n.nodeType == 1 )
+								n.nodeIndex = c++;
+
+						merge[id] = true;
+					}
+
+					var add = false;
+
+					if ( first == 1 ) {
+						if ( last == 0 || node.nodeIndex == last )
+							add = true;
+					} else if ( (node.nodeIndex + last) % first == 0 )
+						add = true;
+
+					if ( add ^ not )
+						tmp.push( node );
+				}
+
+				r = tmp;
+
+			// Otherwise, find the expression to execute
+			} else {
+				var f = jQuery.expr[m[1]];
+				if ( typeof f != "string" )
+					f = jQuery.expr[m[1]][m[2]];
+
+				// Build a custom macro to enclose it
+				f = eval("false||function(a,i){return " + f + "}");
+
+				// Execute it against the current filter
+				r = jQuery.grep( r, f, not );
+			}
+		}
+
+		// Return an array of filtered elements (r)
+		// and the modified expression string (t)
+		return { r: r, t: t };
+	},
+
+	dir: function( elem, dir ){
+		var matched = [];
+		var cur = elem[dir];
+		while ( cur && cur != document ) {
+			if ( cur.nodeType == 1 )
+				matched.push( cur );
+			cur = cur[dir];
+		}
+		return matched;
+	},
+	
+	nth: function(cur,result,dir,elem){
+		result = result || 1;
+		var num = 0;
+
+		for ( ; cur; cur = cur[dir] )
+			if ( cur.nodeType == 1 && ++num == result )
+				break;
+
+		return cur;
+	},
+	
+	sibling: function( n, elem ) {
+		var r = [];
+
+		for ( ; n; n = n.nextSibling ) {
+			if ( n.nodeType == 1 && (!elem || n != elem) )
+				r.push( n );
+		}
+
+		return r;
+	}
+});
+/*
+ * A number of helper functions used for managing events.
+ * Many of the ideas behind this code orignated from 
+ * Dean Edwards' addEvent library.
+ */
+jQuery.event = {
+
+	// Bind an event to an element
+	// Original by Dean Edwards
+	add: function(element, type, handler, data) {
+		// For whatever reason, IE has trouble passing the window object
+		// around, causing it to be cloned in the process
+		if ( jQuery.browser.msie && element.setInterval != undefined )
+			element = window;
+
+		// Make sure that the function being executed has a unique ID
+		if ( !handler.guid )
+			handler.guid = this.guid++;
+			
+		// if data is passed, bind to handler 
+		if( data != undefined ) { 
+        		// Create temporary function pointer to original handler 
+			var fn = handler; 
+
+			// Create unique handler function, wrapped around original handler 
+			handler = function() { 
+				// Pass arguments and context to original handler 
+				return fn.apply(this, arguments); 
+			};
+
+			// Store data in unique handler 
+			handler.data = data;
+
+			// Set the guid of unique handler to the same of original handler, so it can be removed 
+			handler.guid = fn.guid;
+		}
+
+		// Namespaced event handlers
+		var parts = type.split(".");
+		type = parts[0];
+		handler.type = parts[1];
+
+		// Init the element's event structure
+		var events = jQuery.data(element, "events") || jQuery.data(element, "events", {});
+		
+		var handle = jQuery.data(element, "handle", function(){
+			// returned undefined or false
+			var val;
+
+			// Handle the second event of a trigger and when
+			// an event is called after a page has unloaded
+			if ( typeof jQuery == "undefined" || jQuery.event.triggered )
+				return val;
+			
+			val = jQuery.event.handle.apply(element, arguments);
+			
+			return val;
+		});
+
+		// Get the current list of functions bound to this event
+		var handlers = events[type];
+
+		// Init the event handler queue
+		if (!handlers) {
+			handlers = events[type] = {};	
+			
+			// And bind the global event handler to the element
+			if (element.addEventListener)
+				element.addEventListener(type, handle, false);
+			else
+				element.attachEvent("on" + type, handle);
+		}
+
+		// Add the function to the element's handler list
+		handlers[handler.guid] = handler;
+
+		// Keep track of which events have been used, for global triggering
+		this.global[type] = true;
+	},
+
+	guid: 1,
+	global: {},
+
+	// Detach an event or set of events from an element
+	remove: function(element, type, handler) {
+		var events = jQuery.data(element, "events"), ret, index;
+
+		// Namespaced event handlers
+		if ( typeof type == "string" ) {
+			var parts = type.split(".");
+			type = parts[0];
+		}
+
+		if ( events ) {
+			// type is actually an event object here
+			if ( type && type.type ) {
+				handler = type.handler;
+				type = type.type;
+			}
+			
+			if ( !type ) {
+				for ( type in events )
+					this.remove( element, type );
+
+			} else if ( events[type] ) {
+				// remove the given handler for the given type
+				if ( handler )
+					delete events[type][handler.guid];
+				
+				// remove all handlers for the given type
+				else
+					for ( handler in events[type] )
+						// Handle the removal of namespaced events
+						if ( !parts[1] || events[type][handler].type == parts[1] )
+							delete events[type][handler];
+
+				// remove generic event handler if no more handlers exist
+				for ( ret in events[type] ) break;
+				if ( !ret ) {
+					if (element.removeEventListener)
+						element.removeEventListener(type, jQuery.data(element, "handle"), false);
+					else
+						element.detachEvent("on" + type, jQuery.data(element, "handle"));
+					ret = null;
+					delete events[type];
+				}
+			}
+
+			// Remove the expando if it's no longer used
+			for ( ret in events ) break;
+			if ( !ret ) {
+				jQuery.removeData( element, "events" );
+				jQuery.removeData( element, "handle" );
+			}
+		}
+	},
+
+	trigger: function(type, data, element, donative, extra) {
+		// Clone the incoming data, if any
+		data = jQuery.makeArray(data || []);
+
+		// Handle a global trigger
+		if ( !element ) {
+			// Only trigger if we've ever bound an event for it
+			if ( this.global[type] )
+				jQuery("*").add([window, document]).trigger(type, data);
+
+		// Handle triggering a single element
+		} else {
+			var val, ret, fn = jQuery.isFunction( element[ type ] || null ),
+				// Check to see if we need to provide a fake event, or not
+				evt = !data[0] || !data[0].preventDefault;
+			
+			// Pass along a fake event
+			if ( evt )
+				data.unshift( this.fix({ type: type, target: element }) );
+
+			// Enforce the right trigger type
+			data[0].type = type;
+
+			// Trigger the event
+			if ( jQuery.isFunction( jQuery.data(element, "handle") ) )
+				val = jQuery.data(element, "handle").apply( element, data );
+
+			// Handle triggering native .onfoo handlers
+			if ( !fn && element["on"+type] && element["on"+type].apply( element, data ) === false )
+				val = false;
+
+			// Extra functions don't get the custom event object
+			if ( evt )
+				data.shift();
+
+			// Handle triggering of extra function
+			if ( extra && extra.apply( element, data ) === false )
+				val = false;
+
+			// Trigger the native events (except for clicks on links)
+			if ( fn && donative !== false && val !== false && !(jQuery.nodeName(element, 'a') && type == "click") ) {
+				this.triggered = true;
+				element[ type ]();
+			}
+
+			this.triggered = false;
+		}
+
+		return val;
+	},
+
+	handle: function(event) {
+		// returned undefined or false
+		var val;
+
+		// Empty object is for triggered events with no data
+		event = jQuery.event.fix( event || window.event || {} ); 
+
+		// Namespaced event handlers
+		var parts = event.type.split(".");
+		event.type = parts[0];
+
+		var c = jQuery.data(this, "events") && jQuery.data(this, "events")[event.type], args = Array.prototype.slice.call( arguments, 1 );
+		args.unshift( event );
+
+		for ( var j in c ) {
+			// Pass in a reference to the handler function itself
+			// So that we can later remove it
+			args[0].handler = c[j];
+			args[0].data = c[j].data;
+
+			// Filter the functions by class
+			if ( !parts[1] || c[j].type == parts[1] ) {
+				var tmp = c[j].apply( this, args );
+
+				if ( val !== false )
+					val = tmp;
+
+				if ( tmp === false ) {
+					event.preventDefault();
+					event.stopPropagation();
+				}
+			}
+		}
+
+		// Clean up added properties in IE to prevent memory leak
+		if (jQuery.browser.msie)
+			event.target = event.preventDefault = event.stopPropagation =
+				event.handler = event.data = null;
+
+		return val;
+	},
+
+	fix: function(event) {
+		// store a copy of the original event object 
+		// and clone to set read-only properties
+		var originalEvent = event;
+		event = jQuery.extend({}, originalEvent);
+		
+		// add preventDefault and stopPropagation since 
+		// they will not work on the clone
+		event.preventDefault = function() {
+			// if preventDefault exists run it on the original event
+			if (originalEvent.preventDefault)
+				originalEvent.preventDefault();
+			// otherwise set the returnValue property of the original event to false (IE)
+			originalEvent.returnValue = false;
+		};
+		event.stopPropagation = function() {
+			// if stopPropagation exists run it on the original event
+			if (originalEvent.stopPropagation)
+				originalEvent.stopPropagation();
+			// otherwise set the cancelBubble property of the original event to true (IE)
+			originalEvent.cancelBubble = true;
+		};
+		
+		// Fix target property, if necessary
+		if ( !event.target && event.srcElement )
+			event.target = event.srcElement;
+				
+		// check if target is a textnode (safari)
+		if (jQuery.browser.safari && event.target.nodeType == 3)
+			event.target = originalEvent.target.parentNode;
+
+		// Add relatedTarget, if necessary
+		if ( !event.relatedTarget && event.fromElement )
+			event.relatedTarget = event.fromElement == event.target ? event.toElement : event.fromElement;
+
+		// Calculate pageX/Y if missing and clientX/Y available
+		if ( event.pageX == null && event.clientX != null ) {
+			var e = document.documentElement, b = document.body;
+			event.pageX = event.clientX + (e && e.scrollLeft || b.scrollLeft || 0);
+			event.pageY = event.clientY + (e && e.scrollTop || b.scrollTop || 0);
+		}
+			
+		// Add which for key events
+		if ( !event.which && (event.charCode || event.keyCode) )
+			event.which = event.charCode || event.keyCode;
+		
+		// Add metaKey to non-Mac browsers (use ctrl for PC's and Meta for Macs)
+		if ( !event.metaKey && event.ctrlKey )
+			event.metaKey = event.ctrlKey;
+
+		// Add which for click: 1 == left; 2 == middle; 3 == right
+		// Note: button is not normalized, so don't use it
+		if ( !event.which && event.button )
+			event.which = (event.button & 1 ? 1 : ( event.button & 2 ? 3 : ( event.button & 4 ? 2 : 0 ) ));
+			
+		return event;
+	}
+};
+
+jQuery.fn.extend({
+	bind: function( type, data, fn ) {
+		return type == "unload" ? this.one(type, data, fn) : this.each(function(){
+			jQuery.event.add( this, type, fn || data, fn && data );
+		});
+	},
+	
+	one: function( type, data, fn ) {
+		return this.each(function(){
+			jQuery.event.add( this, type, function(event) {
+				jQuery(this).unbind(event);
+				return (fn || data).apply( this, arguments);
+			}, fn && data);
+		});
+	},
+
+	unbind: function( type, fn ) {
+		return this.each(function(){
+			jQuery.event.remove( this, type, fn );
+		});
+	},
+
+	trigger: function( type, data, fn ) {
+		return this.each(function(){
+			jQuery.event.trigger( type, data, this, true, fn );
+		});
+	},
+
+	triggerHandler: function( type, data, fn ) {
+		if ( this[0] )
+			return jQuery.event.trigger( type, data, this[0], false, fn );
+	},
+
+	toggle: function() {
+		// Save reference to arguments for access in closure
+		var a = arguments;
+
+		return this.click(function(e) {
+			// Figure out which function to execute
+			this.lastToggle = 0 == this.lastToggle ? 1 : 0;
+			
+			// Make sure that clicks stop
+			e.preventDefault();
+			
+			// and execute the function
+			return a[this.lastToggle].apply( this, [e] ) || false;
+		});
+	},
+
+	hover: function(f,g) {
+		
+		// A private function for handling mouse 'hovering'
+		function handleHover(e) {
+			// Check if mouse(over|out) are still within the same parent element
+			var p = e.relatedTarget;
+	
+			// Traverse up the tree
+			while ( p && p != this ) try { p = p.parentNode; } catch(e) { p = this; };
+			
+			// If we actually just moused on to a sub-element, ignore it
+			if ( p == this ) return false;
+			
+			// Execute the right function
+			return (e.type == "mouseover" ? f : g).apply(this, [e]);
+		}
+		
+		// Bind the function to the two event listeners
+		return this.mouseover(handleHover).mouseout(handleHover);
+	},
+	
+	ready: function(f) {
+		// Attach the listeners
+		bindReady();
+
+		// If the DOM is already ready
+		if ( jQuery.isReady )
+			// Execute the function immediately
+			f.apply( document, [jQuery] );
+			
+		// Otherwise, remember the function for later
+		else
+			// Add the function to the wait list
+			jQuery.readyList.push( function() { return f.apply(this, [jQuery]); } );
+	
+		return this;
+	}
+});
+
+jQuery.extend({
+	/*
+	 * All the code that makes DOM Ready work nicely.
+	 */
+	isReady: false,
+	readyList: [],
+	
+	// Handle when the DOM is ready
+	ready: function() {
+		// Make sure that the DOM is not already loaded
+		if ( !jQuery.isReady ) {
+			// Remember that the DOM is ready
+			jQuery.isReady = true;
+			
+			// If there are functions bound, to execute
+			if ( jQuery.readyList ) {
+				// Execute all of them
+				jQuery.each( jQuery.readyList, function(){
+					this.apply( document );
+				});
+				
+				// Reset the list of functions
+				jQuery.readyList = null;
+			}
+			// Remove event listener to avoid memory leak
+			if ( jQuery.browser.mozilla || jQuery.browser.opera )
+				document.removeEventListener( "DOMContentLoaded", jQuery.ready, false );
+			
+			// Remove script element used by IE hack
+			if( !window.frames.length ) // don't remove if frames are present (#1187)
+				jQuery(window).load(function(){ jQuery("#__ie_init").remove(); });
+		}
+	}
+});
+
+jQuery.each( ("blur,focus,load,resize,scroll,unload,click,dblclick," +
+	"mousedown,mouseup,mousemove,mouseover,mouseout,change,select," + 
+	"submit,keydown,keypress,keyup,error").split(","), function(i,o){
+	
+	// Handle event binding
+	jQuery.fn[o] = function(f){
+		return f ? this.bind(o, f) : this.trigger(o);
+	};
+});
+
+var readyBound = false;
+
+function bindReady(){
+	if ( readyBound ) return;
+	readyBound = true;
+
+	// If Mozilla is used
+	if ( jQuery.browser.mozilla || jQuery.browser.opera )
+		// Use the handy event callback
+		document.addEventListener( "DOMContentLoaded", jQuery.ready, false );
+	
+	// If IE is used, use the excellent hack by Matthias Miller
+	// http://www.outofhanwell.com/blog/index.php?title=the_window_onload_problem_revisited
+	else if ( jQuery.browser.msie ) {
+	
+		// Only works if you document.write() it
+		document.write("<scr" + "ipt id=__ie_init defer=true " + 
+			"src=//:><\/script>");
+	
+		// Use the defer script hack
+		var script = document.getElementById("__ie_init");
+		
+		// script does not exist if jQuery is loaded dynamically
+		if ( script ) 
+			script.onreadystatechange = function() {
+				if ( this.readyState != "complete" ) return;
+				jQuery.ready();
+			};
+	
+		// Clear from memory
+		script = null;
+	
+	// If Safari  is used
+	} else if ( jQuery.browser.safari )
+		// Continually check to see if the document.readyState is valid
+		jQuery.safariTimer = setInterval(function(){
+			// loaded and complete are both valid states
+			if ( document.readyState == "loaded" || 
+				document.readyState == "complete" ) {
+	
+				// If either one are found, remove the timer
+				clearInterval( jQuery.safariTimer );
+				jQuery.safariTimer = null;
+	
+				// and execute any waiting functions
+				jQuery.ready();
+			}
+		}, 10); 
+
+	// A fallback to window.onload, that will always work
+	jQuery.event.add( window, "load", jQuery.ready );
+}
+jQuery.fn.extend({
+	load: function( url, params, callback ) {
+		if ( jQuery.isFunction( url ) )
+			return this.bind("load", url);
+
+		var off = url.indexOf(" ");
+		if ( off >= 0 ) {
+			var selector = url.slice(off, url.length);
+			url = url.slice(0, off);
+		}
+
+		callback = callback || function(){};
+
+		// Default to a GET request
+		var type = "GET";
+
+		// If the second parameter was provided
+		if ( params )
+			// If it's a function
+			if ( jQuery.isFunction( params ) ) {
+				// We assume that it's the callback
+				callback = params;
+				params = null;
+
+			// Otherwise, build a param string
+			} else {
+				params = jQuery.param( params );
+				type = "POST";
+			}
+
+		var self = this;
+
+		// Request the remote document
+		jQuery.ajax({
+			url: url,
+			type: type,
+			data: params,
+			complete: function(res, status){
+				// If successful, inject the HTML into all the matched elements
+				if ( status == "success" || status == "notmodified" )
+					// See if a selector was specified
+					self.html( selector ?
+						// Create a dummy div to hold the results
+						jQuery("<div/>")
+							// inject the contents of the document in, removing the scripts
+							// to avoid any 'Permission Denied' errors in IE
+							.append(res.responseText.replace(/<script(.|\s)*?\/script>/g, ""))
+
+							// Locate the specified elements
+							.find(selector) :
+
+						// If not, just inject the full result
+						res.responseText );
+
+				// Add delay to account for Safari's delay in globalEval
+				setTimeout(function(){
+					self.each( callback, [res.responseText, status, res] );
+				}, 13);
+			}
+		});
+		return this;
+	},
+
+	serialize: function() {
+		return jQuery.param(this.serializeArray());
+	},
+	serializeArray: function() {
+		return this.map(function(){
+			return jQuery.nodeName(this, "form") ?
+				jQuery.makeArray(this.elements) : this;
+		})
+		.filter(function(){
+			return this.name && !this.disabled && 
+				(this.checked || /select|textarea/i.test(this.nodeName) || 
+					/text|hidden|password/i.test(this.type));
+		})
+		.map(function(i, elem){
+			var val = jQuery(this).val();
+			return val == null ? null :
+				val.constructor == Array ?
+					jQuery.map( val, function(val, i){
+						return {name: elem.name, value: val};
+					}) :
+					{name: elem.name, value: val};
+		}).get();
+	}
+});
+
+// Attach a bunch of functions for handling common AJAX events
+jQuery.each( "ajaxStart,ajaxStop,ajaxComplete,ajaxError,ajaxSuccess,ajaxSend".split(","), function(i,o){
+	jQuery.fn[o] = function(f){
+		return this.bind(o, f);
+	};
+});
+
+var jsc = (new Date).getTime();
+
+jQuery.extend({
+	get: function( url, data, callback, type ) {
+		// shift arguments if data argument was ommited
+		if ( jQuery.isFunction( data ) ) {
+			callback = data;
+			data = null;
+		}
+		
+		return jQuery.ajax({
+			type: "GET",
+			url: url,
+			data: data,
+			success: callback,
+			dataType: type
+		});
+	},
+
+	getScript: function( url, callback ) {
+		return jQuery.get(url, null, callback, "script");
+	},
+
+	getJSON: function( url, data, callback ) {
+		return jQuery.get(url, data, callback, "json");
+	},
+
+	post: function( url, data, callback, type ) {
+		if ( jQuery.isFunction( data ) ) {
+			callback = data;
+			data = {};
+		}
+
+		return jQuery.ajax({
+			type: "POST",
+			url: url,
+			data: data,
+			success: callback,
+			dataType: type
+		});
+	},
+
+	ajaxSetup: function( settings ) {
+		jQuery.extend( jQuery.ajaxSettings, settings );
+	},
+
+	ajaxSettings: {
+		global: true,
+		type: "GET",
+		timeout: 0,
+		contentType: "application/x-www-form-urlencoded",
+		processData: true,
+		async: true,
+		data: null
+	},
+	
+	// Last-Modified header cache for next request
+	lastModified: {},
+
+	ajax: function( s ) {
+		var jsonp, jsre = /=(\?|%3F)/g, status, data;
+
+		// Extend the settings, but re-extend 's' so that it can be
+		// checked again later (in the test suite, specifically)
+		s = jQuery.extend(true, s, jQuery.extend(true, {}, jQuery.ajaxSettings, s));
+
+		// convert data if not already a string
+		if ( s.data && s.processData && typeof s.data != "string" )
+			s.data = jQuery.param(s.data);
+
+		// Handle JSONP Parameter Callbacks
+		if ( s.dataType == "jsonp" ) {
+			if ( s.type.toLowerCase() == "get" ) {
+				if ( !s.url.match(jsre) )
+					s.url += (s.url.match(/\?/) ? "&" : "?") + (s.jsonp || "callback") + "=?";
+			} else if ( !s.data || !s.data.match(jsre) )
+				s.data = (s.data ? s.data + "&" : "") + (s.jsonp || "callback") + "=?";
+			s.dataType = "json";
+		}
+
+		// Build temporary JSONP function
+		if ( s.dataType == "json" && (s.data && s.data.match(jsre) || s.url.match(jsre)) ) {
+			jsonp = "jsonp" + jsc++;
+
+			// Replace the =? sequence both in the query string and the data
+			if ( s.data )
+				s.data = s.data.replace(jsre, "=" + jsonp);
+			s.url = s.url.replace(jsre, "=" + jsonp);
+
+			// We need to make sure
+			// that a JSONP style response is executed properly
+			s.dataType = "script";
+
+			// Handle JSONP-style loading
+			window[ jsonp ] = function(tmp){
+				data = tmp;
+				success();
+				complete();
+				// Garbage collect
+				window[ jsonp ] = undefined;
+				try{ delete window[ jsonp ]; } catch(e){}
+			};
+		}
+
+		if ( s.dataType == "script" && s.cache == null )
+			s.cache = false;
+
+		if ( s.cache === false && s.type.toLowerCase() == "get" )
+			s.url += (s.url.match(/\?/) ? "&" : "?") + "_=" + (new Date()).getTime();
+
+		// If data is available, append data to url for get requests
+		if ( s.data && s.type.toLowerCase() == "get" ) {
+			s.url += (s.url.match(/\?/) ? "&" : "?") + s.data;
+
+			// IE likes to send both get and post data, prevent this
+			s.data = null;
+		}
+
+		// Watch for a new set of requests
+		if ( s.global && ! jQuery.active++ )
+			jQuery.event.trigger( "ajaxStart" );
+
+		// If we're requesting a remote document
+		// and trying to load JSON or Script
+		if ( !s.url.indexOf("http") && s.dataType == "script" ) {
+			var head = document.getElementsByTagName("head")[0];
+			var script = document.createElement("script");
+			script.src = s.url;
+
+			// Handle Script loading
+			if ( !jsonp && (s.success || s.complete) ) {
+				var done = false;
+
+				// Attach handlers for all browsers
+				script.onload = script.onreadystatechange = function(){
+					if ( !done && (!this.readyState || 
+							this.readyState == "loaded" || this.readyState == "complete") ) {
+						done = true;
+						success();
+						complete();
+						head.removeChild( script );
+					}
+				};
+			}
+
+			head.appendChild(script);
+
+			// We handle everything using the script element injection
+			return;
+		}
+
+		var requestDone = false;
+
+		// Create the request object; Microsoft failed to properly
+		// implement the XMLHttpRequest in IE7, so we use the ActiveXObject when it is available
+		var xml = window.ActiveXObject ? new ActiveXObject("Microsoft.XMLHTTP") : new XMLHttpRequest();
+
+		// Open the socket
+		xml.open(s.type, s.url, s.async);
+
+		// Set the correct header, if data is being sent
+		if ( s.data )
+			xml.setRequestHeader("Content-Type", s.contentType);
+
+		// Set the If-Modified-Since header, if ifModified mode.
+		if ( s.ifModified )
+			xml.setRequestHeader("If-Modified-Since",
+				jQuery.lastModified[s.url] || "Thu, 01 Jan 1970 00:00:00 GMT" );
+
+		// Set header so the called script knows that it's an XMLHttpRequest
+		xml.setRequestHeader("X-Requested-With", "XMLHttpRequest");
+
+		// Allow custom headers/mimetypes
+		if ( s.beforeSend )
+			s.beforeSend(xml);
+			
+		if ( s.global )
+		    jQuery.event.trigger("ajaxSend", [xml, s]);
+
+		// Wait for a response to come back
+		var onreadystatechange = function(isTimeout){
+			// The transfer is complete and the data is available, or the request timed out
+			if ( !requestDone && xml && (xml.readyState == 4 || isTimeout == "timeout") ) {
+				requestDone = true;
+				
+				// clear poll interval
+				if (ival) {
+					clearInterval(ival);
+					ival = null;
+				}
+				
+				status = isTimeout == "timeout" && "timeout" ||
+					!jQuery.httpSuccess( xml ) && "error" ||
+					s.ifModified && jQuery.httpNotModified( xml, s.url ) && "notmodified" ||
+					"success";
+
+				if ( status == "success" ) {
+					// Watch for, and catch, XML document parse errors
+					try {
+						// process the data (runs the xml through httpData regardless of callback)
+						data = jQuery.httpData( xml, s.dataType );
+					} catch(e) {
+						status = "parsererror";
+					}
+				}
+
+				// Make sure that the request was successful or notmodified
+				if ( status == "success" ) {
+					// Cache Last-Modified header, if ifModified mode.
+					var modRes;
+					try {
+						modRes = xml.getResponseHeader("Last-Modified");
+					} catch(e) {} // swallow exception thrown by FF if header is not available
+	
+					if ( s.ifModified && modRes )
+						jQuery.lastModified[s.url] = modRes;
+
+					// JSONP handles its own success callback
+					if ( !jsonp )
+						success();	
+				} else
+					jQuery.handleError(s, xml, status);
+
+				// Fire the complete handlers
+				complete();
+
+				// Stop memory leaks
+				if ( s.async )
+					xml = null;
+			}
+		};
+		
+		if ( s.async ) {
+			// don't attach the handler to the request, just poll it instead
+			var ival = setInterval(onreadystatechange, 13); 
+
+			// Timeout checker
+			if ( s.timeout > 0 )
+				setTimeout(function(){
+					// Check to see if the request is still happening
+					if ( xml ) {
+						// Cancel the request
+						xml.abort();
+	
+						if( !requestDone )
+							onreadystatechange( "timeout" );
+					}
+				}, s.timeout);
+		}
+			
+		// Send the data
+		try {
+			xml.send(s.data);
+		} catch(e) {
+			jQuery.handleError(s, xml, null, e);
+		}
+		
+		// firefox 1.5 doesn't fire statechange for sync requests
+		if ( !s.async )
+			onreadystatechange();
+		
+		// return XMLHttpRequest to allow aborting the request etc.
+		return xml;
+
+		function success(){
+			// If a local callback was specified, fire it and pass it the data
+			if ( s.success )
+				s.success( data, status );
+
+			// Fire the global callback
+			if ( s.global )
+				jQuery.event.trigger( "ajaxSuccess", [xml, s] );
+		}
+
+		function complete(){
+			// Process result
+			if ( s.complete )
+				s.complete(xml, status);
+
+			// The request was completed
+			if ( s.global )
+				jQuery.event.trigger( "ajaxComplete", [xml, s] );
+
+			// Handle the global AJAX counter
+			if ( s.global && ! --jQuery.active )
+				jQuery.event.trigger( "ajaxStop" );
+		}
+	},
+
+	handleError: function( s, xml, status, e ) {
+		// If a local callback was specified, fire it
+		if ( s.error ) s.error( xml, status, e );
+
+		// Fire the global callback
+		if ( s.global )
+			jQuery.event.trigger( "ajaxError", [xml, s, e] );
+	},
+
+	// Counter for holding the number of active queries
+	active: 0,
+
+	// Determines if an XMLHttpRequest was successful or not
+	httpSuccess: function( r ) {
+		try {
+			return !r.status && location.protocol == "file:" ||
+				( r.status >= 200 && r.status < 300 ) || r.status == 304 ||
+				jQuery.browser.safari && r.status == undefined;
+		} catch(e){}
+		return false;
+	},
+
+	// Determines if an XMLHttpRequest returns NotModified
+	httpNotModified: function( xml, url ) {
+		try {
+			var xmlRes = xml.getResponseHeader("Last-Modified");
+
+			// Firefox always returns 200. check Last-Modified date
+			return xml.status == 304 || xmlRes == jQuery.lastModified[url] ||
+				jQuery.browser.safari && xml.status == undefined;
+		} catch(e){}
+		return false;
+	},
+
+	httpData: function( r, type ) {
+		var ct = r.getResponseHeader("content-type");
+		var xml = type == "xml" || !type && ct && ct.indexOf("xml") >= 0;
+		var data = xml ? r.responseXML : r.responseText;
+
+		if ( xml && data.documentElement.tagName == "parsererror" )
+			throw "parsererror";
+
+		// If the type is "script", eval it in global context
+		if ( type == "script" )
+			jQuery.globalEval( data );
+
+		// Get the JavaScript object, if JSON is used.
+		if ( type == "json" )
+			data = eval("(" + data + ")");
+
+		return data;
+	},
+
+	// Serialize an array of form elements or a set of
+	// key/values into a query string
+	param: function( a ) {
+		var s = [];
+
+		// If an array was passed in, assume that it is an array
+		// of form elements
+		if ( a.constructor == Array || a.jquery )
+			// Serialize the form elements
+			jQuery.each( a, function(){
+				s.push( encodeURIComponent(this.name) + "=" + encodeURIComponent( this.value ) );
+			});
+
+		// Otherwise, assume that it's an object of key/value pairs
+		else
+			// Serialize the key/values
+			for ( var j in a )
+				// If the value is an array then the key names need to be repeated
+				if ( a[j] && a[j].constructor == Array )
+					jQuery.each( a[j], function(){
+						s.push( encodeURIComponent(j) + "=" + encodeURIComponent( this ) );
+					});
+				else
+					s.push( encodeURIComponent(j) + "=" + encodeURIComponent( a[j] ) );
+
+		// Return the resulting serialization
+		return s.join("&").replace(/%20/g, "+");
+	}
+
+});
+jQuery.fn.extend({
+	show: function(speed,callback){
+		return speed ?
+			this.animate({
+				height: "show", width: "show", opacity: "show"
+			}, speed, callback) :
+			
+			this.filter(":hidden").each(function(){
+				this.style.display = this.oldblock ? this.oldblock : "";
+				if ( jQuery.css(this,"display") == "none" )
+					this.style.display = "block";
+			}).end();
+	},
+	
+	hide: function(speed,callback){
+		return speed ?
+			this.animate({
+				height: "hide", width: "hide", opacity: "hide"
+			}, speed, callback) :
+			
+			this.filter(":visible").each(function(){
+				this.oldblock = this.oldblock || jQuery.css(this,"display");
+				if ( this.oldblock == "none" )
+					this.oldblock = "block";
+				this.style.display = "none";
+			}).end();
+	},
+
+	// Save the old toggle function
+	_toggle: jQuery.fn.toggle,
+	
+	toggle: function( fn, fn2 ){
+		return jQuery.isFunction(fn) && jQuery.isFunction(fn2) ?
+			this._toggle( fn, fn2 ) :
+			fn ?
+				this.animate({
+					height: "toggle", width: "toggle", opacity: "toggle"
+				}, fn, fn2) :
+				this.each(function(){
+					jQuery(this)[ jQuery(this).is(":hidden") ? "show" : "hide" ]();
+				});
+	},
+	
+	slideDown: function(speed,callback){
+		return this.animate({height: "show"}, speed, callback);
+	},
+	
+	slideUp: function(speed,callback){
+		return this.animate({height: "hide"}, speed, callback);
+	},
+
+	slideToggle: function(speed, callback){
+		return this.animate({height: "toggle"}, speed, callback);
+	},
+	
+	fadeIn: function(speed, callback){
+		return this.animate({opacity: "show"}, speed, callback);
+	},
+	
+	fadeOut: function(speed, callback){
+		return this.animate({opacity: "hide"}, speed, callback);
+	},
+	
+	fadeTo: function(speed,to,callback){
+		return this.animate({opacity: to}, speed, callback);
+	},
+	
+	animate: function( prop, speed, easing, callback ) {
+		var opt = jQuery.speed(speed, easing, callback);
+
+		return this[ opt.queue === false ? "each" : "queue" ](function(){
+			opt = jQuery.extend({}, opt);
+			var hidden = jQuery(this).is(":hidden"), self = this;
+			
+			for ( var p in prop ) {
+				if ( prop[p] == "hide" && hidden || prop[p] == "show" && !hidden )
+					return jQuery.isFunction(opt.complete) && opt.complete.apply(this);
+
+				if ( p == "height" || p == "width" ) {
+					// Store display property
+					opt.display = jQuery.css(this, "display");
+
+					// Make sure that nothing sneaks out
+					opt.overflow = this.style.overflow;
+				}
+			}
+
+			if ( opt.overflow != null )
+				this.style.overflow = "hidden";
+
+			opt.curAnim = jQuery.extend({}, prop);
+			
+			jQuery.each( prop, function(name, val){
+				var e = new jQuery.fx( self, opt, name );
+
+				if ( /toggle|show|hide/.test(val) )
+					e[ val == "toggle" ? hidden ? "show" : "hide" : val ]( prop );
+				else {
+					var parts = val.toString().match(/^([+-]=)?([\d+-.]+)(.*)$/),
+						start = e.cur(true) || 0;
+
+					if ( parts ) {
+						var end = parseFloat(parts[2]),
+							unit = parts[3] || "px";
+
+						// We need to compute starting value
+						if ( unit != "px" ) {
+							self.style[ name ] = (end || 1) + unit;
+							start = ((end || 1) / e.cur(true)) * start;
+							self.style[ name ] = start + unit;
+						}
+
+						// If a +=/-= token was provided, we're doing a relative animation
+						if ( parts[1] )
+							end = ((parts[1] == "-=" ? -1 : 1) * end) + start;
+
+						e.custom( start, end, unit );
+					} else
+						e.custom( start, val, "" );
+				}
+			});
+
+			// For JS strict compliance
+			return true;
+		});
+	},
+	
+	queue: function(type, fn){
+		if ( jQuery.isFunction(type) ) {
+			fn = type;
+			type = "fx";
+		}
+
+		if ( !type || (typeof type == "string" && !fn) )
+			return queue( this[0], type );
+
+		return this.each(function(){
+			if ( fn.constructor == Array )
+				queue(this, type, fn);
+			else {
+				queue(this, type).push( fn );
+			
+				if ( queue(this, type).length == 1 )
+					fn.apply(this);
+			}
+		});
+	},
+
+	stop: function(){
+		var timers = jQuery.timers;
+
+		return this.each(function(){
+			for ( var i = 0; i < timers.length; i++ )
+				if ( timers[i].elem == this )
+					timers.splice(i--, 1);
+		}).dequeue();
+	}
+
+});
+
+var queue = function( elem, type, array ) {
+	if ( !elem )
+		return;
+
+	var q = jQuery.data( elem, type + "queue" );
+
+	if ( !q || array )
+		q = jQuery.data( elem, type + "queue", 
+			array ? jQuery.makeArray(array) : [] );
+
+	return q;
+};
+
+jQuery.fn.dequeue = function(type){
+	type = type || "fx";
+
+	return this.each(function(){
+		var q = queue(this, type);
+
+		q.shift();
+
+		if ( q.length )
+			q[0].apply( this );
+	});
+};
+
+jQuery.extend({
+	
+	speed: function(speed, easing, fn) {
+		var opt = speed && speed.constructor == Object ? speed : {
+			complete: fn || !fn && easing || 
+				jQuery.isFunction( speed ) && speed,
+			duration: speed,
+			easing: fn && easing || easing && easing.constructor != Function && easing
+		};
+
+		opt.duration = (opt.duration && opt.duration.constructor == Number ? 
+			opt.duration : 
+			{ slow: 600, fast: 200 }[opt.duration]) || 400;
+	
+		// Queueing
+		opt.old = opt.complete;
+		opt.complete = function(){
+			jQuery(this).dequeue();
+			if ( jQuery.isFunction( opt.old ) )
+				opt.old.apply( this );
+		};
+	
+		return opt;
+	},
+	
+	easing: {
+		linear: function( p, n, firstNum, diff ) {
+			return firstNum + diff * p;
+		},
+		swing: function( p, n, firstNum, diff ) {
+			return ((-Math.cos(p*Math.PI)/2) + 0.5) * diff + firstNum;
+		}
+	},
+	
+	timers: [],
+
+	fx: function( elem, options, prop ){
+		this.options = options;
+		this.elem = elem;
+		this.prop = prop;
+
+		if ( !options.orig )
+			options.orig = {};
+	}
+
+});
+
+jQuery.fx.prototype = {
+
+	// Simple function for setting a style value
+	update: function(){
+		if ( this.options.step )
+			this.options.step.apply( this.elem, [ this.now, this ] );
+
+		(jQuery.fx.step[this.prop] || jQuery.fx.step._default)( this );
+
+		// Set display property to block for height/width animations
+		if ( this.prop == "height" || this.prop == "width" )
+			this.elem.style.display = "block";
+	},
+
+	// Get the current size
+	cur: function(force){
+		if ( this.elem[this.prop] != null && this.elem.style[this.prop] == null )
+			return this.elem[ this.prop ];
+
+		var r = parseFloat(jQuery.curCSS(this.elem, this.prop, force));
+		return r && r > -10000 ? r : parseFloat(jQuery.css(this.elem, this.prop)) || 0;
+	},
+
+	// Start an animation from one number to another
+	custom: function(from, to, unit){
+		this.startTime = (new Date()).getTime();
+		this.start = from;
+		this.end = to;
+		this.unit = unit || this.unit || "px";
+		this.now = this.start;
+		this.pos = this.state = 0;
+		this.update();
+
+		var self = this;
+		function t(){
+			return self.step();
+		}
+
+		t.elem = this.elem;
+
+		jQuery.timers.push(t);
+
+		if ( jQuery.timers.length == 1 ) {
+			var timer = setInterval(function(){
+				var timers = jQuery.timers;
+				
+				for ( var i = 0; i < timers.length; i++ )
+					if ( !timers[i]() )
+						timers.splice(i--, 1);
+
+				if ( !timers.length )
+					clearInterval( timer );
+			}, 13);
+		}
+	},
+
+	// Simple 'show' function
+	show: function(){
+		// Remember where we started, so that we can go back to it later
+		this.options.orig[this.prop] = jQuery.attr( this.elem.style, this.prop );
+		this.options.show = true;
+
+		// Begin the animation
+		this.custom(0, this.cur());
+
+		// Make sure that we start at a small width/height to avoid any
+		// flash of content
+		if ( this.prop == "width" || this.prop == "height" )
+			this.elem.style[this.prop] = "1px";
+		
+		// Start by showing the element
+		jQuery(this.elem).show();
+	},
+
+	// Simple 'hide' function
+	hide: function(){
+		// Remember where we started, so that we can go back to it later
+		this.options.orig[this.prop] = jQuery.attr( this.elem.style, this.prop );
+		this.options.hide = true;
+
+		// Begin the animation
+		this.custom(this.cur(), 0);
+	},
+
+	// Each step of an animation
+	step: function(){
+		var t = (new Date()).getTime();
+
+		if ( t > this.options.duration + this.startTime ) {
+			this.now = this.end;
+			this.pos = this.state = 1;
+			this.update();
+
+			this.options.curAnim[ this.prop ] = true;
+
+			var done = true;
+			for ( var i in this.options.curAnim )
+				if ( this.options.curAnim[i] !== true )
+					done = false;
+
+			if ( done ) {
+				if ( this.options.display != null ) {
+					// Reset the overflow
+					this.elem.style.overflow = this.options.overflow;
+				
+					// Reset the display
+					this.elem.style.display = this.options.display;
+					if ( jQuery.css(this.elem, "display") == "none" )
+						this.elem.style.display = "block";
+				}
+
+				// Hide the element if the "hide" operation was done
+				if ( this.options.hide )
+					this.elem.style.display = "none";
+
+				// Reset the properties, if the item has been hidden or shown
+				if ( this.options.hide || this.options.show )
+					for ( var p in this.options.curAnim )
+						jQuery.attr(this.elem.style, p, this.options.orig[p]);
+			}
+
+			// If a callback was provided, execute it
+			if ( done && jQuery.isFunction( this.options.complete ) )
+				// Execute the complete function
+				this.options.complete.apply( this.elem );
+
+			return false;
+		} else {
+			var n = t - this.startTime;
+			this.state = n / this.options.duration;
+
+			// Perform the easing function, defaults to swing
+			this.pos = jQuery.easing[this.options.easing || (jQuery.easing.swing ? "swing" : "linear")](this.state, n, 0, 1, this.options.duration);
+			this.now = this.start + ((this.end - this.start) * this.pos);
+
+			// Perform the next step of the animation
+			this.update();
+		}
+
+		return true;
+	}
+
+};
+
+jQuery.fx.step = {
+	scrollLeft: function(fx){
+		fx.elem.scrollLeft = fx.now;
+	},
+
+	scrollTop: function(fx){
+		fx.elem.scrollTop = fx.now;
+	},
+
+	opacity: function(fx){
+		jQuery.attr(fx.elem.style, "opacity", fx.now);
+	},
+
+	_default: function(fx){
+		fx.elem.style[ fx.prop ] = fx.now + fx.unit;
+	}
+};
+// The Offset Method
+// Originally By Brandon Aaron, part of the Dimension Plugin
+// http://jquery.com/plugins/project/dimensions
+jQuery.fn.offset = function() {
+	var left = 0, top = 0, elem = this[0], results;
+	
+	if ( elem ) with ( jQuery.browser ) {
+		var	absolute     = jQuery.css(elem, "position") == "absolute", 
+		    parent       = elem.parentNode, 
+		    offsetParent = elem.offsetParent, 
+		    doc          = elem.ownerDocument,
+		    safari2      = safari && parseInt(version) < 522;
+	
+		// Use getBoundingClientRect if available
+		if ( elem.getBoundingClientRect ) {
+			box = elem.getBoundingClientRect();
+		
+			// Add the document scroll offsets
+			add(
+				box.left + Math.max(doc.documentElement.scrollLeft, doc.body.scrollLeft),
+				box.top  + Math.max(doc.documentElement.scrollTop,  doc.body.scrollTop)
+			);
+		
+			// IE adds the HTML element's border, by default it is medium which is 2px
+			// IE 6 and IE 7 quirks mode the border width is overwritable by the following css html { border: 0; }
+			// IE 7 standards mode, the border is always 2px
+			if ( msie ) {
+				var border = jQuery("html").css("borderWidth");
+				border = (border == "medium" || jQuery.boxModel && parseInt(version) >= 7) && 2 || border;
+				add( -border, -border );
+			}
+	
+		// Otherwise loop through the offsetParents and parentNodes
+		} else {
+		
+			// Initial element offsets
+			add( elem.offsetLeft, elem.offsetTop );
+		
+			// Get parent offsets
+			while ( offsetParent ) {
+				// Add offsetParent offsets
+				add( offsetParent.offsetLeft, offsetParent.offsetTop );
+			
+				// Mozilla and Safari > 2 does not include the border on offset parents
+				// However Mozilla adds the border for table cells
+				if ( mozilla && /^t[d|h]$/i.test(parent.tagName) || !safari2 )
+					border( offsetParent );
+				
+				// Safari <= 2 doubles body offsets with an absolutely positioned element or parent
+				if ( safari2 && !absolute && jQuery.css(offsetParent, "position") == "absolute" )
+					absolute = true;
+			
+				// Get next offsetParent
+				offsetParent = offsetParent.offsetParent;
+			}
+		
+			// Get parent scroll offsets
+			while ( parent.tagName && !/^body|html$/i.test(parent.tagName) ) {
+				// Work around opera inline/table scrollLeft/Top bug
+				if ( !/^inline|table-row.*$/i.test(jQuery.css(parent, "display")) )
+					// Subtract parent scroll offsets
+					add( -parent.scrollLeft, -parent.scrollTop );
+			
+				// Mozilla does not add the border for a parent that has overflow != visible
+				if ( mozilla && jQuery.css(parent, "overflow") != "visible" )
+					border( parent );
+			
+				// Get next parent
+				parent = parent.parentNode;
+			}
+		
+			// Safari doubles body offsets with an absolutely positioned element or parent
+			if ( safari2 && absolute )
+				add( -doc.body.offsetLeft, -doc.body.offsetTop );
+		}
+
+		// Return an object with top and left properties
+		results = { top: top, left: left };
+	}
+
+	return results;
+
+	function border(elem) {
+		add( jQuery.css(elem, "borderLeftWidth"), jQuery.css(elem, "borderTopWidth") );
+	}
+
+	function add(l, t) {
+		left += parseInt(l) || 0;
+		top += parseInt(t) || 0;
+	}
+};
+})();
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,304 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Main
+    ( main )
+where
+
+import State.Types ( State, ChapterId, mkChapterId, CommentId
+                   , Comment, getCounts, findComments, mkCommentId
+                   , addChapter, Comment(..), addComment, SessionId(..)
+                   , getLastInfo, SessionInfo(..), getChapterComments
+                   , getChapterURI
+                   )
+import qualified State.Mem
+import qualified State.Disk
+import qualified State.SQLite
+
+import Network.URI ( URI, parseRelativeReference )
+import Data.Function ( on )
+import Data.List ( sort, sortBy, intercalate )
+import Data.Maybe ( fromJust )
+import Data.Array.Unboxed ( UArray, listArray, bounds, (!) )
+import Data.Char ( isPrint, chr )
+import System.Directory ( createDirectory, getTemporaryDirectory, removeDirectoryRecursive )
+import System.FilePath ( (</>) )
+import Control.Monad ( replicateM, forM_, unless, join, replicateM_, foldM )
+import Control.Monad.Random ( getRandomR, getRandom )
+import Control.Applicative ( (<$>), (<*>) )
+import Control.Exception ( finally )
+import System.IO ( stdout, hFlush )
+import qualified Data.Text as T
+import qualified Data.ByteString as B
+import qualified Data.Text.Encoding as E
+
+withTemporaryDirectory :: (FilePath -> IO a) -> IO a
+withTemporaryDirectory act = do
+  t <- getTemporaryDirectory
+  let choices = ['0'..'9'] ++ ['a'..'z'] ++ ['A'..'Z']
+      rndChar = (choices !!) <$> getRandomR (0, length choices - 1)
+  randPart <- replicateM 8 rndChar
+  let d = t </> ("test-" ++ randPart)
+  createDirectory d
+  act d `finally` removeDirectoryRecursive d
+
+data StoreType = Disk | SQLite | Mem deriving (Show, Eq, Enum, Bounded)
+
+mkStore :: FilePath -> StoreType -> IO State
+mkStore d t = case t of
+                Mem    -> State.Mem.new
+                Disk   -> State.Disk.new (d </> "flat-file")
+                SQLite -> State.SQLite.new (d </> "state.db")
+
+withRandomStores :: ((StoreType, State) -> (StoreType, State) -> IO a) -> IO a
+withRandomStores act = do
+  elim <- getRandomR (0, 2)
+  let (before, (_:after)) = splitAt elim [Disk, SQLite, Mem]
+      ts = before ++ after
+  withTemporaryDirectory $ \d ->
+      do [st1, st2] <- zip ts <$> mapM (mkStore d) ts
+         act st1 st2
+
+data Operation = GetCounts (Maybe ChapterId)
+               | FindComments CommentId
+               | AddChapter ChapterId [CommentId] (Maybe URI)
+               | AddComment CommentId (Maybe ChapterId) Comment
+               | GetLastInfo SessionId
+               | GetChapterComments ChapterId
+               | GetChapterURI ChapterId
+                 deriving Show
+
+data Result = Unit
+            | LastInfo (Maybe SessionInfo)
+            | Counts [(CommentId, Int)]
+            | Comments [Comment]
+            | ChapterComments [(CommentId, Comment)]
+            | ChapterURI (Maybe URI)
+              deriving (Eq, Show)
+
+applyOp :: Operation -> State -> IO Result
+applyOp (GetCounts p) st               = Counts . sort <$> getCounts st p
+applyOp (FindComments cId) st          = Comments <$> findComments st cId
+applyOp (AddChapter chId cIds mURI) st = addChapter st chId cIds mURI >> return Unit
+applyOp (AddComment cId mChId c) st    = addComment st cId mChId c >> return Unit
+applyOp (GetLastInfo sId) st           = LastInfo <$> getLastInfo st sId
+applyOp (GetChapterComments chId) st   = ChapterComments <$> getChapterComments st chId
+applyOp (GetChapterURI chId) st        = ChapterURI <$> getChapterURI st chId
+
+choice :: [a] -> IO a
+choice xs = (xs !!) <$> getRandomR (0, length xs - 1)
+
+shuffle :: [a] -> IO [a]
+shuffle xs = do
+  is <- replicateM (length xs) getRandom :: IO [Double]
+  return $ map snd $ sortBy (compare `on` fst) $ zip is xs
+
+randomOp :: IO Operation
+randomOp = join $ choice $ readOps ++ writeOps
+
+randomWriteOp :: IO Operation
+randomWriteOp = join $ choice writeOps
+
+readOps :: [IO Operation]
+readOps = [ GetCounts <$> maybeRand randomChapterId
+          , FindComments <$> randomCommentId
+          , GetLastInfo <$> randomSessionId
+          , GetChapterComments <$> randomChapterId
+          , GetChapterURI <$> randomChapterId
+          ]
+
+writeOps :: [IO Operation]
+writeOps = [ let cIds = do
+                   n <- getRandomR (0, 100)
+                   replicateM n $ randomCommentId
+             in AddChapter <$>
+                randomChapterId <*>
+                cIds <*>
+                maybeRand randomRelativeReference
+           , AddComment
+             <$> randomCommentId
+             <*> maybeRand randomChapterId
+             <*> randomComment
+           ]
+
+maybeRand :: IO a -> IO (Maybe a)
+maybeRand x = do
+  doGen <- getRandom
+  if doGen then Just <$> x else return Nothing
+
+randomChapterId :: IO ChapterId
+randomChapterId = fromJust . mkChapterId <$> randomText
+
+randomCommentId :: IO CommentId
+randomCommentId = fromJust . mkCommentId <$> randomText
+
+randomSessionId :: IO SessionId
+randomSessionId = SessionId <$> randomBS
+
+randomRelativeReference :: IO URI
+randomRelativeReference = do
+  nSegs <- getRandomR (0, 5)
+  let getSeg = join $ choice
+               [ choice ["a", "b", "c"]
+               , do segLength <- getRandomR (1, 10)
+                    replicateM segLength $
+                               choice $ ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9']
+               ]
+  fromJust . parseRelativeReference . ('/':) . intercalate "/" <$> replicateM nSegs getSeg
+
+usableChars :: UArray Int Char
+usableChars = listArray (0, length cs - 1) cs
+    where
+      cs = filter isPrint [chr 0..chr 127]
+
+randomBS :: IO B.ByteString
+randomBS = E.encodeUtf8 <$> randomText
+
+randomText :: IO T.Text
+randomText = join $ choice [commonValue, completelyRandom]
+    where
+      completelyRandom = do
+        let randomChar = do
+              i <- getRandomR $ bounds usableChars
+              return $ usableChars ! i
+        n <- getRandomR (2, 40)
+        T.pack <$> replicateM n randomChar
+      commonValue = choice ["alpha", "beta", "gamma", "delta"]
+
+randomOps :: Int -> Int -> IO [Operation]
+randomOps minOps maxOps = do
+  n <- getRandomR (minOps, maxOps)
+  replicateM n randomOp
+
+doRandomOperations :: (StoreType, State) -> (StoreType, State) -> IO ()
+doRandomOperations (ty1, st1) (ty2, st2) = do
+  ops <- randomOps 10 500
+  _ <- foldM (\soFar op -> do
+                r1 <- applyOp op st1
+                r2 <- applyOp op st2
+                unless (r1 == r2) $ do
+                  putStrLn $ "Got different results for " ++ show op ++ " on " ++
+                           show ty1 ++ " and " ++ show ty2
+                  forM_ (soFar [op]) print
+                  putStrLn $ "Results were:"
+                  print ty1
+                  print r1
+                  putStrLn $ replicate 50 '-'
+                  print ty2
+                  print r2
+                  putStrLn $ replicate 50 '-'
+                  error "Test failed"
+                return (soFar . (op:))) id ops
+  return ()
+
+randomComment :: IO Comment
+randomComment = Comment
+                <$> randomText
+                <*> randomText
+                <*> maybeRand randomText
+                <*> randomTime
+                <*> randomSessionId
+    where
+      -- This one's a little sketchy, because we expect that the time
+      -- of each comment will be monotonically non-decreasing. The
+      -- stores should act sanely in the face of arbitrary time
+      -- values, however, so I'll leave it as a completely random
+      -- value.
+      randomTime = realToFrac <$> (getRandomR (0, 2**32) :: IO Double)
+
+-- |Test that adding a comment to a node makes it show up at the end
+-- of the list of comments when it is subsequently loaded
+storeLoadComment :: State -> IO ()
+storeLoadComment st = do
+  cId <- randomCommentId
+  cs <- findComments st cId
+  c <- randomComment
+  addComment st cId Nothing c
+  cs' <- findComments st cId
+  unless (cs' == (cs ++ [c])) $
+         error "Expected the added comment to come up after adding"
+
+-- |Test that a comment for a node that is part of a chapter is
+-- returned when we request comments for this chapter
+storeLoadCommentChapter :: State -> IO ()
+storeLoadCommentChapter st = do
+  chId <- randomChapterId
+  cId <- randomCommentId
+  ref <- maybeRand randomRelativeReference
+  addChapter st chId [cId] ref
+  cs <- getChapterComments st chId
+  c <- randomComment
+  addComment st cId (Just chId) c
+  let e = (cId, c)
+  cs' <- getChapterComments st chId
+  unless (length cs' == length cs + 1) $
+         error $ unlines [ "Adding a comment to a chapter didn't add:"
+                         , show cs
+                         , show cs'
+                         ]
+  unless (e `elem` cs') $
+         error "The expected pair is not part of the comments"
+
+chapterCommentsOrderedByDate :: State -> IO ()
+chapterCommentsOrderedByDate st = do
+  cs <- getChapterComments st =<< randomChapterId
+  let dates = map (cDate . snd) cs
+      ordered = and $ zipWith (>=) dates (drop 1 dates)
+  unless ordered $
+         error "The comments are not ordered by date"
+
+-- |Test some property (possibly with a side-effect)
+runArbitraryTest :: State -> IO ()
+runArbitraryTest st = ($ st) =<< choice allProps
+
+-- |All defined properties
+allProps :: [State -> IO ()]
+allProps = [ storeLoadComment
+           , chapterCommentsOrderedByDate
+           , storeLoadCommentChapter
+           ]
+
+-- |Run all of the properties at least once, in an arbitrary order
+runRandomTests :: State -> IO ()
+runRandomTests st = do
+  m <- getRandomR (0, 100)
+  let randomProps = replicate m $ runArbitraryTest st
+  props <- shuffle $ map ($ st) allProps ++ randomProps
+  sequence_ props
+
+main :: IO ()
+main = do
+  putStr "Testing store properties: "
+  hFlush stdout
+
+  -- Test properties of stores
+  withTemporaryDirectory $ \d ->
+      forM_ [minBound .. maxBound] $ \typ -> do
+         putStr $ show typ ++ " "
+         hFlush stdout
+
+         st <- mkStore d typ
+
+         -- Run some tests on an empty store
+         runRandomTests st
+
+         replicateM 10 $ do
+           putStr "."
+           hFlush stdout
+
+           -- Do some random operations to get the store into a
+           -- different state
+           n <- getRandomR (10, 500)
+           ops <- replicateM n randomWriteOp
+           forM_ ops $ \op -> applyOp op st
+
+           runRandomTests st
+
+  putStrLn "done.\nRunning store consistency tests"
+  -- Test that completely randomized operations have the same
+  -- observable state between different store types
+  replicateM_ 100 $ do
+         withRandomStores doRandomOperations
+         putStr "."
+         hFlush stdout
+
+  putStrLn "\ndone. Tests passed."
+
