packages feed

queue-sheet-0.7.0.2: src/QueueSheet/Types.hs

------------------------------------------------------------------------------
-- |
-- Module      : QueueSheet.Types
-- Description : queue sheet types
-- Copyright   : Copyright (c) 2020-2022 Travis Cardwell
-- License     : MIT
------------------------------------------------------------------------------

{-# LANGUAGE CPP #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeApplications #-}

module QueueSheet.Types
  ( -- * Name
    Name(..)
    -- * Url
  , Url(..)
    -- * Date
  , Date(..)
    -- * Section
  , Section(..)
  , defaultSection
    -- * Tag
  , Tag(..)
    -- * Item
  , Item(..)
    -- * Queue
  , Queue(..)
    -- * Import
  , Import(..)
    -- * ImportOrQueue
  , ImportOrQueue(..)
    -- * QueuesFile
  , QueuesFile(..)
    -- * QueueSheet
  , QueueSheet(..)
  ) where

-- https://hackage.haskell.org/package/aeson
import qualified Data.Aeson as A
import Data.Aeson (FromJSON(parseJSON), (.:), (.:?), (.!=))
import qualified Data.Aeson.Types as AT

-- https://hackage.haskell.org/package/base
import Control.Applicative ((<|>))
import Control.Monad (unless, when)
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
#if !MIN_VERSION_base (4,11,0)
import Data.Monoid ((<>))
#endif

-- https://hackage.haskell.org/package/ginger
import qualified Text.Ginger as Ginger
import Text.Ginger ((~>))

-- https://hackage.haskell.org/package/scientific
import qualified Data.Scientific as Sci

-- https://hackage.haskell.org/package/text
import qualified Data.Text as T
import Data.Text (Text)

-- https://hackage.haskell.org/package/ttc
import qualified Data.TTC as TTC

-- https://hackage.haskell.org/package/vector
import qualified Data.Vector as V

------------------------------------------------------------------------------
-- $Name

-- | Name of a queue or queue item
--
-- @since 0.3.0.0
newtype Name = Name Text
  deriving newtype (Eq, Show)

instance FromJSON Name where
  parseJSON = fmap Name . parseToString

instance Ginger.ToGVal m Name where
  toGVal (Name t) = Ginger.toGVal $ escapeTeX t

instance TTC.Render Name where
  render (Name t) = TTC.fromT t

------------------------------------------------------------------------------
-- $Url

-- | URL of queue or queue item
--
-- @since 0.3.0.0
newtype Url = Url Text
  deriving newtype (Eq, Show)

instance FromJSON Url where
  parseJSON = fmap Url . parseToString

instance Ginger.ToGVal m Url where
  toGVal (Url t) = Ginger.toGVal $ escapeTeX t

instance TTC.Render Url where
  render (Url t) = TTC.fromT t

------------------------------------------------------------------------------
-- $Date

-- | Date of last queue update
--
-- @since 0.3.0.0
newtype Date = Date Text
  deriving newtype (Eq, Show)

instance FromJSON Date where
  parseJSON = fmap Date . parseToString

instance Ginger.ToGVal m Date where
  toGVal (Date t) = Ginger.toGVal $ escapeTeX t

instance TTC.Render Date where
  render (Date t) = TTC.fromT t

------------------------------------------------------------------------------
-- $Section

-- | Section used to organize queues
--
-- @since 0.3.0.0
newtype Section = Section Text
  deriving newtype (Eq, Show)

instance FromJSON Section where
  parseJSON = fmap Section . parseToString

instance Ginger.ToGVal m Section where
  toGVal (Section t) = Ginger.toGVal $ escapeTeX t

instance TTC.Render Section where
  render (Section t) = TTC.fromT t

-- | The default section is represented as an empty string
defaultSection :: Section
defaultSection = Section ""

------------------------------------------------------------------------------
-- $Tag

-- | Queue tag
--
-- @since 0.3.0.0
newtype Tag = Tag Text
  deriving newtype (Eq, Show)

instance FromJSON Tag where
  parseJSON = A.withText "Tag" $ \t -> do
      when (T.null t) $ fail "empty tag"
      unless (T.all isValidChar t) $ fail ("invalid tag: " ++ T.unpack t)
      return $ Tag t
    where
      isValidChar :: Char -> Bool
      isValidChar c
        | isAsciiLower c = True
        | isAsciiUpper c = True
        | isDigit      c = True
        | otherwise      = c `elem` ("._-" :: String)

------------------------------------------------------------------------------
-- $Item

-- | Queue item
--
-- @since 0.5.0.0
data Item
  = Item
    { itemName :: !Name
    , itemUrl  :: !(Maybe Url)
    , itemTags :: ![Tag]
    }
  deriving (Eq, Show)

instance FromJSON Item where
  parseJSON = \case
    (A.Object o) -> do
      itemName <- o .:  "name"
      itemUrl  <- o .:? "url"
      itemTags <- maybe (pure []) parseCSV =<< (o .:? "tags")
      return Item{..}
    value -> do
      itemName <- Name <$> parseToString value
      let itemUrl  = Nothing
          itemTags = []
      return Item{..}

instance Ginger.ToGVal m Item where
  toGVal Item{..} = Ginger.dict $
    [ "name" ~> itemName
    , "url"  ~> itemUrl
    ] ++ [("tag_" <> tag) ~> True | Tag tag <- itemTags]

------------------------------------------------------------------------------
-- $Queue

-- | Queue information
--
-- @since 0.5.0.0
data Queue
  = Queue
    { queueName    :: !Name
    , queueUrl     :: !(Maybe Url)
    , queueDate    :: !(Maybe Date)
    , queueSection :: !Section
    , queueTags    :: ![Tag]
    , queueItems   :: !(Maybe (Either Item [Item]))
    }
  deriving (Eq, Show)

instance FromJSON Queue where
  parseJSON = A.withObject "Queue" $ \o -> do
    queueName    <- o .:  "name"
    queueUrl     <- o .:? "url"
    queueDate    <- o .:? "date"
    queueSection <- o .:? "section" .!= defaultSection
    queueTags    <- maybe (pure []) parseCSV =<< (o .:? "tags")
    mPrevItem    <- o .:? "prev"
    mNextValue   <- o .:? "next"
    queueItems   <- case (mPrevItem, mNextValue) of
      (_,         Just nextValue) -> Just . Right <$> parseCSV nextValue
      (Just item, Nothing)        -> pure . Just $ Left item
      (Nothing,   Nothing)        -> pure Nothing
    return Queue{..}

------------------------------------------------------------------------------
-- $Import

-- | Import declaration
--
-- @since 0.3.0.0
data Import
  = Import
    { importPath    :: !FilePath
    , importSection :: !(Maybe Section)
    }
  deriving (Eq, Show)

instance FromJSON Import where
  parseJSON = A.withObject "Import" $ \o -> do
    importPath    <- o .:  "import"
    importSection <- o .:? "section"
    return Import{..}

------------------------------------------------------------------------------
-- $ImportOrQueue

-- | Import declaration or queue information
--
-- @since 0.3.0.0
data ImportOrQueue
  = IQImport !Import
  | IQQueue  !Queue
  deriving (Eq, Show)

instance FromJSON ImportOrQueue where
  parseJSON value =
    (IQImport <$> parseJSON value) <|> (IQQueue <$> parseJSON value)

------------------------------------------------------------------------------
-- $QueuesFile

-- | Queues file
--
-- @since 0.3.0.0
data QueuesFile
  = QueuesFile
    { qfSections       :: ![Section]
    , qfImportOrQueues :: ![ImportOrQueue]
    }
  deriving (Eq, Show)

instance FromJSON QueuesFile where
  parseJSON = \case
    (A.Object o) -> do
      qfSections <- (:) defaultSection <$> (o .:? "sections" .!= [])
      qfImportOrQueues <- o .: "queues"
      return QueuesFile{..}
    a@A.Array{} -> do
      let qfSections = [defaultSection]
      qfImportOrQueues <- parseJSON a
      return QueuesFile{..}
    A.String{} -> fail "unexpected string"
    A.Number{} -> fail "unexpected number"
    A.Bool{}   -> fail "unexpected bool"
    A.Null     -> fail "unexpected null"

------------------------------------------------------------------------------
-- $QueueSheet

-- | Queue sheet
--
-- @since 0.3.0.0
data QueueSheet
  = QueueSheet
    { qsSections :: ![Section]
    , qsQueues   :: ![Queue]
    }
  deriving (Eq, Show)

------------------------------------------------------------------------------
-- $Internal

-- | Escape a string for inclusion in a TeX document
escapeTeX :: Text -> Text
escapeTeX = T.foldl go ""
  where
    go :: Text -> Char -> Text
    go acc = \case
      '#'  -> acc <> "\\#"
      '$'  -> acc <> "\\$"
      '%'  -> acc <> "\\%"
      '&'  -> acc <> "\\&"
      '\\' -> acc <> "\\textbackslash{}"
      '^'  -> acc <> "\\textasciicircum{}"
      '_'  -> acc <> "\\_"
      '{'  -> acc <> "\\{"
      '}'  -> acc <> "\\}"
      '~'  -> acc <> "\\textasciitilde{}"
      c    -> acc `T.snoc` c

-- | Parse an array or string in simplified CSV format
--
-- Strings are split on commas, and leading/trailing whitespace is removed
-- from each item.
parseCSV :: A.FromJSON a => A.Value -> AT.Parser [a]
parseCSV = \case
    (A.String t) -> mapM (parseJSON . A.String . T.strip) $ T.splitOn "," t
    (A.Array v)  -> mapM parseJSON $ V.toList v
    A.Object{}   -> fail "unexpected object"
    value        -> (: []) <$> parseJSON value

-- | Parse any scalar value as a string
--
-- Strings, numbers, booleans, and null are parsed as a string.  Empty
-- strings, arrays, and objects result in an error.
parseToString :: A.Value -> AT.Parser Text
parseToString = \case
    (A.String t)
      | T.null t  -> fail "empty string"
      | otherwise -> pure t
    (A.Number n)  -> pure . T.pack . either (show @Double) (show @Integer) $
      Sci.floatingOrInteger n
    (A.Bool b)    -> pure $ if b then "true" else "false"
    A.Null        -> pure "null"
    A.Array{}     -> fail "unexpected array"
    A.Object{}    -> fail "unexpected object"