{-| An add command to help with data entry. -} module AddCommand where import Prelude hiding (putStr, putStrLn, getLine, appendFile) import Ledger import Options import RegisterCommand (showRegisterReport) import System.IO.UTF8 import System.IO (stderr, hFlush) import System.IO.Error import Text.ParserCombinators.Parsec import Utils (ledgerFromStringWithOpts) -- | Read ledger transactions from the terminal, prompting for each field, -- and append them to the ledger file. If the ledger came from stdin, this -- command has no effect. add :: [Opt] -> [String] -> Ledger -> IO () add opts args l | filepath (rawledger l) == "-" = return () | otherwise = do hPutStrLn stderr "Enter one or more transactions, which will be added to your ledger file.\n\ \To complete a transaction, enter . as account name.\n\ \To finish input, enter control-d (discards any transaction in progress)." getAndAddTransactions l args return () -- | Read a number of ledger transactions from the command line, -- prompting, validating, displaying and appending them to the ledger -- file, until end of input. getAndAddTransactions :: Ledger -> [String] -> IO [LedgerTransaction] getAndAddTransactions l args = do -- for now, thread the eoi flag throughout rather than muck about with monads (t, eoi) <- getTransaction l args l <- if isJust t then addTransaction l (fromJust t) else return l if eoi then return $ maybe [] (:[]) t else liftM (fromJust t:) (getAndAddTransactions l args) -- | Get a transaction from the command line, if possible, and a flag -- indicating end of input. getTransaction :: Ledger -> [String] -> IO (Maybe LedgerTransaction, Bool) getTransaction l args = do today <- getCurrentDay (datestr, eoi) <- askFor "date" (Just $ showDate today) (Just $ \s -> null s || (isRight $ parse (smartdate >> many spacenonewline >> eof) "" $ lowercase s)) if eoi then return (Nothing, True) else do (description, eoi) <- if null args then askFor "description" Nothing (Just $ not . null) else (do let description = unwords args hPutStrLn stderr $ "description: " ++ description return (description, False)) if eoi then return (Nothing, True) else do let historymatches = transactionsSimilarTo l description when (not $ null historymatches) (do hPutStrLn stderr "Similar past transactions found:" hPutStr stderr $ concatMap (\(n,t) -> printf "[%3d%%] %s" (round $ n*100 :: Int) (show t)) $ take 3 historymatches) let bestmatch | null historymatches = Nothing | otherwise = Just $ snd $ head $ historymatches bestmatchpostings = maybe Nothing (Just . ltpostings) bestmatch date = fixSmartDate today $ fromparse $ (parse smartdate "" . lowercase) datestr getpostingsandvalidate = do (ps, eoi) <- getPostings bestmatchpostings [] let t = nullledgertxn{ltdate=date ,ltstatus=False ,ltdescription=description ,ltpostings=ps } if eoi && null ps then return (Nothing, eoi) else either (const retry) (return . flip (,) eoi . Just) $ balanceLedgerTransaction t retry = do hPutStrLn stderr $ "\n" ++ nonzerobalanceerror ++ ". Re-enter:" getpostingsandvalidate getpostingsandvalidate -- | Get two or more postings from the command line, if possible, and a -- flag indicating end of input. getPostings :: Maybe [Posting] -> [Posting] -> IO ([Posting], Bool) getPostings bestmatchps enteredps = do (account, eoi) <- askFor (printf "account %d" n) defaultaccount validateaccount if account=="." || eoi then return (enteredps, eoi) else do (amountstr, eoi) <- askFor (printf "amount %d" n) defaultamount validateamount let amount = fromparse $ parse (someamount <|> return missingamt) "" amountstr let p = nullrawposting{paccount=account,pamount=amount} if eoi then if null enteredps then return ([], True) else return (enteredps ++ [p], True) else if amount == missingamt then return $ (enteredps ++ [p], eoi) else getPostings bestmatchps $ enteredps ++ [p] where n = length enteredps + 1 bestmatch | isNothing bestmatchps = Nothing | n <= length ps = Just $ ps !! (n-1) | otherwise = Nothing where Just ps = bestmatchps defaultaccount = maybe Nothing (Just . paccount) bestmatch validateaccount = Just $ \s -> not $ null s defaultamount | n==1 = maybe Nothing (Just . show . pamount) bestmatch -- previously used amount | otherwise = Just $ show $ negate $ sum $ map pamount enteredps -- balancing amount validateamount = Just $ \s -> (null s && (not $ null enteredps)) || (isRight $ parse (someamount>>many spacenonewline>>eof) "" s) -- | Prompt for and read a string value and a flag indicating whether -- input has ended (control-d was pressed), optionally with a default -- value and a validator. A validator will cause the prompt to repeat -- until the input is valid (unless the input is just ctrl-d). askFor :: String -> Maybe String -> Maybe (String -> Bool) -> IO (String, Bool) askFor prompt def validator = do hPutStr stderr $ prompt ++ (maybe "" showdef def) ++ ": " hFlush stderr -- ugly l <- getLine `catch` (\e -> if isEOFError e then return "*EOF*" else ioError e) let (l', eoi) = case l of "*EOF*" -> ("", True) _ -> (l, False) let input = if null l' then fromMaybe l' def else l' case validator of Just valid -> if valid input || (null input && eoi) then return (input, eoi) else askFor prompt def validator Nothing -> return (input, eoi) where showdef s = " [" ++ s ++ "]" -- | Append this transaction to the ledger's file. Also, to the ledger's -- transaction list, but we don't bother updating the other fields - this -- is enough to include new transactions in the history matching. addTransaction :: Ledger -> LedgerTransaction -> IO Ledger addTransaction l t = do appendToLedgerFile l $ show t putStrLn $ printf "\nAdded transaction to %s:" (filepath $ rawledger l) putStrLn =<< registerFromString (show t) return l{rawledger=rl{ledger_txns=ts}} where rl = rawledger l ts = ledger_txns rl ++ [t] -- | Append data to the ledger's file, ensuring proper separation from any -- existing data; or if the file is "-", dump it to stdout. appendToLedgerFile :: Ledger -> String -> IO () appendToLedgerFile l s = if f == "-" then putStr $ sep ++ s else appendFile f $ sep++s where f = filepath $ rawledger l -- we keep looking at the original raw text from when the ledger -- was first read, but that's good enough for now t = rawledgertext l sep | null $ strip t = "" | otherwise = replicate (2 - min 2 (length lastnls)) '\n' where lastnls = takeWhile (=='\n') $ reverse t -- | Convert a string of ledger data into a register report. registerFromString :: String -> IO String registerFromString s = do now <- getCurrentLocalTime l <- ledgerFromStringWithOpts [] [] now s return $ showRegisterReport [Empty] [] l -- | Return a similarity measure, from 0 to 1, for two strings. -- This is Simon White's letter pairs algorithm from -- http://www.catalysoft.com/articles/StrikeAMatch.html -- with a modification for short strings. compareStrings :: String -> String -> Float compareStrings "" "" = 1 compareStrings (a:[]) "" = 0 compareStrings "" (b:[]) = 0 compareStrings (a:[]) (b:[]) = if toUpper a == toUpper b then 1 else 0 compareStrings s1 s2 = 2.0 * (fromIntegral i) / (fromIntegral u) where i = length $ intersect pairs1 pairs2 u = length pairs1 + length pairs2 pairs1 = wordLetterPairs $ uppercase s1 pairs2 = wordLetterPairs $ uppercase s2 wordLetterPairs = concatMap letterPairs . words letterPairs (a:b:rest) = [a,b]:(letterPairs (b:rest)) letterPairs _ = [] compareLedgerDescriptions s t = compareStrings s' t' where s' = simplify s t' = simplify t simplify = filter (not . (`elem` "0123456789")) transactionsSimilarTo :: Ledger -> String -> [(Float,LedgerTransaction)] transactionsSimilarTo l s = sortBy compareRelevanceAndRecency $ filter ((> threshold).fst) $ [(compareLedgerDescriptions s $ ltdescription t, t) | t <- ts] where compareRelevanceAndRecency (n1,t1) (n2,t2) = compare (n2,ltdate t2) (n1,ltdate t1) ts = ledger_txns $ rawledger l threshold = 0 {- doctests @ $ echo "2009/13/1"|hledger -f /dev/null add 2>&1|tail -1|sed -e's/\[[^]]*\]//g' # a bad date is not accepted date : date : @ @ $ echo|hledger -f /dev/null add 2>&1|tail -1|sed -e's/\[[^]]*\]//g' # a blank date is ok date : description: @ @ $ printf "\n\n"|hledger -f /dev/null add 2>&1|tail -1|sed -e's/\[[^]]*\]//g' # a blank description should fail date : description: description: @ -}