Hacking Watson with Haskell - Part 2
In the previous blog post, we read the Watson frames from a JSON file. In this blog post, we will read the Watson state file and print it to the standard output.
Watson State
In addition to the frames, Watson also stores the state of the frame in a separate JSON file, called state
. As far as I understand, there can be 3 possible states at a given time:
- There is no state file: You have installed Watson, but you have not started tracking time yet.
- The state file is empty: You have started using Watson, but currently not tracking any time.
- The state file contains some data: You are tracking time at the moment.
When we say that the state file is empty, actually it is an empty JSON object:
{}
When the state file contains some data, it is a JSON object with 3 keys, project
, start
time and tags
list:
{
"project": "project1",
"start": 1629043200,
"tags": ["tag1", "tag2"]
}
Let’s work with this state file…
Program
This blog post is a Literate Haskell program that attempts to read the Watson state from a JSON file and prints it to the standard output. That’s it for this very blog post.
Let’s start with the language extensions:
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeApplications #-}
We will use aeson package like in the previous post, in addition to the libraries coming with the GHC. Let’s run our imports:
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import System.Directory (doesFileExist)
import System.Environment (getArgs)
import qualified Data.Aeson as Aeson
import qualified Data.Text as T
import qualified Data.Time as Time
Our entry point function is main
as usual. Here is what it will do:
- Read the path to the state JSON file from the command line arguments.
- Attempt to read the state from the file.
- Print the current state to the standard output if reading is successful, otherwise print an appropriate message.
main :: IO ()
main = do
First, attempt to read the path from the command line arguments:
fp <- head <$> getArgs
Then, attempt to read the state from the file:
mState <- readState fp
By now, we have a result of type Maybe CurrentState
. We will pattern match to print the state if it is available:
case mState of
Just state -> print state
Nothing -> putStrLn "State file can not be parsed."
That’s all what main
function does. Now, let’s define the CurrentState
data type which is a sum type encoding both no-tracking and tracking states:
data CurrentState
= CurrentStatePending
| CurrentStateRunning
{ currentStateRunningSince :: !Time.UTCTime
, currentStateRunningProject :: !T.Text
, currentStateRunningTags :: ![T.Text]
}
… and add the Show
and Eq
instances for it:
deriving (Show, Eq)
Let’s define an instance of the FromJSON
type class for it. We will match against a JSON object:
instance Aeson.FromJSON CurrentState where
parseJSON = Aeson.withObject "CurrentState" $ \o -> do
If the object is empty, we will return CurrentStatePending
:
if null o
then pure CurrentStatePending
…, otherwise try to parse the project
, start
and tags
fields:
else CurrentStateRunning
<$> (fromEpoch <$> o Aeson..: "start")
<*> o Aeson..: "project"
<*> o Aeson..: "tags"
… where our fromEpoch
function converts an epoch time to a UTCTime
value:
where
fromEpoch = posixSecondsToUTCTime . fromIntegral @Int
Now, we can define the readState
function:
readState :: FilePath -> IO (Maybe CurrentState)
readState fp = do
exists <- doesFileExist fp
if exists
then do
mState <- Aeson.eitherDecodeFileStrict fp
pure $ case mState of
Left _ -> Nothing
Right state -> Just state
else pure $ Just CurrentStatePending
Done!
As usual, let’s create a symbolic link to the source code file:
ln -sr \
content/posts/2024-08-16_hacking-watson-part-2.md \
content/posts/2024-08-16_hacking-watson-part-2.lhs
…, run our program:
runhaskell -pgmLmarkdown-unlit content/posts/2024-08-16_hacking-watson-part-2.lhs ~/.config/watson/state
…, and see the output:
CurrentStateRunning {currentStateRunningSince = 2024-08-16 14:53:57 UTC, currentStateRunningProject = "vst", currentStateRunningTags = ["gh:vst/vst.github.io"]}
Wrap-Up
With this blog post, we completed the reading part of the Watson files. In the next blog post, we will write the state file and start tracking time with Haskell instead of the Watson CLI.