buchhaltung-0.0.1: src/Buchhaltung/Common.hs
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NoMonomorphismRestriction #-}
{-# LANGUAGE OverloadedStrings #-}
-- convert account transactions in csv format to ledger entries
-- pesco, 2009 http://www.khjk.org/log/2009/oct.html
module Buchhaltung.Common
(module Buchhaltung.Common
,module Buchhaltung.Utils
,module Buchhaltung.Types
,textstrip
)
where
import Buchhaltung.Types
import Buchhaltung.Utils
import Control.Applicative ((<$>))
import Control.Arrow
import Control.Lens hiding (noneOf, Getter, (<&>))
import Control.Monad.RWS.Strict
import Control.Monad.Writer
import qualified Data.Aeson as A
import Data.Char
import qualified Data.Csv as CSV
import Data.Csv.Parser
import Data.Decimal
import Data.Foldable
import qualified Data.HashMap.Strict as HM
import Data.List
import Data.List.NonEmpty (NonEmpty(..))
import qualified Data.List.NonEmpty as E
import qualified Data.ListLike as L
import qualified Data.ListLike.String as L
import qualified Data.Map.Strict as M
import Data.Ord
import qualified Data.Set as S
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.Lazy as TL
import Data.Text.Lazy.Encoding
import qualified Data.Text.Lazy.Encoding as S
import Data.Time.Calendar
import Data.Time.Format
import Data.Traversable (traverse)
import qualified Data.Vector as V
import Hledger.Data hiding (at)
import Hledger (textstrip)
import Hledger.Query
import Hledger.Read
import Hledger.Reports (defreportopts)
import Hledger.Reports.EntriesReport (entriesReport)
import System.IO
import Text.Parsec
import qualified Text.Parsec.Text as T
import qualified Text.PrettyPrint.Boxes as P
import Text.Printf
-- * CONFIGURATION
-- * CSV PARSER
readcsv :: Char -> T.Text -> [[T.Text]]
readcsv sep = map (readcsvrow sep) . T.lines
readcsvrow :: Char -> T.Text -> [T.Text]
readcsvrow sep s = either (error.msg.show) id (parse (p_csvrow sep) "stdin" s)
where msg = printf "CSV (sep %c) Parsing error:\n\n%v\n\n%s" sep s
p_csvrow :: Char -> T.Parser [T.Text]
p_csvrow sep = sepBy1 (p_csvfield sep) (char sep)
p_csvfield :: Char -> T.Parser T.Text
p_csvfield sep = fmap T.pack $ between (char '"') (char '"') p_csvstring
<|> many (noneOf [sep])
p_csvstring :: T.Parser String
p_csvstring = many (noneOf "\"" <|> (string escapedDoubleQuotes >> return '"'))
escapedDoubleQuotes = "\\\""
parens :: Parsec T.Text () Int
parens = ( do char ('('::Char)
m <- parens
char ')'
n <- parens
return $ max (m+1) n
) <|> return 0
-- * dbacl output parser
testdbacl = parseTest (
dbacl_parser [
"Aktiva:Transfer:Visa"
,"Aktiva:Transfer"
]
) ("Aktiva:Transfer 134.32 Aktiva:Transfer:Visa Aktiva:Transfer:Visa 9129.73 a " :: String)
-- | parse dbacl output (see testdbacl for the format of dbacl's output)
dbacl_parse :: [AccountName]
-> String
-> Either ParseError [(AccountName,String)]
dbacl_parse accounts = fmap conv . parse (dbacl_parser sorted) ""
where conv = map (liftM2 (,) fst (L.unwords . snd))
sorted = sortBy (flip $ comparing T.length) $ accounts
dbacl_parser :: [AccountName] -> Parsec String () [(AccountName, [String])]
dbacl_parser accounts = weiter []
where weiter :: [(AccountName, [String])] -> Parsec String () [(AccountName, [String])]
weiter res = choice ((map (cat res) accounts) ++ [info res] )
cat res y = do newc <- try $ do string $ T.unpack y
space
return y
spaces
weiter $ (newc,[]) : res
info ((c,i):res) = do w <- try $ manyTill anyChar (many1 space)
weiter $ (c,i <> [w]) : res
<|> do { w <- many anyChar; return ((c,i++[w]):res) }
info [] = fail "empty list in dbacl_parser: This was not planned"
-- * Utilities
idx :: (Eq a, Show a) => [a] -> a -> Int
idx xs x = maybe (error (show x++": CSV Field not found")) id (findIndex (==x) xs)
-- * Dates
-- | Read the journal file again, before applying Changes (to not
-- overwrite possible changes, that were made in the mean time)
-- saveChanges :: String -- ^ journal path
-- -> (Journal-> (Journal, Integer))
-- -> IO Journal
saveChanges
:: (MonadReader (Options User config env) m, MonadIO m)
=> (Journal -> (Journal, Integer))
-- ^ modifier, returning number of changed
-> m Journal
saveChanges change = do
journalPath <- absolute =<< readLedger imported
liftIO $ do
ej <- readJournalFile Nothing Nothing False -- ignore balance assertions
journalPath
-- print $ length todos
-- putStr $ unlines $ show <$> todos
-- either error (print.length.jtxns) ej
let (j, n) = either error change ej
if n == 0 then putStrLn "\nNo transactions were changed!\n"
else do let res = showTransactions j
writeFile journalPath res
putStrLn $ "\n"++ show n ++" Transactions were changed"
return j
mixed' :: Amount -> MixedAmount
mixed' = mixed . (:[])
showTransactions :: Hledger.Data.Journal -> [Char]
showTransactions = concatMap showTransactionUnelided .
entriesReport defreportopts Hledger.Query.Any
-- * Lenses
jTrans :: Lens' Journal [Transaction]
jTrans = lens jtxns $ \j y->j{jtxns=y}
tPosts :: Lens' Transaction [Posting]
tPosts = lens tpostings $ \t y -> t{tpostings=y}
pAcc :: Lens' Posting AccountName
pAcc = lens paccount $ \p y -> p{paccount=y}
-- | replaces every matching transaction in the given journal counts
-- the number of changed transactions
changeTransaction
:: [(Transaction, Transaction)]
-> Journal
-> (Journal, Integer)
changeTransaction ts = countUpdates (jTrans . traverse) h
where
h t1 = asum $ fmap g ts
where g (t2, tNew) = guard (t1 == t2) *> Just tNew
-- | Update a traversal and count the number of updates
countUpdates :: Traversal' s a
-> (a -> Maybe a)
-> s -> (s, Integer)
countUpdates trav mod = second getSum . runWriter . trav g
where g x = maybe (return x) ((tell (Sum 1) >>) . return) $ mod x
-- instance Monoid.Monoid Integer where
-- mempty = 0
-- mappend = (+)
data WithSource a = WithSource { wTx :: Transaction
, wIdx :: Int
-- ^ index of the posting with source
, wPosting :: Posting
, wSource :: Source
, wInfo :: a
}
deriving (Functor)
-- instance Hashable Day where
-- hash = fromInteger . toModifiedJulianDay
-- hashWithSalt salt = hashWithSalt salt . toModifiedJulianDay
-- instance Hashable Transaction where
-- instance Hashable Posting where
-- instance Hashable PostingType where
-- instance Hashable MixedAmount where
-- instance Hashable Amount where
-- | extracts the source line from a Transaction
extractSource :: ImportTag -> Transaction
-> Either String (WithSource ())
extractSource tag' tx =
left (<> "\nComments: "
<> T.unpack (L.unlines $ pcomment <$> ps))
$ g $ asum $ zipWith f [0..] ps
where f i p = fmap ((,,) i p) . E.nonEmpty . tail
. T.splitOn tag $ pcomment p
tag = commentPrefix tag'
g Nothing = Left $ printf "no comment with matching tag '%s' found." tag
g (Just (i,p,n)) = do
source <- A.eitherDecode' . S.encodeUtf8
. TL.fromStrict . E.head $ n
return $ WithSource tx i p source ()
ps = tpostings tx
injectSource :: ImportTag -> Source -> Transaction -> Transaction
injectSource tag source t = t
{tpostings = [ p1
, p2{pcomment =
commentPrefix tag <> TL.toStrict (json source)
}]}
where [p1,p2] = tpostings t
-- instance MonadReader (Options user Config env) m => ReaderM user env m
commentPrefix :: ImportTag -> T.Text
commentPrefix (ImportTag tag) = tag <> ": "
trimnl :: T.Text -> T.Text
trimnl = mconcat . T.lines
-- * make CSV data easier to handle
-- http://hackage.haskell.org/package/cassava-0.4.1.0/docs/Data-Csv.html#t:NamedRecord
-- parseCsv :: CSV.FromField a => String -> V.Vector (HM.HashMap B.ByteString a)
type MyRecord = (HM.HashMap T.Text T.Text)
parseCsv :: Char -- ^ separator
-> TL.Text -> ([T.Text], [MyRecord])
parseCsv sep = either error ((fmap T.decodeUtf8 . V.toList)
*** V.toList)
. CSV.decodeByNameWith CSV.defaultDecodeOptions
{ decDelimiter = fromIntegral $ ord sep }
. encodeUtf8
getCsvConcat
:: [T.Text] -> MyRecord -> T.Text
getCsvConcat x record = L.unwords $ flip getCsv record <$> x
getCsv :: T.Text -> MyRecord -> T.Text
getCsv c x = lookupErrD (show (HM.keys x)) HM.lookup c x
-- * Import Types
data ImportedEntry' a s = ImportedEntry {
ieT :: Transaction -- ^ transaction without postings (they will be inserted later)
,iePostings :: a
,ieSource :: s -- ^ source to check for duplicates and for Bayesian matching
} deriving Show
type ImportedEntry = ImportedEntry' (AccountId, T.Text) Source
-- ^ postings of [acount,amount]: only ImportedEntry with one
-- posting is currently implemented in the statists functionality of
-- Add.hs (See PROBLEM1) as well in the duplicates algorithm in 'addNew'
type FilledEntry = ImportedEntry' () Source
fromFilled :: FilledEntry -> Entry
fromFilled x = x{ieSource = Right $ ieSource x}
type Entry = ImportedEntry' () (Either String Source)
-- | helper function to create transaction for ImportedEntry
genTrans :: Day -> Maybe Day -> T.Text -> Transaction
genTrans date date2 desc =
nulltransaction{tdate=date, tdescription=desc, tdate2=date2}
normalizeMixedAmountWith
:: (Amount -> Decimal) -> MixedAmount -> MixedAmount
normalizeMixedAmountWith f (Mixed ams) = Mixed $ g <$> ams
where g a = a{aquantity = normalizeDecimal $ f a}
data Importer env = Importer
{ iModifyHandle :: Maybe (Handle -> IO ())
-- ^ e.g. 'windoof'
, iImport :: T.Text -> CommonM (env, Maybe Version) [ImportedEntry]
}
windoof :: Maybe (Handle -> IO ())
windoof = Just $ \h -> hSetEncoding h latin1
>> hSetNewlineMode h universalNewlineMode
parseDatum :: T.Text -> Day
parseDatum = parseTimeOrError True defaultTimeLocale "%d.%m.%Y" . T.unpack
-- | retrieval function
type Getter a = MyRecord -> a
data CsvImport a = CSV
{ cFilter :: MyRecord -> Bool
-- ^ should this csv line be processed?
, cAmount :: Getter T.Text
-- ^ Amount parsable by 'mamoumtp\''
, cDate :: Getter Day
, cVDate :: Getter (Maybe Day)
, cBank :: a -> Getter T.Text
, cAccount :: a -> Getter T.Text
, cHeader :: [T.Text]
, cBayes :: [T.Text]
, cDescription :: [T.Text]
, cVersion :: Version
, cSeparator :: Char
}
data CheckedCsvImport a = UnsafeCSV { cRaw :: CsvImport a }
-- deriving (Show)
toVersionedCSV
:: SFormat DefaultVersion
-> [CsvImport a] -> VersionedCSV a
toVersionedCSV format headers = sequence $ (,) format $ fromListUnique $
(cVersion . cRaw &&& id) . checkRawCSV format <$> headers
type VersionedCSV a = forall m. MonadError Msg m
=> m (SFormat DefaultVersion, M.Map Version (CheckedCsvImport a))
-- ^ (format with default version, _)
data DefaultVersion = DefaultVersion { fromDefaultVersion :: Version }
checkRawCSV :: SFormat b -> CsvImport a -> CheckedCsvImport a
checkRawCSV format rh =
if null missing then UnsafeCSV rh
else error $ printf
("format '%s', version '%s': The configured header misses the following "
++ "fields required for Bayes or Description:\n%s")
(fName format) (cVersion rh) $ unlines $ uncurry (printf "%s: %s") <$> missing
where [head, bayes, desc] = S.fromList . ($rh) <$> [cHeader, cBayes, cDescription]
missing =
concatMap (uncurry zip <&> repeat *** (fmap T.unpack . toList . flip S.difference head))
[("cBayes", bayes), ("cDescription", desc)] :: [(String, String)]
-- * Pretty Printing
table :: [Int] -- ^ max width
-> [T.Text] -- ^ Header
-> [[T.Text]] -- ^ list of cols
-> P.Box
table w h = table1 . table2 w h
table1 :: NonEmpty [P.Box] -- ^ list of rows
-> P.Box
table1 (header :| rows) = P.punctuateH P.top
(P.vcat P.top $ replicate (ml P.rows cols2) $ P.text " | ")
cols2
where h colHead col = P.vcat P.left $ colHead : sep : col
where sep = text' $ L.replicate (ml P.cols $ colHead : col) '-'
ml f = maximum . fmap f
cols2 = zipWith h header $ transpose rows
table2 :: [Int] -- ^ max width
-> [T.Text] -- ^ Header
-> [[T.Text]] -- ^ list of cols
-> NonEmpty [P.Box] -- ^ list of rows
table2 widths header cols =
toRow <$> (header :| transpose cols)
where
toRow = g . zipWith asd widths
asd w = P.para P.left w . T.unpack
g row = P.alignVert P.top mr <$> row
where mr = maximum $ P.rows <$> row
mlen :: L.ListLike l e => [l] -> Int
mlen = maximum . fmap L.length
text' :: T.Text -> P.Box
text' = P.text . T.unpack