packages feed

orgstat-0.1.9: src/arch/Main.hs

{-# LANGUAGE ApplicativeDo, OverloadedStrings, ScopedTypeVariables #-}

module Main where

import Universum

import qualified Data.Attoparsec.Text as A
import qualified Data.HashMap.Strict as HM
import qualified Data.List as L
import qualified Data.OrgMode.Parse as O
import Data.OrgMode.Types
import qualified Data.OrgMode.Types as O
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Data.Time (LocalTime(..), TimeOfDay(..), fromGregorian)
import Data.Time.Format (defaultTimeLocale, parseTimeM)
import Data.Version (showVersion)
import Options.Applicative.Simple
  (Parser, ReadM, help, long, maybeReader, metavar, option, simpleOptions, strOption, switch)
import Paths_orgstat (version)
import System.Directory (doesFileExist)
import System.FilePath (takeBaseName, takeExtension)
import System.FilePath ((</>))

import OrgStat.Logging

----------------------------------------------------------------------------
-- Arguments/options
----------------------------------------------------------------------------

data Args = Args
    { argsInputs       :: ![String]
    , argsOutDir       :: !String
    , argsTodoKeywords :: ![Text]
    , argsDoneKeywords :: ![Text]
    , argsDate         :: !LocalTime
    , argsDebug        :: !Bool
    } deriving Show

argsParser :: Parser Args
argsParser = do
    argsInputs <-
        some $
        strOption
            (long "input" <> metavar "FILEPATH" <>
            help "Files to consider for input")
    argsOutDir <- strOption (long "out-dir" <> metavar "FILENAME" <> help "Output directory")
    argsTodoKeywords <- option (maybeReader $ pure . map fromString . L.words)
                               (long "todo-keywords" <> metavar "STRLIST" <> help "TODO keywords, space-separated list")
    argsDoneKeywords <- option (maybeReader $ pure . map fromString . L.words)
                               (long "done-keywords" <> metavar "STRLIST" <> help "DONE keywords, space-separated list")
    argsDate <- option dateReadM (long "archive-from" <> metavar "DATE <YYYY-MM-DD>")
    argsDebug <- switch (long "debug" <> help "Enable debug logging")
    pure Args{..}
  where
    dateReadM :: ReadM LocalTime
    dateReadM = maybeReader $ \s ->
        case parseTimeM True defaultTimeLocale "%Y-%m-%d" s of
            Nothing -> fail $
                "Couldn't read date " <> show s <>
                ". Correct format is YYYY-MM-DD"
            Just ut -> pure ut


getOptions :: IO Args
getOptions = do
    (res, ()) <-
        simpleOptions
            ("orgarch-" <> showVersion version)
            "----- OrgArch ------"
            "The tool for creating org-mode archives"
            argsParser
            empty
    pure res


----------------------------------------------------------------------------
-- Parsing and processing
----------------------------------------------------------------------------

data LogicException = LogicException Text
    deriving (Show, Typeable)

instance Exception LogicException


-- | Analogous to readOrgFile from OrgStat.IO, but with original
-- orgmode-parse datatype.
readOrgFile
    :: (MonadIO m, MonadCatch m)
    => [Text] -> FilePath -> m (Text, O.Document)
readOrgFile todoKeywords fp = do
    logDebug $ "Reading org file " <> fpt
    unlessM (liftIO $ doesFileExist fp) $
        throwM $ LogicException $ "Org file " <> fpt <> " doesn't exist"
    (content, fname) <- case takeExtension fp of
        ".org" -> (,fp) <$> liftIO (TIO.readFile fp)
        _ -> throwM $ LogicException $
            "File " <> fpt <> " has unknown extension. Need to be .org"
    let filename = T.pack $ takeBaseName fname
    logDebug $ "Parsing org file " <> fpt
    parsed <-
        case A.parseOnly (O.parseDocument todoKeywords) content of
            Left err  -> throwM $ LogicException $ T.pack err
            Right res -> pure res

    pure (filename, parsed)
  where
    fpt = T.pack fp

-- Nothing for DateTime without time-of-day
convertDateTime :: O.DateTime -> Maybe LocalTime
convertDateTime
    O.DateTime
      { yearMonthDay = O.YearMonthDay year month day
      , hourMinute = Just (hour, minute)
      }
  = Just $ LocalTime
      (fromGregorian (toInteger year) month day)
      (TimeOfDay hour minute 0)
convertDateTime _ = Nothing


printDocument :: Document -> Text
printDocument Document{..} =
    documentText <> "\n" <> T.intercalate "\n" (map printHeadline documentHeadlines)
  where
    prependSpace prefix s = if T.null s then "" else prefix <> s

    printStats (StatsPct i) = " [" <> show i <> "%]"
    printStats (StatsOf i j) = " [" <> show i <> "/" <> show j <> "]"

    printHeadline :: Headline -> Text
    printHeadline Headline{..} =
        let Depth d = depth in
        let prefix =
                fromString (take d (Universum.repeat '*')) <>
                maybe "" (\s -> " " <> unStateKeyword s) stateKeyword <>
                maybe "" (\p -> " [#" <> show p <> "]") priority <>
                maybe "" printStats  stats <>
                (prependSpace " " title)
        in
        let tagList = if null tags then "" else ":" <> T.intercalate ":" tags <> ":" in
        let spaceLen = 77 - T.length prefix - T.length tagList in
        let header = prefix <>
                prependSpace (fromString (take spaceLen (Universum.repeat ' '))) tagList in

        let sec = printSection d section in

        let subHeadlinesStr = map printHeadline subHeadlines in

        header <> (prependSpace "\n" sec) <>
                  (if all T.null subHeadlinesStr
                   then "" else "\n" <> T.intercalate "\n" subHeadlinesStr)

    printSection :: Int -> Section -> Text
    printSection depth Section{..} =
        let indent = fromString (take (depth+1) (Universum.repeat ' ')) in
        let Plns plns = sectionPlannings in
        let planningsStr =
                T.intercalate " " $ map (\(k,v) -> show k <> ": " <> printTs v) $
                HM.toList plns in
        let clocksStr = map printClock sectionClocks in
        let withDrawer name xs = [":" <> name <> ":"] <> xs <> [":END:"] in
        let props = unProperties sectionProperties in
        let propsStr = if HM.null props
                       then []
                       else withDrawer "PROPERTIES" $
                            map (\(k,v) -> ":" <> k <> ": " <> v) $ HM.toList props in
        let logbook = unLogbook sectionLogbook in
        let logbookStr = if logbook == [] then [] else
                         withDrawer "LOGBOOK" $ map printClock logbook in
        let drawersStr =
                concatMap (\(Drawer name contents) -> withDrawer name [contents]) sectionDrawers in

        let maybeInclude s = if T.null s then [] else [s] in
        prependSpace indent $
          T.intercalate ("\n" <> indent)
          (concat [ maybeInclude planningsStr
                  , clocksStr, propsStr
                  , logbookStr, drawersStr])
                  <> T.stripEnd sectionParagraph

    printClock :: Clock -> Text
    printClock (Clock (Just ts, Just (h,m))) =
        let withSpace s = if T.length s == 1 then " " <> s else s in
        let withZero s = if T.length s == 1 then "0" <> s else s in
        "CLOCK: " <> printTs ts <> " => " <> withSpace (show h) <> ":" <> withZero (show m)
    printClock (Clock (Just ts, Nothing)) = "CLOCK: " <> printTs ts
    printClock c = error $ "printClock: not yet implemented " <> show c

    printTs :: Timestamp -> Text
    printTs Timestamp{..} =
        bracketOnActive tsActive (dateTime tsTime) <>
        maybe "" (\end -> "--" <> bracketOnActive tsActive (dateTime end)) tsEndTime
      where
        maybePrepend a s = if s == "" then a else a <> " " <> s
        withZero s = if T.length s == 1 then "0" <> s else s
        dateTime DateTime{..} =
          (show (ymdYear yearMonthDay) <> "-" <>
           withZero (show (ymdMonth yearMonthDay)) <> "-" <>
           withZero (show (ymdDay yearMonthDay))) `maybePrepend`
          maybe "" id dayName `maybePrepend`
          maybe "" (\(h,m) -> withZero (show h) <> ":" <> withZero (show m)) hourMinute `maybePrepend`
          maybe "" printRepeater repeater `maybePrepend`
          maybe "" printDelay delay

        bracketOnActive active s = if active == Active then "<" <> s <> ">" else "[" <> s <> "]"

        printTimeUnit = \case
            UnitYear -> "y"
            UnitMonth -> "m"
            UnitWeek -> "w"
            UnitDay -> "d"
            UnitHour -> "h"

        printRepeater Repeater{..} =
            (case repeaterType of
               RepeatCumulate -> "++"
               RepeatCatchUp -> "+"
               RepeatRestart -> ".+") <>
            show repeaterValue <>
            printTimeUnit repeaterUnit

        printDelay Delay{..} =
            (case delayType of
               DelayAll -> "-"
               DelayFirst -> "--") <>
            show delayValue <>
            printTimeUnit delayUnit

-- TODO: repeated common tasks?
filterOrg :: [Text] -> LocalTime -> O.Document -> (O.Document, O.Document)
filterOrg doneKeywords archDate Document{..} =
    let hlUpd = map go documentHeadlines in
    (Document { documentHeadlines = mapMaybe fst hlUpd, ..},
     Document { documentHeadlines = mapMaybe snd hlUpd, ..})
  where
    tsOlder :: Timestamp -> Bool
    tsOlder ts = case convertDateTime (tsTime ts) of
        Nothing -> False
        Just locTime -> locTime <= archDate

    go :: Headline -> (Maybe Headline,Maybe Headline)
    go hl@Headline{..} =
         let subHls = map go subHeadlines in
         -- Whether this headline was CLOSED before archive date
         let isDone = stateKeyword `elem` (map (Just . StateKeyword) doneKeywords) in
         let Plns plannings = sectionPlannings section in
         let isRepeating = case HM.lookup SCHEDULED plannings of
               Nothing -> False
               Just ts -> isJust (repeater (tsTime ts)) in
         let closedOld = case HM.lookup CLOSED plannings of
               Nothing -> True
               Just ts -> tsOlder ts in

         if isRepeating || stateKeyword == Nothing
         then
           let clocks =
                   reverse $
                   L.sortOn (maybe (error "Couldn't convert datetime to sort") id .
                             convertDateTime . tsTime . fst) $
                   map (\case Clock (Just x, y) -> (x,y)
                              c -> error $ "Have encountered a broken clock: " <> show c) $
                   L.nub $
                   concat [ sectionClocks section
                          , unLogbook (sectionLogbook section)
                          ] in
           let (splitArch, splitRemain) = L.partition (tsOlder . fst) clocks in

           let toClock (a, b) = Clock (Just a, b) in

           let remainItem = all (isNothing . snd) subHls && splitArch == [] in
           let hlLeft =
                   hl { section = section { sectionClocks = []
                                          , sectionLogbook = Logbook (map toClock splitRemain)}
                      , subHeadlines = mapMaybe fst subHls } in

           let hlRight =
                   hl { section = section { sectionClocks = []
                                          , sectionLogbook = Logbook (map toClock splitArch)}
                      , subHeadlines = mapMaybe snd subHls } in

           (Just hlLeft,if remainItem then Nothing else Just hlRight)
         else (if isDone && closedOld then (Nothing, Just hl) else (Just hl, Nothing))


main :: IO ()
main = do
    args@Args{..} <- getOptions
    let sev = if argsDebug then Debug else Info
    initLogging sev
    logInfo $ show args

    orgFiles <- forM argsInputs $ readOrgFile $ argsTodoKeywords <> argsDoneKeywords

    logInfo $ show $ map fst orgFiles

    let filteredFiles = map (second $ filterOrg argsDoneKeywords argsDate) orgFiles

    forM_ filteredFiles $ \(fn,(remain,arch)) -> do
        TIO.writeFile (argsOutDir </> (toString fn <> "_remain.org" )) $ printDocument remain
        TIO.writeFile (argsOutDir </> (toString fn <> "_archive.org")) $ printDocument arch

    logInfo "Done"