~alcinnz/bureaucromancy

696da319f63881a1482a71ef5d7c067fb471b61e — Adrian Cochrane 1 year, 1 day ago d634935
Document & add missing files.
M bureaucromancy.cabal => bureaucromancy.cabal +11 -7
@@ 20,7 20,7 @@ name:               bureaucromancy
-- PVP summary:     +-+------- breaking API changes
--                  | | +----- non-breaking API additions
--                  | | | +--- code changes with no API change
version:            0.1.0.0
version:            0.1.0.1

-- A short (one-line) description of the package.
synopsis:           Parse webforms & render to interactive hypertext


@@ 74,9 74,12 @@ library
    -- other-extensions:

    -- Other library packages from which modules are imported.
    build-depends:    base ^>=4.16.4.0, ginger, file-embed-lzma, file-embed, mtl,
            bytestring, text, xml-conduit, network-uri, regex-tdfa, containers,
            filepath, directory, hourglass >= 0.2.12 && < 0.3, tz >= 0.1 && < 0.2,
    build-depends:    base >=4.16.4.0 && <5, ginger>0.10 && <1,
            file-embed-lzma >=0.0.1 && <1, file-embed >=0.0.15 && < 0.1, mtl >2 && <3,
            bytestring >=0.11 && <1, text >=2 && <3, containers >=0.6 && <1,
            xml-conduit >= 1.9 && <2, network-uri >=2.6 && <3, regex-tdfa >=1.3 && <2,
            filepath >=1.4 && <2, directory >=1.2 && <2,
            hourglass >= 0.2.12 && < 0.3, tz >= 0.1 && < 0.2

    -- Directories containing source files.
    hs-source-dirs:   src


@@ 99,8 102,9 @@ executable bureaucromancy

    -- Other library packages from which modules are imported.
    build-depends:
        base ^>=4.16.4.0,
        bureaucromancy, warp, wai, http-types, text, html-conduit
        base >=4.16.4.0 && <5, bureaucromancy,
        warp >= 3.3.31 && < 3.4, wai >= 3.2.3 && < 3.3, http-types >= 0.12.3 && < 0.13,
        text >= 2.0.1 && < 2.1, html-conduit >= 1.3.2 && < 1.4

    -- Directories containing source files.
    hs-source-dirs:   app


@@ 132,5 136,5 @@ test-suite bureaucromancy-test

    -- Test dependencies.
    build-depends:
        base ^>=4.16.4.0,
        base >=4.16.4.0 && <5,
        bureaucromancy

