packages feed

tasklite-core-0.5.0.0: source/Utils.hs

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

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

{-|
Several utility functions (e.g for parsing & serializing UTC stamps)
-}
module Utils where

import Protolude (
  Alternative ((<|>)),
  Applicative (pure),
  Char,
  Double,
  Eq,
  Fractional (fromRational, (/)),
  Functor (fmap),
  IO,
  Int,
  Integer,
  Integral (div, mod),
  Maybe (..),
  Num ((*), (+)),
  Ord ((<), (>)),
  Rational,
  Real (toRational),
  RealFrac (properFraction, truncate),
  Semigroup ((<>)),
  Show,
  Word16,
  flip,
  fromIntegral,
  fromMaybe,
  fst,
  mempty,
  otherwise,
  readMaybe,
  realToFrac,
  show,
  stderr,
  ($),
  (&),
  (.),
  (<&>),
  (==),
 )
import Protolude qualified as P

import Base32 (decode)
import Control.Arrow ((>>>))
import Control.Monad.Catch (catchAll)
import Data.Colour.RGBSpace (RGB (..))
import Data.Hourglass (
  DateTime,
  Elapsed (Elapsed),
  ElapsedP (..),
  ISO8601_DateAndTime (ISO8601_DateAndTime),
  NanoSeconds (NanoSeconds),
  Seconds (Seconds),
  Time (timeFromElapsedP),
  TimeFormat (toFormat),
  TimeFormatString,
  Timeable (timeGetElapsedP),
  timeGetDateTimeOfDay,
  timeParse,
  timePrint,
 )
import Data.Text (Text, unpack)
import Data.Text qualified as T
import Data.Text.Lazy qualified as TL
import Data.Time (UTCTime, ZonedTime, addUTCTime, zonedTimeToUTC)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
import Data.ULID (ULID (ULID, random, timeStamp))
import Data.ULID.Random (ULIDRandom, mkULIDRandom)
import Data.ULID.TimeStamp (ULIDTimeStamp, mkULIDTimeStamp)
import Prettyprinter (Doc, hardline, softline)
import Prettyprinter.Internal.Type (Doc (Empty))
import Prettyprinter.Render.Terminal (
  AnsiStyle,
  Color (Black),
  bgColorDull,
  color,
  colorDull,
 )
import System.Console.ANSI (ConsoleLayer (..), hGetLayerColor)
import System.Random (mkStdGen)

import Config (Config (..))


type IdText = Text
type TagText = Text


data Filter a = NoFilter | Only a
  deriving (Eq, Ord, Show)


data ListModifiedFlag = AllItems | ModifiedItemsOnly
  deriving (Eq, Show)


-- | Always add a hardline after non-empty documents
(<!!>) :: Doc ann -> Doc ann -> Doc ann
Empty <!!> y = y
x <!!> y = x <> hardline <> y


-- | Combine documents with 2 newlines
(<++>) :: Doc ann -> Doc ann -> Doc ann
x <++> y =
  x <> softline <> softline <> y


-- | Combine documents with 2 newlines if both documents are non-empty
(<$$>) :: Doc ann -> Doc ann -> Doc ann
Empty <$$> y = y
x <$$> Empty = x
x <$$> y = x <++> y


infixr 6 <$$>


vsepCollapse :: [Doc ann] -> Doc ann
vsepCollapse = P.foldr (<$$>) Empty


zeroTime :: DateTime
zeroTime = timeFromElapsedP 0


emptyUlid :: ULID
emptyUlid =
  ULID
    { timeStamp = mkULIDTimeStamp 0
    , random = mkULIDRandom (mkStdGen 0) & P.fst
    }


-- | ULID time section if timestamp == 1970-01-01 00:00:00.000
zeroUlidTxt :: Text
zeroUlidTxt = "0000000000"


utcFormatReadable :: TimeFormatString
utcFormatReadable =
  toFormat ("YYYY-MM-DD H:MI:S" :: [Char])


parseUtcNum :: Int -> Maybe DateTime
parseUtcNum number =
  parseUtc (show number)


