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 +97/−0
- LICENSE +30/−0
- README.rst +220/−0
- Setup.hs +2/−0
- TODO.rst +34/−0
- doc-review.cabal +108/−0
- src/Analyze.hs +104/−0
- src/Config.hs +85/−0
- src/Config/Command/Report.hs +14/−0
- src/Config/Command/Run.hs +100/−0
- src/Config/Command/Scan.hs +26/−0
- src/Config/Format.hs +94/−0
- src/Config/GetOpt.hs +90/−0
- src/Config/Store.hs +54/−0
- src/Config/Types.hs +48/−0
- src/Feed.hs +57/−0
- src/Main.hs +449/−0
- src/Privilege.hs +75/−0
- src/Report.hs +75/−0
- src/Server.hs +111/−0
- src/State/Disk.hs +193/−0
- src/State/Logger.hs +163/−0
- src/State/Mem.hs +118/−0
- src/State/SQLite.hs +291/−0
- src/State/Types.hs +91/−0
- static/comments.css +38/−0
- static/default.css +12/−0
- static/support/icons/throbber.gif binary
- static/support/javascript/form.js +819/−0
- static/support/javascript/hsbook.js +141/−0
- static/support/javascript/jquery.js +2992/−0
- test/Main.hs +304/−0
+ 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&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&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."+