packages feed

doc-review (empty) → 0.7

raw patch · 32 files changed

+7035/−0 lines, 32 filesdep +MonadCatchIO-transformersdep +MonadRandomdep +arraysetup-changedbinary-added

Dependencies added: MonadCatchIO-transformers, MonadRandom, array, base, base64-bytestring, binary, bytestring, containers, directory, feed, filepath, haskell98, heist, hexpat, json, monads-fd, network, old-locale, snap-core, snap-server, sqlite, tagchup, text, time, transformers, unix, xhtml-combinators, xml, xml-basic

Files

+ API.rst view
@@ -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.
+ LICENSE view
@@ -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.
+ README.rst view
@@ -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!
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ TODO.rst view
@@ -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)
+ doc-review.cabal view
@@ -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
+ src/Analyze.hs view
@@ -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')
+ src/Config.hs view
@@ -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"+         ]
+ src/Config/Command/Report.hs view
@@ -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 = []
+ src/Config/Command/Run.hs view
@@ -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 }
+ src/Config/Command/Scan.hs view
@@ -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
+ src/Config/Format.hs view
@@ -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 ' '
+ src/Config/GetOpt.hs view
@@ -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 _        = []
+ src/Config/Store.hs view
@@ -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)
+ src/Config/Types.hs view
@@ -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"]
+ src/Feed.hs view
@@ -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+                 }
+ src/Main.hs view
@@ -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
+ src/Privilege.hs view
@@ -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
+ src/Report.hs view
@@ -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
+ src/Server.hs view
@@ -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" ]
+ src/State/Disk.hs view
@@ -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)+                         )
+ src/State/Logger.hs view
@@ -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
+ src/State/Mem.hs view
@@ -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
+ src/State/SQLite.hs view
@@ -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
+ src/State/Types.hs view
@@ -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
+ static/comments.css view
@@ -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;+}
+ static/default.css view
@@ -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;+}
+ static/support/icons/throbber.gif view

binary file changed (absent → 1849 bytes)

+ static/support/javascript/form.js view
@@ -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);
+ static/support/javascript/hsbook.js view
@@ -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>";+}
+ static/support/javascript/jquery.js view
@@ -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;+	}+};+})();
+ test/Main.hs view
@@ -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."+