parseUtc :: Text -> Maybe DateTime
parseUtc utcText =
  let
    utcString = unpack $ T.toLower utcText

    -- TODO: Remove after https://github.com/vincenthz/hs-hourglass/issues/50
    addSpaceAfter10 = T.intercalate " " . T.chunksOf 10
    addSpaceAfter13 = T.intercalate " " . T.chunksOf 13
    unixMicro = "EPOCH ms us" :: [Char]
    unixMilli = "EPOCH ms" :: [Char]

    tParse :: [Char] -> Maybe DateTime
    tParse formatString =
      timeParse (toFormat formatString) utcString
  in
    -- From long (specific) to short (unspecific)
    timeParse ISO8601_DateAndTime utcString
      -- <|> tParse "YYYY-MM-DDtH:MI:S.ns"
      <|> tParse "YYYY-MM-DDtH:MI:S.msusns"
      <|> tParse "YYYY-MM-DDtH:MI:S.msus"
      <|> tParse "YYYY-MM-DDtH:MI:S.ms"
      <|> tParse "YYYY-MM-DDtH:MI:S"
      <|> tParse "YYYY-MM-DDtH:MI"
      <|> tParse "YYYYMMDDtHMIS"
      <|> tParse "YYYY-MM-DD H:MI:S"
      <|> tParse "YYYY-MM-DD H:MI"
      <|> tParse "YYYY-MM-DD"
      <|> timeParse
        (toFormat unixMicro)
        (unpack $ (addSpaceAfter10 . addSpaceAfter13) utcText)
      <|> timeParse (toFormat unixMilli) (unpack $ addSpaceAfter10 utcText)
      <|> tParse "EPOCH"


parseUlidUtcSection :: Text -> Maybe DateTime
parseUlidUtcSection encodedUtc = do
  let
    decodedUtcMaybe = Base32.decode encodedUtc
    getElapsed val = Elapsed $ Seconds $ val `div` 1000

  elapsed <- fmap getElapsed decodedUtcMaybe
  milliSecPart <- fmap (`mod` 1000) decodedUtcMaybe

  let nanoSeconds = NanoSeconds $ milliSecPart * 1000000

  pure $ timeGetDateTimeOfDay $ ElapsedP elapsed nanoSeconds


ulidTextToDateTime :: Text -> Maybe DateTime
ulidTextToDateTime =
  T.take 10 >>> parseUlidUtcSection


{-| `ulidText2utc` converts a ULID to a UTC timestamp

>>> ulidText2utc "01hq68smfe0r9entg3x4rb9441"
Just "2024-02-21 16:43:17.358"
-}
ulidText2utc :: Text -> Maybe Text
ulidText2utc ulid =
  ulid
    & ulidTextToDateTime
    <&> (timePrint (toFormat ("YYYY-MM-DD H:MI:S.ms" :: [Char])) >>> T.pack)


parseUlidText :: Text -> Maybe ULID
parseUlidText ulidText = do
  let
    mkUlidTimeMaybe text = text & ulidTextToDateTime <&> toUlidTime

    mkUlidRandomMaybe :: Text -> Maybe ULIDRandom
    mkUlidRandomMaybe = readMaybe . T.unpack . T.drop 10

  ulidTime <- mkUlidTimeMaybe ulidText
  ulidRandom <- mkUlidRandomMaybe ulidText
  pure $ ULID ulidTime ulidRandom


-- TODO: Remove after https://github.com/vincenthz/hs-hourglass/issues/52
rationalToElapsedP :: Rational -> ElapsedP
rationalToElapsedP secondsFrac =
  let (sec, nanoSec) = properFraction secondsFrac
  in  ElapsedP (Elapsed (Seconds sec)) (NanoSeconds $ truncate $ nanoSec * 1e9)


-- TODO: Remove after https://github.com/vincenthz/hs-hourglass/issues/45
elapsedPToRational :: ElapsedP -> Rational
elapsedPToRational (ElapsedP (Elapsed (Seconds s)) (NanoSeconds ns)) =
  ((1e9 * fromIntegral s) + fromIntegral ns) / 1e9


