packages feed

tasklite-core-0.5.0.0: source/Task.hs

{-# LANGUAGE QuasiQuotes #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}

{-# HLINT ignore "Use uncurry" #-}

{-|
Datatype to represent a task as stored in the `tasks` table
-}
module Task where

import Protolude (
  Applicative ((<*>)),
  Either (Left, Right),
  Enum (toEnum),
  Eq ((==)),
  Float,
  Functor (fmap),
  Generic,
  Hashable,
  Maybe (..),
  Ord ((<), (>)),
  Read,
  Semigroup ((<>)),
  Show,
  decodeUtf8,
  fst,
  otherwise,
  pure,
  show,
  snd,
  ($),
  (&),
  (&&),
  (.),
  (<$>),
  (<&>),
 )
import Protolude qualified as P

import Data.Aeson as Aeson (
  FromJSON,
  ToJSON,
  Value (Object),
  eitherDecodeStrictText,
  encode,
 )
import Data.Aeson.Key as Key (fromText)
import Data.Aeson.KeyMap as KeyMap (fromList, insert)
import Data.ByteString.Lazy qualified as BSL
import Data.Csv qualified as Csv
import Data.Generics (Data)
import Data.Hourglass (DateTime, timePrint)
import Data.Text (Text, pack)
import Data.Text qualified as T
import Data.Yaml as Yaml (encode)
import Database.SQLite.Simple as Sql (
  FromRow (..),
  ResultError (ConversionFailed),
  SQLData (SQLText),
  ToRow (toRow),
  field,
 )
import Database.SQLite.Simple.FromField as Sql.FromField (
  FromField (..),
  returnError,
 )
import Database.SQLite.Simple.Internal (Field (Field))
import Database.SQLite.Simple.Ok (Ok (Ok))
import Database.SQLite.Simple.ToField as Sql.ToField (ToField (..), toField)
import Generic.Random (genericArbitrary, genericArbitraryU, (%))
import Prettyprinter (Pretty (pretty))
import Test.QuickCheck (Arbitrary (arbitrary))
import Test.QuickCheck.Instances.Text ()

import Config (defaultConfig, utcFormat)
import Control.Arrow ((>>>))
import Database.SQLite.Simple (
  Connection,
  Only (Only),
  SQLData (SQLNull),
  query,
 )
import Database.SQLite.Simple.QQ (sql)


data TaskState
  = Done
  | Obsolete
  | Deletable
  deriving (Eq, Enum, Generic, Ord, Read, Show, Data)


instance Arbitrary TaskState where
  arbitrary = genericArbitrary (4 % 2 % 1 % ())


instance Sql.FromField.FromField TaskState where
  fromField f@(Field (SQLText txt) _) =
    case textToTaskState txt of
      Just val -> Ok val
      Nothing ->
        returnError ConversionFailed f $
          T.unpack $
            "expecting a valid TaskState and not \"" <> txt <> "\""
  fromField f =
    returnError ConversionFailed f "expecting SQLText column type"


instance Sql.ToField.ToField TaskState where
  toField = SQLText . show


instance FromJSON TaskState
instance ToJSON TaskState


instance Csv.ToRecord TaskState
instance Csv.ToNamedRecord TaskState


instance Csv.ToField TaskState where
  toField = show


instance Hashable TaskState


stateDefault :: TaskState
stateDefault = toEnum 0


stateOptions :: Text
stateOptions =
  T.intercalate "," $
    fmap (("'" <>) . (<> "'") . show) [stateDefault ..]


textToTaskState :: Text -> Maybe TaskState
textToTaskState txt =
  let
    func t
      | t `P.elem` ["done", "completed", "finished", "fixed"] = Just Done
      | t `P.elem` ["obsolete"] = Just Obsolete
      | t
          `P.elem` [ "deletable"
                   , "deleted"
                   , "removable"
                   , "removed"
                   ] =
          Just Deletable
      | otherwise = Nothing
    txtLower = T.toLower txt
  in
    func txtLower


data DerivedState
  = IsOpen
  | IsClosed
  | IsAsleep
  | IsAwake
  | IsReady
  | IsWaiting
  | IsReview
  | IsDone
  | IsObsolete
  | IsDeletable
  | IsBlocked
  deriving (Eq, Generic, Show)


instance Arbitrary DerivedState where
  arbitrary = genericArbitraryU


{-| A tuple of (Primary State, Secondary State)
 | Check out tasklite.org/concepts for a
 | detailed explanation of the different states
 | and how they relate to each other
-}
type StateHierarchy = (DerivedState, DerivedState)


instance {-# OVERLAPS #-} Pretty StateHierarchy where
  pretty stateH =
    ( if fst stateH == snd stateH
        then show $ fst stateH
        else
          [fst stateH, snd stateH]
            <&> show
            & T.intercalate " and "
    )
      & T.replace "Is" ""
      & pretty


textToDerivedState :: Text -> Maybe DerivedState
textToDerivedState =
  T.toLower >>> \case
    "open" -> Just IsOpen
    "closed" -> Just IsClosed
    "asleep" -> Just IsAsleep
    "awake" -> Just IsAwake
    "ready" -> Just IsReady
    "waiting" -> Just IsWaiting
    "review" -> Just IsReview
    "done" -> Just IsDone
    "obsolete" -> Just IsObsolete
    "deletable" -> Just IsDeletable
    "blocked" -> Just IsBlocked
    _ -> Nothing


derivedStateToQuery :: DerivedState -> Text
derivedStateToQuery = \case
  IsOpen -> "closed_utc is null"
  IsClosed -> "closed_utc is not null"
  IsAsleep ->
    "awake_utc > datetime('now') and \
    \(ready_utc is null or ready_utc > datetime('now')) \
    \and closed_utc is null"
  IsAwake ->
    "awake_utc < datetime('now') and \
    \(ready_utc is null or ready_utc > datetime('now')) \
    \and closed_utc is null"
  IsReady ->
    "(awake_utc is null or awake_utc < datetime('now')) \
    \and ready_utc < datetime('now') \
    \and closed_utc is null"
  IsWaiting ->
    "waiting_utc is not null and \
    \(review_utc is null or review_utc > datetime('now')) \
    \and closed_utc is null"
  IsReview ->
    "waiting_utc is not null and \
    \(review_utc is null or review_utc < datetime('now')) \
    \and closed_utc is null"
  IsDone -> "closed_utc is not null and state is 'Done'"
  IsObsolete -> "closed_utc is not null and state is 'Obsolete'"
  IsDeletable -> "closed_utc is not null and state is 'Deletable'"
  IsBlocked -> "" -- TODO


getStateHierarchy :: DateTime -> Task -> StateHierarchy
getStateHierarchy now task = do
  let
    nowTxt = pack $ timePrint (utcFormat defaultConfig) now

  case Task.state task of
    Just Done -> (IsClosed, IsDone)
    Just Obsolete -> (IsClosed, IsObsolete)
    Just Deletable -> (IsClosed, IsDeletable)
    Nothing -> case closed_utc task of
      Just _ -> (IsClosed, IsClosed)
      Nothing -> case review_utc task of
        Just val ->
          if val > nowTxt
            then (IsOpen, IsWaiting)
            else (IsOpen, IsReview)
        Nothing -> case waiting_utc task of
          Just _ -> (IsOpen, IsWaiting)
          Nothing -> case (ready_utc task, awake_utc task) of
            (Just readyUtc, Just awakeUtc) ->
              if readyUtc < nowTxt && awakeUtc < nowTxt
                then (IsOpen, IsReady)
                else
                  if readyUtc > nowTxt && awakeUtc < nowTxt
                    then (IsOpen, IsAwake)
                    else (IsOpen, IsAsleep)
            (Just readyUtc, Nothing) | readyUtc < nowTxt -> (IsOpen, IsReady)
            (Nothing, Just awakeUtc) | awakeUtc < nowTxt -> (IsOpen, IsAwake)
            (Nothing, Just awakeUtc) | awakeUtc > nowTxt -> (IsOpen, IsAsleep)
            _ -> (IsOpen, IsOpen)


newtype Ulid = Ulid Text


data Task = Task
  { ulid :: Text -- TODO: Use Ulid type
  , body :: Text
  , modified_utc :: Text
  , awake_utc :: Maybe Text
  , ready_utc :: Maybe Text
  , waiting_utc :: Maybe Text
  , review_utc :: Maybe Text
  , due_utc :: Maybe Text
  , closed_utc :: Maybe Text
  , state :: Maybe TaskState
  , group_ulid :: Maybe Text
  , repetition_duration :: Maybe Text
  , recurrence_duration :: Maybe Text
  , priority_adjustment :: Maybe Float
  , user :: Text
  , metadata :: Maybe Aeson.Value
  }
  deriving (Generic, Data, Show, Eq)


-- For conversion from SQLite with SQLite.Simple
instance FromRow Task where
  fromRow =
    Task
      <$> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> field
      <*> ( field <&> \case
              Just (Object obj) -> Just $ Object obj
              _ -> Nothing
          )


instance ToRow Task where
  toRow task =
    [ Sql.ToField.toField task.ulid
    , Sql.ToField.toField task.body
    , Sql.ToField.toField task.modified_utc
    , Sql.ToField.toField task.awake_utc
    , Sql.ToField.toField task.ready_utc
    , Sql.ToField.toField task.waiting_utc
    , Sql.ToField.toField task.review_utc
    , Sql.ToField.toField task.due_utc
    , Sql.ToField.toField task.closed_utc
    , Sql.ToField.toField task.state
    , Sql.ToField.toField task.group_ulid
    , Sql.ToField.toField task.repetition_duration
    , Sql.ToField.toField task.recurrence_duration
    , Sql.ToField.toField task.priority_adjustment
    , Sql.ToField.toField task.user
    , case task.metadata of
        Nothing -> SQLNull
        Just val -> SQLText $ val & (Aeson.encode >>> BSL.toStrict >>> decodeUtf8)
    ]


instance Hashable Task


instance Sql.FromField.FromField Value where
  fromField aField@(Field (SQLText txt) _) =
    case eitherDecodeStrictText txt of
      Left error -> returnError ConversionFailed aField error
      Right value -> Ok value
  fromField f = returnError ConversionFailed f "expecting SQLText column type"


-- For conversion to JSON
instance ToJSON Task


instance Pretty Task where
  pretty =
    pretty
      . T.dropEnd 1 -- Drop trailing newline to maybe add it later
      . decodeUtf8
      . Yaml.encode


instance Arbitrary Task where
  arbitrary = genericArbitraryU


emptyTask :: Task
emptyTask =
  Task
    { ulid = ""
    , body = ""
    , modified_utc = ""
    , awake_utc = Nothing
    , ready_utc = Nothing
    , waiting_utc = Nothing
    , review_utc = Nothing
    , due_utc = Nothing
    , closed_utc = Nothing
    , state = Nothing
    , group_ulid = Nothing
    , repetition_duration = Nothing
    , recurrence_duration = Nothing
    , priority_adjustment = Nothing
    , user = ""
    , metadata = Nothing
    }


setMetadataField :: Text -> Value -> Task -> Task
setMetadataField fieldNameText value task =
  task
    { metadata =
        case metadata task of
          Just (Object obj) ->
            Just $ Object $ KeyMap.insert (Key.fromText fieldNameText) value obj
          Nothing ->
            Just $ Object $ fromList [(Key.fromText fieldNameText, value)]
          _ -> metadata task
    }


{-| Convert a task to a Markdown string with YAML frontmatter that can be edited
| and then converted back to a task.
| Tags and notes are commented out, so they are not accidentally added again.
-}
taskToEditableMarkdown :: Connection -> Task -> P.IO P.ByteString
taskToEditableMarkdown conn task = do
  (tags :: [[P.Text]]) <-
    if T.null task.ulid
      then pure []
      else
        query
          conn
          [sql|
            SELECT tag
            FROM task_to_tag
            WHERE task_ulid == ?
          |]
          (Only task.ulid)

  (notes :: [[P.Text]]) <-
    if T.null task.ulid
      then pure []
      else
        query
          conn
          [sql|
            SELECT note
            FROM task_to_note
            WHERE task_ulid == ?
          |]
          (Only task.ulid)

  let
    indentNoteContent noteContent =
      noteContent
        & T.strip
        & T.lines
        <&> T.stripEnd
        & T.intercalate "\n#     "

    taskWithEmptyBody = task{body = ""}
    frontmatterYaml =
      ( taskWithEmptyBody
          & Yaml.encode
          & P.decodeUtf8
          & T.replace "\nbody: ''\n" "\n"
      )
        <> "\n# | Existing tags and notes can't be edited here, \
           \but new ones can be added\n\n"
        <> (("# tags: " :: Text) <> P.show (P.concat tags) <> "\n")
        <> "tags: []\n"
        <> ( ("\n# notes:\n" :: Text)
              <> ( notes
                    & P.concat
                    <&> (\note -> "# - " <> indentNoteContent note)
                    & T.unlines
                 )
           )
        <> "notes: []\n"

  pure $
    ("---\n" <> frontmatterYaml <> "...\n\n" <> body task)
      & P.encodeUtf8