lib: Hledger.Read.CsvReader cleanup
Exports added: CsvRecord, CsvValue, csvFileFor Exports removed: expandIncludes, parseAndValidateCsvRules, transactionFromCsvRecord
This commit is contained in:
parent
5365fde3bc
commit
2c8a6e988f
@ -1,3 +1,6 @@
|
|||||||
|
-- * -*- eval: (orgstruct-mode 1); orgstruct-heading-prefix-regexp:"-- "; -*-
|
||||||
|
-- ** doc
|
||||||
|
-- In Emacs, use TAB on lines beginning with "-- *" to collapse/expand sections.
|
||||||
{-|
|
{-|
|
||||||
|
|
||||||
A reader for CSV data, using an extra rules file to help interpret the data.
|
A reader for CSV data, using an extra rules file to help interpret the data.
|
||||||
@ -7,36 +10,40 @@ A reader for CSV data, using an extra rules file to help interpret the data.
|
|||||||
-- Here's a command that will render them:
|
-- Here's a command that will render them:
|
||||||
-- stack haddock hledger-lib --fast --no-haddock-deps --haddock-arguments='--ignore-all-exports' --open
|
-- stack haddock hledger-lib --fast --no-haddock-deps --haddock-arguments='--ignore-all-exports' --open
|
||||||
|
|
||||||
|
-- ** language
|
||||||
{-# LANGUAGE CPP #-}
|
{-# LANGUAGE CPP #-}
|
||||||
{-# LANGUAGE FlexibleContexts #-}
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
|
||||||
{-# LANGUAGE TypeFamilies #-}
|
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
{-# LANGUAGE ViewPatterns #-}
|
|
||||||
{-# LANGUAGE TypeSynonymInstances #-}
|
|
||||||
{-# LANGUAGE FlexibleInstances #-}
|
{-# LANGUAGE FlexibleInstances #-}
|
||||||
{-# LANGUAGE PackageImports #-}
|
|
||||||
{-# LANGUAGE MultiWayIf #-}
|
{-# LANGUAGE MultiWayIf #-}
|
||||||
{-# LANGUAGE NamedFieldPuns #-}
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE PackageImports #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
|
{-# LANGUAGE TypeFamilies #-}
|
||||||
|
{-# LANGUAGE TypeSynonymInstances #-}
|
||||||
|
{-# LANGUAGE ViewPatterns #-}
|
||||||
|
|
||||||
|
-- ** doctest setup
|
||||||
|
-- $setup
|
||||||
|
-- >>> :set -XOverloadedStrings
|
||||||
|
|
||||||
|
-- ** exports
|
||||||
module Hledger.Read.CsvReader (
|
module Hledger.Read.CsvReader (
|
||||||
-- * Reader
|
-- * Reader
|
||||||
reader,
|
reader,
|
||||||
-- * Misc.
|
-- * Misc.
|
||||||
CsvRecord,
|
CSV, CsvRecord, CsvValue,
|
||||||
CSV, Record, Field,
|
csvFileFor,
|
||||||
-- rules,
|
|
||||||
rulesFileFor,
|
rulesFileFor,
|
||||||
parseRulesFile,
|
parseRulesFile,
|
||||||
parseAndValidateCsvRules,
|
|
||||||
expandIncludes,
|
|
||||||
transactionFromCsvRecord,
|
|
||||||
printCSV,
|
printCSV,
|
||||||
-- * Tests
|
-- * Tests
|
||||||
tests_CsvReader,
|
tests_CsvReader,
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
|
||||||
|
-- ** imports
|
||||||
import Prelude ()
|
import Prelude ()
|
||||||
import "base-compat-batteries" Prelude.Compat hiding (fail)
|
import "base-compat-batteries" Prelude.Compat hiding (fail)
|
||||||
import qualified "base-compat-batteries" Control.Monad.Fail.Compat as Fail (fail)
|
import qualified "base-compat-batteries" Control.Monad.Fail.Compat as Fail (fail)
|
||||||
@ -80,11 +87,13 @@ import Hledger.Data
|
|||||||
import Hledger.Utils
|
import Hledger.Utils
|
||||||
import Hledger.Read.Common (Reader(..),InputOpts(..),amountp, statusp, genericSourcePos, finaliseJournal)
|
import Hledger.Read.Common (Reader(..),InputOpts(..),amountp, statusp, genericSourcePos, finaliseJournal)
|
||||||
|
|
||||||
type CSV = [Record]
|
-- ** some types
|
||||||
|
|
||||||
type Record = [Field]
|
type CSV = [CsvRecord]
|
||||||
|
type CsvRecord = [CsvValue]
|
||||||
|
type CsvValue = String
|
||||||
|
|
||||||
type Field = String
|
-- ** reader
|
||||||
|
|
||||||
reader :: Reader
|
reader :: Reader
|
||||||
reader = Reader
|
reader = Reader
|
||||||
@ -110,185 +119,23 @@ parse iopts f t = do
|
|||||||
-- better preemptively reverse them once more. XXX inefficient
|
-- better preemptively reverse them once more. XXX inefficient
|
||||||
pj' = journalReverse pj
|
pj' = journalReverse pj
|
||||||
|
|
||||||
-- | Parse special separator names TAB and SPACE, or return the first
|
-- ** reading rules files
|
||||||
-- character. Return Nothing on empty string
|
-- *** rules utilities
|
||||||
parseSeparator :: String -> Maybe Char
|
|
||||||
parseSeparator = specials . map toLower
|
|
||||||
where specials "space" = Just ' '
|
|
||||||
specials "tab" = Just '\t'
|
|
||||||
specials (x:_) = Just x
|
|
||||||
specials [] = Nothing
|
|
||||||
|
|
||||||
-- | Read a Journal from the given CSV data (and filename, used for error
|
-- Not used by hledger; just for lib users,
|
||||||
-- messages), or return an error. Proceed as follows:
|
-- | An pure-exception-throwing IO action that parses this file's content
|
||||||
--
|
-- as CSV conversion rules, interpolating any included files first,
|
||||||
-- 1. parse CSV conversion rules from the specified rules file, or from
|
-- and runs some extra validation checks.
|
||||||
-- the default rules file for the specified CSV file, if it exists,
|
parseRulesFile :: FilePath -> ExceptT String IO CsvRules
|
||||||
-- or throw a parse error; if it doesn't exist, use built-in default rules
|
parseRulesFile f =
|
||||||
--
|
liftIO (readFilePortably f >>= expandIncludes (takeDirectory f))
|
||||||
-- 2. parse the CSV data, or throw a parse error
|
>>= either throwError return . parseAndValidateCsvRules f
|
||||||
--
|
|
||||||
-- 3. convert the CSV records to transactions using the rules
|
|
||||||
--
|
|
||||||
-- 4. if the rules file didn't exist, create it with the default rules and filename
|
|
||||||
--
|
|
||||||
-- 5. return the transactions as a Journal
|
|
||||||
--
|
|
||||||
readJournalFromCsv :: Maybe FilePath -> FilePath -> Text -> IO (Either String Journal)
|
|
||||||
readJournalFromCsv Nothing "-" _ = return $ Left "please use --rules-file when reading CSV from stdin"
|
|
||||||
readJournalFromCsv mrulesfile csvfile csvdata =
|
|
||||||
handle (\(e::IOException) -> return $ Left $ show e) $ do
|
|
||||||
|
|
||||||
-- make and throw an IO exception.. which we catch and convert to an Either above ?
|
-- | Given a CSV file path, what would normally be the corresponding rules file ?
|
||||||
let throwerr = throw . userError
|
|
||||||
|
|
||||||
-- parse the csv rules
|
|
||||||
let rulesfile = fromMaybe (rulesFileFor csvfile) mrulesfile
|
|
||||||
rulesfileexists <- doesFileExist rulesfile
|
|
||||||
rulestext <-
|
|
||||||
if rulesfileexists
|
|
||||||
then do
|
|
||||||
dbg1IO "using conversion rules file" rulesfile
|
|
||||||
readFilePortably rulesfile >>= expandIncludes (takeDirectory rulesfile)
|
|
||||||
else
|
|
||||||
return $ defaultRulesText rulesfile
|
|
||||||
rules <- either throwerr return $ parseAndValidateCsvRules rulesfile rulestext
|
|
||||||
dbg2IO "rules" rules
|
|
||||||
|
|
||||||
-- parse the skip directive's value, if any
|
|
||||||
let skiplines = case getDirective "skip" rules of
|
|
||||||
Nothing -> 0
|
|
||||||
Just "" -> 1
|
|
||||||
Just s -> readDef (throwerr $ "could not parse skip value: " ++ show s) s
|
|
||||||
|
|
||||||
-- parse csv
|
|
||||||
-- parsec seems to fail if you pass it "-" here TODO: try again with megaparsec
|
|
||||||
let parsecfilename = if csvfile == "-" then "(stdin)" else csvfile
|
|
||||||
let separator = fromMaybe ',' (getDirective "separator" rules >>= parseSeparator)
|
|
||||||
dbg2IO "separator" separator
|
|
||||||
records <- (either throwerr id .
|
|
||||||
dbg2 "validateCsv" . validateCsv rules skiplines .
|
|
||||||
dbg2 "parseCsv")
|
|
||||||
`fmap` parseCsv separator parsecfilename csvdata
|
|
||||||
dbg1IO "first 3 csv records" $ take 3 records
|
|
||||||
|
|
||||||
-- identify header lines
|
|
||||||
-- let (headerlines, datalines) = identifyHeaderLines records
|
|
||||||
-- mfieldnames = lastMay headerlines
|
|
||||||
|
|
||||||
let
|
|
||||||
-- convert CSV records to transactions
|
|
||||||
txns = snd $ mapAccumL
|
|
||||||
(\pos r ->
|
|
||||||
let
|
|
||||||
SourcePos name line col = pos
|
|
||||||
line' = (mkPos . (+1) . unPos) line
|
|
||||||
pos' = SourcePos name line' col
|
|
||||||
in
|
|
||||||
(pos, transactionFromCsvRecord pos' rules r)
|
|
||||||
)
|
|
||||||
(initialPos parsecfilename) records
|
|
||||||
|
|
||||||
-- Ensure transactions are ordered chronologically.
|
|
||||||
-- First, if the CSV records seem to be most-recent-first (because
|
|
||||||
-- there's an explicit "newest-first" directive, or there's more
|
|
||||||
-- than one date and the first date is more recent than the last):
|
|
||||||
-- reverse them to get same-date transactions ordered chronologically.
|
|
||||||
txns' =
|
|
||||||
(if newestfirst || mseemsnewestfirst == Just True then reverse else id) txns
|
|
||||||
where
|
|
||||||
newestfirst = dbg3 "newestfirst" $ isJust $ getDirective "newest-first" rules
|
|
||||||
mseemsnewestfirst = dbg3 "mseemsnewestfirst" $
|
|
||||||
case nub $ map tdate txns of
|
|
||||||
ds | length ds > 1 -> Just $ head ds > last ds
|
|
||||||
_ -> Nothing
|
|
||||||
-- Second, sort by date.
|
|
||||||
txns'' = sortBy (comparing tdate) txns'
|
|
||||||
|
|
||||||
when (not rulesfileexists) $ do
|
|
||||||
dbg1IO "creating conversion rules file" rulesfile
|
|
||||||
writeFile rulesfile $ T.unpack rulestext
|
|
||||||
|
|
||||||
return $ Right nulljournal{jtxns=txns''}
|
|
||||||
|
|
||||||
parseCsv :: Char -> FilePath -> Text -> IO (Either String CSV)
|
|
||||||
parseCsv separator filePath csvdata =
|
|
||||||
case filePath of
|
|
||||||
"-" -> liftM (parseCassava separator "(stdin)") T.getContents
|
|
||||||
_ -> return $ parseCassava separator filePath csvdata
|
|
||||||
|
|
||||||
parseCassava :: Char -> FilePath -> Text -> Either String CSV
|
|
||||||
parseCassava separator path content =
|
|
||||||
either (Left . errorBundlePretty) (Right . parseResultToCsv) <$>
|
|
||||||
CassavaMP.decodeWith (decodeOptions separator) Cassava.NoHeader path $
|
|
||||||
BL.fromStrict $ T.encodeUtf8 content
|
|
||||||
|
|
||||||
decodeOptions :: Char -> Cassava.DecodeOptions
|
|
||||||
decodeOptions separator = Cassava.defaultDecodeOptions {
|
|
||||||
Cassava.decDelimiter = fromIntegral (ord separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
parseResultToCsv :: (Foldable t, Functor t) => t (t B.ByteString) -> CSV
|
|
||||||
parseResultToCsv = toListList . unpackFields
|
|
||||||
where
|
|
||||||
toListList = toList . fmap toList
|
|
||||||
unpackFields = (fmap . fmap) (T.unpack . T.decodeUtf8)
|
|
||||||
|
|
||||||
printCSV :: CSV -> String
|
|
||||||
printCSV records = unlined (printRecord `map` records)
|
|
||||||
where printRecord = concat . intersperse "," . map printField
|
|
||||||
printField f = "\"" ++ concatMap escape f ++ "\""
|
|
||||||
escape '"' = "\"\""
|
|
||||||
escape x = [x]
|
|
||||||
unlined = concat . intersperse "\n"
|
|
||||||
|
|
||||||
-- | Return the cleaned up and validated CSV data (can be empty), or an error.
|
|
||||||
validateCsv :: CsvRules -> Int -> Either String CSV -> Either String [CsvRecord]
|
|
||||||
validateCsv _ _ (Left err) = Left err
|
|
||||||
validateCsv rules numhdrlines (Right rs) = validate $ applyConditionalSkips $ drop numhdrlines $ filternulls rs
|
|
||||||
where
|
|
||||||
filternulls = filter (/=[""])
|
|
||||||
skipCount r =
|
|
||||||
case (getEffectiveAssignment rules r "end", getEffectiveAssignment rules r "skip") of
|
|
||||||
(Nothing, Nothing) -> Nothing
|
|
||||||
(Just _, _) -> Just maxBound
|
|
||||||
(Nothing, Just "") -> Just 1
|
|
||||||
(Nothing, Just x) -> Just (read x)
|
|
||||||
applyConditionalSkips [] = []
|
|
||||||
applyConditionalSkips (r:rest) =
|
|
||||||
case skipCount r of
|
|
||||||
Nothing -> r:(applyConditionalSkips rest)
|
|
||||||
Just cnt -> applyConditionalSkips (drop (cnt-1) rest)
|
|
||||||
validate [] = Right []
|
|
||||||
validate rs@(_first:_)
|
|
||||||
| isJust lessthan2 = let r = fromJust lessthan2 in
|
|
||||||
Left $ printf "CSV record %s has less than two fields" (show r)
|
|
||||||
| otherwise = Right rs
|
|
||||||
where
|
|
||||||
lessthan2 = headMay $ filter ((<2).length) rs
|
|
||||||
|
|
||||||
-- -- | The highest (0-based) field index referenced in the field
|
|
||||||
-- -- definitions, or -1 if no fields are defined.
|
|
||||||
-- maxFieldIndex :: CsvRules -> Int
|
|
||||||
-- maxFieldIndex r = maximumDef (-1) $ catMaybes [
|
|
||||||
-- dateField r
|
|
||||||
-- ,statusField r
|
|
||||||
-- ,codeField r
|
|
||||||
-- ,amountField r
|
|
||||||
-- ,amountInField r
|
|
||||||
-- ,amountOutField r
|
|
||||||
-- ,currencyField r
|
|
||||||
-- ,accountField r
|
|
||||||
-- ,account2Field r
|
|
||||||
-- ,date2Field r
|
|
||||||
-- ]
|
|
||||||
|
|
||||||
-- rulesFileFor :: CliOpts -> FilePath -> FilePath
|
|
||||||
-- rulesFileFor CliOpts{rules_file_=Just f} _ = f
|
|
||||||
-- rulesFileFor CliOpts{rules_file_=Nothing} csvfile = replaceExtension csvfile ".rules"
|
|
||||||
rulesFileFor :: FilePath -> FilePath
|
rulesFileFor :: FilePath -> FilePath
|
||||||
rulesFileFor = (++ ".rules")
|
rulesFileFor = (++ ".rules")
|
||||||
|
|
||||||
|
-- | Given a CSV rules file path, what would normally be the corresponding CSV file ?
|
||||||
csvFileFor :: FilePath -> FilePath
|
csvFileFor :: FilePath -> FilePath
|
||||||
csvFileFor = reverse . drop 6 . reverse
|
csvFileFor = reverse . drop 6 . reverse
|
||||||
|
|
||||||
@ -317,8 +164,143 @@ defaultRulesText csvfile = T.pack $ unlines
|
|||||||
," account2 assets:bank:savings\n"
|
," account2 assets:bank:savings\n"
|
||||||
]
|
]
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
addDirective :: (DirectiveName, String) -> CsvRules -> CsvRules
|
||||||
-- Conversion rules parsing
|
addDirective d r = r{rdirectives=d:rdirectives r}
|
||||||
|
|
||||||
|
addAssignment :: (HledgerFieldName, FieldTemplate) -> CsvRules -> CsvRules
|
||||||
|
addAssignment a r = r{rassignments=a:rassignments r}
|
||||||
|
|
||||||
|
setIndexesAndAssignmentsFromList :: [CsvFieldName] -> CsvRules -> CsvRules
|
||||||
|
setIndexesAndAssignmentsFromList fs r = addAssignmentsFromList fs . setCsvFieldIndexesFromList fs $ r
|
||||||
|
|
||||||
|
setCsvFieldIndexesFromList :: [CsvFieldName] -> CsvRules -> CsvRules
|
||||||
|
setCsvFieldIndexesFromList fs r = r{rcsvfieldindexes=zip fs [1..]}
|
||||||
|
|
||||||
|
addAssignmentsFromList :: [CsvFieldName] -> CsvRules -> CsvRules
|
||||||
|
addAssignmentsFromList fs r = foldl' maybeAddAssignment r journalfieldnames
|
||||||
|
where
|
||||||
|
maybeAddAssignment rules f = (maybe id addAssignmentFromIndex $ elemIndex f fs) rules
|
||||||
|
where
|
||||||
|
addAssignmentFromIndex i = addAssignment (f, "%"++show (i+1))
|
||||||
|
|
||||||
|
addConditionalBlock :: ConditionalBlock -> CsvRules -> CsvRules
|
||||||
|
addConditionalBlock b r = r{rconditionalblocks=b:rconditionalblocks r}
|
||||||
|
|
||||||
|
getDirective :: DirectiveName -> CsvRules -> Maybe FieldTemplate
|
||||||
|
getDirective directivename = lookup directivename . rdirectives
|
||||||
|
|
||||||
|
instance ShowErrorComponent String where
|
||||||
|
showErrorComponent = id
|
||||||
|
|
||||||
|
-- | Inline all files referenced by include directives in this hledger CSV rules text, recursively.
|
||||||
|
-- Included file paths may be relative to the directory of the provided file path.
|
||||||
|
-- This is done as a pre-parse step to simplify the CSV rules parser.
|
||||||
|
expandIncludes :: FilePath -> Text -> IO Text
|
||||||
|
expandIncludes dir content = mapM (expandLine dir) (T.lines content) >>= return . T.unlines
|
||||||
|
where
|
||||||
|
expandLine dir line =
|
||||||
|
case line of
|
||||||
|
(T.stripPrefix "include " -> Just f) -> expandIncludes dir' =<< T.readFile f'
|
||||||
|
where
|
||||||
|
f' = dir </> dropWhile isSpace (T.unpack f)
|
||||||
|
dir' = takeDirectory f'
|
||||||
|
_ -> return line
|
||||||
|
|
||||||
|
-- | An error-throwing IO action that parses this text as CSV conversion rules
|
||||||
|
-- and runs some extra validation checks. The file path is used in error messages.
|
||||||
|
parseAndValidateCsvRules :: FilePath -> T.Text -> Either String CsvRules
|
||||||
|
parseAndValidateCsvRules rulesfile s =
|
||||||
|
case parseCsvRules rulesfile s of
|
||||||
|
Left err -> Left $ customErrorBundlePretty err
|
||||||
|
Right rules -> first makeFancyParseError $ validateRules rules
|
||||||
|
where
|
||||||
|
makeFancyParseError :: String -> String
|
||||||
|
makeFancyParseError errorString =
|
||||||
|
parseErrorPretty (FancyError 0 (S.singleton $ ErrorFail errorString) :: ParseError Text String)
|
||||||
|
|
||||||
|
-- | Parse this text as CSV conversion rules. The file path is for error messages.
|
||||||
|
parseCsvRules :: FilePath -> T.Text -> Either (ParseErrorBundle T.Text CustomErr) CsvRules
|
||||||
|
-- parseCsvRules rulesfile s = runParser csvrulesfile nullrules{baseAccount=takeBaseName rulesfile} rulesfile s
|
||||||
|
parseCsvRules rulesfile s =
|
||||||
|
runParser (evalStateT rulesp defrules) rulesfile s
|
||||||
|
|
||||||
|
-- | Return the validated rules, or an error.
|
||||||
|
validateRules :: CsvRules -> Either String CsvRules
|
||||||
|
validateRules rules = do
|
||||||
|
unless (isAssigned "date") $ Left "Please specify (at top level) the date field. Eg: date %1\n"
|
||||||
|
Right rules
|
||||||
|
where
|
||||||
|
isAssigned f = isJust $ getEffectiveAssignment rules [] f
|
||||||
|
|
||||||
|
-- *** rules types
|
||||||
|
|
||||||
|
-- | A set of data definitions and account-matching patterns sufficient to
|
||||||
|
-- convert a particular CSV data file into meaningful journal transactions.
|
||||||
|
data CsvRules = CsvRules {
|
||||||
|
rdirectives :: [(DirectiveName,String)],
|
||||||
|
-- ^ top-level rules, as (keyword, value) pairs
|
||||||
|
rcsvfieldindexes :: [(CsvFieldName, CsvFieldIndex)],
|
||||||
|
-- ^ csv field names and their column number, if declared by a fields list
|
||||||
|
rassignments :: [(HledgerFieldName, FieldTemplate)],
|
||||||
|
-- ^ top-level assignments to hledger fields, as (field name, value template) pairs
|
||||||
|
rconditionalblocks :: [ConditionalBlock]
|
||||||
|
-- ^ conditional blocks, which containing additional assignments/rules to apply to matched csv records
|
||||||
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
type CsvRulesParser a = StateT CsvRules SimpleTextParser a
|
||||||
|
|
||||||
|
-- | The keyword of a CSV rule - "fields", "skip", "if", etc.
|
||||||
|
type DirectiveName = String
|
||||||
|
|
||||||
|
-- | CSV field name.
|
||||||
|
type CsvFieldName = String
|
||||||
|
|
||||||
|
-- | 1-based CSV column number.
|
||||||
|
type CsvFieldIndex = Int
|
||||||
|
|
||||||
|
-- | Percent symbol followed by a CSV field name or column number. Eg: %date, %1.
|
||||||
|
type CsvFieldReference = String
|
||||||
|
|
||||||
|
-- | One of the standard hledger fields or pseudo-fields that can be assigned to.
|
||||||
|
-- Eg date, account1, amount, amount1-in, date-format.
|
||||||
|
type HledgerFieldName = String
|
||||||
|
|
||||||
|
-- | A text value to be assigned to a hledger field, possibly
|
||||||
|
-- containing csv field references to be interpolated.
|
||||||
|
type FieldTemplate = String
|
||||||
|
|
||||||
|
-- | A strptime date parsing pattern, as supported by Data.Time.Format.
|
||||||
|
type DateFormat = String
|
||||||
|
|
||||||
|
-- | A regular expression.
|
||||||
|
type RegexpPattern = String
|
||||||
|
|
||||||
|
-- | A single test for matching a CSV record, in one way or another.
|
||||||
|
data Matcher =
|
||||||
|
RecordMatcher RegexpPattern -- ^ match if this regexp matches the overall CSV record
|
||||||
|
| FieldMatcher CsvFieldReference RegexpPattern -- ^ match if this regexp matches the referenced CSV field's value
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | A conditional block: a set of CSV record matchers, and a sequence
|
||||||
|
-- of rules which will be enabled only if one or more of the matchers
|
||||||
|
-- succeeds.
|
||||||
|
--
|
||||||
|
-- Three types of rule are allowed inside conditional blocks: field
|
||||||
|
-- assignments, skip, end. (A skip or end rule is stored as if it was
|
||||||
|
-- a field assignment, and executed in validateCsv. XXX)
|
||||||
|
data ConditionalBlock = CB {
|
||||||
|
cbMatchers :: [Matcher]
|
||||||
|
,cbAssignments :: [(HledgerFieldName, FieldTemplate)]
|
||||||
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
defrules = CsvRules {
|
||||||
|
rdirectives=[],
|
||||||
|
rcsvfieldindexes=[],
|
||||||
|
rassignments=[],
|
||||||
|
rconditionalblocks=[]
|
||||||
|
}
|
||||||
|
|
||||||
|
-- *** rules parsers
|
||||||
|
|
||||||
{-
|
{-
|
||||||
Grammar for the CSV conversion rules, more or less:
|
Grammar for the CSV conversion rules, more or less:
|
||||||
@ -383,146 +365,6 @@ DIGIT: 0-9
|
|||||||
|
|
||||||
-}
|
-}
|
||||||
|
|
||||||
{- |
|
|
||||||
A set of data definitions and account-matching patterns sufficient to
|
|
||||||
convert a particular CSV data file into meaningful journal transactions.
|
|
||||||
-}
|
|
||||||
data CsvRules = CsvRules {
|
|
||||||
rdirectives :: [(DirectiveName,String)],
|
|
||||||
-- ^ top-level rules, as (keyword, value) pairs
|
|
||||||
rcsvfieldindexes :: [(CsvFieldName, CsvFieldIndex)],
|
|
||||||
-- ^ csv field names and their column number, if declared by a fields list
|
|
||||||
rassignments :: [(HledgerFieldName, FieldTemplate)],
|
|
||||||
-- ^ top-level assignments to hledger fields, as (field name, value template) pairs
|
|
||||||
rconditionalblocks :: [ConditionalBlock]
|
|
||||||
-- ^ conditional blocks, which containing additional assignments/rules to apply to matched csv records
|
|
||||||
} deriving (Show, Eq)
|
|
||||||
|
|
||||||
type CsvRulesParser a = StateT CsvRules SimpleTextParser a
|
|
||||||
|
|
||||||
-- | The keyword of a CSV rule - "fields", "skip", "if", etc.
|
|
||||||
type DirectiveName = String
|
|
||||||
-- | CSV field name.
|
|
||||||
type CsvFieldName = String
|
|
||||||
-- | 1-based CSV column number.
|
|
||||||
type CsvFieldIndex = Int
|
|
||||||
-- | Percent symbol followed by a CSV field name or column number. Eg: %date, %1.
|
|
||||||
type CsvFieldReference = String
|
|
||||||
-- | One of the standard hledger fields or pseudo-fields that can be assigned to.
|
|
||||||
-- Eg date, account1, amount, amount1-in, date-format.
|
|
||||||
type HledgerFieldName = String
|
|
||||||
-- | A text value to be assigned to a hledger field, possibly
|
|
||||||
-- containing csv field references to be interpolated.
|
|
||||||
type FieldTemplate = String
|
|
||||||
-- | A strptime date parsing pattern, as supported by Data.Time.Format.
|
|
||||||
type DateFormat = String
|
|
||||||
-- | A regular expression.
|
|
||||||
type RegexpPattern = String
|
|
||||||
|
|
||||||
-- | A single test for matching a CSV record, in one way or another.
|
|
||||||
data Matcher =
|
|
||||||
RecordMatcher RegexpPattern -- ^ match if this regexp matches the overall CSV record
|
|
||||||
| FieldMatcher CsvFieldReference RegexpPattern -- ^ match if this regexp matches the referenced CSV field's value
|
|
||||||
deriving (Show, Eq)
|
|
||||||
|
|
||||||
-- | A conditional block: a set of CSV record matchers, and a sequence
|
|
||||||
-- of rules which will be enabled only if one or more of the matchers
|
|
||||||
-- succeeds.
|
|
||||||
--
|
|
||||||
-- Three types of rule are allowed inside conditional blocks: field
|
|
||||||
-- assignments, skip, end. (A skip or end rule is stored as if it was
|
|
||||||
-- a field assignment, and executed in validateCsv. XXX)
|
|
||||||
data ConditionalBlock = CB {
|
|
||||||
cbMatchers :: [Matcher]
|
|
||||||
,cbAssignments :: [(HledgerFieldName, FieldTemplate)]
|
|
||||||
} deriving (Show, Eq)
|
|
||||||
|
|
||||||
defrules = CsvRules {
|
|
||||||
rdirectives=[],
|
|
||||||
rcsvfieldindexes=[],
|
|
||||||
rassignments=[],
|
|
||||||
rconditionalblocks=[]
|
|
||||||
}
|
|
||||||
|
|
||||||
addDirective :: (DirectiveName, String) -> CsvRules -> CsvRules
|
|
||||||
addDirective d r = r{rdirectives=d:rdirectives r}
|
|
||||||
|
|
||||||
addAssignment :: (HledgerFieldName, FieldTemplate) -> CsvRules -> CsvRules
|
|
||||||
addAssignment a r = r{rassignments=a:rassignments r}
|
|
||||||
|
|
||||||
setIndexesAndAssignmentsFromList :: [CsvFieldName] -> CsvRules -> CsvRules
|
|
||||||
setIndexesAndAssignmentsFromList fs r = addAssignmentsFromList fs . setCsvFieldIndexesFromList fs $ r
|
|
||||||
|
|
||||||
setCsvFieldIndexesFromList :: [CsvFieldName] -> CsvRules -> CsvRules
|
|
||||||
setCsvFieldIndexesFromList fs r = r{rcsvfieldindexes=zip fs [1..]}
|
|
||||||
|
|
||||||
addAssignmentsFromList :: [CsvFieldName] -> CsvRules -> CsvRules
|
|
||||||
addAssignmentsFromList fs r = foldl' maybeAddAssignment r journalfieldnames
|
|
||||||
where
|
|
||||||
maybeAddAssignment rules f = (maybe id addAssignmentFromIndex $ elemIndex f fs) rules
|
|
||||||
where
|
|
||||||
addAssignmentFromIndex i = addAssignment (f, "%"++show (i+1))
|
|
||||||
|
|
||||||
addConditionalBlock :: ConditionalBlock -> CsvRules -> CsvRules
|
|
||||||
addConditionalBlock b r = r{rconditionalblocks=b:rconditionalblocks r}
|
|
||||||
|
|
||||||
getDirective :: DirectiveName -> CsvRules -> Maybe FieldTemplate
|
|
||||||
getDirective directivename = lookup directivename . rdirectives
|
|
||||||
|
|
||||||
instance ShowErrorComponent String where
|
|
||||||
showErrorComponent = id
|
|
||||||
|
|
||||||
-- Not used by hledger; just for lib users,
|
|
||||||
-- | An pure-exception-throwing IO action that parses this file's content
|
|
||||||
-- as CSV conversion rules, interpolating any included files first,
|
|
||||||
-- and runs some extra validation checks.
|
|
||||||
parseRulesFile :: FilePath -> ExceptT String IO CsvRules
|
|
||||||
parseRulesFile f =
|
|
||||||
liftIO (readFilePortably f >>= expandIncludes (takeDirectory f))
|
|
||||||
>>= either throwError return . parseAndValidateCsvRules f
|
|
||||||
|
|
||||||
-- | Inline all files referenced by include directives in this hledger CSV rules text, recursively.
|
|
||||||
-- Included file paths may be relative to the directory of the provided file path.
|
|
||||||
-- This is done as a pre-parse step to simplify the CSV rules parser.
|
|
||||||
expandIncludes :: FilePath -> Text -> IO Text
|
|
||||||
expandIncludes dir content = mapM (expandLine dir) (T.lines content) >>= return . T.unlines
|
|
||||||
where
|
|
||||||
expandLine dir line =
|
|
||||||
case line of
|
|
||||||
(T.stripPrefix "include " -> Just f) -> expandIncludes dir' =<< T.readFile f'
|
|
||||||
where
|
|
||||||
f' = dir </> dropWhile isSpace (T.unpack f)
|
|
||||||
dir' = takeDirectory f'
|
|
||||||
_ -> return line
|
|
||||||
|
|
||||||
-- | An error-throwing IO action that parses this text as CSV conversion rules
|
|
||||||
-- and runs some extra validation checks. The file path is used in error messages.
|
|
||||||
parseAndValidateCsvRules :: FilePath -> T.Text -> Either String CsvRules
|
|
||||||
parseAndValidateCsvRules rulesfile s =
|
|
||||||
case parseCsvRules rulesfile s of
|
|
||||||
Left err -> Left $ customErrorBundlePretty err
|
|
||||||
Right rules -> first makeFancyParseError $ validateRules rules
|
|
||||||
where
|
|
||||||
makeFancyParseError :: String -> String
|
|
||||||
makeFancyParseError errorString =
|
|
||||||
parseErrorPretty (FancyError 0 (S.singleton $ ErrorFail errorString) :: ParseError Text String)
|
|
||||||
|
|
||||||
-- | Parse this text as CSV conversion rules. The file path is for error messages.
|
|
||||||
parseCsvRules :: FilePath -> T.Text -> Either (ParseErrorBundle T.Text CustomErr) CsvRules
|
|
||||||
-- parseCsvRules rulesfile s = runParser csvrulesfile nullrules{baseAccount=takeBaseName rulesfile} rulesfile s
|
|
||||||
parseCsvRules rulesfile s =
|
|
||||||
runParser (evalStateT rulesp defrules) rulesfile s
|
|
||||||
|
|
||||||
-- | Return the validated rules, or an error.
|
|
||||||
validateRules :: CsvRules -> Either String CsvRules
|
|
||||||
validateRules rules = do
|
|
||||||
unless (isAssigned "date") $ Left "Please specify (at top level) the date field. Eg: date %1\n"
|
|
||||||
Right rules
|
|
||||||
where
|
|
||||||
isAssigned f = isJust $ getEffectiveAssignment rules [] f
|
|
||||||
|
|
||||||
-- parsers
|
|
||||||
|
|
||||||
rulesp :: CsvRulesParser CsvRules
|
rulesp :: CsvRulesParser CsvRules
|
||||||
rulesp = do
|
rulesp = do
|
||||||
_ <- many $ choiceInState
|
_ <- many $ choiceInState
|
||||||
@ -564,13 +406,10 @@ directives :: [String]
|
|||||||
directives =
|
directives =
|
||||||
["date-format"
|
["date-format"
|
||||||
,"separator"
|
,"separator"
|
||||||
-- ,"default-account1"
|
-- ,"default-account"
|
||||||
-- ,"default-currency"
|
-- ,"default-currency"
|
||||||
-- ,"skip-lines" -- old
|
|
||||||
,"skip"
|
,"skip"
|
||||||
,"newest-first"
|
,"newest-first"
|
||||||
-- ,"base-account"
|
|
||||||
-- ,"base-currency"
|
|
||||||
, "balance-type"
|
, "balance-type"
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -734,10 +573,182 @@ regexp = do
|
|||||||
-- -- ,"!="
|
-- -- ,"!="
|
||||||
-- ]
|
-- ]
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
-- ** reading csv files
|
||||||
-- Converting CSV records to journal transactions
|
|
||||||
|
|
||||||
type CsvRecord = [String]
|
-- | Read a Journal from the given CSV data (and filename, used for error
|
||||||
|
-- messages), or return an error. Proceed as follows:
|
||||||
|
--
|
||||||
|
-- 1. parse CSV conversion rules from the specified rules file, or from
|
||||||
|
-- the default rules file for the specified CSV file, if it exists,
|
||||||
|
-- or throw a parse error; if it doesn't exist, use built-in default rules
|
||||||
|
--
|
||||||
|
-- 2. parse the CSV data, or throw a parse error
|
||||||
|
--
|
||||||
|
-- 3. convert the CSV records to transactions using the rules
|
||||||
|
--
|
||||||
|
-- 4. if the rules file didn't exist, create it with the default rules and filename
|
||||||
|
--
|
||||||
|
-- 5. return the transactions as a Journal
|
||||||
|
--
|
||||||
|
readJournalFromCsv :: Maybe FilePath -> FilePath -> Text -> IO (Either String Journal)
|
||||||
|
readJournalFromCsv Nothing "-" _ = return $ Left "please use --rules-file when reading CSV from stdin"
|
||||||
|
readJournalFromCsv mrulesfile csvfile csvdata =
|
||||||
|
handle (\(e::IOException) -> return $ Left $ show e) $ do
|
||||||
|
|
||||||
|
-- make and throw an IO exception.. which we catch and convert to an Either above ?
|
||||||
|
let throwerr = throw . userError
|
||||||
|
|
||||||
|
-- parse the csv rules
|
||||||
|
let rulesfile = fromMaybe (rulesFileFor csvfile) mrulesfile
|
||||||
|
rulesfileexists <- doesFileExist rulesfile
|
||||||
|
rulestext <-
|
||||||
|
if rulesfileexists
|
||||||
|
then do
|
||||||
|
dbg1IO "using conversion rules file" rulesfile
|
||||||
|
readFilePortably rulesfile >>= expandIncludes (takeDirectory rulesfile)
|
||||||
|
else
|
||||||
|
return $ defaultRulesText rulesfile
|
||||||
|
rules <- either throwerr return $ parseAndValidateCsvRules rulesfile rulestext
|
||||||
|
dbg2IO "rules" rules
|
||||||
|
|
||||||
|
-- parse the skip directive's value, if any
|
||||||
|
let skiplines = case getDirective "skip" rules of
|
||||||
|
Nothing -> 0
|
||||||
|
Just "" -> 1
|
||||||
|
Just s -> readDef (throwerr $ "could not parse skip value: " ++ show s) s
|
||||||
|
|
||||||
|
-- parse csv
|
||||||
|
-- parsec seems to fail if you pass it "-" here TODO: try again with megaparsec
|
||||||
|
let parsecfilename = if csvfile == "-" then "(stdin)" else csvfile
|
||||||
|
let separator = fromMaybe ',' (getDirective "separator" rules >>= parseSeparator)
|
||||||
|
dbg2IO "separator" separator
|
||||||
|
records <- (either throwerr id .
|
||||||
|
dbg2 "validateCsv" . validateCsv rules skiplines .
|
||||||
|
dbg2 "parseCsv")
|
||||||
|
`fmap` parseCsv separator parsecfilename csvdata
|
||||||
|
dbg1IO "first 3 csv records" $ take 3 records
|
||||||
|
|
||||||
|
-- identify header lines
|
||||||
|
-- let (headerlines, datalines) = identifyHeaderLines records
|
||||||
|
-- mfieldnames = lastMay headerlines
|
||||||
|
|
||||||
|
let
|
||||||
|
-- convert CSV records to transactions
|
||||||
|
txns = snd $ mapAccumL
|
||||||
|
(\pos r ->
|
||||||
|
let
|
||||||
|
SourcePos name line col = pos
|
||||||
|
line' = (mkPos . (+1) . unPos) line
|
||||||
|
pos' = SourcePos name line' col
|
||||||
|
in
|
||||||
|
(pos, transactionFromCsvRecord pos' rules r)
|
||||||
|
)
|
||||||
|
(initialPos parsecfilename) records
|
||||||
|
|
||||||
|
-- Ensure transactions are ordered chronologically.
|
||||||
|
-- First, if the CSV records seem to be most-recent-first (because
|
||||||
|
-- there's an explicit "newest-first" directive, or there's more
|
||||||
|
-- than one date and the first date is more recent than the last):
|
||||||
|
-- reverse them to get same-date transactions ordered chronologically.
|
||||||
|
txns' =
|
||||||
|
(if newestfirst || mseemsnewestfirst == Just True then reverse else id) txns
|
||||||
|
where
|
||||||
|
newestfirst = dbg3 "newestfirst" $ isJust $ getDirective "newest-first" rules
|
||||||
|
mseemsnewestfirst = dbg3 "mseemsnewestfirst" $
|
||||||
|
case nub $ map tdate txns of
|
||||||
|
ds | length ds > 1 -> Just $ head ds > last ds
|
||||||
|
_ -> Nothing
|
||||||
|
-- Second, sort by date.
|
||||||
|
txns'' = sortBy (comparing tdate) txns'
|
||||||
|
|
||||||
|
when (not rulesfileexists) $ do
|
||||||
|
dbg1IO "creating conversion rules file" rulesfile
|
||||||
|
writeFile rulesfile $ T.unpack rulestext
|
||||||
|
|
||||||
|
return $ Right nulljournal{jtxns=txns''}
|
||||||
|
|
||||||
|
-- | Parse special separator names TAB and SPACE, or return the first
|
||||||
|
-- character. Return Nothing on empty string
|
||||||
|
parseSeparator :: String -> Maybe Char
|
||||||
|
parseSeparator = specials . map toLower
|
||||||
|
where specials "space" = Just ' '
|
||||||
|
specials "tab" = Just '\t'
|
||||||
|
specials (x:_) = Just x
|
||||||
|
specials [] = Nothing
|
||||||
|
|
||||||
|
parseCsv :: Char -> FilePath -> Text -> IO (Either String CSV)
|
||||||
|
parseCsv separator filePath csvdata =
|
||||||
|
case filePath of
|
||||||
|
"-" -> liftM (parseCassava separator "(stdin)") T.getContents
|
||||||
|
_ -> return $ parseCassava separator filePath csvdata
|
||||||
|
|
||||||
|
parseCassava :: Char -> FilePath -> Text -> Either String CSV
|
||||||
|
parseCassava separator path content =
|
||||||
|
either (Left . errorBundlePretty) (Right . parseResultToCsv) <$>
|
||||||
|
CassavaMP.decodeWith (decodeOptions separator) Cassava.NoHeader path $
|
||||||
|
BL.fromStrict $ T.encodeUtf8 content
|
||||||
|
|
||||||
|
decodeOptions :: Char -> Cassava.DecodeOptions
|
||||||
|
decodeOptions separator = Cassava.defaultDecodeOptions {
|
||||||
|
Cassava.decDelimiter = fromIntegral (ord separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
parseResultToCsv :: (Foldable t, Functor t) => t (t B.ByteString) -> CSV
|
||||||
|
parseResultToCsv = toListList . unpackFields
|
||||||
|
where
|
||||||
|
toListList = toList . fmap toList
|
||||||
|
unpackFields = (fmap . fmap) (T.unpack . T.decodeUtf8)
|
||||||
|
|
||||||
|
printCSV :: CSV -> String
|
||||||
|
printCSV records = unlined (printRecord `map` records)
|
||||||
|
where printRecord = concat . intersperse "," . map printField
|
||||||
|
printField f = "\"" ++ concatMap escape f ++ "\""
|
||||||
|
escape '"' = "\"\""
|
||||||
|
escape x = [x]
|
||||||
|
unlined = concat . intersperse "\n"
|
||||||
|
|
||||||
|
-- | Return the cleaned up and validated CSV data (can be empty), or an error.
|
||||||
|
validateCsv :: CsvRules -> Int -> Either String CSV -> Either String [CsvRecord]
|
||||||
|
validateCsv _ _ (Left err) = Left err
|
||||||
|
validateCsv rules numhdrlines (Right rs) = validate $ applyConditionalSkips $ drop numhdrlines $ filternulls rs
|
||||||
|
where
|
||||||
|
filternulls = filter (/=[""])
|
||||||
|
skipCount r =
|
||||||
|
case (getEffectiveAssignment rules r "end", getEffectiveAssignment rules r "skip") of
|
||||||
|
(Nothing, Nothing) -> Nothing
|
||||||
|
(Just _, _) -> Just maxBound
|
||||||
|
(Nothing, Just "") -> Just 1
|
||||||
|
(Nothing, Just x) -> Just (read x)
|
||||||
|
applyConditionalSkips [] = []
|
||||||
|
applyConditionalSkips (r:rest) =
|
||||||
|
case skipCount r of
|
||||||
|
Nothing -> r:(applyConditionalSkips rest)
|
||||||
|
Just cnt -> applyConditionalSkips (drop (cnt-1) rest)
|
||||||
|
validate [] = Right []
|
||||||
|
validate rs@(_first:_)
|
||||||
|
| isJust lessthan2 = let r = fromJust lessthan2 in
|
||||||
|
Left $ printf "CSV record %s has less than two fields" (show r)
|
||||||
|
| otherwise = Right rs
|
||||||
|
where
|
||||||
|
lessthan2 = headMay $ filter ((<2).length) rs
|
||||||
|
|
||||||
|
-- -- | The highest (0-based) field index referenced in the field
|
||||||
|
-- -- definitions, or -1 if no fields are defined.
|
||||||
|
-- maxFieldIndex :: CsvRules -> Int
|
||||||
|
-- maxFieldIndex r = maximumDef (-1) $ catMaybes [
|
||||||
|
-- dateField r
|
||||||
|
-- ,statusField r
|
||||||
|
-- ,codeField r
|
||||||
|
-- ,amountField r
|
||||||
|
-- ,amountInField r
|
||||||
|
-- ,amountOutField r
|
||||||
|
-- ,currencyField r
|
||||||
|
-- ,accountField r
|
||||||
|
-- ,account2Field r
|
||||||
|
-- ,date2Field r
|
||||||
|
-- ]
|
||||||
|
|
||||||
|
-- ** converting csv records to transactions
|
||||||
|
|
||||||
showRules rules record =
|
showRules rules record =
|
||||||
unlines $ catMaybes [ (("the "++fld++" rule is: ")++) <$> getEffectiveAssignment rules record fld | fld <- journalfieldnames]
|
unlines $ catMaybes [ (("the "++fld++" rule is: ")++) <$> getEffectiveAssignment rules record fld | fld <- journalfieldnames]
|
||||||
@ -940,6 +951,7 @@ mkPosting rules record number accountFld amountFld amountInFld amountOutFld bala
|
|||||||
-- XXX what's this needed for ? Test & document, or drop.
|
-- XXX what's this needed for ? Test & document, or drop.
|
||||||
-- Also, this the only place we interpolate in a keyword rule, I think.
|
-- Also, this the only place we interpolate in a keyword rule, I think.
|
||||||
`withDefault` ruleval ("default-account" ++ number))
|
`withDefault` ruleval ("default-account" ++ number))
|
||||||
|
-- XXX what's this needed for ? Test & document, or drop.
|
||||||
mdefaultcurrency = rule "default-currency"
|
mdefaultcurrency = rule "default-currency"
|
||||||
currency = fromMaybe (fromMaybe "" mdefaultcurrency) $
|
currency = fromMaybe (fromMaybe "" mdefaultcurrency) $
|
||||||
fieldval ("currency"++number) `withDefault` fieldval "currency"
|
fieldval ("currency"++number) `withDefault` fieldval "currency"
|
||||||
@ -985,7 +997,7 @@ improveUnknownAccountName p@Posting{..}
|
|||||||
-- possibly set by a balance-type rule.
|
-- possibly set by a balance-type rule.
|
||||||
-- The CSV rules and current record are also provided, to be shown in case
|
-- The CSV rules and current record are also provided, to be shown in case
|
||||||
-- balance-type's argument is bad (XXX refactor).
|
-- balance-type's argument is bad (XXX refactor).
|
||||||
mkBalanceAssertion :: CsvRules -> Record -> (Amount, GenericSourcePos) -> BalanceAssertion
|
mkBalanceAssertion :: CsvRules -> CsvRecord -> (Amount, GenericSourcePos) -> BalanceAssertion
|
||||||
mkBalanceAssertion rules record (amt, pos) = assrt{baamount=amt, baposition=pos}
|
mkBalanceAssertion rules record (amt, pos) = assrt{baamount=amt, baposition=pos}
|
||||||
where
|
where
|
||||||
assrt =
|
assrt =
|
||||||
@ -1162,8 +1174,7 @@ parseDateWithCustomOrDefaultFormats mformat s = firstJust $ map parsewith format
|
|||||||
(:[])
|
(:[])
|
||||||
mformat
|
mformat
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
-- ** tests
|
||||||
-- tests
|
|
||||||
|
|
||||||
tests_CsvReader = tests "CsvReader" [
|
tests_CsvReader = tests "CsvReader" [
|
||||||
tests "parseCsvRules" [
|
tests "parseCsvRules" [
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import Data.Maybe
|
|||||||
-- import Data.Text (Text)
|
-- import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import System.Console.CmdArgs.Explicit
|
import System.Console.CmdArgs.Explicit
|
||||||
import Hledger.Read.CsvReader (CSV, Record, printCSV)
|
import Hledger.Read.CsvReader (CSV, CsvRecord, printCSV)
|
||||||
|
|
||||||
import Hledger
|
import Hledger
|
||||||
import Hledger.Cli.CliOptions
|
import Hledger.Cli.CliOptions
|
||||||
@ -71,7 +71,7 @@ postingsReportAsCsv (_,is) =
|
|||||||
:
|
:
|
||||||
map postingsReportItemAsCsvRecord is
|
map postingsReportItemAsCsvRecord is
|
||||||
|
|
||||||
postingsReportItemAsCsvRecord :: PostingsReportItem -> Record
|
postingsReportItemAsCsvRecord :: PostingsReportItem -> CsvRecord
|
||||||
postingsReportItemAsCsvRecord (_, _, _, p, b) = [idx,date,code,desc,acct,amt,bal]
|
postingsReportItemAsCsvRecord (_, _, _, p, b) = [idx,date,code,desc,acct,amt,bal]
|
||||||
where
|
where
|
||||||
idx = show $ maybe 0 tindex $ ptransaction p
|
idx = show $ maybe 0 tindex $ ptransaction p
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user