formatElapsedP :: Config -> IO ElapsedP -> IO Text
formatElapsedP conf =
  fmap (timePrint conf.utcFormat >>> T.pack)


toUlidTime :: DateTime -> ULIDTimeStamp
toUlidTime =
  timeGetElapsedP
    >>> elapsedPToRational
    >>> realToFrac
    >>> mkULIDTimeStamp


setDateTime :: ULID -> DateTime -> ULID
setDateTime ulid dateTime =
  ULID
    (toUlidTime dateTime)
    (random ulid)


{-| Currently not needed
 addStartUtc :: DateTime -> Iso8601.Interval -> Iso8601.Interval
 addStartUtc utc interval = case interval of
   Iso8601.Interval (Iso8601.JustDuration duration) ->
     Iso8601.Interval (Iso8601.StartDuration (dateTimeToUtcTime utc) duration)
   _ -> interval
-}
zonedTimeToDateTime :: ZonedTime -> DateTime
zonedTimeToDateTime zTime =
  zTime
    & zonedTimeToUTC
    & utcTimeToPOSIXSeconds
    & toRational
    & rationalToElapsedP
    & timeFromElapsedP


utcTimeToDateTime :: UTCTime -> DateTime
utcTimeToDateTime utcTime =
  utcTime
    & utcTimeToPOSIXSeconds
    & toRational
    & rationalToElapsedP
    & timeFromElapsedP


dateTimeToUtcTime :: DateTime -> UTCTime
dateTimeToUtcTime dateTime =
  dateTime
    & timeGetElapsedP
    & elapsedPToRational
    & fromRational
    & flip addUTCTime (posixSecondsToUTCTime 0)


-- From https://mail.haskell.org/pipermail/haskell-cafe/2009-August/065854.html
numDigits :: Integer -> Integer -> Integer
numDigits base num =
  let
    ilog b n
      | n < b = (0, n)
      | otherwise =
          let (e, r) = ilog (b * b) n
          in  if r < b then (2 * e, r) else (2 * e + 1, r `div` b)
  in
    1 + fst (ilog base num)


colr :: Config -> Color -> AnsiStyle
colr conf newColor =
  if conf.noColor
    then mempty
    else color newColor


colrDull :: Config -> Color -> AnsiStyle
colrDull conf newColor =
  if conf.noColor
    then mempty
    else colorDull newColor


bgColrDull :: Config -> Color -> AnsiStyle
bgColrDull conf newColor =
  if conf.noColor
    then mempty
    else bgColorDull newColor


removeColorsIfNecessary :: Config -> IO Config
removeColorsIfNecessary conf = do
  if conf.noColor
    then
      pure
        conf
          { idStyle = mempty
          , priorityStyle = mempty
          , dateStyle = mempty
          , bodyStyle = mempty
          , bodyClosedStyle = mempty
          , closedStyle = mempty
          , dueStyle = mempty
          , overdueStyle = mempty
          , tagStyle = mempty
          }
    else
      pure conf


applyColorMode :: Config -> IO Config
applyColorMode conf = do
  layerColorBgMb <-
    catchAll
      (hGetLayerColor stderr Background)
      (\_ -> pure Nothing)

  let
    calcLuminance :: RGB Word16 -> Double
    calcLuminance (RGB{..}) =
      ( 0.3 * fromIntegral channelRed
          + 0.6 * fromIntegral channelGreen
          + 0.1 * fromIntegral channelBlue
      )
        / 65536

    isLightMode =
      layerColorBgMb
        <&> calcLuminance
        & fromMaybe 0 -- Default to dark mode
        & (> 0.5)

  pure $
    if isLightMode
      then conf{bodyStyle = colrDull conf Black}
      else conf


countCharTL :: Char -> TL.Text -> P.Int64
countCharTL char =
  TL.filter (== char) >>> TL.length


countChar :: Char -> Text -> P.Int
countChar char =
  T.filter (== char) >>> T.length