packages feed

hascard-0.5.0.3: app/Main.hs

{-# LANGUAGE TemplateHaskell #-}
module Main where

import UI
import Control.Exception (displayException, try)
import Control.Monad (void, when)
import Data.Version (showVersion)
import Data.Void
import Lens.Micro.Platform
import Paths_hascard (version)
import Parser
import Options.Applicative hiding (many)
import System.Directory (makeAbsolute)
import System.FilePath (takeExtension, takeBaseName, takeDirectory)
import System.Process (runCommand)
import System.Random.MWC (createSystemRandom)
import Text.Megaparsec (parse, many, errorBundlePretty, ParseErrorBundle)
import Text.Megaparsec.Char.Lexer (charLiteral)
import qualified Data.Map.Strict as Map (empty)
import qualified System.Directory as D
import qualified Stack

data Command = Command
  | Run RunOpts
  | Import ImportOpts

data Opts = Opts
  { _optCommand      :: Maybe Command
  , _optVersion      :: Bool
  }

data RunOpts = RunOpts
  { _optFile         :: Maybe String
  , _optSubset       :: Int
  , _optChunk        :: Chunk
  , _optShuffle      :: Bool
  , _optBlankMode    :: Bool
  }

makeLenses ''Opts
makeLenses ''RunOpts
makeLenses ''ImportOpts

main :: IO ()
main = do
  useEscapeCode <- getUseEscapeCode
  when useEscapeCode $ void (runCommand "echo -n \\\\e[5 q")
  
  options <- execParser optsWithHelp
  case (options ^. optVersion, options ^. optCommand) of
    (True, _)                -> putStrLn (showVersion version)
    (_, Just (Run rOpts))    -> run rOpts
    (_, Just (Import iOpts)) -> doImport iOpts
    (_, Nothing)             ->
      do g <- createSystemRandom 
         let gs = GlobalState {_mwc=g, _states=Map.empty, _stack=Stack.empty, _parameters= defaultParameters }
         start Nothing gs

opts :: Parser Opts
opts = Opts
  <$> optional (hsubparser
    (  command "run" (info (Run <$> runOpts) ( progDesc "Run hascard with CLI options"))
    <> command "import" (info (Import <$> importOpts) (progDesc "Convert a delimited text file (e.g. exported from Quizlet) to a file compatible with hascard.\
    \ The delimiters can be specified via CLI options. By default, terms and definitions are assumed to be separated by tabs, and different cards by new lines.\
    \ Either 'Definition' cards are generated (traditional flashcards), or 'Open question' cards (where the answer needs to be typed).\
    \ For 'Open question' cards, a delimiter can be specified which separates multiple correct answers."))))
  <*> switch (long "version" <> short 'v' <> help "Show version number")

runOpts :: Parser RunOpts
runOpts = RunOpts
  <$> optional (argument str (metavar "FILE" <> help "A .txt or .md file containing flashcards"))
  <*> option auto (long "amount" <> short 'a' <> metavar "n" <> help "Use the first n cards in the deck (most useful combined with shuffle)" <> value (-1))
  <*> option auto (long "chunk" <> short 'c' <> metavar "i/n" <> help "Split the deck into n chunks, and review the i'th one. Counting starts at 1." <> value (Chunk 1 1))
  <*> switch (long "shuffle" <> short 's' <> help "Randomize card order")
  <*> switch (long "blank" <> short 'b' <> help "Disable review mode: do not keep track of which questions were correctly and incorrectly answered")

importOpts :: Parser ImportOpts
importOpts = ImportOpts
  <$> argument str (metavar "INPUT" <> help "A delimited text file")
  <*> argument str (metavar "DESINATION" <> help "The filename/path to which the output should be saved")
  <*> option auto (long "type" <> short 't' <> metavar "'open' or 'def'" <> help "The type of card to which the input is transformed; default: open." <> value Open)
  <*> switch (long "reverse" <> short 'r' <> help "Reverse direction of question and answer, i.e. right part becomes the question.")
  <*> strOption (long "row-delimiter" <> metavar "delimiter" <> help "The delimiter used to separate different cards; default: \\n." <> value "\n")
  <*> strOption (long "term-def-delimiter" <> metavar "delimiter" <> help "The delimiter used to separate terms and definitions; default: \\t." <> value "\t")
  <*> optional (strOption (long "def-delimiter" <> metavar "delimiter" <> help "The delimiter used to separate different definitions for the same term; no delimiter is used by default."))

optsWithHelp :: ParserInfo Opts
optsWithHelp = info (opts <**> helper) $
              fullDesc <> progDesc "Run the normal application with `hascard`. To run directly on a file, and with CLI options, see `hascard run --help`.\
              \ For converting delimited text files, see `hascard import --help`."
              <> header "Hascard - a TUI for reviewing notes"

nothingIf :: (a -> Bool) -> a -> Maybe a
nothingIf p a
  | p a = Nothing
  | otherwise = Just a

mkGlobalState :: RunOpts -> GenIO -> GlobalState
mkGlobalState opts gen = 
  let ps = Parameters { _pShuffle = opts^.optShuffle, _pSubset = nothingIf (<0) (opts^.optSubset)
                      , _pChunk = opts^.optChunk, _pReviewMode = not (opts^.optBlankMode), _pOk = False }
  in GlobalState {_mwc=gen, _states=Map.empty, _stack=Stack.empty, _parameters=ps }

cleanFilePath :: FilePath -> IO (Either String FilePath)
cleanFilePath fp = case takeExtension fp of
  ".txt" -> return $ Right fp
  ".md"  -> return $ Right fp
  ""     -> do existence <- mapM D.doesFileExist [fp <> ".txt", fp <> ".md"]
               return $ case existence of
                 [True, True] -> Left "Both a .txt and .md file of this name exist, and it is unclear which to use. Specify the file extension."
                 [True, _   ] -> Right $ fp <> ".txt"     
                 [_   , True] -> Right $ fp <> ".md"
                 _            -> Left $ "No .txt or .md file with the name \""
                                        <> takeBaseName fp
                                        <> "\" in the directory \""
                                        <> takeDirectory fp
                                        <> "\""
              
  _      -> return $ Left "Incorrect file type, provide a .txt file"
  
run :: RunOpts -> IO ()
run opts = run' (opts ^. optFile)
  where
    run' Nothing = createSystemRandom >>= start Nothing . mkGlobalState opts
    run' (Just dirtyFp) = do
      msgOrFp <- cleanFilePath dirtyFp
      case msgOrFp of
        Left msg -> putStrLn msg
        Right fp -> do
          valOrExc <- try (readFile fp) :: IO (Either IOError String)
          case valOrExc of
            Left exc -> putStrLn (displayException exc)
            Right val -> case parseCards val of
              Left parseError -> putStr parseError
              Right result ->
                do gen <- createSystemRandom
                   makeAbsolute fp >>= addRecent
                   start (Just (fp, result)) (mkGlobalState opts gen)

start :: Maybe (FilePath, [Card]) -> GlobalState -> IO ()
start Nothing gs = runBrickFlashcards (gs `goToState` mainMenuState)
start (Just (fp, cards)) gs = runBrickFlashcards =<< (gs `goToState`) <$> cardsWithOptionsState gs fp cards

doImport :: ImportOpts -> IO ()
doImport opts' = do
  let opts = opts' & optRowDelimiter %~ parseStringLiteral
  valOrExc <- try $ readFile (opts ^. optInput) :: IO (Either IOError String)
  case valOrExc of
    Left exc -> putStrLn (displayException exc)
    Right val -> do
      let mCards = parseImportInput opts val
      case mCards of
        Right cards -> do
          writeFile (opts ^. optOutput) . cardsToString $ cards
          putStrLn "Successfully converted the file."
        Left msg -> putStrLn msg
         
parseStringLiteral :: String -> String
parseStringLiteral s = case parse (many charLiteral) "" s of
  Left errorBundle -> error (errorBundlePretty (errorBundle :: ParseErrorBundle String Void))
  Right result -> result