A i18n/en => i18n/en +0 -0
M src/Text/HTML/Form.hs => src/Text/HTML/Form.hs +90 -2
@@ 1,4 1,5 @@
{-# LANGUAGE OverloadedStrings, TypeSynonymInstances, FlexibleInstances #-}
-- | Parse webforms out of webpages
module Text.HTML.Form (Form(..), Input(..), OptionGroup(..), Option(..),
    FileSelector(..), defaultFileData, ImageData(..), defaultImageData,
    TextArea(..), defaultTextArea, parseElement, parseDocument, ensureButtons) where


@@ 17,101 18,176 @@ import Network.URI (parseURIReference, URI, nullURI)
import Text.Regex.TDFA (Regex, defaultCompOpt, defaultExecOpt)
import Text.Regex.TDFA.Text (compile)

-- | A collection of controls intended to be handle by a particular URL endpoint.
data Form = Form {
    -- | The URL which should receive valid input from this form.
    action :: URI,
    -- | How to encode the data to be received by the URL.
    enctype :: Text,
    -- | Which HTTP method to use.
    method :: Text,
    -- | Whether to validate the form data before submitting it to the endpoint.
    validate :: Bool,
    -- | Where to display the response.
    target :: Text,
    -- | Which character sets to encode the data in.
    acceptCharset :: [Text],
    -- | Whether to offer autocompletions for all controls.
    autocomplete :: Bool,
    -- | The name of this form.
    formName :: Text,
    -- | The purpose of this form, typically using an external vocabulary.
    rel :: Text,
    -- | What data should be sent to the endpoint.
    inputs :: [Input]
}

-- | Individual piece of data to send to a webservice.
data Input = Input {
    -- Core attributes
    -- | Human-legible yet brief description of this input.
    label :: Text,
    -- | Human-legible longer-form description of this input.
    description :: Node,
    -- | How this control should be presented to the user, supporting all the HTML5 input types.
    -- Support for more types may be added in the future, with any unsupported types
    -- fallingback to text entry.
    inputType :: Text,
    -- | In which query parameter should we store the text direction?
    dirname :: Text,
    -- | In which query parameter should we store this value?
    inputName :: Text,
    -- State
    -- | The user-provided value or caller-provided default to upload to the server.
    value :: Text,
    -- | Whether to autocomplete this input, if its enabled on the form.
    inputAutocomplete :: Text,
    -- | Whether this input has initial focus.
    autofocus :: Bool,
    -- | Whether (for certain types) to upload the data for this input.
    checked :: Bool,
    -- | Whether to temporarily-disallow users from editting this value.
    disabled :: Bool,
    -- | Whether to permanantly-disallow users from editting this value.
    readonly :: Bool,
    -- Input behaviour
    -- | Whether to allow entering multiple values.
    multiple :: Bool,
    -- | If this control is used to submit the form, where to upload it.
    formAction :: Maybe URI,
    -- | If this control is used to submit the form, which text encoding to use in the upload.
    formEnctype :: Maybe Text,
    -- | If this control is used to submit the form, which HTTP method to use.
    formMethod :: Maybe Text,
    -- | If this control is used to submit the form, whether to enforce validation.
    formValidate :: Bool,
    -- | If this control is used to submit the form, where to render the response.
    formTarget :: Maybe Text,
    -- | Suggests which keyboard to use for the input.
    inputMode :: Text,
    -- | Autocompletion values provided by caller.
    list :: [OptionGroup],
    -- Validation
    -- | The minimum & maximum values for the value of this input.
    range :: (Maybe Text, Maybe Text),
    -- | In which period from start do valid values occur?
    step :: Maybe Text,
    -- | The minimum & maximum lengths for the value of this input.
    lengthRange :: (Maybe Int, Maybe Int),
    -- | Optional regex to enforce on the value of this input.
    pattern :: Maybe Regex,
    -- | Whether this control must have a value for it to be considered valid.
    required :: Bool,
    -- Presentation
    -- | Sample value, often visual clarity of its role incurs inaccessibility.
    -- Make sure to communicate what's implied here elsewhere.
    placeholder :: Text,
    -- sort by tabindex?
    -- | Longform clarifications.
    title :: Text,
    -- | How wide the control should be.
    size :: Maybe Int,
    -- | Additional data for inputs of type "file".
    fileData :: FileSelector,
    -- | Additional data for inputs of type "image".
    imageData :: ImageData,
    -- | Additional data for inputs of type "textarea".
    textArea :: TextArea
}
-- | A labelled-group of options, that can be collectively disabled.
data OptionGroup = OptGroup {
    -- | A brief human-legible description of the options on this group.
    optsLabel :: Text,
    -- | Whether these options can be selected.
    optsDisabled :: Bool,
    -- | The options in this group.
    subopts :: [Option]
}
-- | A possible value for an input.
data Option = Option {
    -- | Human-legible text identifying this option.
    optLabel :: Text,
    -- | Machine-legible text identifying this option.
    optValue :: Text,
    -- | Whether the option is selected.
    optSelected :: Bool,
    -- | Whether the option can be selected.
    optDisabled :: Bool
}
-- | Data specific to "file" inputs.
data FileSelector = FileSelector {
    -- | The MIMEtypes of the files which can be validly entered into this control.
    fileAccept :: [Text],
    -- | Whether options for capturing from a camera should be offered.
    fileCapture :: Text
}
-- | Empty values for file data.
defaultFileData :: FileSelector
defaultFileData = FileSelector [] ""
-- | Data specific to "image" inputs.
data ImageData = ImageData {
    -- | Text describing the image, in case the reader can't view it.
    imgAlt :: Maybe Text,
    -- | How much screenspace the image takes up.
    imgSize :: (Maybe Int, Maybe Int),
    imgSrc :: Maybe Text
    -- | The link to the image.
    imgSrc :: Maybe URI
}
-- | Empty values for image data.
defaultImageData :: ImageData
defaultImageData = ImageData Nothing (Nothing, Nothing) Nothing
-- | Data specific to textarea inputs.
data TextArea = TextArea {
    -- | Whether to enable autocorrect.
    autocorrect :: Bool,
    -- | Number of rows to display.
    rows :: Maybe Int,
    -- | Whether to enable spellcheck.
    spellcheck :: Maybe Bool,
    -- | Whether to enable text-wrap.
    textwrap :: Maybe Bool
}
-- | Empty values for textarea data.
defaultTextArea :: TextArea
defaultTextArea = TextArea True Nothing Nothing Nothing

-- | Helper for looking up attributes on a selected element, with fallback.
attr :: Text -> Cursor -> Text -> Text
attr n el def | [ret] <- n `laxAttribute` el = ret
    | otherwise = def
-- | Helper for looking up attributes on a selected element, with fallback & callback.
attr' :: Text -> Cursor -> (Text -> a) -> Text -> a
attr' n el cb def = cb $ attr n el def
-- | Variant of `attr'` which passes which unpacks the callback's argument to a string.
attr'' :: Text -> Cursor -> (String -> a) -> Text -> a
attr'' n el cb def = attr' n el (cb . Txt.unpack) def
-- | Helper for checking whether an attribute is present.
hasAttr :: Name -> Cursor -> Bool
hasAttr n = not . null . hasAttribute n
-- | Helper for looking up an attribute on a selected element if present.
mAttr :: Text -> Cursor -> Maybe Text
mAttr n = listToMaybe . laxAttribute n
-- | Parse a form from the selected HTML element.
parseElement :: Cursor -> Maybe Form
parseElement el | _:_ <- laxElement "form" el = Just Form {
        action = attr'' "action" el (fromMaybe nullURI . parseURIReference) ".",


@@ 127,11 203,14 @@ parseElement el | _:_ <- laxElement "form" el = Just Form {
      }
    | otherwise = Nothing

-- | Helper to retrieve the root node of a document.
root :: Axis
root = singleton . last . orSelf ancestor
-- | Case-insensitive element selection.
laxElements :: [Text] -> Axis
laxElements ns = checkName (\x -> or [
    on (==) Txt.toCaseFold n $ nameLocalName x | n <- ns])
-- | Retrieve all the inputs associated with a form element.
queryInputs :: Cursor -> [Cursor]
queryInputs form = (allInputs >=> inForm) form
  where


@@ 141,6 220,7 @@ queryInputs form = (allInputs >=> inForm) form
        laxAttribute "form" x == laxAttribute "id" form ||
        nestedInForm x)
    nestedInForm x = listToMaybe ((ancestor >=> laxElement "form") x) == Just form
-- | Parse an input from the selected element.
parseInput :: Cursor -> Maybe Input
parseInput el | _:_ <- laxElement "input" el = Just Input {
        label = fromMaybe


@@ 188,7 268,7 @@ parseInput el | _:_ <- laxElement "input" el = Just Input {
            imgAlt = mAttr "alt" el,
            imgSize = (attr'' "width" el readMaybe "",
                attr'' "height" el readMaybe ""),
            imgSrc = mAttr "src" el
            imgSrc = attr'' "src" el (parseURIReference) ""
        },
        textArea = defaultTextArea
      }


@@ 327,6 407,7 @@ parseInput el | _:_ <- laxElement "input" el = Just Input {
    filterSelect = descendant >=>
        checkNot (orSelf ancestor >=> laxElement "select") >=>
        content
-- | Parse the options beneath a selected element.
parseOptions :: Cursor -> [OptionGroup]
parseOptions el = [parseGroup opt
    | opt <- (descendant >=> laxElements ["option", "optgroup"] >=>


@@ 348,6 429,7 @@ parseOptions el = [parseGroup opt
        optDisabled = hasAttr "disabled" opt || disabledOverride
      }

-- | Parse a named or numerically-indexed form from an HTML document.
parseDocument :: Document -> Text -> Maybe Form
parseDocument doc n
    | Just n' <- readMaybe $ Txt.unpack n, n' < length (forms doc') =


@@ 358,22 440,28 @@ parseDocument doc n
    forms = orSelf descendant >=> laxElement "form"
    doc' = fromDocument doc

-- | Helper to select elements which fail a test.
checkNot :: Boolean b => (Cursor -> b) -> Axis
checkNot test = check (not . bool . test)
-- | Helper to maybe-get the right side of an either.
rightToMaybe :: Either a b -> Maybe b
rightToMaybe (Left _)  = Nothing
rightToMaybe (Right x) = Just x
instance Eq Cursor where
    a == b = node a == node b
-- | Helper to return the 1st Just from its 2 arguments.
orElse :: Maybe a -> Maybe a -> Maybe a
orElse ret@(Just _) _ = ret
orElse _ ret = ret
infixr 0 `orElse`
-- | Helper to retrieve the concatenated text under a selected element.
text :: Cursor -> Text
text = Txt.concat . (descendant >=> content)
-- | Concise synonym for an XML text node.
mkEl :: Text -> Node
mkEl = NodeContent

-- | Add submit & reset buttons to a form if they were missing!
ensureButtons :: Form -> Form
ensureButtons = ensureButton "submit" "Submit" . ensureButton "reset" "Reset"
  where

M src/Text/HTML/Form/Colours.hs => src/Text/HTML/Form/Colours.hs +7 -1
@@ 1,8 1,14 @@
-- | A decent colour-pallet for users to select between,
-- for where we can't provide free-form colour selection.
module Text.HTML.Form.Colours(tailwindColours, Colour) where

-- Finally a good use for Tailwind!
-- Otherwise it strikes me as little more than an illegible alternative to
-- the `style` attribute!

type Colour = String -- Stores a hexcode, with preceding "#"
-- | Stores a CSS hexcode, with preceding "#"
type Colour = String
-- | The Tailwind colour pallet: https://v1.tailwindcss.com/docs/customizing-colors#default-color-palette
tailwindColours :: [(String, [(Int, Colour)])]
tailwindColours = [
    "Slate"~>[50~>"#f8fafc", 100~>"#f1f5f9", 200~>"#e2e8f0", 300~>"#cbd5e1",

M src/Text/HTML/Form/Query.hs => src/Text/HTML/Form/Query.hs +8 -0
@@ 1,4 1,5 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Convert query data between parsed form data, multi-maps, & URI query strings.
module Text.HTML.Form.Query(renderQueryString, renderQueryString', renderQuery',
        applyQuery, applyQuery') where



@@ 8,15 9,19 @@ import Data.List (intercalate)
import Data.Text (unpack)
import qualified Data.Text as Txt

-- | Serialize a form to a URI query string.
renderQueryString :: Form -> String
renderQueryString = renderQueryString' . renderQuery'
-- | Serialize a key-value multi-map to a URI query string.
renderQueryString' :: [(String, String)] -> String
renderQueryString' query = intercalate "&" [
    escape key ++ '=':escape val | (key, val) <- query
  ]

-- | Serialize a form to a key-value multi-map.
renderQuery' :: Form -> [(String, String)]
renderQuery' form = concatMap renderInput' $ inputs form
-- | Serialize an input to a key-value multi-map.
renderInput' :: Input -> [(String, String)]
renderInput' Input { inputType = inputType' }
    | inputType' `elem` ["submit", "reset", "button", "file"] = []


@@ 33,13 38,16 @@ renderInput' Input { inputType = "<select>",
            grp <- opts, opt <- subopts grp, optSelected opt]
renderInput' Input { inputName = k, value = v } = [(unpack k, unpack v)]

-- | escape a URI string.
escape :: String -> String
escape = escapeURIString isUnescapedInURIComponent

-- | Adjust an input to store the appropriate values encoded in a key-value multi-map.
applyQuery :: Input -> [(String, String)] -> Input
applyQuery input@Input { inputName = n } qs
    | inputType input `notElem` ["submit", "reset", "button", "checkbox", "radio"],
        Just val' <- unpack n `lookup` qs = input { value = Txt.pack val' }
    | otherwise = input
-- | Adjust all inputs in a form to store the values encoded in a key-value multi-map.
applyQuery' :: Form -> [(String, String)] -> Form
applyQuery' form qs = form { inputs = flip applyQuery qs `map` inputs form }

M src/Text/HTML/Form/Validate.hs => src/Text/HTML/Form/Validate.hs +16 -1
@@ 1,4 1,6 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Does the form contain valid data according to specified rules?
-- Can we normalize it to be more likely to do so?
module Text.HTML.Form.Validate(isInputValid, isInputValid', isFormValid, isFormValid',
        inputErrorMessage, inputErrorMessage', normalizeInput, normalizeForm) where



@@ 10,19 12,24 @@ import Network.URI (parseAbsoluteURI)
import Data.Maybe (isJust, isNothing)
import Text.Regex.TDFA ((=~), matchTest)

-- | Are all inputs in a form valid according to their rules?
isFormValid :: Form -> Bool
isFormValid = all isInputValid . inputs

-- | Are all inputs in a form valid according to their rules, once normalized?
isFormValid' :: Form -> Bool
isFormValid' = all isInputValid' . inputs

-- | Is the given input valid?
isInputValid :: Input -> Bool
isInputValid = null . inputErrorMessage

-- | Is the given input once normalized valid?
isInputValid' :: Input -> Bool
isInputValid' = null . inputErrorMessage'

inputErrorMessage :: Input -> [Char]
-- | Describe why a form input is invalid, or return the empty string.
inputErrorMessage :: Input -> String
inputErrorMessage Input { inputType = "hidden" } = "" -- Don't validate hiddens!
inputErrorMessage self@Input { required = True }
    | inputType self == "checkbox", not $ checked self = "Required!"


@@ 88,21 95,28 @@ inputErrorMessage self@Input { inputType = "url" }
inputErrorMessage self@Input { inputType = "week" } = isTime' self
inputErrorMessage _ = ""

-- | Describe why an input, once normalized, is invalid? Or returns empty string.
inputErrorMessage' :: Input -> [Char]
inputErrorMessage' = inputErrorMessage . normalizeInput

-- | Helper to parse the time stored in an input.
parseTime :: String -> Maybe DateTime
parseTime = fmap localTimeUnwrap . localTimeParse ISO8601_DateAndTime
-- | Does the input store a time?
isTime :: Input -> Bool
isTime = isJust . parseTime . Txt.unpack . value
-- | Emit an error message if an input doesn't store a valid time.
isTime' :: Input -> String
isTime' x | isTime x = ""
    | otherwise = "Invalid time format!"
-- | Parse a Text into any type that can be parsed from strings.
readMaybe' :: Read a => Txt.Text -> Maybe a
readMaybe' = readMaybe . Txt.unpack
-- | Does the input store a valid URL?
isURL :: Txt.Text -> Bool
isURL = isNothing . parseAbsoluteURI . Txt.unpack

-- | Implicitly tweak the input to make it more likely to be valid.
normalizeInput :: Input -> Input
normalizeInput self@Input { inputType = "url", value = val }
    | not $ ':' `Txt.elem` val = self { -- Is there a better check?


@@ 111,5 125,6 @@ normalizeInput self@Input { inputType = "url", value = val }
-- Other aspects we wish to normalize?
normalizeInput self = self

-- | Implicitly tweak all of a form's inputs to make them more likely to be valid.
normalizeForm :: Form -> Form
normalizeForm self = self { inputs = map normalizeInput $ inputs self }

M src/Text/HTML/Form/WebApp.hs => src/Text/HTML/Form/WebApp.hs +18 -1
@@ 1,5 1,7 @@
{-# LANGUAGE OverloadedStrings #-}
module Text.HTML.Form.WebApp (renderPage, Form(..)) where
-- | Renders forms to an HTML menu, for the sake of highly-constrained browser engines.
-- Like those dealing with TV remotes.
module Text.HTML.Form.WebApp (renderPage, Form(..), Query) where

import Data.ByteString as BS
import Data.ByteString.Char8 as B8


@@ 27,7 29,11 @@ import Text.Ginger.Html (html)
import Data.Hourglass (Elapsed(..), Seconds(..), timeGetElapsed, localTimeToGlobal)
import Text.HTML.Form.Colours (tailwindColours)

-- | The query string manipulated by this serverside webapp.
type Query = [(ByteString, Maybe ByteString)]
-- | Converts URI path & query to rendered hyper-linked HTML representing menus
-- for selecting values to upload to the server as prescribed by the given form.
-- These values are returned to caller on the Left-branch.
renderPage :: Form -> [Text] -> Query -> IO (Maybe (Either Query Text))
renderPage form (n:path) query
    | Just ix <- readMaybe $ Txt.unpack n, ix < Prelude.length (inputs form) =


@@ 36,8 42,10 @@ renderPage form [] _ = return $ Just $ Right $ Txt.concat [
    "<a href='/0/?", Txt.pack $ renderQueryString form, "'>Start!</a>"]
renderPage _ _ _ = return Nothing

-- | Is this input type amongst the date-time family?
isCalType :: Text -> Bool
isCalType = flip L.elem ["date", "datetime-local", "datetime", "month", "time", "week"]
-- | Render an input to the corresponding HTML, or form data to submit.
renderInput :: Form -> Int -> Input -> [Text] -> [(ByteString, Maybe ByteString)] ->
    IO (Maybe (Either Query Text))
renderInput form ix input [""] qs = renderInput form ix input [] qs


@@ 216,30 224,39 @@ renderInput form ix input [] qs = do
renderInput _ _ input _ _ =
    return $ Just $ Right $ Txt.concat ["Unknown input type: ", inputType input]

-- | Coerce Colour Pallet data into dynamically-typed Ginger data.
colourGVal :: (ToGVal m a1, ToGVal m b, Eq a2, Num a2) => (a1, [(a2, b)]) -> GVal m
colourGVal (key, hues) = orderedDict ["label"~>key, "value"~>lookup 500 hues]
shadeGVal :: (ToGVal m a1, ToGVal m a2) => (a1, a2) -> GVal m
shadeGVal (key, val) = orderedDict ["label"~>key, "value"~>val]

-- | Convert Text to UTF8 ByteString data.
utf8 :: Text -> ByteString
utf8 = Txt.encodeUtf8
-- | Convert String to UTF8 ByteString data.
utf8' :: String -> ByteString
utf8' = utf8 . Txt.pack
-- | Set the given key in the query to the given value.
set :: Text -> Text -> [(ByteString, Maybe ByteString)]
    -> [(ByteString, Maybe ByteString)]
set "" _ qs = qs -- Mostly for buttons!
set k' v' qs = (utf8 k', Just $ utf8 v'):[q | q@(k, _) <- qs, k /= utf8 k']
-- | Remove given key from the query.
unset :: Text -> Text -> [(ByteString, Maybe ByteString)]
    -> [(ByteString, Maybe ByteString)]
unset k' v' qs = [q | q@(k, v) <- qs, not (k == utf8 k' && v == Just (utf8 v'))]
-- | Retrieve the value corresponding to the given key in the query.
get :: Text -> [(ByteString, Maybe ByteString)] -> String
get k' qs
    | Just (Just ret) <- utf8 k' `lookup` qs =
        Txt.unpack $ Txt.decodeUtf8 ret
    | otherwise = ""
-- | Convert the query data to string-pairs, for use in Query submodule.
strQuery :: [(ByteString, Maybe ByteString)] -> [(String, String)]
strQuery qs = [(B8.unpack k, B8.unpack $ fromMaybe "" v) | (k, v) <- qs]

-- | Monadically takes a predicate and a list, and returns the pair of lists
-- of elements which do and do not satisfy the predicate, respectively.
partitionM :: Monad f => (a -> f Bool) -> [a] -> f ([a], [a])
partitionM _ [] = pure ([], [])
partitionM f (x:xs) = do

M src/Text/HTML/Form/WebApp/Ginger.hs => src/Text/HTML/Form/WebApp/Ginger.hs +18 -2
@@ 1,4 1,5 @@
{-# LANGUAGE TemplateHaskell, OverloadedStrings, FlexibleContexts #-}
-- | Shuttle parsed form data to Ginger's dynamically-typed datamodel.
module Text.HTML.Form.WebApp.Ginger(template, template', resolveSource, list') where

import Text.HTML.Form


@@ 19,7 20,7 @@ import Data.Text as Txt
import Data.Text.Encoding as Txt
import Data.Text.Lazy as Txt (toStrict)
import Data.ByteString.Char8 as B8
import Network.URI (uriToString, escapeURIString, isUnescapedInURIComponent)
import Network.URI (uriToString, escapeURIString, isUnescapedInURIComponent, nullURI)
import Text.XML (Document(..), Element(..), Prologue(..), Node, def, renderText)

import Data.List (nub)


@@ 29,11 30,14 @@ import qualified Data.Map as M
import Text.HTML.Form.Validate (inputErrorMessage')
import Text.HTML.Form.Query (applyQuery)

-- | A key-value query string.
type Query = [(ByteString, Maybe ByteString)]
-- | Run the given template with the given Bureaucromancy data.
template :: Monad m => String -> Form -> Int -> Input -> Query ->
        m (Maybe (Either Query Text))
template name form ix input query =
    template' name form ix input query $ const $ toGVal ()
-- | Run the given template with the given Bureaucromancy & Ginger data.
template' :: Monad m => String -> Form -> Int -> Input -> Query ->
        (Text -> GVal (Run SourcePos (Writer Html) Html)) ->
        m (Maybe (Either Query Text))


@@ 57,12 61,14 @@ template' name form ix input query ctxt'
        return$toGVal$Txt.pack $ escapeURIString isUnescapedInURIComponent uri'
    xURI _ = return $ toGVal ()

-- | Lookup the given template from a compiled-in directory.
resolveSource :: FilePath -> Maybe (Maybe [Char])
resolveSource ('/':path) = resolveSource path
resolveSource path = Just $ fmap utf8 $
    flip lookup $(makeRelativeToProject "tpl" >>= embedRecursiveDir) $
    normalise $ '/':path

-- | Convert a query into Ginger's datamodel.
query2gval :: Monad m => Query -> GVal m
query2gval qs =
    (orderedDict [(Txt.decodeUtf8 k, (list1 vs){ asFunction = Just $ gElem vs })


@@ 76,6 82,7 @@ query2gval qs =
    gElem xs [(_, x)] | Just x' <- asBytes x = return$toGVal$Prelude.elem x' xs
    gElem _ _ = return $ toGVal ()

-- | Convert a form to Ginger's datamodel.
form2gval :: Form -> GVal m
form2gval form = orderedDict [
    "action"   ~> uriToString id (action form) "",


@@ 89,6 96,7 @@ form2gval form = orderedDict [
    "rel"      ~> rel form
  ]

-- | Convert an input to Ginger's datamodel.
input2gval :: (Int, Input) -> Query -> GVal m
input2gval (ix, input) query = orderedDict [
    "index"       ~> ix,


@@ 138,23 146,26 @@ input2gval (ix, input) query = orderedDict [
    "alt"         ~> imgAlt (imageData input),
    "width"       ~> fst (imgSize $ imageData input),
    "height"      ~> snd (imgSize $ imageData input),
    "src"         ~> imgSrc (imageData input),
    "src"         ~> uriToString id (fromMaybe nullURI $ imgSrc $ imageData input) "",
    "autocorrect" ~> autocorrect (textArea input),
    "cols"        ~> size input,
    "rows"        ~> rows (textArea input),
    "spellcheck"  ~> spellcheck (textArea input),
    "textwrap"    ~> textwrap (textArea input)
  ]
-- | Convert an XML node to Ginger's datamodel.
html :: Node -> Html
html node = unsafeRawHtml $ Txt.toStrict $ renderText def (
    Document (Prologue [] Nothing []) (Element "div" M.empty [node]) []
  )
-- | Convert an option group to Ginger's datamodel.
optgroup2gval :: [ByteString] -> OptionGroup -> GVal m
optgroup2gval query optgroup = orderedDict [
    "label"    ~> optsLabel optgroup,
    "disabled" ~> optsDisabled optgroup,
    ("opts", list' $ Prelude.map (opt2gval query) $ subopts optgroup)
  ]
-- | Convert an option to Ginger's datamodel.
opt2gval :: [ByteString] -> Option -> GVal m
opt2gval query opt = orderedDict [
    "label"    ~> optLabel opt,


@@ 163,17 174,22 @@ opt2gval query opt = orderedDict [
    "disabled" ~> optDisabled opt
  ]

-- | A ginger list which in most uses looks like its initial value.
list1 :: ToGVal m a => [a] -> GVal m
list1 vs@(v:_) = (toGVal v) {
    asList = Just $ Prelude.map toGVal vs,
    V.length = Just $ Prelude.length vs
  }
list1 [] = (toGVal True) { asList = Just [], V.length = Just 0 }
-- | Type-constrained conversion of a list to Ginger's datamodel,
-- serves to avoid type-inference issues.
list' :: [GVal m] -> GVal m
list' = toGVal

-- | Aggregates values in a key-value list under their keys.
groupSort :: Eq k => [(k, Maybe v)] -> [(k, [v])]
groupSort q = [(k, [v | (k', Just v) <- q, k == k']) | k <- nub $ Prelude.map fst q]

-- | Convert from UTF-8 bytestring to a string.
utf8 :: ByteString -> String
utf8 = Txt.unpack . Txt.decodeUtf8

M src/Text/HTML/Form/WebApp/Ginger/Hourglass.hs => src/Text/HTML/Form/WebApp/Ginger/Hourglass.hs +15 -0
@@ 1,4 1,6 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Converts data between Ginger templates & HourGlass,
-- whilst decomposing the datamodel further.
module Text.HTML.Form.WebApp.Ginger.Hourglass(
    timeData, modifyTime, modifyTime', timeParseOrNow, gSeqTo, gPad2) where



@@ 10,6 12,7 @@ import qualified Data.Text as Txt
import Text.Read (readMaybe)
import System.IO.Unsafe (unsafePerformIO) -- For use with localDateCurrent

-- | Converts HourGlass data to Ginger's datamodel.
timeData :: LocalTime DateTime -> GVal a
timeData datetime = orderedDict [
    "year" ~> abs (dateYear date),


@@ 33,16 36,20 @@ timeData datetime = orderedDict [
  where
    date = dtDate $ localTimeUnwrap datetime

-- Converts an enum to Ginger's datamodel.
enumG :: (Enum x, Show x) => x -> GVal a
enumG = showG fromEnum
-- | Converts showable data to Ginger's datamodel via a callback.
showG :: (Show x, ToGVal m a) => (x -> a) -> x -> GVal m
showG cb x = (toGVal $ cb x) {
    asText = Txt.pack $ show x,
    asHtml = unsafeRawHtml $ Txt.pack $ show x
  }
-- Retrieves the integral value from HourGlass Nanoseconds.
unwrapNanos :: NanoSeconds -> Int
unwrapNanos (NanoSeconds x) = fromEnum x

-- | Interpret an operation upon a given time.
modifyTime :: Txt.Text -> LocalTime DateTime -> Maybe (LocalTime DateTime)
modifyTime "-hour" time = modLTime time $ flip timeAdd mempty { durationHours = -1 }
modifyTime "+hour" time = modLTime time $ flip timeAdd mempty { durationHours = 1 }


@@ 109,27 116,33 @@ modifyTime op time = case op of
    "-date7" -> addPeriod' time mempty { periodDays = -7 }
    "+date7" -> addPeriod' time mempty { periodDays = 7 }
    _ -> Nothing
-- | Helper for modifying HourGlass data.
modLTime :: LocalTime a -> (a -> b) -> Maybe (LocalTime b)
modLTime a = Just . flip fmap a
-- | Helper for adding an offset to a HourGlass local time.
addPeriod' :: LocalTime DateTime -> Period -> Maybe (LocalTime DateTime)
addPeriod' time period = modLTime time $ \time' -> time' {
    dtDate = dtDate time' `dateAddPeriod` period
  }
-- | Helper for adding an offset to the timezone of a local time as stored by HourGlass.
offsetTZ :: Time t => LocalTime t -> Int -> Maybe (LocalTime t)
offsetTZ time mins = Just $ localTimeSetTimezone
    (TimezoneOffset $ timezoneOffsetToMinutes (localTimeGetTimezone time) + mins)
    time

-- | Helper for modifying time component of HourGlass data.
modifyTime' :: Txt.Text -> String -> Maybe String
modifyTime' op time
    | Just ret <- modifyTime op $ unsafePerformIO $ timeParseOrNow time =
        Just $ localTimePrint ISO8601_DateAndTime ret
    | otherwise = Nothing
-- | Parse a string to HourGlass data, falling back to the current time.
timeParseOrNow :: String -> IO (LocalTime DateTime)
timeParseOrNow txt = case localTimeParse ISO8601_DateAndTime txt of
    Just ret -> return ret
    Nothing -> localDateCurrent

-- | A sequence to be called from Ginger templates.
gSeqTo :: [(a, GVal m)] -> GVal m
gSeqTo [(_, from), (_, to)]
    | Just x <- toInt from, Just y <- toInt to = toGVal [x..y]


@@ 137,6 150,8 @@ gSeqTo [(_, from), (_, than), (_, to)]
    | Just x <- toInt from, Just y <- toInt than, Just z <- toInt to = toGVal [x,y..z]
gSeqTo _ = toGVal ()

-- | A padding function to be called from Ginger templates,
-- prepending 0 when needed to get 2 digits.
gPad2 :: [(a, GVal m)] -> GVal m
gPad2 [(_, x)] | Just y <- toInt x, y < 10 = toGVal $ '0':show x
    | Just y <- toInt x = toGVal $ show y

M src/Text/HTML/Form/WebApp/Ginger/TZ.hs => src/Text/HTML/Form/WebApp/Ginger/TZ.hs +4 -0
@@ 1,4 1,5 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Exposes data for a menu of timezones.
module Text.HTML.Form.WebApp.Ginger.TZ(tzdata, continents) where

import Text.Ginger.GVal as V (GVal, toGVal, orderedDict, (~>), list)


@@ 11,6 12,7 @@ import Data.List (nub)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC

-- | Parses timezone data into a menu for Ginger templates.
tzdata :: Int64 -> String -> GVal m
tzdata now prefix = list [orderedDict [
        "label" ~> label,


@@ 23,6 25,7 @@ tzdata now prefix = list [orderedDict [
    contains "" = BSC.notElem '/'
    contains "..." = BSC.notElem '/'
    contains x = BS.isPrefixOf x
-- | Serialize an offset to string, ensuring 0 is prepended to minutes when needed.
formatOffset :: (Show a, Integral a) => a -> [Char]
formatOffset offset
    | minutes < 10 = show hours ++ ':':'0': show minutes


@@ 31,6 34,7 @@ formatOffset offset
    hours = offset `div` 60
    minutes = abs $ offset `rem` 60

-- | Retrieves continents list for Ginger templates.
continents :: GVal m
continents = list $ map toGVal $ nub $ "...":[prefix |
        (label, _) <- M.toList tzNameLabelMap,