packages feed

hledger-lib 1.29.2 → 1.30

raw patch · 28 files changed

+3744/−1761 lines, 28 filesdep +terminal-sizedep ~megaparsecPVP ok

version bump matches the API change (PVP)

Dependencies added: terminal-size

Dependency ranges changed: megaparsec

API changes (from Hackage documentation)

- Hledger.Data.RawOptions: inRawOpts :: String -> RawOpts -> Bool
- Hledger.Read.CsvReader: csvFileFor :: FilePath -> FilePath
- Hledger.Read.CsvReader: instance GHC.Classes.Eq Hledger.Read.CsvReader.ConditionalBlock
- Hledger.Read.CsvReader: instance GHC.Classes.Eq Hledger.Read.CsvReader.CsvRules
- Hledger.Read.CsvReader: instance GHC.Classes.Eq Hledger.Read.CsvReader.Matcher
- Hledger.Read.CsvReader: instance GHC.Classes.Eq Hledger.Read.CsvReader.MatcherPrefix
- Hledger.Read.CsvReader: instance GHC.Show.Show Hledger.Read.CsvReader.ConditionalBlock
- Hledger.Read.CsvReader: instance GHC.Show.Show Hledger.Read.CsvReader.CsvRules
- Hledger.Read.CsvReader: instance GHC.Show.Show Hledger.Read.CsvReader.Matcher
- Hledger.Read.CsvReader: instance GHC.Show.Show Hledger.Read.CsvReader.MatcherPrefix
- Hledger.Read.CsvReader: instance Text.Megaparsec.Error.ShowErrorComponent GHC.Base.String
- Hledger.Read.CsvReader: parseRulesFile :: FilePath -> ExceptT String IO CsvRules
- Hledger.Read.CsvReader: printCSV :: CSV -> Text
- Hledger.Read.CsvReader: rulesFileFor :: FilePath -> FilePath
- Hledger.Read.CsvReader: type CSV = [CsvRecord]
- Hledger.Read.CsvReader: type CsvRecord = [CsvValue]
- Hledger.Read.CsvReader: type CsvValue = Text
+ Hledger.Data: [tlcomment] :: TimeclockEntry -> Text
+ Hledger.Data: [tltags] :: TimeclockEntry -> [Tag]
+ Hledger.Data.RawOptions: toggleopt :: String -> RawOpts -> Bool
+ Hledger.Data.Types: [tlcomment] :: TimeclockEntry -> Text
+ Hledger.Data.Types: [tltags] :: TimeclockEntry -> [Tag]
+ Hledger.Read.Common: [verbose_tags_] :: InputOpts -> Bool
+ Hledger.Read.Common: verbose_tags :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
+ Hledger.Read.CsvUtils: printCSV :: [CsvRecord] -> Text
+ Hledger.Read.CsvUtils: tests_CsvUtils :: TestTree
+ Hledger.Read.CsvUtils: type CSV = [CsvRecord]
+ Hledger.Read.CsvUtils: type CsvRecord = [CsvValue]
+ Hledger.Read.CsvUtils: type CsvValue = Text
+ Hledger.Read.InputOptions: [verbose_tags_] :: InputOpts -> Bool
+ Hledger.Read.InputOptions: verbose_tags :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
+ Hledger.Read.RulesReader: dataFileFor :: FilePath -> Maybe FilePath
+ Hledger.Read.RulesReader: instance GHC.Classes.Eq Hledger.Read.RulesReader.ConditionalBlock
+ Hledger.Read.RulesReader: instance GHC.Classes.Eq Hledger.Read.RulesReader.CsvRules
+ Hledger.Read.RulesReader: instance GHC.Classes.Eq Hledger.Read.RulesReader.Matcher
+ Hledger.Read.RulesReader: instance GHC.Classes.Eq Hledger.Read.RulesReader.MatcherPrefix
+ Hledger.Read.RulesReader: instance GHC.Show.Show Hledger.Read.RulesReader.ConditionalBlock
+ Hledger.Read.RulesReader: instance GHC.Show.Show Hledger.Read.RulesReader.CsvRules
+ Hledger.Read.RulesReader: instance GHC.Show.Show Hledger.Read.RulesReader.Matcher
+ Hledger.Read.RulesReader: instance GHC.Show.Show Hledger.Read.RulesReader.MatcherPrefix
+ Hledger.Read.RulesReader: instance Text.Megaparsec.Error.ShowErrorComponent GHC.Base.String
+ Hledger.Read.RulesReader: readJournalFromCsv :: Maybe (Either CsvRules FilePath) -> FilePath -> Text -> ExceptT String IO Journal
+ Hledger.Read.RulesReader: reader :: MonadIO m => Reader m
+ Hledger.Read.RulesReader: rulesFileFor :: FilePath -> FilePath
+ Hledger.Read.RulesReader: tests_RulesReader :: TestTree
+ Hledger.Reports.ReportOptions: CalcPostingsCount :: BalanceCalculation
+ Hledger.Utils: multicol :: Int -> [String] -> String
+ Hledger.Utils.IO: black' :: String -> String
+ Hledger.Utils.IO: blue' :: String -> String
+ Hledger.Utils.IO: bold' :: String -> String
+ Hledger.Utils.IO: brightBlack' :: String -> String
+ Hledger.Utils.IO: brightBlue' :: String -> String
+ Hledger.Utils.IO: brightCyan' :: String -> String
+ Hledger.Utils.IO: brightGreen' :: String -> String
+ Hledger.Utils.IO: brightMagenta' :: String -> String
+ Hledger.Utils.IO: brightRed' :: String -> String
+ Hledger.Utils.IO: brightWhite' :: String -> String
+ Hledger.Utils.IO: brightYellow' :: String -> String
+ Hledger.Utils.IO: cyan' :: String -> String
+ Hledger.Utils.IO: expandGlob :: FilePath -> FilePath -> IO [FilePath]
+ Hledger.Utils.IO: faint' :: String -> String
+ Hledger.Utils.IO: getTerminalHeight :: IO (Maybe Int)
+ Hledger.Utils.IO: getTerminalHeightWidth :: IO (Maybe (Int, Int))
+ Hledger.Utils.IO: getTerminalWidth :: IO (Maybe Int)
+ Hledger.Utils.IO: green' :: String -> String
+ Hledger.Utils.IO: magenta' :: String -> String
+ Hledger.Utils.IO: red' :: String -> String
+ Hledger.Utils.IO: rgb' :: Word8 -> Word8 -> Word8 -> String -> String
+ Hledger.Utils.IO: sortByModTime :: [FilePath] -> IO [FilePath]
+ Hledger.Utils.IO: white' :: String -> String
+ Hledger.Utils.IO: yellow' :: String -> String
- Hledger.Data: TimeclockEntry :: SourcePos -> TimeclockCode -> LocalTime -> AccountName -> Text -> TimeclockEntry
+ Hledger.Data: TimeclockEntry :: SourcePos -> TimeclockCode -> LocalTime -> AccountName -> Text -> Text -> [Tag] -> TimeclockEntry
- Hledger.Data.Balancing: balancingOpts :: HasBalancingOpts c_a3wHq => Lens' c_a3wHq BalancingOpts
+ Hledger.Data.Balancing: balancingOpts :: HasBalancingOpts c_a3D1J => Lens' c_a3D1J BalancingOpts
- Hledger.Data.Balancing: class HasBalancingOpts c_a3wHq
+ Hledger.Data.Balancing: class HasBalancingOpts c_a3D1J
- Hledger.Data.Balancing: commodity_styles :: HasBalancingOpts c_a3wHq => Lens' c_a3wHq (Maybe (Map CommoditySymbol AmountStyle))
+ Hledger.Data.Balancing: commodity_styles :: HasBalancingOpts c_a3D1J => Lens' c_a3D1J (Maybe (Map CommoditySymbol AmountStyle))
- Hledger.Data.Balancing: ignore_assertions :: HasBalancingOpts c_a3wHq => Lens' c_a3wHq Bool
+ Hledger.Data.Balancing: ignore_assertions :: HasBalancingOpts c_a3D1J => Lens' c_a3D1J Bool
- Hledger.Data.Balancing: infer_balancing_costs :: HasBalancingOpts c_a3wHq => Lens' c_a3wHq Bool
+ Hledger.Data.Balancing: infer_balancing_costs :: HasBalancingOpts c_a3D1J => Lens' c_a3D1J Bool
- Hledger.Data.Journal: journalAddInferredEquityPostings :: Journal -> Journal
+ Hledger.Data.Journal: journalAddInferredEquityPostings :: Bool -> Journal -> Journal
- Hledger.Data.Journal: journalModifyTransactions :: Day -> Journal -> Either String Journal
+ Hledger.Data.Journal: journalModifyTransactions :: Bool -> Day -> Journal -> Either String Journal
- Hledger.Data.Journal: journalTransactionsSimilarTo :: Journal -> Query -> Text -> Int -> [(Double, Transaction)]
+ Hledger.Data.Journal: journalTransactionsSimilarTo :: Journal -> Text -> Query -> SimilarityScore -> Int -> [(DateWeightedSimilarityScore, Age, SimilarityScore, Transaction)]
- Hledger.Data.PeriodicTransaction: runPeriodicTransaction :: PeriodicTransaction -> DateSpan -> [Transaction]
+ Hledger.Data.PeriodicTransaction: runPeriodicTransaction :: Bool -> PeriodicTransaction -> DateSpan -> [Transaction]
- Hledger.Data.Posting: postingAddInferredEquityPostings :: Text -> Posting -> [Posting]
+ Hledger.Data.Posting: postingAddInferredEquityPostings :: Bool -> Text -> Posting -> [Posting]
- Hledger.Data.Transaction: transactionAddInferredEquityPostings :: AccountName -> Transaction -> Transaction
+ Hledger.Data.Transaction: transactionAddInferredEquityPostings :: Bool -> AccountName -> Transaction -> Transaction
- Hledger.Data.TransactionModifier: modifyTransactions :: (AccountName -> Maybe AccountType) -> (AccountName -> [Tag]) -> Map CommoditySymbol AmountStyle -> Day -> [TransactionModifier] -> [Transaction] -> Either String [Transaction]
+ Hledger.Data.TransactionModifier: modifyTransactions :: (AccountName -> Maybe AccountType) -> (AccountName -> [Tag]) -> Map CommoditySymbol AmountStyle -> Day -> Bool -> [TransactionModifier] -> [Transaction] -> Either String [Transaction]
- Hledger.Data.Types: TimeclockEntry :: SourcePos -> TimeclockCode -> LocalTime -> AccountName -> Text -> TimeclockEntry
+ Hledger.Data.Types: TimeclockEntry :: SourcePos -> TimeclockCode -> LocalTime -> AccountName -> Text -> Text -> [Tag] -> TimeclockEntry
- Hledger.Query: parseQueryTerm :: Day -> Text -> Either String (Either Query QueryOpt)
+ Hledger.Query: parseQueryTerm :: Day -> Text -> Either String (Query, [QueryOpt])
- Hledger.Read.Common: InputOpts :: Maybe StorageFormat -> Maybe FilePath -> [String] -> Bool -> Bool -> Bool -> String -> Maybe DateSpan -> DateSpan -> Bool -> Bool -> Bool -> BalancingOpts -> Bool -> Day -> InputOpts
+ Hledger.Read.Common: InputOpts :: Maybe StorageFormat -> Maybe FilePath -> [String] -> Bool -> Bool -> Bool -> String -> Maybe DateSpan -> Bool -> DateSpan -> Bool -> Bool -> Bool -> BalancingOpts -> Bool -> Day -> InputOpts
- Hledger.Read.Common: aliases :: HasInputOpts c_a3KLt => Lens' c_a3KLt [String]
+ Hledger.Read.Common: aliases :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr [String]
- Hledger.Read.Common: anon__ :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.Common: anon__ :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.Common: auto__ :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.Common: auto__ :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.Common: balancingopts :: HasInputOpts c_a3KLt => Lens' c_a3KLt BalancingOpts
+ Hledger.Read.Common: balancingopts :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr BalancingOpts
- Hledger.Read.Common: class HasInputOpts c_a3KLt
+ Hledger.Read.Common: class HasInputOpts c_a3Rdr
- Hledger.Read.Common: forecast :: HasInputOpts c_a3KLt => Lens' c_a3KLt (Maybe DateSpan)
+ Hledger.Read.Common: forecast :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr (Maybe DateSpan)
- Hledger.Read.Common: infer_costs :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.Common: infer_costs :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.Common: infer_equity :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.Common: infer_equity :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.Common: inputOpts :: HasInputOpts c_a3KLt => Lens' c_a3KLt InputOpts
+ Hledger.Read.Common: inputOpts :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr InputOpts
- Hledger.Read.Common: ioDay :: HasInputOpts c_a3KLt => Lens' c_a3KLt Day
+ Hledger.Read.Common: ioDay :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Day
- Hledger.Read.Common: journalAddAutoPostings :: Day -> BalancingOpts -> Journal -> Either String Journal
+ Hledger.Read.Common: journalAddAutoPostings :: Bool -> Day -> BalancingOpts -> Journal -> Either String Journal
- Hledger.Read.Common: journalAddForecast :: Maybe DateSpan -> Journal -> Journal
+ Hledger.Read.Common: journalAddForecast :: Bool -> Maybe DateSpan -> Journal -> Journal
- Hledger.Read.Common: mformat :: HasInputOpts c_a3KLt => Lens' c_a3KLt (Maybe StorageFormat)
+ Hledger.Read.Common: mformat :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr (Maybe StorageFormat)
- Hledger.Read.Common: mrules_file :: HasInputOpts c_a3KLt => Lens' c_a3KLt (Maybe FilePath)
+ Hledger.Read.Common: mrules_file :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr (Maybe FilePath)
- Hledger.Read.Common: new__ :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.Common: new__ :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.Common: new_save :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.Common: new_save :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.Common: pivot :: HasInputOpts c_a3KLt => Lens' c_a3KLt String
+ Hledger.Read.Common: pivot :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr String
- Hledger.Read.Common: reportspan :: HasInputOpts c_a3KLt => Lens' c_a3KLt DateSpan
+ Hledger.Read.Common: reportspan :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr DateSpan
- Hledger.Read.Common: strict :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.Common: strict :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.InputOptions: InputOpts :: Maybe StorageFormat -> Maybe FilePath -> [String] -> Bool -> Bool -> Bool -> String -> Maybe DateSpan -> DateSpan -> Bool -> Bool -> Bool -> BalancingOpts -> Bool -> Day -> InputOpts
+ Hledger.Read.InputOptions: InputOpts :: Maybe StorageFormat -> Maybe FilePath -> [String] -> Bool -> Bool -> Bool -> String -> Maybe DateSpan -> Bool -> DateSpan -> Bool -> Bool -> Bool -> BalancingOpts -> Bool -> Day -> InputOpts
- Hledger.Read.InputOptions: aliases :: HasInputOpts c_a3KLt => Lens' c_a3KLt [String]
+ Hledger.Read.InputOptions: aliases :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr [String]
- Hledger.Read.InputOptions: anon__ :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.InputOptions: anon__ :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.InputOptions: auto__ :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.InputOptions: auto__ :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.InputOptions: balancingopts :: HasInputOpts c_a3KLt => Lens' c_a3KLt BalancingOpts
+ Hledger.Read.InputOptions: balancingopts :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr BalancingOpts
- Hledger.Read.InputOptions: class HasInputOpts c_a3KLt
+ Hledger.Read.InputOptions: class HasInputOpts c_a3Rdr
- Hledger.Read.InputOptions: forecast :: HasInputOpts c_a3KLt => Lens' c_a3KLt (Maybe DateSpan)
+ Hledger.Read.InputOptions: forecast :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr (Maybe DateSpan)
- Hledger.Read.InputOptions: infer_costs :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.InputOptions: infer_costs :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.InputOptions: infer_equity :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.InputOptions: infer_equity :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.InputOptions: inputOpts :: HasInputOpts c_a3KLt => Lens' c_a3KLt InputOpts
+ Hledger.Read.InputOptions: inputOpts :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr InputOpts
- Hledger.Read.InputOptions: ioDay :: HasInputOpts c_a3KLt => Lens' c_a3KLt Day
+ Hledger.Read.InputOptions: ioDay :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Day
- Hledger.Read.InputOptions: mformat :: HasInputOpts c_a3KLt => Lens' c_a3KLt (Maybe StorageFormat)
+ Hledger.Read.InputOptions: mformat :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr (Maybe StorageFormat)
- Hledger.Read.InputOptions: mrules_file :: HasInputOpts c_a3KLt => Lens' c_a3KLt (Maybe FilePath)
+ Hledger.Read.InputOptions: mrules_file :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr (Maybe FilePath)
- Hledger.Read.InputOptions: new__ :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.InputOptions: new__ :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.InputOptions: new_save :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.InputOptions: new_save :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Read.InputOptions: pivot :: HasInputOpts c_a3KLt => Lens' c_a3KLt String
+ Hledger.Read.InputOptions: pivot :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr String
- Hledger.Read.InputOptions: reportspan :: HasInputOpts c_a3KLt => Lens' c_a3KLt DateSpan
+ Hledger.Read.InputOptions: reportspan :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr DateSpan
- Hledger.Read.InputOptions: strict :: HasInputOpts c_a3KLt => Lens' c_a3KLt Bool
+ Hledger.Read.InputOptions: strict :: HasInputOpts c_a3Rdr => Lens' c_a3Rdr Bool
- Hledger.Reports.ReportOptions: accountlistmode :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI AccountListMode
+ Hledger.Reports.ReportOptions: accountlistmode :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF AccountListMode
- Hledger.Reports.ReportOptions: average :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: average :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: balanceaccum :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI BalanceAccumulation
+ Hledger.Reports.ReportOptions: balanceaccum :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF BalanceAccumulation
- Hledger.Reports.ReportOptions: balancecalc :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI BalanceCalculation
+ Hledger.Reports.ReportOptions: balancecalc :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF BalanceCalculation
- Hledger.Reports.ReportOptions: budgetpat :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI (Maybe Text)
+ Hledger.Reports.ReportOptions: budgetpat :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF (Maybe Text)
- Hledger.Reports.ReportOptions: class HasReportOptsNoUpdate c_a3SzI
+ Hledger.Reports.ReportOptions: class HasReportOptsNoUpdate c_a3ZeF
- Hledger.Reports.ReportOptions: class HasReportSpec c_a3Txx
+ Hledger.Reports.ReportOptions: class HasReportSpec c_a40cu
- Hledger.Reports.ReportOptions: color__ :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: color__ :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: conversionop :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI (Maybe ConversionOp)
+ Hledger.Reports.ReportOptions: conversionop :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF (Maybe ConversionOp)
- Hledger.Reports.ReportOptions: date2NoUpdate :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: date2NoUpdate :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: declared :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: declared :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: depthNoUpdate :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI (Maybe Int)
+ Hledger.Reports.ReportOptions: depthNoUpdate :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF (Maybe Int)
- Hledger.Reports.ReportOptions: drop__ :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Int
+ Hledger.Reports.ReportOptions: drop__ :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Int
- Hledger.Reports.ReportOptions: empty__ :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: empty__ :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: format :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI StringFormat
+ Hledger.Reports.ReportOptions: format :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF StringFormat
- Hledger.Reports.ReportOptions: infer_prices :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: infer_prices :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: interval :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Interval
+ Hledger.Reports.ReportOptions: interval :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Interval
- Hledger.Reports.ReportOptions: invert :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: invert :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: layout :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Layout
+ Hledger.Reports.ReportOptions: layout :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Layout
- Hledger.Reports.ReportOptions: no_elide :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: no_elide :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: no_total :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: no_total :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: normalbalance :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI (Maybe NormalSign)
+ Hledger.Reports.ReportOptions: normalbalance :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF (Maybe NormalSign)
- Hledger.Reports.ReportOptions: percent :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: percent :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: periodNoUpdate :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Period
+ Hledger.Reports.ReportOptions: periodNoUpdate :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Period
- Hledger.Reports.ReportOptions: pretty :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: pretty :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: querystringNoUpdate :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI [Text]
+ Hledger.Reports.ReportOptions: querystringNoUpdate :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF [Text]
- Hledger.Reports.ReportOptions: realNoUpdate :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: realNoUpdate :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: related :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: related :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: reportOptsNoUpdate :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI ReportOpts
+ Hledger.Reports.ReportOptions: reportOptsNoUpdate :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF ReportOpts
- Hledger.Reports.ReportOptions: reportSpec :: HasReportSpec c_a3Txx => Lens' c_a3Txx ReportSpec
+ Hledger.Reports.ReportOptions: reportSpec :: HasReportSpec c_a40cu => Lens' c_a40cu ReportSpec
- Hledger.Reports.ReportOptions: row_total :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: row_total :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: rsDay :: HasReportSpec c_a3Txx => Lens' c_a3Txx Day
+ Hledger.Reports.ReportOptions: rsDay :: HasReportSpec c_a40cu => Lens' c_a40cu Day
- Hledger.Reports.ReportOptions: rsQuery :: HasReportSpec c_a3Txx => Lens' c_a3Txx Query
+ Hledger.Reports.ReportOptions: rsQuery :: HasReportSpec c_a40cu => Lens' c_a40cu Query
- Hledger.Reports.ReportOptions: rsQueryOpts :: HasReportSpec c_a3Txx => Lens' c_a3Txx [QueryOpt]
+ Hledger.Reports.ReportOptions: rsQueryOpts :: HasReportSpec c_a40cu => Lens' c_a40cu [QueryOpt]
- Hledger.Reports.ReportOptions: rsReportOpts :: HasReportSpec c_a3Txx => Lens' c_a3Txx ReportOpts
+ Hledger.Reports.ReportOptions: rsReportOpts :: HasReportSpec c_a40cu => Lens' c_a40cu ReportOpts
- Hledger.Reports.ReportOptions: show_costs :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: show_costs :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: sort_amount :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: sort_amount :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: statusesNoUpdate :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI [Status]
+ Hledger.Reports.ReportOptions: statusesNoUpdate :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF [Status]
- Hledger.Reports.ReportOptions: transpose__ :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: transpose__ :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: txn_dates :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI Bool
+ Hledger.Reports.ReportOptions: txn_dates :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF Bool
- Hledger.Reports.ReportOptions: value :: HasReportOptsNoUpdate c_a3SzI => Lens' c_a3SzI (Maybe ValuationType)
+ Hledger.Reports.ReportOptions: value :: HasReportOptsNoUpdate c_a3ZeF => Lens' c_a3ZeF (Maybe ValuationType)

Files

CHANGES.md view
@@ -5,10 +5,51 @@ | | | |_) | |_|_|_.__/             +Breaking changes++Misc. changes+nn --> Internal/api/developer-ish changes in the hledger-lib (and hledger) packages. For user-visible changes, see the hledger package changelog. +# 1.30 2023-06-01++Breaking changes++- dropped: Hledger.Data.RawOptions.inRawOpts++Misc. changes++- added more terminal size, ANSI style/color helpers in Hledger.Utils.IO+  (and therefore Hledger and Hledger.Cli.Script):++      getTerminalHeightWidth+      getTerminalHeight+      getTerminalWidth+      bold'+      faint'+      black'+      red'+      green'+      yellow'+      blue'+      magenta'+      cyan'+      white'+      brightBlack'+      brightRed'+      brightGreen'+      brightYellow'+      brightBlue'+      brightMagenta'+      brightCyan'+      brightWhite'+      rgb'+      multicol+      expandGlob+      sortByModTime+ # 1.29.2 2023-04-07  # 1.29.1 2023-03-16@@ -23,7 +64,6 @@ - Allow building with GHC 9.6.1; add base-compat (#2011)  # 1.29 2023-03-11- - added terminal colour detection helpers:   terminalIsLight   terminalLightness
Hledger/Data/Balancing.hs view
@@ -35,6 +35,7 @@ import Data.Array.ST (STArray, getElems, newListArray, writeArray) import Data.Foldable (asum) import Data.Function ((&))+import Data.Functor ((<&>)) import "base-compat" Data.Functor.Compat (void) import qualified Data.HashTable.Class as H (toList) import qualified Data.HashTable.ST.Cuckoo as H@@ -239,6 +240,7 @@         minferredamt = case ptype p of           RegularPosting         | not (hasAmount p) -> Just realsum           BalancedVirtualPosting | not (hasAmount p) -> Just bvsum+          VirtualPosting         | not (hasAmount p) -> Just 0           _                                          -> Nothing       in         case minferredamt of@@ -504,9 +506,17 @@ balanceTransactionAndCheckAssertionsB (Right t@Transaction{tpostings=ps}) = do   -- make sure we can handle the balance assignments   mapM_ checkIllegalBalanceAssignmentB ps-  -- for each posting, infer its amount from the balance assignment if applicable,-  -- update the account's running balance and check the balance assertion if any-  ps' <- mapM (addOrAssignAmountAndCheckAssertionB . postingStripPrices) ps+  -- for each posting, in date order (though without disturbing their display order),+  -- 1. infer its amount from the balance assignment if applicable,+  -- 2. update the account's running balance, and+  -- 3. check the balance assertion if any.+  ps' <- ps+    & zip [1..]                 -- attach original positions+    & sortOn (postingDate.snd)  -- sort by date+    & mapM (addOrAssignAmountAndCheckAssertionB)  -- infer amount, check assertion on each one+    <&> sortOn fst              -- restore original order+    <&> map snd                 -- discard positions+   -- infer any remaining missing amounts, and make sure the transaction is now fully balanced   styles <- R.reader bsStyles   case balanceTransactionHelper defbalancingopts{commodity_styles_=styles} t{tpostings=ps'} of@@ -517,18 +527,20 @@       -- and save the balanced transaction.       updateTransactionB t' +type NumberedPosting = (Integer, Posting)+ -- | If this posting has an explicit amount, add it to the account's running balance. -- If it has a missing amount and a balance assignment, infer the amount from, and -- reset the running balance to, the assigned balance. -- If it has a missing amount and no balance assignment, leave it for later. -- Then test the balance assertion if any.-addOrAssignAmountAndCheckAssertionB :: Posting -> Balancing s Posting-addOrAssignAmountAndCheckAssertionB p@Posting{paccount=acc, pamount=amt, pbalanceassertion=mba}+addOrAssignAmountAndCheckAssertionB :: NumberedPosting -> Balancing s NumberedPosting+addOrAssignAmountAndCheckAssertionB (i,p@Posting{paccount=acc, pamount=amt, pbalanceassertion=mba})   -- an explicit posting amount   | hasAmount p = do       newbal <- addToRunningBalanceB acc amt       whenM (R.reader bsAssrt) $ checkBalanceAssertionB p newbal-      return p+      return (i,p)    -- no explicit posting amount, but there is a balance assignment   | Just BalanceAssertion{baamount,batotal,bainclusive} <- mba = do@@ -542,10 +554,10 @@       diff <- (if bainclusive then setInclusiveRunningBalanceB else setRunningBalanceB) acc newbal       let p' = p{pamount=filterMixedAmount (not . amountIsZero) diff, poriginal=Just $ originalPosting p}       whenM (R.reader bsAssrt) $ checkBalanceAssertionB p' newbal-      return p'+      return (i,p')    -- no explicit posting amount, no balance assignment-  | otherwise = return p+  | otherwise = return (i,p)  -- | Add the posting's amount to its account's running balance, and -- optionally check the posting's balance assertion if any.
Hledger/Data/Dates.hs view
@@ -235,8 +235,8 @@ splitSpan adjust (Months n)   ds = splitspan (if adjust then startofmonth   else id) addGregorianMonthsClip n     ds splitSpan adjust (Quarters n) ds = splitspan (if adjust then startofquarter else id) addGregorianMonthsClip (3*n) ds splitSpan adjust (Years n)    ds = splitspan (if adjust then startofyear    else id) addGregorianYearsClip n      ds-splitSpan _ (DayOfMonth n)  ds = splitspan (nthdayofmonthcontaining n)  addGregorianMonthsClip 1 ds-splitSpan _ (DayOfYear m n) ds = splitspan (nthdayofyearcontaining m n) addGregorianYearsClip 1 ds+splitSpan _ (DayOfMonth dom)  ds = splitspan (nthdayofmonthcontaining dom) (addGregorianMonthsToMonthday dom) 1 ds+splitSpan _ (DayOfYear m n)   ds = splitspan (nthdayofyearcontaining m n) (addGregorianYearsClip) 1 ds splitSpan _ (WeekdayOfMonth n wd) ds = splitspan (nthweekdayofmonthcontaining n wd) advancemonths 1 ds   where     advancemonths 0 = id@@ -249,9 +249,21 @@     -- The first representative of each weekday     starts = map (\d -> addDays (toInteger $ d - n) $ nthdayofweekcontaining n s) days +-- Like addGregorianMonthsClip, add one month to the given date, clipping when needed+-- to fit it within the next month's length. But also, keep a target day of month in mind,+-- and revert to that or as close to it as possible in subsequent longer months.+-- Eg, using it to step through 31sts gives 1/31, 2/28, 3/31, 4/30, 5/31..+addGregorianMonthsToMonthday :: MonthDay -> Integer -> Day -> Day+addGregorianMonthsToMonthday dom n d =+  let (y,m,_) = toGregorian $ addGregorianMonthsClip n d+  in fromGregorian y m dom+ -- Split the given span into exact spans using the provided helper functions:--- the start function is applied to the span's start date to get the first sub-span's start date--- the addInterval function is applied to an integer n (multiplying it by mult) and the span's start date to get the nth sub-span's start date+-- 1. The start function is applied to the span's start date to get the first sub-span's start date.+-- 2. The addInterval function is used to calculate the subsequent spans' start dates,+-- possibly with stride increased by the mult multiplier.+-- It should adapt to spans of varying length, eg if splitting on "every 31st of month"+-- addInterval should adjust to 28/29/30 in short months but return to 31 in the long months. splitspan :: (Day -> Day) -> (Integer -> Day -> Day) -> Int -> DateSpan -> [DateSpan] splitspan start addInterval mult ds = spansFromBoundaries e bdrys   where@@ -610,9 +622,9 @@         mmddOfPrevYear = addDays (toInteger mdy-1) $ applyN (m-1) nextmonth $ prevyear s         s = startofyear date --- | For given date d find month-long interval that starts on nth day of month--- and covers it.--- The given day of month should be basically valid (1-31), or an error is raised.+-- | For a given date d find the month-long period that starts on day n of a month+-- that includes d. (It will begin on day n or either d's month or the previous month.)+-- The given day of month should be in the range 1-31, or an error will be raised. -- -- Examples: lets take 2017-11-22. Month-long intervals covering it that -- start on 1st-22nd of month will start in Nov. However
Hledger/Data/Errors.hs view
@@ -118,9 +118,17 @@     Just t  -> (f, errabsline, merrcols, ex)       where         (SourcePos f tl _) = fst $ tsourcepos t-        tcommentlines = max 0 (length (T.lines $ tcomment t) - 1)         mpindex = transactionFindPostingIndex (==p) t-        errrelline = maybe 0 (tcommentlines+) mpindex   -- XXX doesn't count posting coment lines+        errrelline = case mpindex of+          Nothing -> 0+          Just pindex ->+            commentExtraLines (tcomment t) + +            sum (map postingLines $ take pindex $ tpostings t)+            where+              -- How many lines are used to render this posting ?+              postingLines p' = 1 + commentExtraLines (pcomment p')+              -- How many extra lines does this comment add to a transaction or posting rendering ?+              commentExtraLines c = max 0 (length (T.lines c) - 1)         errabsline = unPos tl + errrelline         txntxt = showTransaction t & textChomp & (<>"\n")         merrcols = findpostingerrorcolumns p t txntxt
Hledger/Data/Journal.hs view
@@ -3,6 +3,7 @@ {-# LANGUAGE Rank2Types          #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections       #-}+{-# LANGUAGE RecordWildCards #-}  {-| @@ -122,8 +123,8 @@ import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T-import Safe (headMay, headDef, maximumMay, minimumMay)-import Data.Time.Calendar (Day, addDays, fromGregorian)+import Safe (headMay, headDef, maximumMay, minimumMay, lastDef)+import Data.Time.Calendar (Day, addDays, fromGregorian, diffDays) import Data.Time.Clock.POSIX (POSIXTime) import Data.Tree (Tree(..), flatten) import Text.Printf (printf)@@ -140,6 +141,9 @@ import Hledger.Data.Valuation import Hledger.Query import System.FilePath (takeFileName)+import Data.Ord (comparing)+import Hledger.Data.Dates (nulldate)+import Data.List (sort)   -- | A parser of text that runs in some monad, keeping a Journal as state.@@ -429,20 +433,38 @@     as = a : parentAccountNames a -- PERF: cache in journal ? +type DateWeightedSimilarityScore = Double+type SimilarityScore = Double+type Age = Integer+ -- | Find up to N most similar and most recent transactions matching--- the given transaction description and query. Transactions are--- listed with their description's similarity score (see--- compareDescriptions), sorted by highest score and then by date.--- Only transactions with a similarity score greater than a minimum--- threshold (currently 0) are returned.-journalTransactionsSimilarTo :: Journal -> Query -> Text -> Int -> [(Double,Transaction)]-journalTransactionsSimilarTo Journal{jtxns} q desc n =+-- the given transaction description and query and exceeding the given+-- description similarity score (0 to 1, see compareDescriptions).+-- Returns transactions along with+-- their age in days compared to the latest transaction date,+-- their description similarity score,+-- and a heuristically date-weighted variant of this that favours more recent transactions.+journalTransactionsSimilarTo :: Journal -> Text -> Query -> SimilarityScore -> Int+  -> [(DateWeightedSimilarityScore, Age, SimilarityScore, Transaction)]+journalTransactionsSimilarTo Journal{jtxns} desc q similaritythreshold n =   take n $-  sortBy (\(s1,t1) (s2,t2) -> compare (s2,tdate t2) (s1,tdate t1)) $-  filter ((> threshold).fst)+  dbg1With (+    unlines . +    ("up to 30 transactions above description similarity threshold "<>show similaritythreshold<>" ordered by recency-weighted similarity:":) .+    take 30 .+    map ( \(w,a,s,Transaction{..}) -> printf "weighted:%8.3f  age:%4d similarity:%5.3f  %s %s" w a s (show tdate) tdescription )) $+  sortBy (comparing (negate.first4)) $+  map (\(s,t) -> (weightedScore (s,t), age t, s, t)) $+  filter ((> similaritythreshold).fst)   [(compareDescriptions desc $ tdescription t, t) | t <- jtxns, q `matchesTransaction` t]   where-    threshold = 0+    latest = lastDef nulldate $ sort $ map tdate jtxns+    age = diffDays latest . tdate+    -- Combine similarity and recency heuristically. This gave decent results+    -- in my "find most recent invoice" use case in 2023-03,+    -- but will probably need more attention.+    weightedScore :: (Double, Transaction) -> Double+    weightedScore (s, t) = 100 * s - fromIntegral (age t) / 4  -- | Return a similarity score from 0 to 1.5 for two transaction descriptions.  -- This is based on compareStrings, with the following modifications:@@ -763,13 +785,12 @@ -- postings to transactions, eg). Or if a modifier rule fails to parse, -- return the error message. A reference date is provided to help interpret -- relative dates in transaction modifier queries.-journalModifyTransactions :: Day -> Journal -> Either String Journal-journalModifyTransactions d j =-    case modifyTransactions (journalAccountType j) (journalInheritedAccountTags j) (journalCommodityStyles j) d (jtxnmodifiers j) (jtxns j) of-      Right ts -> Right j{jtxns=ts}-      Left err -> Left err----+-- The first argument selects whether to add visible tags to generated postings & modified transactions.+journalModifyTransactions :: Bool -> Day -> Journal -> Either String Journal+journalModifyTransactions verbosetags d j =+  case modifyTransactions (journalAccountType j) (journalInheritedAccountTags j) (journalCommodityStyles j) d verbosetags (jtxnmodifiers j) (jtxns j) of+    Right ts -> Right j{jtxns=ts}+    Left err -> Left err  -- | Choose and apply a consistent display style to the posting -- amounts in each commodity (see journalCommodityStyles).@@ -895,8 +916,8 @@     styles = journalCommodityStyles j  -- | Add inferred equity postings to a 'Journal' using transaction prices.-journalAddInferredEquityPostings :: Journal -> Journal-journalAddInferredEquityPostings j = journalMapTransactions (transactionAddInferredEquityPostings equityAcct) j+journalAddInferredEquityPostings :: Bool -> Journal -> Journal+journalAddInferredEquityPostings verbosetags j = journalMapTransactions (transactionAddInferredEquityPostings verbosetags equityAcct) j   where     equityAcct = journalConversionAccount j @@ -963,17 +984,17 @@  -- overcomplicated/unused amount traversal stuff ----- | Get an ordered list of 'AmountStyle's from the amounts in this+--  Get an ordered list of 'AmountStyle's from the amounts in this -- journal which influence canonical amount display styles. See -- traverseJournalAmounts. -- journalAmounts :: Journal -> [Amount] -- journalAmounts = getConst . traverseJournalAmounts (Const . (:[])) ----- | Apply a transformation to the journal amounts traversed by traverseJournalAmounts.+--  Apply a transformation to the journal amounts traversed by traverseJournalAmounts. -- overJournalAmounts :: (Amount -> Amount) -> Journal -> Journal -- overJournalAmounts f = runIdentity . traverseJournalAmounts (Identity . f) ----- | A helper that traverses over most amounts in the journal,+--  A helper that traverses over most amounts in the journal, -- in particular the ones which influence canonical amount display styles, -- processing them with the given applicative function. --
Hledger/Data/PeriodicTransaction.hs view
@@ -11,6 +11,7 @@ ) where +import Data.Function ((&)) import Data.Maybe (isNothing) import qualified Data.Text as T import qualified Data.Text.IO as T@@ -34,7 +35,7 @@     t = T.pack str     (i,s) = parsePeriodExpr' nulldate t   mapM_ (T.putStr . showTransaction) $-    runPeriodicTransaction+    runPeriodicTransaction True       nullperiodictransaction{ ptperiodexpr=t , ptspan=s, ptinterval=i, ptpostings=["a" `post` usd 1] }       nulldatespan @@ -43,7 +44,7 @@     t = T.pack str     (i,s) = parsePeriodExpr' nulldate t   mapM_ (T.putStr . showTransaction) $-    runPeriodicTransaction+    runPeriodicTransaction True       nullperiodictransaction{ ptperiodexpr=t , ptspan=s, ptinterval=i, ptpostings=["a" `post` usd 1] }       spn @@ -186,7 +187,7 @@ --     a           $1.00 -- <BLANKLINE> ----- >>> let reportperiod="daily from 2018/01/03" in let (i,s) = parsePeriodExpr' nulldate reportperiod in runPeriodicTransaction (nullperiodictransaction{ptperiodexpr=reportperiod, ptspan=s, ptinterval=i, ptpostings=["a" `post` usd 1]}) (DateSpan (Just $ Flex $ fromGregorian 2018 01 01) (Just $ Flex $ fromGregorian 2018 01 03))+-- >>> let reportperiod="daily from 2018/01/03" in let (i,s) = parsePeriodExpr' nulldate reportperiod in runPeriodicTransaction True (nullperiodictransaction{ptperiodexpr=reportperiod, ptspan=s, ptinterval=i, ptpostings=["a" `post` usd 1]}) (DateSpan (Just $ Flex $ fromGregorian 2018 01 01) (Just $ Flex $ fromGregorian 2018 01 03)) -- [] -- -- >>> _ptgenspan "every 3 months from 2019-05" (DateSpan (Just $ Flex $ fromGregorian 2020 01 01) (Just $ Flex $ fromGregorian 2020 02 01))@@ -211,8 +212,8 @@ --     a           $1.00 -- <BLANKLINE> -runPeriodicTransaction :: PeriodicTransaction -> DateSpan -> [Transaction]-runPeriodicTransaction PeriodicTransaction{..} requestedspan =+runPeriodicTransaction :: Bool -> PeriodicTransaction -> DateSpan -> [Transaction]+runPeriodicTransaction verbosetags PeriodicTransaction{..} requestedspan =     [ t{tdate=d} | (DateSpan (Just efd) _) <- alltxnspans, let d = fromEFDay efd, spanContainsDate requestedspan d ]   where     t = nulltransaction{@@ -220,11 +221,11 @@           ,tstatus      = ptstatus           ,tcode        = ptcode           ,tdescription = ptdescription-          ,tcomment     = ptcomment-                          `commentAddTagNextLine` ("generated-transaction",period)-          ,ttags        = ("_generated-transaction",period) :-                          ("generated-transaction" ,period) :-                          pttags+          ,tcomment     = ptcomment &+            (if verbosetags then (`commentAddTagNextLine` ("generated-transaction",period)) else id)+          ,ttags        = pttags &+            (("_generated-transaction",period) :) &+            (if verbosetags then (("generated-transaction" ,period) :) else id)           ,tpostings    = ptpostings           }     period = "~ " <> ptperiodexpr
Hledger/Data/Posting.hs view
@@ -74,6 +74,7 @@  import Data.Default (def) import Data.Foldable (asum)+import Data.Function ((&)) import qualified Data.Map as M import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.List (foldl', sort, union)@@ -439,8 +440,8 @@ -- | Generate inferred equity postings from a 'Posting' using transaction prices. -- Make sure not to generate equity postings when there are already matched -- conversion postings.-postingAddInferredEquityPostings :: Text -> Posting -> [Posting]-postingAddInferredEquityPostings equityAcct p+postingAddInferredEquityPostings :: Bool -> Text -> Posting -> [Posting]+postingAddInferredEquityPostings verbosetags equityAcct p     | "_price-matched" `elem` map fst (ptags p) = [p]     | otherwise = taggedPosting : concatMap conversionPostings priceAmounts   where@@ -460,8 +461,11 @@         cost = amountCost amt         amtCommodity  = commodity amt         costCommodity = commodity cost-        cp = p{ pcomment = pcomment p `commentAddTag` ("generated-posting","")-              , ptags = [("_conversion-matched", ""), ("generated-posting", ""), ("_generated-posting", "")]+        cp = p{ pcomment = pcomment p & (if verbosetags then (`commentAddTag` ("generated-posting","conversion")) else id)+              , ptags    =+                   ("_conversion-matched","") : -- implementation-specific internal tag, not for users+                   ("_generated-posting","conversion") :+                   (if verbosetags then [("generated-posting", "conversion")] else [])               , pbalanceassertion = Nothing               , poriginal = Nothing               }
Hledger/Data/RawOptions.hs view
@@ -13,8 +13,8 @@   setboolopt,   unsetboolopt,   appendopts,-  inRawOpts,   boolopt,+  toggleopt,   choiceopt,   collectopts,   stringopt,@@ -56,12 +56,14 @@ appendopts :: [(String,String)] -> RawOpts -> RawOpts appendopts new = overRawOpts (++new) --- | Is the named option present ?-inRawOpts :: String -> RawOpts -> Bool-inRawOpts name = isJust . lookup name . unRawOpts-+-- | Is the named flag present ? boolopt :: String -> RawOpts -> Bool-boolopt = inRawOpts+boolopt name = isJust . lookup name . unRawOpts++-- | Like boolopt, except if the flag is repeated on the command line it toggles the value.+-- An even number of repetitions is equivalent to none.+toggleopt :: String -> RawOpts -> Bool+toggleopt name rawopts = odd $ length [ n | (n,_) <- unRawOpts rawopts, n==name]  -- | From a list of RawOpts, get the last one (ie the right-most on the command line) -- for which the given predicate returns a Just value.
Hledger/Data/Timeclock.hs view
@@ -57,7 +57,7 @@     | odate > idate = entryFromTimeclockInOut i o' : timeclockEntriesToTransactions now [i',o]     | otherwise = [entryFromTimeclockInOut i o]     where-      o = TimeclockEntry (tlsourcepos i) Out end "" ""+      o = TimeclockEntry (tlsourcepos i) Out end "" "" "" []       end = if itime > now then itime else now       (itime,otime) = (tldatetime i,tldatetime o)       (idate,odate) = (localDay itime,localDay otime)@@ -120,8 +120,8 @@             tstatus      = Cleared,             tcode        = "",             tdescription = desc,-            tcomment     = "",-            ttags        = [],+            tcomment     = tlcomment i,+            ttags        = tltags i,             tpostings    = ps,             tprecedingcomment=""           }@@ -162,11 +162,11 @@           future = utcToLocalTime tz $ addUTCTime 100 now'           futurestr = showtime future       step "started yesterday, split session at midnight"-      txndescs [clockin (mktime yesterday "23:00:00") "" ""] @?= ["23:00-23:59","00:00-"++nowstr]+      txndescs [clockin (mktime yesterday "23:00:00") "" "" "" []] @?= ["23:00-23:59","00:00-"++nowstr]       step "split multi-day sessions at each midnight"-      txndescs [clockin (mktime (addDays (-2) today) "23:00:00") "" ""] @?= ["23:00-23:59","00:00-23:59","00:00-"++nowstr]+      txndescs [clockin (mktime (addDays (-2) today) "23:00:00") "" "" "" []] @?= ["23:00-23:59","00:00-23:59","00:00-"++nowstr]       step "auto-clock-out if needed"-      txndescs [clockin (mktime today "00:00:00") "" ""] @?= ["00:00-"++nowstr]+      txndescs [clockin (mktime today "00:00:00") "" "" "" []] @?= ["00:00-"++nowstr]       step "use the clockin time for auto-clockout if it's in the future"-      txndescs [clockin future "" ""] @?= [printf "%s-%s" futurestr futurestr]+      txndescs [clockin future "" "" "" []] @?= [printf "%s-%s" futurestr futurestr]  ]
Hledger/Data/Transaction.hs view
@@ -222,9 +222,9 @@ transactionToCost styles cost t = t{tpostings = mapMaybe (postingToCost styles cost) $ tpostings t}  -- | Add inferred equity postings to a 'Transaction' using transaction prices.-transactionAddInferredEquityPostings :: AccountName -> Transaction -> Transaction-transactionAddInferredEquityPostings equityAcct t =-    t{tpostings=concatMap (postingAddInferredEquityPostings equityAcct) $ tpostings t}+transactionAddInferredEquityPostings :: Bool -> AccountName -> Transaction -> Transaction+transactionAddInferredEquityPostings verbosetags equityAcct t =+    t{tpostings=concatMap (postingAddInferredEquityPostings verbosetags equityAcct) $ tpostings t}  type IdxPosting = (Int, Posting) 
Hledger/Data/TransactionModifier.hs view
@@ -14,6 +14,7 @@  import Prelude hiding (Applicative(..)) import Control.Applicative (Applicative(..), (<|>))+import Data.Function ((&)) import qualified Data.Map as M import Data.Maybe (catMaybes) import qualified Data.Text as T@@ -40,17 +41,20 @@ modifyTransactions :: (AccountName -> Maybe AccountType)                    -> (AccountName -> [Tag])                    -> M.Map CommoditySymbol AmountStyle-                   -> Day -> [TransactionModifier] -> [Transaction]+                   -> Day -> Bool -> [TransactionModifier] -> [Transaction]                    -> Either String [Transaction]-modifyTransactions atypes atags styles d tmods ts = do-  fs <- mapM (transactionModifierToFunction atypes atags styles d) tmods  -- convert modifiers to functions, or return a parse error+modifyTransactions atypes atags styles d verbosetags tmods ts = do+  fs <- mapM (transactionModifierToFunction atypes atags styles d verbosetags) tmods  -- convert modifiers to functions, or return a parse error   let     modifytxn t = t''       where         t' = foldr (flip (.)) id fs t  -- apply each function in turn-        t'' = if t' == t  -- and add some tags if it was changed+        t'' = if t' == t               then t'-              else t'{tcomment=tcomment t' `commentAddTag` ("modified",""), ttags=("modified","") : ttags t'}+              else t'{tcomment=tcomment t' & (if verbosetags then (`commentAddTag` ("modified","")) else id)+                     ,ttags=ttags t' & (("_modified","") :) & (if verbosetags then (("modified","") :) else id)+                     }+   Right $ map modifytxn ts  -- | Converts a 'TransactionModifier' to a 'Transaction'-transforming function@@ -67,7 +71,7 @@ -- >>> import qualified Data.Text.IO as T -- >>> t = nulltransaction{tpostings=["ping" `post` usd 1]} -- >>> tmpost acc amt = TMPostingRule (acc `post` amt) False--- >>> test = either putStr (T.putStr.showTransaction) . fmap ($ t) . transactionModifierToFunction (const Nothing) (const []) mempty nulldate+-- >>> test = either putStr (T.putStr.showTransaction) . fmap ($ t) . transactionModifierToFunction (const Nothing) (const []) mempty nulldate True -- >>> test $ TransactionModifier "" ["pong" `tmpost` usd 2] -- 0000-01-01 --     ping           $1.00@@ -86,12 +90,12 @@ transactionModifierToFunction :: (AccountName -> Maybe AccountType)                               -> (AccountName -> [Tag])                               -> M.Map CommoditySymbol AmountStyle-                              -> Day -> TransactionModifier+                              -> Day -> Bool -> TransactionModifier                               -> Either String (Transaction -> Transaction)-transactionModifierToFunction atypes atags styles refdate TransactionModifier{tmquerytxt, tmpostingrules} = do+transactionModifierToFunction atypes atags styles refdate verbosetags TransactionModifier{tmquerytxt, tmpostingrules} = do   q <- simplifyQuery . fst <$> parseQuery refdate tmquerytxt   let-    fs = map (\tmpr -> addAccountTags . tmPostingRuleToFunction styles q tmquerytxt tmpr) tmpostingrules+    fs = map (\tmpr -> addAccountTags . tmPostingRuleToFunction verbosetags styles q tmquerytxt tmpr) tmpostingrules     addAccountTags p = p `postingAddTags` atags (paccount p)     generatePostings p = p : map ($ p) (if matchesPostingExtra atypes q p then fs else [])   Right $ \t@(tpostings -> ps) -> txnTieKnot t{tpostings=concatMap generatePostings ps}@@ -100,20 +104,19 @@ -- which will be used to make a new posting based on the old one (an "automated posting"). -- The new posting's amount can optionally be the old posting's amount multiplied by a constant. -- If the old posting had a total-priced amount, the new posting's multiplied amount will be unit-priced.--- The new posting will have two tags added: a normal generated-posting: tag which also appears in the comment,--- and a hidden _generated-posting: tag which does not.--- The TransactionModifier's query text is also provided, and saved--- as the tags' value.-tmPostingRuleToFunction :: M.Map CommoditySymbol AmountStyle -> Query -> T.Text -> TMPostingRule -> (Posting -> Posting)-tmPostingRuleToFunction styles query querytxt tmpr =+-- The new posting will have a hidden _generated-posting: tag added,+-- and with a true first argument, also a visible generated-posting: tag.+-- The provided TransactionModifier's query text is saved as the tags' value.+tmPostingRuleToFunction :: Bool -> M.Map CommoditySymbol AmountStyle -> Query -> T.Text -> TMPostingRule -> (Posting -> Posting)+tmPostingRuleToFunction verbosetags styles query querytxt tmpr =   \p -> postingApplyCommodityStyles styles . renderPostingCommentDates $ pr       { pdate    = pdate  pr <|> pdate  p       , pdate2   = pdate2 pr <|> pdate2 p       , pamount  = amount' p-      , pcomment = pcomment pr `commentAddTag` ("generated-posting",qry)-      , ptags    = ("generated-posting", qry) :-                   ("_generated-posting",qry) :-                   ptags pr+      , pcomment = pcomment pr & (if verbosetags then (`commentAddTag` ("generated-posting",qry)) else id)+      , ptags    = ptags pr+                   & (("_generated-posting",qry) :)+                   & (if verbosetags then (("generated-posting", qry) :) else id)       }   where     pr = tmprPosting tmpr
Hledger/Data/Types.hs view
@@ -504,7 +504,9 @@       tlcode        :: TimeclockCode,       tldatetime    :: LocalTime,       tlaccount     :: AccountName,-      tldescription :: Text+      tldescription :: Text,+      tlcomment     :: Text,+      tltags        :: [Tag]     } deriving (Eq,Ord,Generic)  -- | A market price declaration made by the journal format's P directive.
Hledger/Query.hs view
@@ -8,6 +8,7 @@ {-# LANGUAGE FlexibleContexts   #-} {-# LANGUAGE OverloadedStrings  #-} {-# LANGUAGE ViewPatterns       #-}+{-# LANGUAGE TupleSections      #-}  module Hledger.Query (   -- * Query and QueryOpt@@ -75,16 +76,17 @@  import Control.Applicative ((<|>), many, optional) import Data.Default (Default(..))-import Data.Either (fromLeft, partitionEithers)+import Data.Either (partitionEithers) import Data.List (partition, intercalate) import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Calendar (Day, fromGregorian ) import Safe (readDef, readMay, maximumByMay, maximumMay, minimumMay)-import Text.Megaparsec (between, noneOf, sepBy)-import Text.Megaparsec.Char (char, string)+import Text.Megaparsec (between, noneOf, sepBy, try, (<?>), notFollowedBy)+import Text.Megaparsec.Char (char, string, string') + import Hledger.Utils hiding (words') import Hledger.Data.Types import Hledger.Data.AccountName@@ -161,17 +163,6 @@ -- whitespace, it (or the whole term including prefix) should be enclosed -- in single or double quotes. ----- >>> parseQuery nulldate "expenses:dining out"--- Right (Or [Acct (RegexpCI "expenses:dining"),Acct (RegexpCI "out")],[])------ >>> parseQuery nulldate "\"expenses:dining out\""--- Right (Acct (RegexpCI "expenses:dining out"),[])-parseQuery :: Day -> T.Text -> Either String (Query,[QueryOpt])-parseQuery d = parseQueryList d . words'' queryprefixes---- | Convert a list of query expression containing to a query and zero--- or more query options; or return an error message if query parsing fails.--- -- A query term is either: -- -- 1. a search pattern, which matches on one or more fields, eg:@@ -191,6 +182,16 @@ -- Period expressions may contain relative dates, so a reference date is -- required to fully parse these. --+-- >>> parseQuery nulldate "expenses:dining out"+-- Right (Or [Acct (RegexpCI "expenses:dining"),Acct (RegexpCI "out")],[])+--+-- >>> parseQuery nulldate "\"expenses:dining out\""+-- Right (Acct (RegexpCI "expenses:dining out"),[])+parseQuery :: Day -> T.Text -> Either String (Query,[QueryOpt])+parseQuery d t = parseQueryList d $ words'' queryprefixes t++-- | Convert a list of space-separated queries to a single query+-- -- Multiple terms are combined as follows: -- 1. multiple account patterns are OR'd together -- 2. multiple description patterns are OR'd together@@ -199,22 +200,28 @@ parseQueryList :: Day -> [T.Text] -> Either String (Query, [QueryOpt]) parseQueryList d termstrs = do   eterms <- mapM (parseQueryTerm d) termstrs-  let (pats, opts) = partitionEithers eterms-      (descpats, pats') = partition queryIsDesc pats-      (acctpats, pats'') = partition queryIsAcct pats'-      (statuspats, otherpats) = partition queryIsStatus pats''-      q = simplifyQuery $ And $ [Or acctpats, Or descpats, Or statuspats] ++ otherpats-  Right (q, opts)+  let (pats, optss) = unzip eterms+      q = combineQueryList pats+  Right (q, concat optss) +combineQueryList :: [Query] -> Query+combineQueryList pats = q+  where+    (descpats, pats') = partition queryIsDesc pats+    (acctpats, pats'') = partition queryIsAcct pats'+    (statuspats, otherpats) = partition queryIsStatus pats''+    q = simplifyQuery $ And $ [Or acctpats, Or descpats, Or statuspats] ++ otherpats+   -- XXX -- | Quote-and-prefix-aware version of words - don't split on spaces which -- are inside quotes, including quotes which may have one of the specified -- prefixes in front, and maybe an additional not: prefix in front of that. words'' :: [T.Text] -> T.Text -> [T.Text]-words'' prefixes = fromparse . parsewith maybeprefixedquotedphrases -- XXX-    where-      maybeprefixedquotedphrases :: SimpleTextParser [T.Text]-      maybeprefixedquotedphrases = choice' [prefixedQuotedPattern, singleQuotedPattern, doubleQuotedPattern, patterns] `sepBy` skipNonNewlineSpaces1+words'' prefixes = fromparse . parsewith maybePrefixedQuotedPhrases -- XXX+   where+      maybePrefixedQuotedPhrases :: SimpleTextParser [T.Text]+      maybePrefixedQuotedPhrases = choice' [prefixedQuotedPattern, singleQuotedPattern, doubleQuotedPattern, patterns] `sepBy`+          (notFollowedBy (skipNonNewlineSpaces >> char ')') >> skipNonNewlineSpaces1)       prefixedQuotedPattern :: SimpleTextParser T.Text       prefixedQuotedPattern = do         not' <- fromMaybe "" `fmap` (optional $ string "not:")@@ -253,6 +260,7 @@     ,"depth"     ,"tag"     ,"type"+    ,"expr"     ]  defaultprefix :: T.Text@@ -270,41 +278,109 @@  -- | Parse a single query term as either a query or a query option, -- or return an error message if parsing fails.-parseQueryTerm :: Day -> T.Text -> Either String (Either Query QueryOpt)-parseQueryTerm _ (T.stripPrefix "inacctonly:" -> Just s) = Right $ Right $ QueryOptInAcctOnly s-parseQueryTerm _ (T.stripPrefix "inacct:" -> Just s) = Right $ Right $ QueryOptInAcct s+parseQueryTerm :: Day -> T.Text -> Either String (Query, [QueryOpt])+parseQueryTerm _ (T.stripPrefix "inacctonly:" -> Just s) = Right (Any, [QueryOptInAcctOnly s])+parseQueryTerm _ (T.stripPrefix "inacct:" -> Just s) = Right (Any, [QueryOptInAcct s]) parseQueryTerm d (T.stripPrefix "not:" -> Just s) =   case parseQueryTerm d s of-    Right (Left m)  -> Right $ Left $ Not m-    Right (Right _) -> Right $ Left Any -- not:somequeryoption will be ignored-    Left err        -> Left err-parseQueryTerm _ (T.stripPrefix "code:" -> Just s) = Left . Code <$> toRegexCI s-parseQueryTerm _ (T.stripPrefix "desc:" -> Just s) = Left . Desc <$> toRegexCI s-parseQueryTerm _ (T.stripPrefix "payee:" -> Just s) = Left <$> payeeTag (Just s)-parseQueryTerm _ (T.stripPrefix "note:" -> Just s) = Left <$> noteTag (Just s)-parseQueryTerm _ (T.stripPrefix "acct:" -> Just s) = Left . Acct <$> toRegexCI s+    Right (q, qopts) -> Right (Not q, qopts)+    Left err         -> Left err+parseQueryTerm _ (T.stripPrefix "code:" -> Just s) = (,[]) . Code <$> toRegexCI s+parseQueryTerm _ (T.stripPrefix "desc:" -> Just s) = (,[]) . Desc <$> toRegexCI s+parseQueryTerm _ (T.stripPrefix "payee:" -> Just s) = (,[]) <$> payeeTag (Just s)+parseQueryTerm _ (T.stripPrefix "note:" -> Just s) = (,[]) <$> noteTag (Just s)+parseQueryTerm _ (T.stripPrefix "acct:" -> Just s) = (,[]) . Acct <$> toRegexCI s parseQueryTerm d (T.stripPrefix "date2:" -> Just s) =         case parsePeriodExpr d s of Left e         -> Left $ "\"date2:"++T.unpack s++"\" gave a "++showDateParseError e-                                    Right (_,spn) -> Right $ Left $ Date2 spn+                                    Right (_,spn) -> Right (Date2 spn, []) parseQueryTerm d (T.stripPrefix "date:" -> Just s) =         case parsePeriodExpr d s of Left e         -> Left $ "\"date:"++T.unpack s++"\" gave a "++showDateParseError e-                                    Right (_,spn) -> Right $ Left $ Date spn+                                    Right (_,spn) -> Right (Date spn, []) parseQueryTerm _ (T.stripPrefix "status:" -> Just s) =         case parseStatus s of Left e   -> Left $ "\"status:"++T.unpack s++"\" gave a parse error: " ++ e-                              Right st -> Right $ Left $ StatusQ st-parseQueryTerm _ (T.stripPrefix "real:" -> Just s) = Right $ Left $ Real $ parseBool s || T.null s-parseQueryTerm _ (T.stripPrefix "amt:" -> Just s) = Right $ Left $ Amt ord q where (ord, q) = either error id $ parseAmountQueryTerm s  -- PARTIAL:+                              Right st -> Right (StatusQ st, [])+parseQueryTerm _ (T.stripPrefix "real:" -> Just s) = Right (Real $ parseBool s || T.null s, [])+parseQueryTerm _ (T.stripPrefix "amt:" -> Just s) = Right (Amt ord q, []) where (ord, q) = either error id $ parseAmountQueryTerm s  -- PARTIAL: parseQueryTerm _ (T.stripPrefix "depth:" -> Just s)-  | n >= 0    = Right $ Left $ Depth n+  | n >= 0    = Right (Depth n, [])   | otherwise = Left "depth: should have a positive number"   where n = readDef 0 (T.unpack s) -parseQueryTerm _ (T.stripPrefix "cur:" -> Just s) = Left . Sym <$> toRegexCI ("^" <> s <> "$") -- support cur: as an alias-parseQueryTerm _ (T.stripPrefix "tag:" -> Just s) = Left <$> parseTag s-parseQueryTerm _ (T.stripPrefix "type:" -> Just s) = Left <$> parseTypeCodes s-parseQueryTerm _ "" = Right $ Left $ Any+parseQueryTerm _ (T.stripPrefix "cur:" -> Just s) = (,[]) . Sym <$> toRegexCI ("^" <> s <> "$") -- support cur: as an alias+parseQueryTerm _ (T.stripPrefix "tag:" -> Just s) = (,[]) <$> parseTag s+parseQueryTerm _ (T.stripPrefix "type:" -> Just s) = (,[]) <$> parseTypeCodes s+parseQueryTerm d (T.stripPrefix "expr:" -> Just s) = parseBooleanQuery d s+parseQueryTerm _ "" = Right (Any, []) parseQueryTerm d s = parseQueryTerm d $ defaultprefix<>":"<>s +-- | Parses a boolean query expression.+--+-- Boolean queries combine smaller queries into larger ones. The boolean operators+-- made available through this function are "NOT e", "e AND e", "e OR e", and "e e".+-- Query options defined in multiple sub-queries are simply combined by concatenating+-- all options into one list.+--+-- Boolean operators in queries take precedence over one another. For instance, the+-- prefix-operator "NOT e" is always parsed before "e AND e", "e AND e" before "e OR e",+-- and "e OR e" before "e e".+--+-- The space-separation operator is left as it was the default before the introduction of+-- boolean operators. It takes the behaviour defined in the interpretQueryList function,+-- whereas the NOT, OR, and AND operators simply wrap a list of queries with the associated+--+--+-- The result of this function is either an error encountered during parsing of the+-- expression or the combined query and query options.+--+-- >>> parseBooleanQuery nulldate "expenses:dining AND out"+-- Right (And [Acct (RegexpCI "expenses:dining"),Acct (RegexpCI "out")],[])+--+-- >>> parseBooleanQuery nulldate "expenses:dining AND desc:a OR desc:b"+-- Right (Or [And [Acct (RegexpCI "expenses:dining"),Desc (RegexpCI "a")],Desc (RegexpCI "b")],[])+parseBooleanQuery :: Day -> T.Text -> Either String (Query,[QueryOpt])+parseBooleanQuery d t = either (Left . ("failed to parse query:" <>) . customErrorBundlePretty) Right $ parsewith spacedQueriesP t+  where+    regexP       :: SimpleTextParser T.Text+    regexP       = choice'+      [ stripquotes . T.pack <$> between (char '\'') (char '\'') (many $ noneOf ("'" :: [Char])),+        stripquotes . T.pack <$> between (char '"') (char '"') (many $ noneOf ("\"" :: [Char])),+        T.pack <$> (notFollowedBy keywordSpaceP >> (many $ noneOf (") \n\r" :: [Char]))) ]+    queryPrefixP :: SimpleTextParser T.Text+    queryPrefixP = (string "not:" <> (fromMaybe "" <$> optional queryPrefixP))+               <|> choice' (string <$> queryprefixes)+               <?> "query prefix"+    queryTermP   :: SimpleTextParser (Query, [QueryOpt])+    queryTermP   = do+      prefix <- optional queryPrefixP+      queryRegex <- regexP++      case parseQueryTerm d (fromMaybe "" prefix <> queryRegex) of+        Right q  -> return q+        Left err -> error' err++    keywordSpaceP :: SimpleTextParser T.Text+    keywordSpaceP = choice' (string' <$> ["not ", "and ", "or "])++    parQueryP,notQueryP :: SimpleTextParser (Query, [QueryOpt])+    parQueryP = between (char '(' >> skipNonNewlineSpaces)+                        (try $ skipNonNewlineSpaces >> char ')')+                        spacedQueriesP+            <|> queryTermP+    notQueryP = (maybe id (\_ (q, qopts) -> (Not q, qopts)) <$> optional (try $ string' "not" >> notFollowedBy (char ':') >> skipNonNewlineSpaces1)) <*> parQueryP++    andQueriesP,orQueriesP,spacedQueriesP :: SimpleTextParser (Query, [QueryOpt])+    andQueriesP    = nArityOp And <$> notQueryP `sepBy` (try $ skipNonNewlineSpaces >> string' "and" >> skipNonNewlineSpaces1)+    orQueriesP     = nArityOp Or <$> andQueriesP `sepBy` (try $ skipNonNewlineSpaces >> string' "or" >> skipNonNewlineSpaces1)+    spacedQueriesP = nArityOp combineQueryList <$> orQueriesP `sepBy` skipNonNewlineSpaces1++    nArityOp       :: ([Query] -> Query) -> [(Query, [QueryOpt])] -> (Query, [QueryOpt])+    nArityOp f res = let (qs, qoptss) = unzip res+                         qoptss'      = concat qoptss+                      in case qs of+                         []     -> (Any, qoptss')+                         (q:[]) -> (simplifyQuery q, qoptss')+                         _      -> (simplifyQuery $ f qs, qoptss')+ -- | Parse the argument of an amt query term ([OP][SIGN]NUM), to an -- OrdPlus and a Quantity, or if parsing fails, an error message. OP -- can be <=, <, >=, >, or = . NUM can be a simple integer or decimal.@@ -856,6 +932,26 @@      parseQuery nulldate "'a a' 'b"                                    @?= Right (Or [Acct $ toRegexCI' "a a",Acct $ toRegexCI' "'b"], [])      parseQuery nulldate "\""                                          @?= Right (Acct $ toRegexCI' "\"", []) +  ,testCase "parseBooleanQuery" $ do+     parseBooleanQuery nulldate "(tag:'atag=a')"     @?= Right (Tag (toRegexCI' "atag") (Just $ toRegexCI' "a"), [])+     parseBooleanQuery nulldate "(  tag:\"atag=a\"  )"     @?= Right (Tag (toRegexCI' "atag") (Just $ toRegexCI' "a"), [])+     parseBooleanQuery nulldate "(acct:'expenses:food')"     @?= Right (Acct $ toRegexCI' "expenses:food", [])+     parseBooleanQuery nulldate "(((acct:'expenses:food')))" @?= Right (Acct $ toRegexCI' "expenses:food", [])+     parseBooleanQuery nulldate "acct:'expenses:food' AND desc:'b'" @?= Right (And [Acct $ toRegexCI' "expenses:food", Desc $ toRegexCI' "b"], [])+     parseBooleanQuery nulldate "((desc:'a') AND (desc:'b') OR (desc:'c'))" @?= Right (Or [And [Desc $ toRegexCI' "a", Desc $ toRegexCI' "b"], Desc $ toRegexCI' "c"], [])+     parseBooleanQuery nulldate "((desc:'a') OR (desc:'b') AND (desc:'c'))" @?= Right (Or [Desc $ toRegexCI' "a", And [Desc $ toRegexCI' "b", Desc $ toRegexCI' "c"]], [])+     parseBooleanQuery nulldate "((desc:'a') AND desc:'b' AND (desc:'c'))" @?= Right (And [Desc $ toRegexCI' "a", Desc $ toRegexCI' "b", Desc $ toRegexCI' "c"], [])+     parseBooleanQuery nulldate "(NOT (desc:'a') AND (desc:'b'))" @?= Right (And [Not $ Desc $ toRegexCI' "a", Desc $ toRegexCI' "b"], [])+     parseBooleanQuery nulldate "((desc:'a') AND (NOT desc:'b'))" @?= Right (And [Desc $ toRegexCI' "a", Not $ Desc $ toRegexCI' "b"], [])+     parseBooleanQuery nulldate "(desc:'a' AND desc:'b')" @?= Right (And [Desc $ toRegexCI' "a", Desc $ toRegexCI' "b"], [])+     parseBooleanQuery nulldate "(acct:'a' acct:'b')" @?= Right (Or [Acct $ toRegexCI' "a", Acct $ toRegexCI' "b"], [])+     parseBooleanQuery nulldate " acct:'a' acct:'b'" @?= Right (Or [Acct $ toRegexCI' "a", Acct $ toRegexCI' "b"], [])+     parseBooleanQuery nulldate "not:a" @?= Right (Not $ Acct $ toRegexCI' "a", [])+     parseBooleanQuery nulldate "expenses:food OR (tag:A expenses:drink)" @?= Right (Or [Acct $ toRegexCI' "expenses:food", And [Acct $ toRegexCI' "expenses:drink", Tag (toRegexCI' "A") Nothing]], [])+     parseBooleanQuery nulldate "not a" @?= Right (Not $ Acct $ toRegexCI' "a", [])+     parseBooleanQuery nulldate "nota" @?= Right (Acct $ toRegexCI' "nota", [])+     parseBooleanQuery nulldate "not (acct:a)" @?= Right (Not $ Acct $ toRegexCI' "a", [])+   ,testCase "words''" $ do       (words'' [] "a b")                   @?= ["a","b"]       (words'' [] "'a b'")                 @?= ["a b"]@@ -873,25 +969,25 @@      filterQuery queryIsDepth (And [Date nulldatespan, Not (Or [Any, Depth 1])]) @?= Any   -- XXX unclear    ,testCase "parseQueryTerm" $ do-     parseQueryTerm nulldate "a"                                @?= Right (Left $ Acct $ toRegexCI' "a")-     parseQueryTerm nulldate "acct:expenses:autres d\233penses" @?= Right (Left $ Acct $ toRegexCI' "expenses:autres d\233penses")-     parseQueryTerm nulldate "not:desc:a b"                     @?= Right (Left $ Not $ Desc $ toRegexCI' "a b")-     parseQueryTerm nulldate "status:1"                         @?= Right (Left $ StatusQ Cleared)-     parseQueryTerm nulldate "status:*"                         @?= Right (Left $ StatusQ Cleared)-     parseQueryTerm nulldate "status:!"                         @?= Right (Left $ StatusQ Pending)-     parseQueryTerm nulldate "status:0"                         @?= Right (Left $ StatusQ Unmarked)-     parseQueryTerm nulldate "status:"                          @?= Right (Left $ StatusQ Unmarked)-     parseQueryTerm nulldate "payee:x"                          @?= Left <$> payeeTag (Just "x")-     parseQueryTerm nulldate "note:x"                           @?= Left <$> noteTag (Just "x")-     parseQueryTerm nulldate "real:1"                           @?= Right (Left $ Real True)-     parseQueryTerm nulldate "date:2008"                        @?= Right (Left $ Date $ DateSpan (Just $ Flex $ fromGregorian 2008 01 01) (Just $ Flex $ fromGregorian 2009 01 01))-     parseQueryTerm nulldate "date:from 2012/5/17"              @?= Right (Left $ Date $ DateSpan (Just $ Exact $ fromGregorian 2012 05 17) Nothing)-     parseQueryTerm nulldate "date:20180101-201804"             @?= Right (Left $ Date $ DateSpan (Just $ Exact $ fromGregorian 2018 01 01) (Just $ Flex $ fromGregorian 2018 04 01))-     parseQueryTerm nulldate "inacct:a"                         @?= Right (Right $ QueryOptInAcct "a")-     parseQueryTerm nulldate "tag:a"                            @?= Right (Left $ Tag (toRegexCI' "a") Nothing)-     parseQueryTerm nulldate "tag:a=some value"                 @?= Right (Left $ Tag (toRegexCI' "a") (Just $ toRegexCI' "some value"))-     parseQueryTerm nulldate "amt:<0"                           @?= Right (Left $ Amt Lt 0)-     parseQueryTerm nulldate "amt:>10000.10"                    @?= Right (Left $ Amt AbsGt 10000.1)+     parseQueryTerm nulldate "a"                                @?= Right (Acct $ toRegexCI' "a", [])+     parseQueryTerm nulldate "acct:expenses:autres d\233penses" @?= Right (Acct $ toRegexCI' "expenses:autres d\233penses", [])+     parseQueryTerm nulldate "not:desc:a b"                     @?= Right (Not $ Desc $ toRegexCI' "a b", [])+     parseQueryTerm nulldate "status:1"                         @?= Right (StatusQ Cleared, [])+     parseQueryTerm nulldate "status:*"                         @?= Right (StatusQ Cleared, [])+     parseQueryTerm nulldate "status:!"                         @?= Right (StatusQ Pending, [])+     parseQueryTerm nulldate "status:0"                         @?= Right (StatusQ Unmarked, [])+     parseQueryTerm nulldate "status:"                          @?= Right (StatusQ Unmarked, [])+     parseQueryTerm nulldate "payee:x"                          @?= (,[]) <$> payeeTag (Just "x")+     parseQueryTerm nulldate "note:x"                           @?= (,[]) <$> noteTag (Just "x")+     parseQueryTerm nulldate "real:1"                           @?= Right (Real True, [])+     parseQueryTerm nulldate "date:2008"                        @?= Right (Date $ DateSpan (Just $ Flex $ fromGregorian 2008 01 01) (Just $ Flex $ fromGregorian 2009 01 01), [])+     parseQueryTerm nulldate "date:from 2012/5/17"              @?= Right (Date $ DateSpan (Just $ Exact $ fromGregorian 2012 05 17) Nothing, [])+     parseQueryTerm nulldate "date:20180101-201804"             @?= Right (Date $ DateSpan (Just $ Exact $ fromGregorian 2018 01 01) (Just $ Flex $ fromGregorian 2018 04 01), [])+     parseQueryTerm nulldate "inacct:a"                         @?= Right (Any, [QueryOptInAcct "a"])+     parseQueryTerm nulldate "tag:a"                            @?= Right (Tag (toRegexCI' "a") Nothing, [])+     parseQueryTerm nulldate "tag:a=some value"                 @?= Right (Tag (toRegexCI' "a") (Just $ toRegexCI' "some value"), [])+     parseQueryTerm nulldate "amt:<0"                           @?= Right (Amt Lt 0, [])+     parseQueryTerm nulldate "amt:>10000.10"                    @?= Right (Amt AbsGt 10000.1, [])    ,testCase "parseAmountQueryTerm" $ do      parseAmountQueryTerm "<0"        @?= Right (Lt,0) -- special case for convenience, since AbsLt 0 would be always false@@ -961,7 +1057,7 @@       assertBool "" $ not $ (Tag (toRegex' "foo foo") (Just $ toRegex' " ar ba ")) `matchesPosting` nullposting{ptags=[("foo foo","bar bar")]}     ,testCase "a tag match on a posting also sees inherited tags" $ assertBool "" $ (Tag (toRegex' "txntag") Nothing) `matchesPosting` nullposting{ptransaction=Just nulltransaction{ttags=[("txntag","")]}}     ,testCase "cur:" $ do-      let toSym = fromLeft (error' "No query opts") . either error' id . parseQueryTerm (fromGregorian 2000 01 01) . ("cur:"<>)+      let toSym = fst . either error' id . parseQueryTerm (fromGregorian 2000 01 01) . ("cur:"<>)       assertBool "" $ not $ toSym "$" `matchesPosting` nullposting{pamount=mixedAmount $ usd 1} -- becomes "^$$", ie testing for null symbol       assertBool "" $ (toSym "\\$") `matchesPosting` nullposting{pamount=mixedAmount $ usd 1} -- have to quote $ for regexpr       assertBool "" $ (toSym "shekels") `matchesPosting` nullposting{pamount=mixedAmount nullamt{acommodity="shekels"}}
Hledger/Read.hs view
@@ -80,6 +80,7 @@ import Hledger.Read.InputOptions import Hledger.Read.JournalReader as JournalReader import Hledger.Read.CsvReader (tests_CsvReader)+import Hledger.Read.RulesReader (tests_RulesReader) -- import Hledger.Read.TimedotReader (tests_TimedotReader) -- import Hledger.Read.TimeclockReader (tests_TimeclockReader) import Hledger.Utils@@ -308,4 +309,5 @@    tests_Common   ,tests_CsvReader   ,tests_JournalReader+  ,tests_RulesReader   ]
Hledger/Read/Common.hs view
@@ -128,7 +128,7 @@ import Data.Bifunctor (bimap, second) import Data.Char (digitToInt, isDigit, isSpace) import Data.Decimal (DecimalRaw (Decimal), Decimal)-import Data.Either (lefts, rights)+import Data.Either (rights) import Data.Function ((&)) import Data.Functor ((<&>), ($>), void) import Data.List (find, genericReplicate, union)@@ -198,7 +198,7 @@         -- Do we really need to do all this work just to get the requested end date? This is duplicating         -- much of reportOptsToSpec.         ropts = rawOptsToReportOpts day rawopts-        argsquery = lefts . rights . map (parseQueryTerm day) $ querystring_ ropts+        argsquery = map fst . rights . map (parseQueryTerm day) $ querystring_ ropts         datequery = simplifyQuery . filterQuery queryIsDate . And $ queryFromFlags ropts : argsquery          styles = either err id $ commodityStyleFromRawOpts rawopts@@ -214,6 +214,7 @@       ,new_save_          = True       ,pivot_             = stringopt "pivot" rawopts       ,forecast_          = forecastPeriodFromRawOpts day rawopts+      ,verbose_tags_      = boolopt "verbose-tags" rawopts       ,reportspan_        = DateSpan (Exact <$> queryStartDate False datequery) (Exact <$> queryEndDate False datequery)       ,auto_              = boolopt "auto" rawopts       ,infer_equity_      = boolopt "infer-equity" rawopts && conversionop_ ropts /= Just ToCost@@ -322,16 +323,18 @@       &   journalReverse                                 -- convert all lists to the order they were parsed       &   journalAddAccountTypes                         -- build a map of all known account types       &   journalApplyCommodityStyles                    -- Infer and apply commodity styles - should be done early-      <&> journalAddForecast (forecastPeriod iopts pj)   -- Add forecast transactions if enabled+      <&> journalAddForecast (verbose_tags_) (forecastPeriod iopts pj)   -- Add forecast transactions if enabled       <&> journalPostingsAddAccountTags                  -- Add account tags to postings, so they can be matched by auto postings.       >>= (if auto_ && not (null $ jtxnmodifiers pj)-              then journalAddAutoPostings _ioDay balancingopts_  -- Add auto postings if enabled, and account tags if needed-              else pure)-      -- >>= Right . dbg0With (concatMap (T.unpack.showTransaction).jtxns)  -- debug+            then journalAddAutoPostings verbose_tags_ _ioDay balancingopts_  -- Add auto postings if enabled, and account tags if needed+            else pure)+      -- XXX how to force debug output here ?+       -- >>= Right . dbg0With (concatMap (T.unpack.showTransaction).jtxns)+       -- >>= \j -> deepseq (concatMap (T.unpack.showTransaction).jtxns $ j) (return j)       >>= journalMarkRedundantCosts                      -- Mark redundant costs, to help journalBalanceTransactions ignore them       >>= journalBalanceTransactions balancingopts_                         -- Balance all transactions and maybe check balance assertions.       >>= (if infer_costs_  then journalInferCostsFromEquity else pure)     -- Maybe infer costs from equity postings where possible-      <&> (if infer_equity_ then journalAddInferredEquityPostings else id)  -- Maybe infer equity postings from costs where possible+      <&> (if infer_equity_ then journalAddInferredEquityPostings verbose_tags_ else id)  -- Maybe infer equity postings from costs where possible       <&> journalInferMarketPricesFromTransactions       -- infer market prices from commodity-exchanging transactions       <&> traceOrLogAt 6 ("journalFinalise: " <> takeFileName f)  -- debug logging       <&> dbgJournalAcctDeclOrder ("journalFinalise: " <> takeFileName f <> "   acct decls           : ")@@ -346,28 +349,29 @@     return j  -- | Apply any auto posting rules to generate extra postings on this journal's transactions.-journalAddAutoPostings :: Day -> BalancingOpts -> Journal -> Either String Journal-journalAddAutoPostings d bopts =+-- With a true first argument, adds visible tags to generated postings and modified transactions.+journalAddAutoPostings :: Bool -> Day -> BalancingOpts -> Journal -> Either String Journal+journalAddAutoPostings verbosetags d bopts =     -- Balance all transactions without checking balance assertions,     journalBalanceTransactions bopts{ignore_assertions_=True}     -- then add the auto postings     -- (Note adding auto postings after balancing means #893b fails;     -- adding them before balancing probably means #893a, #928, #938 fail.)-    >=> journalModifyTransactions d+    >=> journalModifyTransactions verbosetags d  -- | Generate periodic transactions from all periodic transaction rules in the journal. -- These transactions are added to the in-memory Journal (but not the on-disk file). -- -- The start & end date for generated periodic transactions are determined in -- a somewhat complicated way; see the hledger manual -> Periodic transactions.-journalAddForecast :: Maybe DateSpan -> Journal -> Journal-journalAddForecast Nothing             j = j-journalAddForecast (Just forecastspan) j = j{jtxns = jtxns j ++ forecasttxns}+journalAddForecast :: Bool -> Maybe DateSpan -> Journal -> Journal+journalAddForecast _ Nothing j = j+journalAddForecast verbosetags (Just forecastspan) j = j{jtxns = jtxns j ++ forecasttxns}   where     forecasttxns =         map (txnTieKnot . transactionTransformPostings (postingApplyCommodityStyles $ journalCommodityStyles j))       . filter (spanContainsDate forecastspan . tdate)-      . concatMap (`runPeriodicTransaction` forecastspan)+      . concatMap (\pt -> runPeriodicTransaction verbosetags pt forecastspan)       $ jperiodictxns j  setYear :: Year -> JournalParser m ()
Hledger/Read/CsvReader.hs view
@@ -3,1509 +3,72 @@ -- 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.---}--- Lots of haddocks in this file are for non-exported types.--- Here's a command that will render them:--- stack haddock hledger-lib --fast --no-haddock-deps --haddock-arguments='--ignore-all-exports' --open----- ** language-{-# LANGUAGE FlexibleContexts     #-}-{-# LANGUAGE FlexibleInstances    #-}-{-# LANGUAGE OverloadedStrings    #-}-{-# LANGUAGE RecordWildCards      #-}-{-# LANGUAGE ScopedTypeVariables  #-}-{-# LANGUAGE TypeFamilies         #-}-{-# LANGUAGE ViewPatterns         #-}----- ** exports-module Hledger.Read.CsvReader (-  -- * Reader-  reader,-  -- * Misc.-  CSV, CsvRecord, CsvValue,-  csvFileFor,-  rulesFileFor,-  parseRulesFile,-  printCSV,-  -- * Tests-  tests_CsvReader,-)-where----- ** imports-import Prelude hiding (Applicative(..))-import Control.Applicative (Applicative(..))-import Control.Monad              (unless, when, void)-import Control.Monad.Except       (ExceptT(..), liftEither, throwError)-import qualified Control.Monad.Fail as Fail-import Control.Monad.IO.Class     (MonadIO, liftIO)-import Control.Monad.State.Strict (StateT, get, modify', evalStateT)-import Control.Monad.Trans.Class  (lift)-import Data.Char                  (toLower, isDigit, isSpace, isAlphaNum, ord)-import Data.Bifunctor             (first)-import Data.Functor               ((<&>))-import Data.List (elemIndex, foldl', intersperse, mapAccumL, nub, sortOn)-import Data.List.Extra (groupOn)-import Data.Maybe (catMaybes, fromMaybe, isJust)-import Data.MemoUgly (memo)-import qualified Data.Set as S-import Data.Text (Text)-import qualified Data.Text as T-import qualified Data.Text.Encoding as T-import qualified Data.Text.IO as T-import qualified Data.Text.Lazy as TL-import qualified Data.Text.Lazy.Builder as TB-import Data.Time ( Day, TimeZone, UTCTime, LocalTime, ZonedTime(ZonedTime),-  defaultTimeLocale, getCurrentTimeZone, localDay, parseTimeM, utcToLocalTime, localTimeToUTC, zonedTimeToUTC)-import Safe (atMay, headMay, lastMay, readMay)-import System.Directory (doesFileExist)-import System.FilePath ((</>), takeDirectory, takeExtension, takeFileName)-import qualified Data.Csv as Cassava-import qualified Data.Csv.Parser.Megaparsec as CassavaMP-import qualified Data.ByteString as B-import qualified Data.ByteString.Lazy as BL-import Data.Foldable (asum, toList)-import Text.Megaparsec hiding (match, parse)-import Text.Megaparsec.Char (char, newline, string)-import Text.Megaparsec.Custom (parseErrorAt)-import Text.Printf (printf)--import Hledger.Data-import Hledger.Utils-import Hledger.Read.Common (aliasesFromOpts, Reader(..), InputOpts(..), amountp, statusp, journalFinalise, accountnamep )----- ** doctest setup--- $setup--- >>> :set -XOverloadedStrings----- ** some types--type CSV       = [CsvRecord]-type CsvRecord = [CsvValue]-type CsvValue  = Text----- ** reader--reader :: MonadIO m => Reader m-reader = Reader-  {rFormat     = "csv"-  ,rExtensions = ["csv","tsv","ssv"]-  ,rReadFn     = parse-  ,rParser    = error' "sorry, CSV files can't be included yet"  -- PARTIAL:-  }---- | Parse and post-process a "Journal" from CSV data, or give an error.--- Does not check balance assertions.--- XXX currently ignores the provided data, reads it from the file path instead.-parse :: InputOpts -> FilePath -> Text -> ExceptT String IO Journal-parse iopts f t = do-  let rulesfile = mrules_file_ iopts-  readJournalFromCsv rulesfile f t-  -- apply any command line account aliases. Can fail with a bad replacement pattern.-  >>= liftEither . journalApplyAliases (aliasesFromOpts iopts)-      -- journalFinalise assumes the journal's items are-      -- reversed, as produced by JournalReader's parser.-      -- But here they are already properly ordered. So we'd-      -- better preemptively reverse them once more. XXX inefficient-      . journalReverse-  >>= journalFinalise iopts{balancingopts_=(balancingopts_ iopts){ignore_assertions_=True}} f t----- ** reading rules files---- *** rules utilities---- 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---- | Given a CSV file path, what would normally be the corresponding rules file ?-rulesFileFor :: FilePath -> FilePath-rulesFileFor = (++ ".rules")---- | Given a CSV rules file path, what would normally be the corresponding CSV file ?-csvFileFor :: FilePath -> FilePath-csvFileFor = reverse . drop 6 . reverse--defaultRulesText :: FilePath -> Text-defaultRulesText csvfile = T.pack $ unlines-  ["# hledger csv conversion rules for " ++ csvFileFor (takeFileName csvfile)-  ,"# cf http://hledger.org/hledger.html#csv"-  ,""-  ,"account1 assets:bank:checking"-  ,""-  ,"fields date, description, amount1"-  ,""-  ,"#skip 1"-  ,"#newest-first"-  ,""-  ,"#date-format %-d/%-m/%Y"-  ,"#date-format %-m/%-d/%Y"-  ,"#date-format %Y-%h-%d"-  ,""-  ,"#currency $"-  ,""-  ,"if ITUNES"-  ," account2 expenses:entertainment"-  ,""-  ,"if (TO|FROM) SAVINGS"-  ," account2 assets:bank:savings\n"-  ]--addDirective :: (DirectiveName, Text) -> CsvRulesParsed -> CsvRulesParsed-addDirective d r = r{rdirectives=d:rdirectives r}--addAssignment :: (HledgerFieldName, FieldTemplate) -> CsvRulesParsed -> CsvRulesParsed-addAssignment a r = r{rassignments=a:rassignments r}--setIndexesAndAssignmentsFromList :: [CsvFieldName] -> CsvRulesParsed -> CsvRulesParsed-setIndexesAndAssignmentsFromList fs = addAssignmentsFromList fs . setCsvFieldIndexesFromList fs--setCsvFieldIndexesFromList :: [CsvFieldName] -> CsvRulesParsed -> CsvRulesParsed-setCsvFieldIndexesFromList fs r = r{rcsvfieldindexes=zip fs [1..]}--addAssignmentsFromList :: [CsvFieldName] -> CsvRulesParsed -> CsvRulesParsed-addAssignmentsFromList fs r = foldl' maybeAddAssignment r journalfieldnames-  where-    maybeAddAssignment rules f = (maybe id addAssignmentFromIndex $ elemIndex f fs) rules-      where-        addAssignmentFromIndex i = addAssignment (f, T.pack $ '%':show (i+1))--addConditionalBlock :: ConditionalBlock -> CsvRulesParsed -> CsvRulesParsed-addConditionalBlock b r = r{rconditionalblocks=b:rconditionalblocks r}--addConditionalBlocks :: [ConditionalBlock] -> CsvRulesParsed -> CsvRulesParsed-addConditionalBlocks bs r = r{rconditionalblocks=bs++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 dir0 content = mapM (expandLine dir0) (T.lines content) <&> T.unlines-  where-    expandLine dir1 line =-      case line of-        (T.stripPrefix "include " -> Just f) -> expandIncludes dir2 =<< T.readFile f'-          where-            f' = dir1 </> T.unpack (T.dropWhile isSpace f)-            dir2 = 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 HledgerParseErrorData) CsvRules--- parseCsvRules rulesfile s = runParser csvrulesfile nullrules{baseAccount=takeBaseName rulesfile} rulesfile s-parseCsvRules = runParser (evalStateT rulesp defrules)---- | 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"-  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' a = CsvRules' {-  rdirectives        :: [(DirectiveName,Text)],-    -- ^ 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-  rblocksassigning :: a -- (String -> [ConditionalBlock])-    -- ^ all conditional blocks which can potentially assign field with a given name (memoized)-}---- | Type used by parsers. Directives, assignments and conditional blocks--- are in the reverse order compared to what is in the file and rblocksassigning is non-functional,--- could not be used for processing CSV records yet-type CsvRulesParsed = CsvRules' ()---- | Type used after parsing is done. Directives, assignments and conditional blocks--- are in the same order as they were in the unput file and rblocksassigning is functional.--- Ready to be used for CSV record processing-type CsvRules = CsvRules' (Text -> [ConditionalBlock])--instance Eq CsvRules where-  r1 == r2 = (rdirectives r1, rcsvfieldindexes r1, rassignments r1) ==-             (rdirectives r2, rcsvfieldindexes r2, rassignments r2)---- Custom Show instance used for debug output: omit the rblocksassigning field, which isn't showable.-instance Show CsvRules where-  show r = "CsvRules { rdirectives = " ++ show (rdirectives r) ++-           ", rcsvfieldindexes = "     ++ show (rcsvfieldindexes r) ++-           ", rassignments = "         ++ show (rassignments r) ++-           ", rconditionalblocks = "   ++ show (rconditionalblocks r) ++-           " }"--type CsvRulesParser a = StateT CsvRulesParsed SimpleTextParser a---- | The keyword of a CSV rule - "fields", "skip", "if", etc.-type DirectiveName    = Text---- | CSV field name.-type CsvFieldName     = Text---- | 1-based CSV column number.-type CsvFieldIndex    = Int---- | Percent symbol followed by a CSV field name or column number. Eg: %date, %1.-type CsvFieldReference = Text---- | One of the standard hledger fields or pseudo-fields that can be assigned to.--- Eg date, account1, amount, amount1-in, date-format.-type HledgerFieldName = Text---- | A text value to be assigned to a hledger field, possibly--- containing csv field references to be interpolated.-type FieldTemplate    = Text---- | A strptime date parsing pattern, as supported by Data.Time.Format.-type DateFormat       = Text---- | A prefix for a matcher test, either & or none (implicit or).-data MatcherPrefix = And | None-  deriving (Show, Eq)---- | A single test for matching a CSV record, in one way or another.-data Matcher =-    RecordMatcher MatcherPrefix Regexp                          -- ^ match if this regexp matches the overall CSV record-  | FieldMatcher MatcherPrefix CsvFieldReference Regexp         -- ^ 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 :: CsvRulesParsed-defrules = CsvRules' {-  rdirectives=[],-  rcsvfieldindexes=[],-  rassignments=[],-  rconditionalblocks=[],-  rblocksassigning = ()-  }---- | Create CsvRules from the content parsed out of the rules file-mkrules :: CsvRulesParsed -> CsvRules-mkrules rules =-  let conditionalblocks = reverse $ rconditionalblocks rules-      maybeMemo = if length conditionalblocks >= 15 then memo else id-  in-    CsvRules' {-    rdirectives=reverse $ rdirectives rules,-    rcsvfieldindexes=rcsvfieldindexes rules,-    rassignments=reverse $ rassignments rules,-    rconditionalblocks=conditionalblocks,-    rblocksassigning = maybeMemo (\f -> filter (any ((==f).fst) . cbAssignments) conditionalblocks)-    }--matcherPrefix :: Matcher -> MatcherPrefix-matcherPrefix (RecordMatcher prefix _) = prefix-matcherPrefix (FieldMatcher prefix _ _) = prefix---- | Group matchers into associative pairs based on prefix, e.g.:---   A---   & B---   C---   D---   & E---   => [[A, B], [C], [D, E]]-groupedMatchers :: [Matcher] -> [[Matcher]]-groupedMatchers [] = []-groupedMatchers (x:xs) = (x:ys) : groupedMatchers zs-  where (ys, zs) = span (\y -> matcherPrefix y == And) xs----- *** rules parsers--{--Grammar for the CSV conversion rules, more or less:--RULES: RULE*--RULE: ( FIELD-LIST | FIELD-ASSIGNMENT | CONDITIONAL-BLOCK | SKIP | TIMEZONE | NEWEST-FIRST | INTRA-DAY-REVERSED | DATE-FORMAT | DECIMAL-MARK | COMMENT | BLANK ) NEWLINE--FIELD-LIST: fields SPACE FIELD-NAME ( SPACE? , SPACE? FIELD-NAME )*--FIELD-NAME: QUOTED-FIELD-NAME | BARE-FIELD-NAME--QUOTED-FIELD-NAME: " (any CHAR except double-quote)+ "--BARE-FIELD-NAME: any CHAR except space, tab, #, ;--FIELD-ASSIGNMENT: JOURNAL-FIELD ASSIGNMENT-SEPARATOR FIELD-VALUE--JOURNAL-FIELD: date | date2 | status | code | description | comment | account1 | account2 | amount | JOURNAL-PSEUDO-FIELD--JOURNAL-PSEUDO-FIELD: amount-in | amount-out | currency--ASSIGNMENT-SEPARATOR: SPACE | ( : SPACE? )--FIELD-VALUE: VALUE (possibly containing CSV-FIELD-REFERENCEs)--CSV-FIELD-REFERENCE: % CSV-FIELD--CSV-FIELD: ( FIELD-NAME | FIELD-NUMBER ) (corresponding to a CSV field)--FIELD-NUMBER: DIGIT+--CONDITIONAL-BLOCK: if ( FIELD-MATCHER NEWLINE )+ INDENTED-BLOCK--FIELD-MATCHER: ( CSV-FIELD-NAME SPACE? )? ( MATCHOP SPACE? )? PATTERNS--MATCHOP: ~--PATTERNS: ( NEWLINE REGEXP )* REGEXP--INDENTED-BLOCK: ( SPACE ( FIELD-ASSIGNMENT | COMMENT ) NEWLINE )+--REGEXP: ( NONSPACE CHAR* ) SPACE?--VALUE: SPACE? ( CHAR* ) SPACE?--COMMENT: SPACE? COMMENT-CHAR VALUE--COMMENT-CHAR: # | ; | *--NONSPACE: any CHAR not a SPACE-CHAR--BLANK: SPACE?--SPACE: SPACE-CHAR+--SPACE-CHAR: space | tab--CHAR: any character except newline--DIGIT: 0-9---}--rulesp :: CsvRulesParser CsvRules-rulesp = do-  _ <- many $ choice-    [blankorcommentlinep                                                <?> "blank or comment line"-    ,(directivep        >>= modify' . addDirective)                     <?> "directive"-    ,(fieldnamelistp    >>= modify' . setIndexesAndAssignmentsFromList) <?> "field name list"-    ,(fieldassignmentp  >>= modify' . addAssignment)                    <?> "field assignment"-    -- conditionalblockp backtracks because it shares "if" prefix with conditionaltablep.-    ,try (conditionalblockp >>= modify' . addConditionalBlock)          <?> "conditional block"-    -- 'reverse' is there to ensure that conditions are added in the order they listed in the file-    ,(conditionaltablep >>= modify' . addConditionalBlocks . reverse)   <?> "conditional table"-    ]-  eof-  mkrules <$> get--blankorcommentlinep :: CsvRulesParser ()-blankorcommentlinep = lift (dbgparse 8 "trying blankorcommentlinep") >> choiceInState [blanklinep, commentlinep]--blanklinep :: CsvRulesParser ()-blanklinep = lift skipNonNewlineSpaces >> newline >> return () <?> "blank line"--commentlinep :: CsvRulesParser ()-commentlinep = lift skipNonNewlineSpaces >> commentcharp >> lift restofline >> return () <?> "comment line"--commentcharp :: CsvRulesParser Char-commentcharp = oneOf (";#*" :: [Char])--directivep :: CsvRulesParser (DirectiveName, Text)-directivep = (do-  lift $ dbgparse 8 "trying directive"-  d <- choiceInState $ map (lift . string) directives-  v <- (((char ':' >> lift (many spacenonewline)) <|> lift (some spacenonewline)) >> directivevalp)-       <|> (optional (char ':') >> lift skipNonNewlineSpaces >> lift eolof >> return "")-  return (d, v)-  ) <?> "directive"--directives :: [Text]-directives =-  ["date-format"-  ,"decimal-mark"-  ,"separator"-  -- ,"default-account"-  -- ,"default-currency"-  ,"skip"-  ,"timezone"-  ,"newest-first"-  ,"intra-day-reversed"-  , "balance-type"-  ]--directivevalp :: CsvRulesParser Text-directivevalp = T.pack <$> anySingle `manyTill` lift eolof--fieldnamelistp :: CsvRulesParser [CsvFieldName]-fieldnamelistp = (do-  lift $ dbgparse 8 "trying fieldnamelist"-  string "fields"-  optional $ char ':'-  lift skipNonNewlineSpaces1-  let separator = lift skipNonNewlineSpaces >> char ',' >> lift skipNonNewlineSpaces-  f <- fromMaybe "" <$> optional fieldnamep-  fs <- some $ (separator >> fromMaybe "" <$> optional fieldnamep)-  lift restofline-  return . map T.toLower $ f:fs-  ) <?> "field name list"--fieldnamep :: CsvRulesParser Text-fieldnamep = quotedfieldnamep <|> barefieldnamep--quotedfieldnamep :: CsvRulesParser Text-quotedfieldnamep =-    char '"' *> takeWhile1P Nothing (`notElem` ("\"\n:;#~" :: [Char])) <* char '"'--barefieldnamep :: CsvRulesParser Text-barefieldnamep = takeWhile1P Nothing (`notElem` (" \t\n,;#~" :: [Char]))--fieldassignmentp :: CsvRulesParser (HledgerFieldName, FieldTemplate)-fieldassignmentp = do-  lift $ dbgparse 8 "trying fieldassignmentp"-  f <- journalfieldnamep-  v <- choiceInState [ assignmentseparatorp >> fieldvalp-                     , lift eolof >> return ""-                     ]-  return (f,v)-  <?> "field assignment"--journalfieldnamep :: CsvRulesParser Text-journalfieldnamep = do-  lift (dbgparse 8 "trying journalfieldnamep")-  choiceInState $ map (lift . string) journalfieldnames--maxpostings = 99---- Transaction fields and pseudo fields for CSV conversion.--- Names must precede any other name they contain, for the parser--- (amount-in before amount; date2 before date). TODO: fix-journalfieldnames =-  concat [[ "account" <> i-          ,"amount" <> i <> "-in"-          ,"amount" <> i <> "-out"-          ,"amount" <> i-          ,"balance" <> i-          ,"comment" <> i-          ,"currency" <> i-          ] | x <- [maxpostings, (maxpostings-1)..1], let i = T.pack $ show x]-  ++-  ["amount-in"-  ,"amount-out"-  ,"amount"-  ,"balance"-  ,"code"-  ,"comment"-  ,"currency"-  ,"date2"-  ,"date"-  ,"description"-  ,"status"-  ,"skip" -- skip and end are not really fields, but we list it here to allow conditional rules that skip records-  ,"end"-  ]--assignmentseparatorp :: CsvRulesParser ()-assignmentseparatorp = do-  lift $ dbgparse 8 "trying assignmentseparatorp"-  _ <- choiceInState [ lift skipNonNewlineSpaces >> char ':' >> lift skipNonNewlineSpaces-                     , lift skipNonNewlineSpaces1-                     ]-  return ()--fieldvalp :: CsvRulesParser Text-fieldvalp = do-  lift $ dbgparse 8 "trying fieldvalp"-  T.pack <$> anySingle `manyTill` lift eolof---- A conditional block: one or more matchers, one per line, followed by one or more indented rules.-conditionalblockp :: CsvRulesParser ConditionalBlock-conditionalblockp = do-  lift $ dbgparse 8 "trying conditionalblockp"-  -- "if\nMATCHER" or "if    \nMATCHER" or "if MATCHER"-  start <- getOffset-  string "if" >> ( (newline >> return Nothing)-                  <|> (lift skipNonNewlineSpaces1 >> optional newline))-  ms <- some matcherp-  as <- catMaybes <$>-    many (lift skipNonNewlineSpaces1 >>-          choice [ lift eolof >> return Nothing-                 , fmap Just fieldassignmentp-                 ])-  when (null as) $-    customFailure $ parseErrorAt start $  "start of conditional block found, but no assignment rules afterward\n(assignment rules in a conditional block should be indented)"-  return $ CB{cbMatchers=ms, cbAssignments=as}-  <?> "conditional block"---- A conditional table: "if" followed by separator, followed by some field names,--- followed by many lines, each of which has:--- one matchers, followed by field assignments (as many as there were fields)-conditionaltablep :: CsvRulesParser [ConditionalBlock]-conditionaltablep = do-  lift $ dbgparse 8 "trying conditionaltablep"-  start <- getOffset-  string "if"-  sep <- lift $ satisfy (\c -> not (isAlphaNum c || isSpace c))-  fields <- journalfieldnamep `sepBy1` (char sep)-  newline-  body <- flip manyTill (lift eolof) $ do-    off <- getOffset-    m <- matcherp' $ void $ char sep-    vs <- T.split (==sep) . T.pack <$> lift restofline-    if (length vs /= length fields)-      then customFailure $ parseErrorAt off $ ((printf "line of conditional table should have %d values, but this one has only %d" (length fields) (length vs)) :: String)-      else return (m,vs)-  when (null body) $-    customFailure $ parseErrorAt start $ "start of conditional table found, but no assignment rules afterward"-  return $ flip map body $ \(m,vs) ->-    CB{cbMatchers=[m], cbAssignments=zip fields vs}-  <?> "conditional table"---- A single matcher, on one line.-matcherp' :: CsvRulesParser () -> CsvRulesParser Matcher-matcherp' end = try (fieldmatcherp end) <|> recordmatcherp end--matcherp :: CsvRulesParser Matcher-matcherp = matcherp' (lift eolof)---- A single whole-record matcher.--- A pattern on the whole line, not beginning with a csv field reference.-recordmatcherp :: CsvRulesParser () -> CsvRulesParser Matcher-recordmatcherp end = do-  lift $ dbgparse 8 "trying recordmatcherp"-  -- pos <- currentPos-  -- _  <- optional (matchoperatorp >> lift skipNonNewlineSpaces >> optional newline)-  p <- matcherprefixp-  r <- regexp end-  return $ RecordMatcher p r-  -- when (null ps) $-  --   Fail.fail "start of record matcher found, but no patterns afterward\n(patterns should not be indented)"-  <?> "record matcher"---- | A single matcher for a specific field. A csv field reference--- (like %date or %1), and a pattern on the rest of the line,--- optionally space-separated. Eg:--- %description chez jacques-fieldmatcherp :: CsvRulesParser () -> CsvRulesParser Matcher-fieldmatcherp end = do-  lift $ dbgparse 8 "trying fieldmatcher"-  -- An optional fieldname (default: "all")-  -- f <- fromMaybe "all" `fmap` (optional $ do-  --        f' <- fieldnamep-  --        lift skipNonNewlineSpaces-  --        return f')-  p <- matcherprefixp-  f <- csvfieldreferencep <* lift skipNonNewlineSpaces-  -- optional operator.. just ~ (case insensitive infix regex) for now-  -- _op <- fromMaybe "~" <$> optional matchoperatorp-  lift skipNonNewlineSpaces-  r <- regexp end-  return $ FieldMatcher p f r-  <?> "field matcher"--matcherprefixp :: CsvRulesParser MatcherPrefix-matcherprefixp = do-  lift $ dbgparse 8 "trying matcherprefixp"-  (char '&' >> lift skipNonNewlineSpaces >> return And) <|> return None--csvfieldreferencep :: CsvRulesParser CsvFieldReference-csvfieldreferencep = do-  lift $ dbgparse 8 "trying csvfieldreferencep"-  char '%'-  T.cons '%' . textQuoteIfNeeded <$> fieldnamep---- A single regular expression-regexp :: CsvRulesParser () -> CsvRulesParser Regexp-regexp end = do-  lift $ dbgparse 8 "trying regexp"-  -- notFollowedBy matchoperatorp-  c <- lift nonspace-  cs <- anySingle `manyTill` end-  case toRegexCI . T.strip . T.pack $ c:cs of-       Left x -> Fail.fail $ "CSV parser: " ++ x-       Right x -> return x---- -- A match operator, indicating the type of match to perform.--- -- Currently just ~ meaning case insensitive infix regex match.--- matchoperatorp :: CsvRulesParser String--- matchoperatorp = fmap T.unpack $ choiceInState $ map string---   ["~"---   -- ,"!~"---   -- ,"="---   -- ,"!="---   ]----- ** reading csv files---- | 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 -> ExceptT String IO Journal-readJournalFromCsv Nothing "-" _ = throwError "please use --rules-file when reading CSV from stdin"-readJournalFromCsv mrulesfile csvfile csvdata = do-    -- parse the csv rules-    let rulesfile = fromMaybe (rulesFileFor csvfile) mrulesfile-    rulesfileexists <- liftIO $ doesFileExist rulesfile-    rulestext <- liftIO $ if rulesfileexists-      then do-        dbg6IO "using conversion rules file" rulesfile-        readFilePortably rulesfile >>= expandIncludes (takeDirectory rulesfile)-      else-        return $ defaultRulesText rulesfile-    rules <- liftEither $ parseAndValidateCsvRules rulesfile rulestext-    dbg6IO "csv rules" rules--    mtzin <- case getDirective "timezone" rules of-              Nothing -> return Nothing-              Just s  ->-                maybe (throwError $ "could not parse time zone: " ++ T.unpack s) (return.Just) $-                parseTimeM False defaultTimeLocale "%Z" $ T.unpack s-    tzout <- liftIO getCurrentTimeZone--    -- skip header lines, if there is a top-level skip rule-    skiplines <- case getDirective "skip" rules of-                      Nothing -> return 0-                      Just "" -> return 1-                      Just s  -> maybe (throwError $ "could not parse skip value: " ++ show s) return . readMay $ T.unpack s-    let csvdata' = T.unlines $ drop skiplines $ T.lines csvdata--    -- parse csv-    let-      -- parsec seems to fail if you pass it "-" here TODO: try again with megaparsec-      parsecfilename = if csvfile == "-" then "(stdin)" else csvfile-      separator =-        case getDirective "separator" rules >>= parseSeparator of-          Just c           -> c-          _ | ext == "ssv" -> ';'-          _ | ext == "tsv" -> '\t'-          _                -> ','-          where-            ext = map toLower $ drop 1 $ takeExtension csvfile-    dbg6IO "using separator" separator-    csv <- dbg7 "parseCsv" <$> parseCsv separator parsecfilename csvdata'-    records <- liftEither $ dbg7 "validateCsv" <$> validateCsv rules csv-    dbg6IO "first 3 csv records" $ take 3 records--    -- identify header lines-    -- let (headerlines, datalines) = identifyHeaderLines records-    --     mfieldnames = lastMay headerlines--    let-      -- convert CSV records to transactions, saving the CSV line numbers for error positions-      txns = dbg7 "csv txns" $ snd $ mapAccumL-                     (\pos r ->-                        let-                          SourcePos name line col = pos-                          line' = (mkPos . (+1) . unPos) line-                          pos' = SourcePos name line' col-                        in-                          (pos', transactionFromCsvRecord timesarezoned mtzin tzout pos rules r)-                     )-                     (initialPos parsecfilename) records-        where-          timesarezoned =-            case csvRule rules "date-format" of-              Just f | any (`T.isInfixOf` f) ["%Z","%z","%EZ","%Ez"] -> True-              _ -> False--      -- Do our best to ensure transactions will be ordered chronologically,-      -- from oldest to newest. This is done in several steps:-      -- 1. Intra-day order: if there's an "intra-day-reversed" rule,-      -- assume each day's CSV records were ordered in reverse of the overall date order,-      -- so reverse each day's txns.-      intradayreversed = dbg6 "intra-day-reversed" $ isJust $ getDirective "intra-day-reversed" rules-      txns1 = dbg7 "txns1" $-        (if intradayreversed then concatMap reverse . groupOn tdate else id) txns-      -- 2. Overall date order: now if there's a "newest-first" rule,-      -- or if there's multiple dates and the first is more recent than the last,-      -- assume CSV records were ordered newest dates first,-      -- so reverse all txns.-      newestfirst = dbg6 "newest-first" $ isJust $ getDirective "newest-first" rules-      mdatalooksnewestfirst = dbg6 "mdatalooksnewestfirst" $-        case nub $ map tdate txns of-          ds | length ds > 1 -> Just $ head ds > last ds-          _                  -> Nothing-      txns2 = dbg7 "txns2" $-        (if newestfirst || mdatalooksnewestfirst == Just True then reverse else id) txns1-      -- 3. Disordered dates: in case the CSV records were ordered by chaos,-      -- do a final sort by date. If it was only a few records out of order,-      -- this will hopefully refine any good ordering done by steps 1 and 2.-      txns3 = dbg7 "date-sorted csv txns" $ sortOn tdate txns2--    liftIO $ unless rulesfileexists $ do-      dbg1IO "creating conversion rules file" rulesfile-      T.writeFile rulesfile rulestext--    return nulljournal{jtxns=txns3}---- | Parse special separator names TAB and SPACE, or return the first--- character. Return Nothing on empty string-parseSeparator :: Text -> Maybe Char-parseSeparator = specials . T.toLower-  where specials "space" = Just ' '-        specials "tab"   = Just '\t'-        specials xs      = fst <$> T.uncons xs--parseCsv :: Char -> FilePath -> Text -> ExceptT String IO CSV-parseCsv separator filePath csvdata = ExceptT $-  case filePath of-    "-" -> parseCassava separator "(stdin)" <$> T.getContents-    _   -> return $ if T.null csvdata then Right mempty else 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.decodeUtf8--printCSV :: CSV -> TL.Text-printCSV = TB.toLazyText . unlinesB . map printRecord-    where printRecord = foldMap TB.fromText . intersperse "," . map printField-          printField = wrap "\"" "\"" . T.replace "\"" "\"\""---- | Return the cleaned up and validated CSV data (can be empty), or an error.-validateCsv :: CsvRules -> CSV -> Either String [CsvRecord]-validateCsv rules = validate . applyConditionalSkips . filternulls-  where-    filternulls = filter (/=[""])-    skipnum 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 $ T.unpack x)-    applyConditionalSkips [] = []-    applyConditionalSkips (r:rest) =-      case skipnum r of-        Nothing -> r:(applyConditionalSkips rest)-        Just cnt -> applyConditionalSkips (drop (cnt-1) rest)-    validate [] = Right []-    validate rs@(_first:_) = case lessthan2 of-        Just r  -> Left $ printf "CSV record %s has less than two fields" (show r)-        Nothing -> 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 =-  T.unlines $ catMaybes [ (("the "<>fld<>" rule is: ")<>) <$> getEffectiveAssignment rules record fld | fld <- journalfieldnames]---- | Look up the value (template) of a csv rule by rule keyword.-csvRule :: CsvRules -> DirectiveName -> Maybe FieldTemplate-csvRule rules = (`getDirective` rules)---- | Look up the value template assigned to a hledger field by field--- list/field assignment rules, taking into account the current record and--- conditional rules.-hledgerField :: CsvRules -> CsvRecord -> HledgerFieldName -> Maybe FieldTemplate-hledgerField = getEffectiveAssignment---- | Look up the final value assigned to a hledger field, with csv field--- references interpolated.-hledgerFieldValue :: CsvRules -> CsvRecord -> HledgerFieldName -> Maybe Text-hledgerFieldValue rules record = fmap (renderTemplate rules record) . hledgerField rules record--transactionFromCsvRecord :: Bool -> Maybe TimeZone -> TimeZone -> SourcePos -> CsvRules -> CsvRecord -> Transaction-transactionFromCsvRecord timesarezoned mtzin tzout sourcepos rules record = t-  where-    -----------------------------------------------------------------------    -- 1. Define some helpers:--    rule     = csvRule           rules        :: DirectiveName    -> Maybe FieldTemplate-    -- ruleval  = csvRuleValue      rules record :: DirectiveName    -> Maybe String-    field    = hledgerField      rules record :: HledgerFieldName -> Maybe FieldTemplate-    fieldval = hledgerFieldValue rules record :: HledgerFieldName -> Maybe Text-    mdateformat = rule "date-format"-    parsedate = parseDateWithCustomOrDefaultFormats timesarezoned mtzin tzout mdateformat-    mkdateerror datefield datevalue mdateformat' = T.unpack $ T.unlines-      ["error: could not parse \""<>datevalue<>"\" as a date using date format "-        <>maybe "\"YYYY/M/D\", \"YYYY-M-D\" or \"YYYY.M.D\"" (T.pack . show) mdateformat'-      ,showRecord record-      ,"the "<>datefield<>" rule is:   "<>(fromMaybe "required, but missing" $ field datefield)-      ,"the date-format is: "<>fromMaybe "unspecified" mdateformat'-      ,"you may need to "-        <>"change your "<>datefield<>" rule, "-        <>maybe "add a" (const "change your") mdateformat'<>" date-format rule, "-        <>"or "<>maybe "add a" (const "change your") mskip<>" skip rule"-      ,"for m/d/y or d/m/y dates, use date-format %-m/%-d/%Y or date-format %-d/%-m/%Y"-      ]-      where-        mskip = rule "skip"--    -----------------------------------------------------------------------    -- 2. Gather values needed for the transaction itself, by evaluating the-    -- field assignment rules using the CSV record's data, and parsing a bit-    -- more where needed (dates, status).--    date        = fromMaybe "" $ fieldval "date"-    -- PARTIAL:-    date'       = fromMaybe (error' $ mkdateerror "date" date mdateformat) $ parsedate date-    mdate2      = fieldval "date2"-    mdate2'     = (maybe (error' $ mkdateerror "date2" (fromMaybe "" mdate2) mdateformat) Just . parsedate) =<< mdate2-    status      =-      case fieldval "status" of-        Nothing -> Unmarked-        Just s  -> either statuserror id $ runParser (statusp <* eof) "" s-          where-            statuserror err = error' . T.unpack $ T.unlines-              ["error: could not parse \""<>s<>"\" as a cleared status (should be *, ! or empty)"-              ,"the parse error is:      "<>T.pack (customErrorBundlePretty err)-              ]-    code        = maybe "" singleline' $ fieldval "code"-    description = maybe "" singleline' $ fieldval "description"-    comment     = maybe "" unescapeNewlines $ fieldval "comment"-    precomment  = maybe "" unescapeNewlines $ fieldval "precomment"--    singleline' = T.unwords . filter (not . T.null) . map T.strip . T.lines-    unescapeNewlines = T.intercalate "\n" . T.splitOn "\\n"--    -----------------------------------------------------------------------    -- 3. Generate the postings for which an account has been assigned-    -- (possibly indirectly due to an amount or balance assignment)--    p1IsVirtual = (accountNamePostingType <$> fieldval "account1") == Just VirtualPosting-    ps = [p | n <- [1..maxpostings]-         ,let cmt  = maybe "" unescapeNewlines $ fieldval ("comment"<> T.pack (show n))-         ,let currency = fromMaybe "" (fieldval ("currency"<> T.pack (show n)) <|> fieldval "currency")-         ,let mamount  = getAmount rules record currency p1IsVirtual n-         ,let mbalance = getBalance rules record currency n-         ,Just (acct,isfinal) <- [getAccount rules record mamount mbalance n]  -- skips Nothings-         ,let acct' | not isfinal && acct==unknownExpenseAccount &&-                      fromMaybe False (mamount >>= isNegativeMixedAmount) = unknownIncomeAccount-                    | otherwise = acct-         ,let p = nullposting{paccount          = accountNameWithoutPostingType acct'-                             ,pamount           = fromMaybe missingmixedamt mamount-                             ,ptransaction      = Just t-                             ,pbalanceassertion = mkBalanceAssertion rules record <$> mbalance-                             ,pcomment          = cmt-                             ,ptype             = accountNamePostingType acct-                             }-         ]--    -----------------------------------------------------------------------    -- 4. Build the transaction (and name it, so the postings can reference it).--    t = nulltransaction{-           tsourcepos        = (sourcepos, sourcepos)  -- the CSV line number-          ,tdate             = date'-          ,tdate2            = mdate2'-          ,tstatus           = status-          ,tcode             = code-          ,tdescription      = description-          ,tcomment          = comment-          ,tprecedingcomment = precomment-          ,tpostings         = ps-          }---- | Figure out the amount specified for posting N, if any.--- A currency symbol to prepend to the amount, if any, is provided,--- and whether posting 1 requires balancing or not.--- This looks for a non-empty amount value assigned to "amountN", "amountN-in", or "amountN-out".--- For postings 1 or 2 it also looks at "amount", "amount-in", "amount-out".--- If more than one of these has a value, it looks for one that is non-zero.--- If there's multiple non-zeros, or no non-zeros but multiple zeros, it throws an error.-getAmount :: CsvRules -> CsvRecord -> Text -> Bool -> Int -> Maybe MixedAmount-getAmount rules record currency p1IsVirtual n =-  -- Warning! Many tricky corner cases here.-  -- Keep synced with:-  -- hledger_csv.m4.md -> CSV FORMAT -> "amount", "Setting amounts",-  -- hledger/test/csv.test -> 13, 31-34-  let-    unnumberedfieldnames = ["amount","amount-in","amount-out"]--    -- amount field names which can affect this posting-    fieldnames = map (("amount"<> T.pack (show n))<>) ["","-in","-out"]-                 -- For posting 1, also recognise the old amount/amount-in/amount-out names.-                 -- For posting 2, the same but only if posting 1 needs balancing.-                 ++ if n==1 || n==2 && not p1IsVirtual then unnumberedfieldnames else []--    -- assignments to any of these field names with non-empty values-    assignments = [(f,a') | f <- fieldnames-                          , Just v <- [T.strip . renderTemplate rules record <$> hledgerField rules record f]-                          , not $ T.null v-                          -- XXX maybe ignore rule-generated values like "", "-", "$", "-$", "$-" ? cf CSV FORMAT -> "amount", "Setting amounts",-                          , let a = parseAmount rules record currency v-                          -- With amount/amount-in/amount-out, in posting 2,-                          -- flip the sign and convert to cost, as they did before 1.17-                          , let a' = if f `elem` unnumberedfieldnames && n==2 then mixedAmountCost (maNegate a) else a-                          ]--    -- if any of the numbered field names are present, discard all the unnumbered ones-    discardUnnumbered xs = if null numbered then xs else numbered-      where-        numbered = filter (T.any isDigit . fst) xs--    -- discard all zero amounts, unless all amounts are zero, in which case discard all but the first-    discardExcessZeros xs = if null nonzeros then take 1 xs else nonzeros-      where-        nonzeros = filter (not . mixedAmountLooksZero . snd) xs--    -- for -out fields, flip the sign  XXX unless it's already negative ? back compat issues / too confusing ?-    negateIfOut f = if "-out" `T.isSuffixOf` f then maNegate else id--  in case discardExcessZeros $ discardUnnumbered assignments of-      []      -> Nothing-      [(f,a)] -> Just $ negateIfOut f a-      fs      -> error' . T.unpack . textChomp . T.unlines $  -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-          -- PARTIAL:-        ["in CSV rules:"-        ,"While processing " <> showRecord record-        ,"while calculating amount for posting " <> T.pack (show n)-        ] ++-        ["rule \"" <> f <> " " <>-          fromMaybe "" (hledgerField rules record f) <>-          "\" assigned value \"" <> wbToText (showMixedAmountB noColour a) <> "\"" -- XXX not sure this is showing all the right info-          | (f,a) <- fs-        ] ++-        [""-        ,"Multiple non-zero amounts were assigned for an amount field."-        ,"Please ensure just one non-zero amount is assigned, perhaps with an if rule."-        ,"See also: https://hledger.org/hledger.html#setting-amounts"-        ,"(hledger manual -> CSV format -> Tips -> Setting amounts)"-        ]--- | Figure out the expected balance (assertion or assignment) specified for posting N,--- if any (and its parse position).-getBalance :: CsvRules -> CsvRecord -> Text -> Int -> Maybe (Amount, SourcePos)-getBalance rules record currency n = do-  v <- (fieldval ("balance"<> T.pack (show n))-        -- for posting 1, also recognise the old field name-        <|> if n==1 then fieldval "balance" else Nothing)-  case v of-    "" -> Nothing-    s  -> Just (-            parseBalanceAmount rules record currency n s-           ,initialPos ""  -- parse position to show when assertion fails,-           )               -- XXX the csv record's line number would be good-  where-    fieldval = fmap T.strip . hledgerFieldValue rules record :: HledgerFieldName -> Maybe Text---- | Given a non-empty amount string (from CSV) to parse, along with a--- possibly non-empty currency symbol to prepend,--- parse as a hledger MixedAmount (as in journal format), or raise an error.--- The whole CSV record is provided for the error message.-parseAmount :: CsvRules -> CsvRecord -> Text -> Text -> MixedAmount-parseAmount rules record currency s =-    either mkerror mixedAmount $  -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-      -- PARTIAL:-    runParser (evalStateT (amountp <* eof) journalparsestate) "" $-    currency <> simplifySign s-  where-    journalparsestate = nulljournal{jparsedecimalmark=parseDecimalMark rules}-    mkerror e = error' . T.unpack $ T.unlines-      ["error: could not parse \"" <> s <> "\" as an amount"-      ,showRecord record-      ,showRules rules record-      -- ,"the default-currency is: "++fromMaybe "unspecified" (getDirective "default-currency" rules)-      ,"the parse error is:      " <> T.pack (customErrorBundlePretty e)-      ,"you may need to \-        \change your amount*, balance*, or currency* rules, \-        \or add or change your skip rule"-      ]---- XXX unify these ^v---- | Almost but not quite the same as parseAmount.--- Given a non-empty amount string (from CSV) to parse, along with a--- possibly non-empty currency symbol to prepend,--- parse as a hledger Amount (as in journal format), or raise an error.--- The CSV record and the field's numeric suffix are provided for the error message.-parseBalanceAmount :: CsvRules -> CsvRecord -> Text -> Int -> Text -> Amount-parseBalanceAmount rules record currency n s =-  either (mkerror n s) id $-    runParser (evalStateT (amountp <* eof) journalparsestate) "" $-    currency <> simplifySign s-                  -- the csv record's line number would be good-  where-    journalparsestate = nulljournal{jparsedecimalmark=parseDecimalMark rules}-    mkerror n' s' e = error' . T.unpack $ T.unlines-      ["error: could not parse \"" <> s' <> "\" as balance"<> T.pack (show n') <> " amount"-      ,showRecord record-      ,showRules rules record-      -- ,"the default-currency is: "++fromMaybe "unspecified" mdefaultcurrency-      ,"the parse error is:      "<> T.pack (customErrorBundlePretty e)-      ]---- Read a valid decimal mark from the decimal-mark rule, if any.--- If the rule is present with an invalid argument, raise an error.-parseDecimalMark :: CsvRules -> Maybe DecimalMark-parseDecimalMark rules = do-    s <- rules `csvRule` "decimal-mark"-    case T.uncons s of-        Just (c, rest) | T.null rest && isDecimalMark c -> return c-        _ -> error' . T.unpack $ "decimal-mark's argument should be \".\" or \",\" (not \""<>s<>"\")"---- | Make a balance assertion for the given amount, with the given parse--- position (to be shown in assertion failures), with the assertion type--- possibly set by a balance-type rule.--- The CSV rules and current record are also provided, to be shown in case--- balance-type's argument is bad (XXX refactor).-mkBalanceAssertion :: CsvRules -> CsvRecord -> (Amount, SourcePos) -> BalanceAssertion-mkBalanceAssertion rules record (amt, pos) = assrt{baamount=amt, baposition=pos}-  where-    assrt =-      case getDirective "balance-type" rules of-        Nothing    -> nullassertion-        Just "="   -> nullassertion-        Just "=="  -> nullassertion{batotal=True}-        Just "=*"  -> nullassertion{bainclusive=True}-        Just "==*" -> nullassertion{batotal=True, bainclusive=True}-        Just x     -> error' . T.unpack $ T.unlines  -- PARTIAL:-          [ "balance-type \"" <> x <>"\" is invalid. Use =, ==, =* or ==*."-          , showRecord record-          , showRules rules record-          ]---- | Figure out the account name specified for posting N, if any.--- And whether it is the default unknown account (which may be--- improved later) or an explicitly set account (which may not).-getAccount :: CsvRules -> CsvRecord -> Maybe MixedAmount -> Maybe (Amount, SourcePos) -> Int -> Maybe (AccountName, Bool)-getAccount rules record mamount mbalance n =-  let-    fieldval = hledgerFieldValue rules record :: HledgerFieldName -> Maybe Text-    maccount = T.strip <$> fieldval ("account"<> T.pack (show n))-  in case maccount of-    -- accountN is set to the empty string - no posting will be generated-    Just "" -> Nothing-    -- accountN is set (possibly to "expenses:unknown"! #1192) - mark it final-    Just a  ->-      -- Check it and reject if invalid.. sometimes people try-      -- to set an amount or comment along with the account name.-      case parsewith (accountnamep >> eof) a of-        Left e  -> usageError $ errorBundlePretty e-        Right _ -> Just (a, True)-    -- accountN is unset-    Nothing ->-      case (mamount, mbalance) of-        -- amountN is set, or implied by balanceN - set accountN to-        -- the default unknown account ("expenses:unknown") and-        -- allow it to be improved later-        (Just _, _) -> Just (unknownExpenseAccount, False)-        (_, Just _) -> Just (unknownExpenseAccount, False)-        -- amountN is also unset - no posting will be generated-        (Nothing, Nothing) -> Nothing---- | Default account names to use when needed.-unknownExpenseAccount = "expenses:unknown"-unknownIncomeAccount  = "income:unknown"--type CsvAmountString = Text---- | Canonicalise the sign in a CSV amount string.--- Such strings can have a minus sign, parentheses (equivalent to minus),--- or any two of these (which cancel out),--- or a plus sign (which is removed),--- or any sign by itself with no following number (which is removed).--- See hledger > CSV FORMAT > Tips > Setting amounts.------ These are supported (note, not every possibile combination):------ >>> simplifySign "1"--- "1"--- >>> simplifySign "+1"--- "1"--- >>> simplifySign "-1"--- "-1"--- >>> simplifySign "(1)"--- "-1"--- >>> simplifySign "--1"--- "1"--- >>> simplifySign "-(1)"--- "1"--- >>> simplifySign "-+1"--- "-1"--- >>> simplifySign "(-1)"--- "1"--- >>> simplifySign "((1))"--- "1"--- >>> simplifySign "-"--- ""--- >>> simplifySign "()"--- ""--- >>> simplifySign "+"--- ""-simplifySign :: CsvAmountString -> CsvAmountString-simplifySign amtstr-  | Just (' ',t) <- T.uncons amtstr = simplifySign t-  | Just (t,' ') <- T.unsnoc amtstr = simplifySign t-  | Just ('(',t) <- T.uncons amtstr, Just (amt,')') <- T.unsnoc t = simplifySign $ negateStr amt-  | Just ('-',b) <- T.uncons amtstr, Just ('(',t) <- T.uncons b, Just (amt,')') <- T.unsnoc t = simplifySign amt-  | Just ('-',m) <- T.uncons amtstr, Just ('-',amt) <- T.uncons m = amt-  | Just ('-',m) <- T.uncons amtstr, Just ('+',amt) <- T.uncons m = negateStr amt-  | amtstr `elem` ["-","+","()"] = ""-  | Just ('+',amt) <- T.uncons amtstr = simplifySign amt-  | otherwise = amtstr--negateStr :: Text -> Text-negateStr amtstr = case T.uncons amtstr of-    Just ('-',s) -> s-    _            -> T.cons '-' amtstr---- | Show a (approximate) recreation of the original CSV record.-showRecord :: CsvRecord -> Text-showRecord r = "CSV record: "<>T.intercalate "," (map (wrap "\"" "\"") r)---- | Given the conversion rules, a CSV record and a hledger field name, find--- the value template ultimately assigned to this field, if any, by a field--- assignment at top level or in a conditional block matching this record.------ Note conditional blocks' patterns are matched against an approximation of the--- CSV record: all the field values, without enclosing quotes, comma-separated.----getEffectiveAssignment :: CsvRules -> CsvRecord -> HledgerFieldName -> Maybe FieldTemplate-getEffectiveAssignment rules record f = lastMay $ map snd $ assignments-  where-    -- all active assignments to field f, in order-    assignments = dbg9 "csv assignments" $ filter ((==f).fst) $ toplevelassignments ++ conditionalassignments-      where-        -- all top level field assignments-        toplevelassignments    = rassignments rules-        -- all field assignments in conditional blocks assigning to field f and active for the current csv record-        conditionalassignments = concatMap cbAssignments $ filter isBlockActive $ (rblocksassigning rules) f-          where-            -- does this conditional block match the current csv record ?-            isBlockActive :: ConditionalBlock -> Bool-            isBlockActive CB{..} = any (all matcherMatches) $ groupedMatchers cbMatchers-              where-                -- does this individual matcher match the current csv record ?-                matcherMatches :: Matcher -> Bool-                matcherMatches (RecordMatcher _ pat) = regexMatchText pat' wholecsvline-                  where-                    pat' = dbg7 "regex" pat-                    -- A synthetic whole CSV record to match against. Note, this can be-                    -- different from the original CSV data:-                    -- - any whitespace surrounding field values is preserved-                    -- - any quotes enclosing field values are removed-                    -- - and the field separator is always comma-                    -- which means that a field containing a comma will look like two fields.-                    wholecsvline = dbg7 "wholecsvline" $ T.intercalate "," record-                matcherMatches (FieldMatcher _ csvfieldref pat) = regexMatchText pat csvfieldvalue-                  where-                    -- the value of the referenced CSV field to match against.-                    csvfieldvalue = dbg7 "csvfieldvalue" $ replaceCsvFieldReference rules record csvfieldref---- | Render a field assignment's template, possibly interpolating referenced--- CSV field values. Outer whitespace is removed from interpolated values.-renderTemplate ::  CsvRules -> CsvRecord -> FieldTemplate -> Text-renderTemplate rules record t = maybe t mconcat $ parseMaybe-    (many $ takeWhile1P Nothing (/='%')-        <|> replaceCsvFieldReference rules record <$> referencep)-    t-  where-    referencep = liftA2 T.cons (char '%') (takeWhile1P (Just "reference") isFieldNameChar) :: Parsec HledgerParseErrorData Text Text-    isFieldNameChar c = isAlphaNum c || c == '_' || c == '-'---- | Replace something that looks like a reference to a csv field ("%date" or "%1)--- with that field's value. If it doesn't look like a field reference, or if we--- can't find such a field, replace it with the empty string.-replaceCsvFieldReference :: CsvRules -> CsvRecord -> CsvFieldReference -> Text-replaceCsvFieldReference rules record s = case T.uncons s of-    Just ('%', fieldname) -> fromMaybe "" $ csvFieldValue rules record fieldname-    _                     -> s---- | Get the (whitespace-stripped) value of a CSV field, identified by its name or--- column number, ("date" or "1"), from the given CSV record, if such a field exists.-csvFieldValue :: CsvRules -> CsvRecord -> CsvFieldName -> Maybe Text-csvFieldValue rules record fieldname = do-  fieldindex <--    if T.all isDigit fieldname -    then readMay $ T.unpack fieldname-    else lookup (T.toLower fieldname) $ rcsvfieldindexes rules-  T.strip <$> atMay record (fieldindex-1)---- | Parse the date string using the specified date-format, or if unspecified--- the "simple date" formats (YYYY/MM/DD, YYYY-MM-DD, YYYY.MM.DD, leading--- zeroes optional). If a timezone is provided, we assume the DateFormat--- produces a zoned time and we localise that to the given timezone.-parseDateWithCustomOrDefaultFormats :: Bool -> Maybe TimeZone -> TimeZone -> Maybe DateFormat -> Text -> Maybe Day-parseDateWithCustomOrDefaultFormats timesarezoned mtzin tzout mformat s = localdate <$> mutctime-  -- this time code can probably be simpler, I'm just happy to get out alive-  where-    localdate :: UTCTime -> Day =-      localDay .-      dbg7 ("time in output timezone "++show tzout) .-      utcToLocalTime tzout-    mutctime :: Maybe UTCTime = asum $ map parseWithFormat formats--    parseWithFormat :: String -> Maybe UTCTime-    parseWithFormat fmt =-      if timesarezoned-      then-        dbg7 "zoned CSV time, expressed as UTC" $-        parseTimeM True defaultTimeLocale fmt $ T.unpack s :: Maybe UTCTime-      else-        -- parse as a local day and time; then if an input timezone is provided,-        -- assume it's in that, otherwise assume it's in the output timezone;-        -- then convert to UTC like the above-        let-          mlocaltime =-            fmap (dbg7 "unzoned CSV time") $-            parseTimeM True defaultTimeLocale fmt $ T.unpack s :: Maybe LocalTime-          localTimeAsZonedTime tz lt =  ZonedTime lt tz-        in-          case mtzin of-            Just tzin ->-              (dbg7 ("unzoned CSV time, declared as "++show tzin++ ", expressed as UTC") . -              localTimeToUTC tzin)-              <$> mlocaltime-            Nothing ->-              (dbg7 ("unzoned CSV time, treated as "++show tzout++ ", expressed as UTC") .-                zonedTimeToUTC .-                localTimeAsZonedTime tzout)-              <$> mlocaltime--    formats = map T.unpack $ maybe-               ["%Y/%-m/%-d"-               ,"%Y-%-m-%-d"-               ,"%Y.%-m.%-d"-               -- ,"%-m/%-d/%Y"-                -- ,parseTimeM TruedefaultTimeLocale "%Y/%m/%e" (take 5 s ++ "0" ++ drop 5 s)-                -- ,parseTimeM TruedefaultTimeLocale "%Y-%m-%e" (take 5 s ++ "0" ++ drop 5 s)-                -- ,parseTimeM TruedefaultTimeLocale "%m/%e/%Y" ('0':s)-                -- ,parseTimeM TruedefaultTimeLocale "%m-%e-%Y" ('0':s)-               ]-               (:[])-                mformat----- ** tests--tests_CsvReader = testGroup "CsvReader" [-   testGroup "parseCsvRules" [-     testCase "empty file" $-      parseCsvRules "unknown" "" @?= Right (mkrules defrules)-   ]-  ,testGroup "rulesp" [-     testCase "trailing comments" $-      parseWithState' defrules rulesp "skip\n# \n#\n" @?= Right (mkrules $ defrules{rdirectives = [("skip","")]})--    ,testCase "trailing blank lines" $-      parseWithState' defrules rulesp "skip\n\n  \n" @?= (Right (mkrules $ defrules{rdirectives = [("skip","")]}))--    ,testCase "no final newline" $-      parseWithState' defrules rulesp "skip" @?= (Right (mkrules $ defrules{rdirectives=[("skip","")]}))--    ,testCase "assignment with empty value" $-      parseWithState' defrules rulesp "account1 \nif foo\n  account2 foo\n" @?=-        (Right (mkrules $ defrules{rassignments = [("account1","")], rconditionalblocks = [CB{cbMatchers=[RecordMatcher None (toRegex' "foo")],cbAssignments=[("account2","foo")]}]}))-   ]-  ,testGroup "conditionalblockp" [-    testCase "space after conditional" $ -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-       -- #1120-      parseWithState' defrules conditionalblockp "if a\n account2 b\n \n" @?=-        (Right $ CB{cbMatchers=[RecordMatcher None $ toRegexCI' "a"],cbAssignments=[("account2","b")]})--  ,testGroup "csvfieldreferencep" [-    testCase "number" $ parseWithState' defrules csvfieldreferencep "%1" @?= (Right "%1")-   ,testCase "name" $ parseWithState' defrules csvfieldreferencep "%date" @?= (Right "%date")-   ,testCase "quoted name" $ parseWithState' defrules csvfieldreferencep "%\"csv date\"" @?= (Right "%\"csv date\"")-   ]--  ,testGroup "matcherp" [--    testCase "recordmatcherp" $-      parseWithState' defrules matcherp "A A\n" @?= (Right $ RecordMatcher None $ toRegexCI' "A A")--   ,testCase "recordmatcherp.starts-with-&" $-      parseWithState' defrules matcherp "& A A\n" @?= (Right $ RecordMatcher And $ toRegexCI' "A A")--   ,testCase "fieldmatcherp.starts-with-%" $-      parseWithState' defrules matcherp "description A A\n" @?= (Right $ RecordMatcher None $ toRegexCI' "description A A")--   ,testCase "fieldmatcherp" $-      parseWithState' defrules matcherp "%description A A\n" @?= (Right $ FieldMatcher None "%description" $ toRegexCI' "A A")--   ,testCase "fieldmatcherp.starts-with-&" $-      parseWithState' defrules matcherp "& %description A A\n" @?= (Right $ FieldMatcher And "%description" $ toRegexCI' "A A")--   -- ,testCase "fieldmatcherp with operator" $-   --    parseWithState' defrules matcherp "%description ~ A A\n" @?= (Right $ FieldMatcher "%description" "A A")--   ]--  ,testGroup "getEffectiveAssignment" [-    let rules = mkrules $ defrules {rcsvfieldindexes=[("csvdate",1)],rassignments=[("date","%csvdate")]}--    in testCase "toplevel" $ getEffectiveAssignment rules ["a","b"] "date" @?= (Just "%csvdate")--   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a"] [("date","%csvdate")]]}-    in testCase "conditional" $ getEffectiveAssignment rules ["a","b"] "date" @?= (Just "%csvdate")--   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1),("description",2)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a", FieldMatcher None "%description" $ toRegex' "b"] [("date","%csvdate")]]}-    in testCase "conditional-with-or-a" $ getEffectiveAssignment rules ["a"] "date" @?= (Just "%csvdate")--   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1),("description",2)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a", FieldMatcher None "%description" $ toRegex' "b"] [("date","%csvdate")]]}-    in testCase "conditional-with-or-b" $ getEffectiveAssignment rules ["_", "b"] "date" @?= (Just "%csvdate")--   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1),("description",2)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a", FieldMatcher And "%description" $ toRegex' "b"] [("date","%csvdate")]]}-    in testCase "conditional.with-and" $ getEffectiveAssignment rules ["a", "b"] "date" @?= (Just "%csvdate")--   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1),("description",2)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a", FieldMatcher And "%description" $ toRegex' "b", FieldMatcher None "%description" $ toRegex' "c"] [("date","%csvdate")]]}-    in testCase "conditional.with-and-or" $ getEffectiveAssignment rules ["_", "c"] "date" @?= (Just "%csvdate")--   ]--  ]-- ]+A reader for CSV (character-separated) data.+This also reads a rules file to help interpret the CSV data.++-}++--- ** language+{-# LANGUAGE FlexibleContexts     #-}+{-# LANGUAGE FlexibleInstances    #-}+{-# LANGUAGE OverloadedStrings    #-}+{-# LANGUAGE ScopedTypeVariables  #-}+{-# LANGUAGE TypeFamilies         #-}++--- ** exports+module Hledger.Read.CsvReader (+  -- * Reader+  reader,+  -- * Tests+  tests_CsvReader,+)+where++--- ** imports+import Prelude hiding (Applicative(..))+import Control.Monad.Except       (ExceptT(..), liftEither)+import Control.Monad.IO.Class     (MonadIO)+import Data.Text (Text)++import Hledger.Data+import Hledger.Utils+import Hledger.Read.Common (aliasesFromOpts, Reader(..), InputOpts(..), journalFinalise)+import Hledger.Read.RulesReader (readJournalFromCsv)++--- ** doctest setup+-- $setup+-- >>> :set -XOverloadedStrings++--- ** reader++reader :: MonadIO m => Reader m+reader = Reader+  {rFormat     = "csv"+  ,rExtensions = ["csv","tsv","ssv"]+  ,rReadFn     = parse+  ,rParser     = error' "sorry, CSV files can't be included yet"  -- PARTIAL:+  }++-- | Parse and post-process a "Journal" from CSV data, or give an error.+-- This currently ignores the provided data, and reads it from the file path instead.+-- This file path is normally the CSV(/SSV/TSV) data file, and a corresponding rules file is inferred.+-- But it can also be the rules file, in which case the corresponding data file is inferred.+-- This does not check balance assertions.+parse :: InputOpts -> FilePath -> Text -> ExceptT String IO Journal+parse iopts f t = do+  let mrulesfile = mrules_file_ iopts+  readJournalFromCsv (Right <$> mrulesfile) f t+  -- apply any command line account aliases. Can fail with a bad replacement pattern.+  >>= liftEither . journalApplyAliases (aliasesFromOpts iopts)+      -- journalFinalise assumes the journal's items are+      -- reversed, as produced by JournalReader's parser.+      -- But here they are already properly ordered. So we'd+      -- better preemptively reverse them once more. XXX inefficient+      . journalReverse+  >>= journalFinalise iopts{balancingopts_=(balancingopts_ iopts){ignore_assertions_=True}} f t++--- ** tests++tests_CsvReader = testGroup "CsvReader" [+  ]+
+ Hledger/Read/CsvUtils.hs view
@@ -0,0 +1,49 @@+--- * -*- outline-regexp:"--- \\*"; -*-+--- ** doc+{-|++CSV utilities.++-}++--- ** language+{-# LANGUAGE OverloadedStrings    #-}++--- ** exports+module Hledger.Read.CsvUtils (+  CSV, CsvRecord, CsvValue,+  printCSV,+  -- * Tests+  tests_CsvUtils,+)+where++--- ** imports+import Prelude hiding (Applicative(..))+import Data.List (intersperse)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Lazy as TL+import qualified Data.Text.Lazy.Builder as TB++import Hledger.Utils++--- ** doctest setup+-- $setup+-- >>> :set -XOverloadedStrings++type CSV       = [CsvRecord]+type CsvRecord = [CsvValue]+type CsvValue  = Text++printCSV :: [CsvRecord] -> TL.Text+printCSV = TB.toLazyText . unlinesB . map printRecord+    where printRecord = foldMap TB.fromText . intersperse "," . map printField+          printField = wrap "\"" "\"" . T.replace "\"" "\"\""++--- ** tests++tests_CsvUtils :: TestTree+tests_CsvUtils = testGroup "CsvUtils" [+  ]+
Hledger/Read/InputOptions.hs view
@@ -34,6 +34,7 @@     ,new_save_          :: Bool                 -- ^ save latest new transactions state for next time     ,pivot_             :: String               -- ^ use the given field's value as the account name     ,forecast_          :: Maybe DateSpan       -- ^ span in which to generate forecast transactions+    ,verbose_tags_      :: Bool                 -- ^ add user-visible tags when generating/modifying transactions & postings ?     ,reportspan_        :: DateSpan             -- ^ a dirty hack keeping the query dates in InputOpts. This rightfully lives in ReportSpec, but is duplicated here.     ,auto_              :: Bool                 -- ^ generate automatic postings when journal is parsed ?     ,infer_equity_      :: Bool                 -- ^ infer equity conversion postings from costs ?@@ -53,6 +54,7 @@     , new_save_          = True     , pivot_             = ""     , forecast_          = Nothing+    , verbose_tags_      = False     , reportspan_        = nulldatespan     , auto_              = False     , infer_equity_      = False
Hledger/Read/JournalReader.hs view
@@ -100,9 +100,10 @@ import Hledger.Read.Common import Hledger.Utils -import qualified Hledger.Read.TimedotReader as TimedotReader (reader)-import qualified Hledger.Read.TimeclockReader as TimeclockReader (reader) import qualified Hledger.Read.CsvReader as CsvReader (reader)+import qualified Hledger.Read.RulesReader as RulesReader (reader)+import qualified Hledger.Read.TimeclockReader as TimeclockReader (reader)+import qualified Hledger.Read.TimedotReader as TimedotReader (reader)  --- ** doctest setup -- $setup@@ -137,6 +138,7 @@   reader  ,TimeclockReader.reader  ,TimedotReader.reader+ ,RulesReader.reader  ,CsvReader.reader --  ,LedgerReader.reader  ]@@ -168,7 +170,7 @@ -- split that off. Eg "csv:-" -> (Just "csv", "-"). splitReaderPrefix :: PrefixedFilePath -> (Maybe String, FilePath) splitReaderPrefix f =-  headDef (Nothing, f)+  headDef (Nothing, f) $   [(Just r, drop (length r + 1) f) | r <- readerNames, (r++":") `isPrefixOf` f]  --- ** reader
+ Hledger/Read/RulesReader.hs view
@@ -0,0 +1,2994 @@+--- * module+--- ** doc+-- In Emacs, use TAB on lines beginning with "-- *" to collapse/expand sections.+{-|++A reader for a CSV rules file. +This reads the actual data from a file specified by a `source` rule+or from a similarly-named file in the same directory.++Most of the code for reading rules files and csv files is in this module.+-}+-- Lots of haddocks in this file are for non-exported types.+-- Here's a command that will render them:+-- stack haddock hledger-lib --fast --no-haddock-deps --haddock-arguments='--ignore-all-exports' --open++--- ** language+{-# LANGUAGE FlexibleInstances    #-}+{-# LANGUAGE OverloadedStrings    #-}+{-# LANGUAGE RecordWildCards      #-}+{-# LANGUAGE ScopedTypeVariables  #-}+{-# LANGUAGE ViewPatterns         #-}+{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}++--- ** exports+module Hledger.Read.RulesReader (+  -- * Reader+  reader,+  -- * Misc.+  readJournalFromCsv,+  -- readRulesFile,+  -- parseCsvRules,+  -- validateCsvRules,+  -- CsvRules,+  dataFileFor,+  rulesFileFor,+  -- * Tests+  tests_RulesReader,+)+where++--- ** imports+import Prelude hiding (Applicative(..))+import Control.Applicative (Applicative(..))+import Control.Monad              (unless, when, void)+import Control.Monad.Except       (ExceptT(..), liftEither, throwError)+import qualified Control.Monad.Fail as Fail+import Control.Monad.IO.Class     (MonadIO, liftIO)+import Control.Monad.State.Strict (StateT, get, modify', evalStateT)+import Control.Monad.Trans.Class  (lift)+import Data.Char                  (toLower, isDigit, isSpace, isAlphaNum, ord)+import Data.Bifunctor             (first)+import Data.Functor               ((<&>))+import Data.List (elemIndex, foldl', mapAccumL, nub, sortOn)+import Data.List.Extra (groupOn)+import Data.Maybe (catMaybes, fromMaybe, isJust)+import Data.MemoUgly (memo)+import qualified Data.Set as S+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import qualified Data.Text.IO as T+import Data.Time ( Day, TimeZone, UTCTime, LocalTime, ZonedTime(ZonedTime),+  defaultTimeLocale, getCurrentTimeZone, localDay, parseTimeM, utcToLocalTime, localTimeToUTC, zonedTimeToUTC)+import Safe (atMay, headMay, lastMay, readMay)+import System.FilePath ((</>), takeDirectory, takeExtension, stripExtension, takeFileName)+import qualified Data.Csv as Cassava+import qualified Data.Csv.Parser.Megaparsec as CassavaMegaparsec+import qualified Data.ByteString as B+import qualified Data.ByteString.Lazy as BL+import Data.Foldable (asum, toList)+import Text.Megaparsec hiding (match, parse)+import Text.Megaparsec.Char (char, newline, string)+import Text.Megaparsec.Custom (parseErrorAt)+import Text.Printf (printf)++import Hledger.Data+import Hledger.Utils+import Hledger.Read.Common (aliasesFromOpts, Reader(..), InputOpts(..), amountp, statusp, journalFinalise, accountnamep )+import Hledger.Read.CsvUtils+import System.Directory (doesFileExist, getHomeDirectory)++--- ** doctest setup+-- $setup+-- >>> :set -XOverloadedStrings++--- ** reader+_READER__________________________________________ = undefined  -- VSCode outline separator+++reader :: MonadIO m => Reader m+reader = Reader+  {rFormat     = "rules"+  ,rExtensions = ["rules"]+  ,rReadFn     = parse+  ,rParser     = error' "sorry, rules files can't be included"  -- PARTIAL:+  }++isFileName f = takeFileName f == f++getDownloadDir = do+  home <- getHomeDirectory+  return $ home </> "Downloads"  -- XXX++-- | Parse and post-process a "Journal" from the given rules file path, or give an error.+-- A data file is inferred from the @source@ rule, otherwise from a similarly-named file+-- in the same directory.+-- The source rule can specify a glob pattern and supports ~ for home directory.+-- If it is a bare filename it will be relative to the defaut download directory+-- on this system. If is a relative file path it will be relative to the rules+-- file's directory. When a glob pattern matches multiple files, the alphabetically+-- last is used. (Eg in case of multiple numbered downloads, the highest-numbered+-- will be used.)+-- The provided text, or a --rules-file option, are ignored by this reader.+-- Balance assertions are not checked.+parse :: InputOpts -> FilePath -> Text -> ExceptT String IO Journal+parse iopts f _ = do+  rules <- readRulesFile $ dbg4 "reading rules file" f+  -- XXX higher-than usual debug level for file reading to bypass excessive noise from elsewhere, normally 6 or 7+  mdatafile <- liftIO $ do+    dldir <- getDownloadDir+    let rulesdir = takeDirectory f+    let msource = T.unpack <$> getDirective "source" rules+    fs <- case msource of+            Just src -> expandGlob dir (dbg4 "source" src) >>= sortByModTime <&> dbg4 ("matched files"<>desc<>", newest first")+              where (dir,desc) = if isFileName src then (dldir," in download directory") else (rulesdir,"")+            Nothing  -> return [maybe err (dbg4 "inferred source") $ dataFileFor f]  -- shouldn't fail, f has .rules extension+              where err = error' $ "could not infer a data file for " <> f+    return $ dbg4 "data file" $ headMay fs+  case mdatafile of+    Nothing -> return nulljournal  -- data file specified by source rule was not found+    Just dat -> do+      exists <- liftIO $ doesFileExist dat+      if not (dat=="-" || exists)+      then return nulljournal      -- data file inferred from rules file name was not found+      else do+        t <- liftIO $ readFileOrStdinPortably dat+        readJournalFromCsv (Just $ Left rules) dat t+        -- apply any command line account aliases. Can fail with a bad replacement pattern.+        >>= liftEither . journalApplyAliases (aliasesFromOpts iopts)+            -- journalFinalise assumes the journal's items are+            -- reversed, as produced by JournalReader's parser.+            -- But here they are already properly ordered. So we'd+            -- better preemptively reverse them once more. XXX inefficient+            . journalReverse+        >>= journalFinalise iopts{balancingopts_=(balancingopts_ iopts){ignore_assertions_=True}} f ""++--- ** reading rules files+--- *** rules utilities+_RULES_READING__________________________________________ = undefined++-- | Given a rules file path, what would be the corresponding data file ?+-- (Remove a .rules extension.)+dataFileFor :: FilePath -> Maybe FilePath+dataFileFor = stripExtension "rules"++-- | Given a csv file path, what would be the corresponding rules file ?+-- (Add a .rules extension.)+rulesFileFor :: FilePath -> FilePath+rulesFileFor = (++ ".rules")++-- | An exception-throwing IO action that reads and validates+-- the specified CSV rules file (which may include other rules files).+readRulesFile :: FilePath -> ExceptT String IO CsvRules+readRulesFile f =+  liftIO (do+    dbg6IO "using conversion rules file" f+    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 dir0 content = mapM (expandLine dir0) (T.lines content) <&> T.unlines+  where+    expandLine dir1 line =+      case line of+        (T.stripPrefix "include " -> Just f) -> expandIncludes dir2 =<< T.readFile f'+          where+            f' = dir1 </> T.unpack (T.dropWhile isSpace f)+            dir2 = takeDirectory f'+        _ -> return line++-- defaultRulesText :: FilePath -> Text+-- defaultRulesText _csvfile = T.pack $ unlines+--   ["# hledger csv conversion rules" --  for " ++ csvFileFor (takeFileName csvfile)+--   ,"# cf http://hledger.org/hledger.html#csv"+--   ,""+--   ,"account1 assets:bank:checking"+--   ,""+--   ,"fields date, description, amount1"+--   ,""+--   ,"#skip 1"+--   ,"#newest-first"+--   ,""+--   ,"#date-format %-d/%-m/%Y"+--   ,"#date-format %-m/%-d/%Y"+--   ,"#date-format %Y-%h-%d"+--   ,""+--   ,"#currency $"+--   ,""+--   ,"if ITUNES"+--   ," account2 expenses:entertainment"+--   ,""+--   ,"if (TO|FROM) SAVINGS"+--   ," account2 assets:bank:savings\n"+--   ]++-- | 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 $ validateCsvRules rules+  where+    makeFancyParseError :: String -> String+    makeFancyParseError errorString =+      parseErrorPretty (FancyError 0 (S.singleton $ ErrorFail errorString) :: ParseError Text String)++instance ShowErrorComponent String where+  showErrorComponent = id++-- | Parse this text as CSV conversion rules. The file path is for error messages.+parseCsvRules :: FilePath -> T.Text -> Either (ParseErrorBundle T.Text HledgerParseErrorData) CsvRules+-- parseCsvRules rulesfile s = runParser csvrulesfile nullrules{baseAccount=takeBaseName rulesfile} rulesfile s+parseCsvRules = runParser (evalStateT rulesp defrules)++-- | Return the validated rules, or an error.+validateCsvRules :: CsvRules -> Either String CsvRules+validateCsvRules rules = do+  unless (isAssigned "date")   $ Left "Please specify (at top level) the date field. Eg: date %1"+  Right rules+  where+    isAssigned f = isJust $ getEffectiveAssignment rules [] f++--- *** rules types+_RULES_TYPES__________________________________________ = undefined++-- | A set of data definitions and account-matching patterns sufficient to+-- convert a particular CSV data file into meaningful journal transactions.+data CsvRules' a = CsvRules' {+  rdirectives        :: [(DirectiveName,Text)],+    -- ^ 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+  rblocksassigning :: a -- (String -> [ConditionalBlock])+    -- ^ all conditional blocks which can potentially assign field with a given name (memoized)+}++-- | Type used by parsers. Directives, assignments and conditional blocks+-- are in the reverse order compared to what is in the file and rblocksassigning is non-functional,+-- could not be used for processing CSV records yet+type CsvRulesParsed = CsvRules' ()++-- | Type used after parsing is done. Directives, assignments and conditional blocks+-- are in the same order as they were in the unput file and rblocksassigning is functional.+-- Ready to be used for CSV record processing+type CsvRules = CsvRules' (Text -> [ConditionalBlock])  -- XXX simplify++instance Eq CsvRules where+  r1 == r2 = (rdirectives r1, rcsvfieldindexes r1, rassignments r1) ==+             (rdirectives r2, rcsvfieldindexes r2, rassignments r2)++-- Custom Show instance used for debug output: omit the rblocksassigning field, which isn't showable.+instance Show CsvRules where+  show r = "CsvRules { rdirectives = " ++ show (rdirectives r) +++           ", rcsvfieldindexes = "     ++ show (rcsvfieldindexes r) +++           ", rassignments = "         ++ show (rassignments r) +++           ", rconditionalblocks = "   ++ show (rconditionalblocks r) +++           " }"++type CsvRulesParser a = StateT CsvRulesParsed SimpleTextParser a++-- | The keyword of a CSV rule - "fields", "skip", "if", etc.+type DirectiveName    = Text++-- | CSV field name.+type CsvFieldName     = Text++-- | 1-based CSV column number.+type CsvFieldIndex    = Int++-- | Percent symbol followed by a CSV field name or column number. Eg: %date, %1.+type CsvFieldReference = Text++-- | One of the standard hledger fields or pseudo-fields that can be assigned to.+-- Eg date, account1, amount, amount1-in, date-format.+type HledgerFieldName = Text++-- | A text value to be assigned to a hledger field, possibly+-- containing csv field references to be interpolated.+type FieldTemplate    = Text++-- | A strptime date parsing pattern, as supported by Data.Time.Format.+type DateFormat       = Text++-- | A prefix for a matcher test, either & or none (implicit or).+data MatcherPrefix = And | None+  deriving (Show, Eq)++-- | A single test for matching a CSV record, in one way or another.+data Matcher =+    RecordMatcher MatcherPrefix Regexp                          -- ^ match if this regexp matches the overall CSV record+  | FieldMatcher MatcherPrefix CsvFieldReference Regexp         -- ^ 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 :: CsvRulesParsed+defrules = CsvRules' {+  rdirectives=[],+  rcsvfieldindexes=[],+  rassignments=[],+  rconditionalblocks=[],+  rblocksassigning = ()+  }++-- | Create CsvRules from the content parsed out of the rules file+mkrules :: CsvRulesParsed -> CsvRules+mkrules rules =+  let conditionalblocks = reverse $ rconditionalblocks rules+      maybeMemo = if length conditionalblocks >= 15 then memo else id+  in+    CsvRules' {+    rdirectives=reverse $ rdirectives rules,+    rcsvfieldindexes=rcsvfieldindexes rules,+    rassignments=reverse $ rassignments rules,+    rconditionalblocks=conditionalblocks,+    rblocksassigning = maybeMemo (\f -> filter (any ((==f).fst) . cbAssignments) conditionalblocks)+    }++--- *** rules parsers+_RULES_PARSING__________________________________________ = undefined++{-+Grammar for the CSV conversion rules, more or less:++RULES: RULE*++RULE: ( SOURCE | FIELD-LIST | FIELD-ASSIGNMENT | CONDITIONAL-BLOCK | SKIP | TIMEZONE | NEWEST-FIRST | INTRA-DAY-REVERSED | DATE-FORMAT | DECIMAL-MARK | COMMENT | BLANK ) NEWLINE++SOURCE: source SPACE FILEPATH++FIELD-LIST: fields SPACE FIELD-NAME ( SPACE? , SPACE? FIELD-NAME )*++FIELD-NAME: QUOTED-FIELD-NAME | BARE-FIELD-NAME++QUOTED-FIELD-NAME: " (any CHAR except double-quote)+ "++BARE-FIELD-NAME: any CHAR except space, tab, #, ;++FIELD-ASSIGNMENT: JOURNAL-FIELD ASSIGNMENT-SEPARATOR FIELD-VALUE++JOURNAL-FIELD: date | date2 | status | code | description | comment | account1 | account2 | amount | JOURNAL-PSEUDO-FIELD++JOURNAL-PSEUDO-FIELD: amount-in | amount-out | currency++ASSIGNMENT-SEPARATOR: SPACE | ( : SPACE? )++FIELD-VALUE: VALUE (possibly containing CSV-FIELD-REFERENCEs)++CSV-FIELD-REFERENCE: % CSV-FIELD++CSV-FIELD: ( FIELD-NAME | FIELD-NUMBER ) (corresponding to a CSV field)++FIELD-NUMBER: DIGIT+++CONDITIONAL-BLOCK: if ( FIELD-MATCHER NEWLINE )+ INDENTED-BLOCK++FIELD-MATCHER: ( CSV-FIELD-NAME SPACE? )? ( MATCHOP SPACE? )? PATTERNS++MATCHOP: ~++PATTERNS: ( NEWLINE REGEXP )* REGEXP++INDENTED-BLOCK: ( SPACE ( FIELD-ASSIGNMENT | COMMENT ) NEWLINE )+++REGEXP: ( NONSPACE CHAR* ) SPACE?++VALUE: SPACE? ( CHAR* ) SPACE?++COMMENT: SPACE? COMMENT-CHAR VALUE++COMMENT-CHAR: # | ; | *++NONSPACE: any CHAR not a SPACE-CHAR++BLANK: SPACE?++SPACE: SPACE-CHAR+++SPACE-CHAR: space | tab++CHAR: any character except newline++DIGIT: 0-9++-}++addDirective :: (DirectiveName, Text) -> CsvRulesParsed -> CsvRulesParsed+addDirective d r = r{rdirectives=d:rdirectives r}++addAssignment :: (HledgerFieldName, FieldTemplate) -> CsvRulesParsed -> CsvRulesParsed+addAssignment a r = r{rassignments=a:rassignments r}++setIndexesAndAssignmentsFromList :: [CsvFieldName] -> CsvRulesParsed -> CsvRulesParsed+setIndexesAndAssignmentsFromList fs = addAssignmentsFromList fs . setCsvFieldIndexesFromList fs+  where+    setCsvFieldIndexesFromList :: [CsvFieldName] -> CsvRulesParsed -> CsvRulesParsed+    setCsvFieldIndexesFromList fs' r = r{rcsvfieldindexes=zip fs' [1..]}++    addAssignmentsFromList :: [CsvFieldName] -> CsvRulesParsed -> CsvRulesParsed+    addAssignmentsFromList fs' r = foldl' maybeAddAssignment r journalfieldnames+      where+        maybeAddAssignment rules f = (maybe id addAssignmentFromIndex $ elemIndex f fs') rules+          where+            addAssignmentFromIndex i = addAssignment (f, T.pack $ '%':show (i+1))++addConditionalBlock :: ConditionalBlock -> CsvRulesParsed -> CsvRulesParsed+addConditionalBlock b r = r{rconditionalblocks=b:rconditionalblocks r}++addConditionalBlocks :: [ConditionalBlock] -> CsvRulesParsed -> CsvRulesParsed+addConditionalBlocks bs r = r{rconditionalblocks=bs++rconditionalblocks r}++rulesp :: CsvRulesParser CsvRules+rulesp = do+  _ <- many $ choice+    [blankorcommentlinep                                                <?> "blank or comment line"+    ,(directivep        >>= modify' . addDirective)                     <?> "directive"+    ,(fieldnamelistp    >>= modify' . setIndexesAndAssignmentsFromList) <?> "field name list"+    ,(fieldassignmentp  >>= modify' . addAssignment)                    <?> "field assignment"+    -- conditionalblockp backtracks because it shares "if" prefix with conditionaltablep.+    ,try (conditionalblockp >>= modify' . addConditionalBlock)          <?> "conditional block"+    -- 'reverse' is there to ensure that conditions are added in the order they listed in the file+    ,(conditionaltablep >>= modify' . addConditionalBlocks . reverse)   <?> "conditional table"+    ]+  eof+  mkrules <$> get++blankorcommentlinep :: CsvRulesParser ()+blankorcommentlinep = lift (dbgparse 8 "trying blankorcommentlinep") >> choiceInState [blanklinep, commentlinep]++blanklinep :: CsvRulesParser ()+blanklinep = lift skipNonNewlineSpaces >> newline >> return () <?> "blank line"++commentlinep :: CsvRulesParser ()+commentlinep = lift skipNonNewlineSpaces >> commentcharp >> lift restofline >> return () <?> "comment line"++commentcharp :: CsvRulesParser Char+commentcharp = oneOf (";#*" :: [Char])++directivep :: CsvRulesParser (DirectiveName, Text)+directivep = (do+  lift $ dbgparse 8 "trying directive"+  d <- choiceInState $ map (lift . string) directives+  v <- (((char ':' >> lift (many spacenonewline)) <|> lift (some spacenonewline)) >> directivevalp)+       <|> (optional (char ':') >> lift skipNonNewlineSpaces >> lift eolof >> return "")+  return (d, v)+  ) <?> "directive"++directives :: [Text]+directives =+  ["source"+  ,"date-format"+  ,"decimal-mark"+  ,"separator"+  -- ,"default-account"+  -- ,"default-currency"+  ,"skip"+  ,"timezone"+  ,"newest-first"+  ,"intra-day-reversed"+  , "balance-type"+  ]++directivevalp :: CsvRulesParser Text+directivevalp = T.pack <$> anySingle `manyTill` lift eolof++fieldnamelistp :: CsvRulesParser [CsvFieldName]+fieldnamelistp = (do+  lift $ dbgparse 8 "trying fieldnamelist"+  string "fields"+  optional $ char ':'+  lift skipNonNewlineSpaces1+  let separator = lift skipNonNewlineSpaces >> char ',' >> lift skipNonNewlineSpaces+  f <- fromMaybe "" <$> optional fieldnamep+  fs <- some $ (separator >> fromMaybe "" <$> optional fieldnamep)+  lift restofline+  return . map T.toLower $ f:fs+  ) <?> "field name list"++fieldnamep :: CsvRulesParser Text+fieldnamep = quotedfieldnamep <|> barefieldnamep++quotedfieldnamep :: CsvRulesParser Text+quotedfieldnamep =+    char '"' *> takeWhile1P Nothing (`notElem` ("\"\n:;#~" :: [Char])) <* char '"'++barefieldnamep :: CsvRulesParser Text+barefieldnamep = takeWhile1P Nothing (`notElem` (" \t\n,;#~" :: [Char]))++fieldassignmentp :: CsvRulesParser (HledgerFieldName, FieldTemplate)+fieldassignmentp = do+  lift $ dbgparse 8 "trying fieldassignmentp"+  f <- journalfieldnamep+  v <- choiceInState [ assignmentseparatorp >> fieldvalp+                     , lift eolof >> return ""+                     ]+  return (f,v)+  <?> "field assignment"++journalfieldnamep :: CsvRulesParser Text+journalfieldnamep = do+  lift (dbgparse 8 "trying journalfieldnamep")+  choiceInState $ map (lift . string) journalfieldnames++maxpostings = 99++-- Transaction fields and pseudo fields for CSV conversion.+-- Names must precede any other name they contain, for the parser+-- (amount-in before amount; date2 before date). TODO: fix+journalfieldnames =+  concat [[ "account" <> i+          ,"amount" <> i <> "-in"+          ,"amount" <> i <> "-out"+          ,"amount" <> i+          ,"balance" <> i+          ,"comment" <> i+          ,"currency" <> i+          ] | x <- [maxpostings, (maxpostings-1)..1], let i = T.pack $ show x]+  +++  ["amount-in"+  ,"amount-out"+  ,"amount"+  ,"balance"+  ,"code"+  ,"comment"+  ,"currency"+  ,"date2"+  ,"date"+  ,"description"+  ,"status"+  ,"skip" -- skip and end are not really fields, but we list it here to allow conditional rules that skip records+  ,"end"+  ]++assignmentseparatorp :: CsvRulesParser ()+assignmentseparatorp = do+  lift $ dbgparse 8 "trying assignmentseparatorp"+  _ <- choiceInState [ lift skipNonNewlineSpaces >> char ':' >> lift skipNonNewlineSpaces+                     , lift skipNonNewlineSpaces1+                     ]+  return ()++fieldvalp :: CsvRulesParser Text+fieldvalp = do+  lift $ dbgparse 8 "trying fieldvalp"+  T.pack <$> anySingle `manyTill` lift eolof++-- A conditional block: one or more matchers, one per line, followed by one or more indented rules.+conditionalblockp :: CsvRulesParser ConditionalBlock+conditionalblockp = do+  lift $ dbgparse 8 "trying conditionalblockp"+  -- "if\nMATCHER" or "if    \nMATCHER" or "if MATCHER"+  start <- getOffset+  string "if" >> ( (newline >> return Nothing)+                  <|> (lift skipNonNewlineSpaces1 >> optional newline))+  ms <- some matcherp+  as <- catMaybes <$>+    many (lift skipNonNewlineSpaces1 >>+          choice [ lift eolof >> return Nothing+                 , fmap Just fieldassignmentp+                 ])+  when (null as) $+    customFailure $ parseErrorAt start $  "start of conditional block found, but no assignment rules afterward\n(assignment rules in a conditional block should be indented)"+  return $ CB{cbMatchers=ms, cbAssignments=as}+  <?> "conditional block"++-- A conditional table: "if" followed by separator, followed by some field names,+-- followed by many lines, each of which has:+-- one matchers, followed by field assignments (as many as there were fields)+conditionaltablep :: CsvRulesParser [ConditionalBlock]+conditionaltablep = do+  lift $ dbgparse 8 "trying conditionaltablep"+  start <- getOffset+  string "if"+  sep <- lift $ satisfy (\c -> not (isAlphaNum c || isSpace c))+  fields <- journalfieldnamep `sepBy1` (char sep)+  newline+  body <- flip manyTill (lift eolof) $ do+    off <- getOffset+    m <- matcherp' $ void $ char sep+    vs <- T.split (==sep) . T.pack <$> lift restofline+    if (length vs /= length fields)+      then customFailure $ parseErrorAt off $ ((printf "line of conditional table should have %d values, but this one has only %d" (length fields) (length vs)) :: String)+      else return (m,vs)+  when (null body) $+    customFailure $ parseErrorAt start $ "start of conditional table found, but no assignment rules afterward"+  return $ flip map body $ \(m,vs) ->+    CB{cbMatchers=[m], cbAssignments=zip fields vs}+  <?> "conditional table"++-- A single matcher, on one line.+matcherp' :: CsvRulesParser () -> CsvRulesParser Matcher+matcherp' end = try (fieldmatcherp end) <|> recordmatcherp end++matcherp :: CsvRulesParser Matcher+matcherp = matcherp' (lift eolof)++-- A single whole-record matcher.+-- A pattern on the whole line, not beginning with a csv field reference.+recordmatcherp :: CsvRulesParser () -> CsvRulesParser Matcher+recordmatcherp end = do+  lift $ dbgparse 8 "trying recordmatcherp"+  -- pos <- currentPos+  -- _  <- optional (matchoperatorp >> lift skipNonNewlineSpaces >> optional newline)+  p <- matcherprefixp+  r <- regexp end+  return $ RecordMatcher p r+  -- when (null ps) $+  --   Fail.fail "start of record matcher found, but no patterns afterward\n(patterns should not be indented)"+  <?> "record matcher"++-- | A single matcher for a specific field. A csv field reference+-- (like %date or %1), and a pattern on the rest of the line,+-- optionally space-separated. Eg:+-- %description chez jacques+fieldmatcherp :: CsvRulesParser () -> CsvRulesParser Matcher+fieldmatcherp end = do+  lift $ dbgparse 8 "trying fieldmatcher"+  -- An optional fieldname (default: "all")+  -- f <- fromMaybe "all" `fmap` (optional $ do+  --        f' <- fieldnamep+  --        lift skipNonNewlineSpaces+  --        return f')+  p <- matcherprefixp+  f <- csvfieldreferencep <* lift skipNonNewlineSpaces+  -- optional operator.. just ~ (case insensitive infix regex) for now+  -- _op <- fromMaybe "~" <$> optional matchoperatorp+  lift skipNonNewlineSpaces+  r <- regexp end+  return $ FieldMatcher p f r+  <?> "field matcher"++matcherprefixp :: CsvRulesParser MatcherPrefix+matcherprefixp = do+  lift $ dbgparse 8 "trying matcherprefixp"+  (char '&' >> lift skipNonNewlineSpaces >> return And) <|> return None++csvfieldreferencep :: CsvRulesParser CsvFieldReference+csvfieldreferencep = do+  lift $ dbgparse 8 "trying csvfieldreferencep"+  char '%'+  T.cons '%' . textQuoteIfNeeded <$> fieldnamep++-- A single regular expression+regexp :: CsvRulesParser () -> CsvRulesParser Regexp+regexp end = do+  lift $ dbgparse 8 "trying regexp"+  -- notFollowedBy matchoperatorp+  c <- lift nonspace+  cs <- anySingle `manyTill` end+  case toRegexCI . T.strip . T.pack $ c:cs of+       Left x -> Fail.fail $ "CSV parser: " ++ x+       Right x -> return x++-- -- A match operator, indicating the type of match to perform.+-- -- Currently just ~ meaning case insensitive infix regex match.+-- matchoperatorp :: CsvRulesParser String+-- matchoperatorp = fmap T.unpack $ choiceInState $ map string+--   ["~"+--   -- ,"!~"+--   -- ,"="+--   -- ,"!="+--   ]++_RULES_LOOKUP__________________________________________ = undefined++getDirective :: DirectiveName -> CsvRules -> Maybe FieldTemplate+getDirective directivename = lookup directivename . rdirectives++-- | Look up the value (template) of a csv rule by rule keyword.+csvRule :: CsvRules -> DirectiveName -> Maybe FieldTemplate+csvRule rules = (`getDirective` rules)++-- | Look up the value template assigned to a hledger field by field+-- list/field assignment rules, taking into account the current record and+-- conditional rules.+hledgerField :: CsvRules -> CsvRecord -> HledgerFieldName -> Maybe FieldTemplate+hledgerField = getEffectiveAssignment++-- | Look up the final value assigned to a hledger field, with csv field+-- references interpolated.+hledgerFieldValue :: CsvRules -> CsvRecord -> HledgerFieldName -> Maybe Text+hledgerFieldValue rules record = fmap (renderTemplate rules record) . hledgerField rules record++-- | Given the conversion rules, a CSV record and a hledger field name, find+-- the value template ultimately assigned to this field, if any, by a field+-- assignment at top level or in a conditional block matching this record.+--+-- Note conditional blocks' patterns are matched against an approximation of the+-- CSV record: all the field values, without enclosing quotes, comma-separated.+--+getEffectiveAssignment :: CsvRules -> CsvRecord -> HledgerFieldName -> Maybe FieldTemplate+getEffectiveAssignment rules record f = lastMay $ map snd $ assignments+  where+    -- all active assignments to field f, in order+    assignments = dbg9 "csv assignments" $ filter ((==f).fst) $ toplevelassignments ++ conditionalassignments+      where+        -- all top level field assignments+        toplevelassignments    = rassignments rules+        -- all field assignments in conditional blocks assigning to field f and active for the current csv record+        conditionalassignments = concatMap cbAssignments $ filter isBlockActive $ (rblocksassigning rules) f+          where+            -- does this conditional block match the current csv record ?+            isBlockActive :: ConditionalBlock -> Bool+            isBlockActive CB{..} = any (all matcherMatches) $ groupedMatchers cbMatchers+              where+                -- does this individual matcher match the current csv record ?+                matcherMatches :: Matcher -> Bool+                matcherMatches (RecordMatcher _ pat) = regexMatchText pat' wholecsvline+                  where+                    pat' = dbg7 "regex" pat+                    -- A synthetic whole CSV record to match against. Note, this can be+                    -- different from the original CSV data:+                    -- - any whitespace surrounding field values is preserved+                    -- - any quotes enclosing field values are removed+                    -- - and the field separator is always comma+                    -- which means that a field containing a comma will look like two fields.+                    wholecsvline = dbg7 "wholecsvline" $ T.intercalate "," record+                matcherMatches (FieldMatcher _ csvfieldref pat) = regexMatchText pat csvfieldvalue+                  where+                    -- the value of the referenced CSV field to match against.+                    csvfieldvalue = dbg7 "csvfieldvalue" $ replaceCsvFieldReference rules record csvfieldref++                -- | Group matchers into associative pairs based on prefix, e.g.:+                --   A+                --   & B+                --   C+                --   D+                --   & E+                --   => [[A, B], [C], [D, E]]+                groupedMatchers :: [Matcher] -> [[Matcher]]+                groupedMatchers [] = []+                groupedMatchers (x:xs) = (x:ys) : groupedMatchers zs+                  where+                    (ys, zs) = span (\y -> matcherPrefix y == And) xs+                    matcherPrefix :: Matcher -> MatcherPrefix+                    matcherPrefix (RecordMatcher prefix _) = prefix+                    matcherPrefix (FieldMatcher prefix _ _) = prefix++-- | Render a field assignment's template, possibly interpolating referenced+-- CSV field values. Outer whitespace is removed from interpolated values.+renderTemplate ::  CsvRules -> CsvRecord -> FieldTemplate -> Text+renderTemplate rules record t = maybe t mconcat $ parseMaybe+    (many $ takeWhile1P Nothing (/='%')+        <|> replaceCsvFieldReference rules record <$> referencep)+    t+  where+    referencep = liftA2 T.cons (char '%') (takeWhile1P (Just "reference") isFieldNameChar) :: Parsec HledgerParseErrorData Text Text+    isFieldNameChar c = isAlphaNum c || c == '_' || c == '-'++-- | Replace something that looks like a reference to a csv field ("%date" or "%1)+-- with that field's value. If it doesn't look like a field reference, or if we+-- can't find such a field, replace it with the empty string.+replaceCsvFieldReference :: CsvRules -> CsvRecord -> CsvFieldReference -> Text+replaceCsvFieldReference rules record s = case T.uncons s of+    Just ('%', fieldname) -> fromMaybe "" $ csvFieldValue rules record fieldname+    _                     -> s++-- | Get the (whitespace-stripped) value of a CSV field, identified by its name or+-- column number, ("date" or "1"), from the given CSV record, if such a field exists.+csvFieldValue :: CsvRules -> CsvRecord -> CsvFieldName -> Maybe Text+csvFieldValue rules record fieldname = do+  fieldindex <-+    if T.all isDigit fieldname+    then readMay $ T.unpack fieldname+    else lookup (T.toLower fieldname) $ rcsvfieldindexes rules+  T.strip <$> atMay record (fieldindex-1)++_CSV_READING__________________________________________ = undefined++-- | Read a Journal from the given CSV data (and filename, used for error+-- messages), or return an error. Proceed as follows:+--+-- 1. Conversion rules are provided, or they are parsed from the specified+--    rules file, or from the default rules file for the CSV data file.+--    If rules parsing fails, or the required rules file does not exist, throw an error.+--+-- 2. Parse the CSV data using the rules, or throw an error.+--+-- 3. Convert the CSV records to hledger transactions using the rules.+--+-- 4. Return the transactions as a Journal.+--+readJournalFromCsv :: Maybe (Either CsvRules FilePath) -> FilePath -> Text -> ExceptT String IO Journal+readJournalFromCsv Nothing "-" _ = throwError "please use --rules-file when reading CSV from stdin"+readJournalFromCsv merulesfile csvfile csvtext = do+    -- for now, correctness is the priority here, efficiency not so much++    rules <- case merulesfile of+      Just (Left rs)         -> return rs+      Just (Right rulesfile) -> readRulesFile rulesfile+      Nothing                -> readRulesFile $ rulesFileFor csvfile+    dbg6IO "csv rules" rules++    -- convert the csv data to lines and remove all empty/blank lines+    let csvlines1 = dbg9 "csvlines1" $ filter (not . T.null . T.strip) $ dbg9 "csvlines0" $ T.lines csvtext++    -- if there is a top-level skip rule, skip the specified number of non-empty lines+    skiplines <- case getDirective "skip" rules of+                      Nothing -> return 0+                      Just "" -> return 1+                      Just s  -> maybe (throwError $ "could not parse skip value: " ++ show s) return . readMay $ T.unpack s+    let csvlines2 = dbg9 "csvlines2" $ drop skiplines csvlines1++    -- convert back to text and parse as csv records+    let+      csvtext1 = T.unlines csvlines2+      separator =+        case getDirective "separator" rules >>= parseSeparator of+          Just c           -> c+          _ | ext == "ssv" -> ';'+          _ | ext == "tsv" -> '\t'+          _                -> ','+          where+            ext = map toLower $ drop 1 $ takeExtension csvfile+      -- parsec seemed to fail if you pass it "-" here   -- TODO: try again with megaparsec+      parsecfilename = if csvfile == "-" then "(stdin)" else csvfile+    dbg6IO "using separator" separator+    -- parse csv records+    csvrecords0 <- dbg7 "parseCsv" <$> parseCsv separator parsecfilename csvtext1+    -- remove any records skipped by conditional skip or end rules+    let csvrecords1 = applyConditionalSkips rules csvrecords0+    -- and check the remaining records for any obvious problems+    csvrecords <- liftEither $ dbg7 "validateCsv" <$> validateCsv csvrecords1+    dbg6IO "first 3 csv records" $ take 3 csvrecords++    -- XXX identify header lines some day ?+    -- let (headerlines, datalines) = identifyHeaderLines csvrecords'+    --     mfieldnames = lastMay headerlines++    tzout <- liftIO getCurrentTimeZone+    mtzin <- case getDirective "timezone" rules of+              Nothing -> return Nothing+              Just s  ->+                maybe (throwError $ "could not parse time zone: " ++ T.unpack s) (return.Just) $+                parseTimeM False defaultTimeLocale "%Z" $ T.unpack s+    let+      -- convert CSV records to transactions, saving the CSV line numbers for error positions+      txns = dbg7 "csv txns" $ snd $ mapAccumL+                     (\pos r ->+                        let+                          SourcePos name line col = pos+                          line' = (mkPos . (+1) . unPos) line+                          pos' = SourcePos name line' col+                        in+                          (pos', transactionFromCsvRecord timesarezoned mtzin tzout pos rules r)+                     )+                     (initialPos parsecfilename) csvrecords+        where+          timesarezoned =+            case csvRule rules "date-format" of+              Just f | any (`T.isInfixOf` f) ["%Z","%z","%EZ","%Ez"] -> True+              _ -> False++      -- Do our best to ensure transactions will be ordered chronologically,+      -- from oldest to newest. This is done in several steps:+      -- 1. Intra-day order: if there's an "intra-day-reversed" rule,+      -- assume each day's CSV records were ordered in reverse of the overall date order,+      -- so reverse each day's txns.+      intradayreversed = dbg6 "intra-day-reversed" $ isJust $ getDirective "intra-day-reversed" rules+      txns1 = dbg7 "txns1" $+        (if intradayreversed then concatMap reverse . groupOn tdate else id) txns+      -- 2. Overall date order: now if there's a "newest-first" rule,+      -- or if there's multiple dates and the first is more recent than the last,+      -- assume CSV records were ordered newest dates first,+      -- so reverse all txns.+      newestfirst = dbg6 "newest-first" $ isJust $ getDirective "newest-first" rules+      mdatalooksnewestfirst = dbg6 "mdatalooksnewestfirst" $+        case nub $ map tdate txns of+          ds | length ds > 1 -> Just $ head ds > last ds+          _                  -> Nothing+      txns2 = dbg7 "txns2" $+        (if newestfirst || mdatalooksnewestfirst == Just True then reverse else id) txns1+      -- 3. Disordered dates: in case the CSV records were ordered by chaos,+      -- do a final sort by date. If it was only a few records out of order,+      -- this will hopefully refine any good ordering done by steps 1 and 2.+      txns3 = dbg7 "date-sorted csv txns" $ sortOn tdate txns2++    return nulljournal{jtxns=txns3}++-- | Parse special separator names TAB and SPACE, or return the first+-- character. Return Nothing on empty string+parseSeparator :: Text -> Maybe Char+parseSeparator = specials . T.toLower+  where specials "space" = Just ' '+        specials "tab"   = Just '\t'+        specials xs      = fst <$> T.uncons xs++-- Call parseCassava on a file or stdin, converting the result to ExceptT.+parseCsv :: Char -> FilePath -> Text -> ExceptT String IO [CsvRecord]+parseCsv separator filePath csvtext = ExceptT $+  case filePath of+    "-" -> parseCassava separator "(stdin)" <$> T.getContents+    _   -> return $ if T.null csvtext then Right mempty else parseCassava separator filePath csvtext++-- Parse text into CSV records, using Cassava and the given field separator.+parseCassava :: Char -> FilePath -> Text -> Either String [CsvRecord]+parseCassava separator path content =+  -- XXX we now remove all blank lines before parsing; will Cassava will still produce [""] records ?+  -- filter (/=[""])+  either (Left . errorBundlePretty) (Right . parseResultToCsv) <$>+  CassavaMegaparsec.decodeWith decodeOptions Cassava.NoHeader path $+  BL.fromStrict $ T.encodeUtf8 content+  where+    decodeOptions = Cassava.defaultDecodeOptions {+                      Cassava.decDelimiter = fromIntegral (ord separator)+                    }+    parseResultToCsv :: (Foldable t, Functor t) => t (t B.ByteString) -> [CsvRecord]+    parseResultToCsv = toListList . unpackFields+      where+        toListList = toList . fmap toList+        unpackFields  = (fmap . fmap) T.decodeUtf8++-- | Scan for csv records where a conditional `skip` or `end` rule applies,+-- and apply that rule, removing one or more following records.+applyConditionalSkips :: CsvRules -> [CsvRecord] -> [CsvRecord]+applyConditionalSkips _ [] = []+applyConditionalSkips rules (r:rest) =+  case skipnum r of+    Nothing -> r : applyConditionalSkips rules rest+    Just cnt -> applyConditionalSkips rules $ drop (cnt-1) rest+  where+    skipnum r1 =+      case (getEffectiveAssignment rules r1 "end", getEffectiveAssignment rules r1 "skip") of+        (Nothing, Nothing) -> Nothing+        (Just _, _) -> Just maxBound+        (Nothing, Just "") -> Just 1+        (Nothing, Just x) -> Just (read $ T.unpack x)++-- | Do some validation on the parsed CSV records:+-- check that they all have at least two fields.+validateCsv :: [CsvRecord] -> Either String [CsvRecord]+validateCsv [] = Right []+validateCsv rs@(_first:_) =+  case lessthan2 of+    Just r  -> Left $ printf "CSV record %s has less than two fields" (show r)+    Nothing -> 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++transactionFromCsvRecord :: Bool -> Maybe TimeZone -> TimeZone -> SourcePos -> CsvRules -> CsvRecord -> Transaction+transactionFromCsvRecord timesarezoned mtzin tzout sourcepos rules record = t+  where+    ----------------------------------------------------------------------+    -- 1. Define some helpers:++    rule     = csvRule           rules        :: DirectiveName    -> Maybe FieldTemplate+    -- ruleval  = csvRuleValue      rules record :: DirectiveName    -> Maybe String+    field    = hledgerField      rules record :: HledgerFieldName -> Maybe FieldTemplate+    fieldval = hledgerFieldValue rules record :: HledgerFieldName -> Maybe Text+    mdateformat = rule "date-format"+    parsedate = parseDateWithCustomOrDefaultFormats timesarezoned mtzin tzout mdateformat+    mkdateerror datefield datevalue mdateformat' = T.unpack $ T.unlines+      ["error: could not parse \""<>datevalue<>"\" as a date using date format "+        <>maybe "\"YYYY/M/D\", \"YYYY-M-D\" or \"YYYY.M.D\"" (T.pack . show) mdateformat'+      ,showRecord record+      ,"the "<>datefield<>" rule is:   "<>(fromMaybe "required, but missing" $ field datefield)+      ,"the date-format is: "<>fromMaybe "unspecified" mdateformat'+      ,"you may need to "+        <>"change your "<>datefield<>" rule, "+        <>maybe "add a" (const "change your") mdateformat'<>" date-format rule, "+        <>"or "<>maybe "add a" (const "change your") mskip<>" skip rule"+      ,"for m/d/y or d/m/y dates, use date-format %-m/%-d/%Y or date-format %-d/%-m/%Y"+      ]+      where+        mskip = rule "skip"++    ----------------------------------------------------------------------+    -- 2. Gather values needed for the transaction itself, by evaluating the+    -- field assignment rules using the CSV record's data, and parsing a bit+    -- more where needed (dates, status).++    date        = fromMaybe "" $ fieldval "date"+    -- PARTIAL:+    date'       = fromMaybe (error' $ mkdateerror "date" date mdateformat) $ parsedate date+    mdate2      = fieldval "date2"+    mdate2'     = (maybe (error' $ mkdateerror "date2" (fromMaybe "" mdate2) mdateformat) Just . parsedate) =<< mdate2+    status      =+      case fieldval "status" of+        Nothing -> Unmarked+        Just s  -> either statuserror id $ runParser (statusp <* eof) "" s+          where+            statuserror err = error' . T.unpack $ T.unlines+              ["error: could not parse \""<>s<>"\" as a cleared status (should be *, ! or empty)"+              ,"the parse error is:      "<>T.pack (customErrorBundlePretty err)+              ]+    code        = maybe "" singleline' $ fieldval "code"+    description = maybe "" singleline' $ fieldval "description"+    comment     = maybe "" unescapeNewlines $ fieldval "comment"+    precomment  = maybe "" unescapeNewlines $ fieldval "precomment"++    singleline' = T.unwords . filter (not . T.null) . map T.strip . T.lines+    unescapeNewlines = T.intercalate "\n" . T.splitOn "\\n"++    ----------------------------------------------------------------------+    -- 3. Generate the postings for which an account has been assigned+    -- (possibly indirectly due to an amount or balance assignment)++    p1IsVirtual = (accountNamePostingType <$> fieldval "account1") == Just VirtualPosting+    ps = [p | n <- [1..maxpostings]+         ,let cmt  = maybe "" unescapeNewlines $ fieldval ("comment"<> T.pack (show n))+         ,let currency = fromMaybe "" (fieldval ("currency"<> T.pack (show n)) <|> fieldval "currency")+         ,let mamount  = getAmount rules record currency p1IsVirtual n+         ,let mbalance = getBalance rules record currency n+         ,Just (acct,isfinal) <- [getAccount rules record mamount mbalance n]  -- skips Nothings+         ,let acct' | not isfinal && acct==unknownExpenseAccount &&+                      fromMaybe False (mamount >>= isNegativeMixedAmount) = unknownIncomeAccount+                    | otherwise = acct+         ,let p = nullposting{paccount          = accountNameWithoutPostingType acct'+                             ,pamount           = fromMaybe missingmixedamt mamount+                             ,ptransaction      = Just t+                             ,pbalanceassertion = mkBalanceAssertion rules record <$> mbalance+                             ,pcomment          = cmt+                             ,ptype             = accountNamePostingType acct+                             }+         ]++    ----------------------------------------------------------------------+    -- 4. Build the transaction (and name it, so the postings can reference it).++    t = nulltransaction{+           tsourcepos        = (sourcepos, sourcepos)  -- the CSV line number+          ,tdate             = date'+          ,tdate2            = mdate2'+          ,tstatus           = status+          ,tcode             = code+          ,tdescription      = description+          ,tcomment          = comment+          ,tprecedingcomment = precomment+          ,tpostings         = ps+          }++-- | Parse the date string using the specified date-format, or if unspecified+-- the "simple date" formats (YYYY/MM/DD, YYYY-MM-DD, YYYY.MM.DD, leading+-- zeroes optional). If a timezone is provided, we assume the DateFormat+-- produces a zoned time and we localise that to the given timezone.+parseDateWithCustomOrDefaultFormats :: Bool -> Maybe TimeZone -> TimeZone -> Maybe DateFormat -> Text -> Maybe Day+parseDateWithCustomOrDefaultFormats timesarezoned mtzin tzout mformat s = localdate <$> mutctime+  -- this time code can probably be simpler, I'm just happy to get out alive+  where+    localdate :: UTCTime -> Day =+      localDay .+      dbg7 ("time in output timezone "++show tzout) .+      utcToLocalTime tzout+    mutctime :: Maybe UTCTime = asum $ map parseWithFormat formats++    parseWithFormat :: String -> Maybe UTCTime+    parseWithFormat fmt =+      if timesarezoned+      then+        dbg7 "zoned CSV time, expressed as UTC" $+        parseTimeM True defaultTimeLocale fmt $ T.unpack s :: Maybe UTCTime+      else+        -- parse as a local day and time; then if an input timezone is provided,+        -- assume it's in that, otherwise assume it's in the output timezone;+        -- then convert to UTC like the above+        let+          mlocaltime =+            fmap (dbg7 "unzoned CSV time") $+            parseTimeM True defaultTimeLocale fmt $ T.unpack s :: Maybe LocalTime+          localTimeAsZonedTime tz lt =  ZonedTime lt tz+        in+          case mtzin of+            Just tzin ->+              (dbg7 ("unzoned CSV time, declared as "++show tzin++ ", expressed as UTC") .+              localTimeToUTC tzin)+              <$> mlocaltime+            Nothing ->+              (dbg7 ("unzoned CSV time, treated as "++show tzout++ ", expressed as UTC") .+                zonedTimeToUTC .+                localTimeAsZonedTime tzout)+              <$> mlocaltime++    formats = map T.unpack $ maybe+               ["%Y/%-m/%-d"+               ,"%Y-%-m-%-d"+               ,"%Y.%-m.%-d"+               -- ,"%-m/%-d/%Y"+                -- ,parseTimeM TruedefaultTimeLocale "%Y/%m/%e" (take 5 s ++ "0" ++ drop 5 s)+                -- ,parseTimeM TruedefaultTimeLocale "%Y-%m-%e" (take 5 s ++ "0" ++ drop 5 s)+                -- ,parseTimeM TruedefaultTimeLocale "%m/%e/%Y" ('0':s)+                -- ,parseTimeM TruedefaultTimeLocale "%m-%e-%Y" ('0':s)+               ]+               (:[])+                mformat++-- | Figure out the amount specified for posting N, if any.+-- A currency symbol to prepend to the amount, if any, is provided,+-- and whether posting 1 requires balancing or not.+-- This looks for a non-empty amount value assigned to "amountN", "amountN-in", or "amountN-out".+-- For postings 1 or 2 it also looks at "amount", "amount-in", "amount-out".+-- If more than one of these has a value, it looks for one that is non-zero.+-- If there's multiple non-zeros, or no non-zeros but multiple zeros, it throws an error.+getAmount :: CsvRules -> CsvRecord -> Text -> Bool -> Int -> Maybe MixedAmount+getAmount rules record currency p1IsVirtual n =+  -- Warning! Many tricky corner cases here.+  -- Keep synced with:+  -- hledger_csv.m4.md -> CSV FORMAT -> "amount", "Setting amounts",+  -- hledger/test/csv.test -> 13, 31-34+  let+    unnumberedfieldnames = ["amount","amount-in","amount-out"]++    -- amount field names which can affect this posting+    fieldnames = map (("amount"<> T.pack (show n))<>) ["","-in","-out"]+                 -- For posting 1, also recognise the old amount/amount-in/amount-out names.+                 -- For posting 2, the same but only if posting 1 needs balancing.+                 ++ if n==1 || n==2 && not p1IsVirtual then unnumberedfieldnames else []++    -- assignments to any of these field names with non-empty values+    assignments = [(f,a') | f <- fieldnames+                          , Just v <- [T.strip . renderTemplate rules record <$> hledgerField rules record f]+                          , not $ T.null v+                          -- XXX maybe ignore rule-generated values like "", "-", "$", "-$", "$-" ? cf CSV FORMAT -> "amount", "Setting amounts",+                          , let a = parseAmount rules record currency v+                          -- With amount/amount-in/amount-out, in posting 2,+                          -- flip the sign and convert to cost, as they did before 1.17+                          , let a' = if f `elem` unnumberedfieldnames && n==2 then mixedAmountCost (maNegate a) else a+                          ]++    -- if any of the numbered field names are present, discard all the unnumbered ones+    discardUnnumbered xs = if null numbered then xs else numbered+      where+        numbered = filter (T.any isDigit . fst) xs++    -- discard all zero amounts, unless all amounts are zero, in which case discard all but the first+    discardExcessZeros xs = if null nonzeros then take 1 xs else nonzeros+      where+        nonzeros = filter (not . mixedAmountLooksZero . snd) xs++    -- for -out fields, flip the sign  XXX unless it's already negative ? back compat issues / too confusing ?+    negateIfOut f = if "-out" `T.isSuffixOf` f then maNegate else id++  in case discardExcessZeros $ discardUnnumbered assignments of+      []      -> Nothing+      [(f,a)] -> Just $ negateIfOut f a+      fs      -> error' . T.unpack . textChomp . T.unlines $  -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+          -- PARTIAL:+        ["in CSV rules:"+        ,"While processing " <> showRecord record+        ,"while calculating amount for posting " <> T.pack (show n)+        ] +++        ["rule \"" <> f <> " " <>+          fromMaybe "" (hledgerField rules record f) <>+          "\" assigned value \"" <> wbToText (showMixedAmountB noColour a) <> "\"" -- XXX not sure this is showing all the right info+          | (f,a) <- fs+        ] +++        [""+        ,"Multiple non-zero amounts were assigned for an amount field."+        ,"Please ensure just one non-zero amount is assigned, perhaps with an if rule."+        ,"See also: https://hledger.org/hledger.html#setting-amounts"+        ,"(hledger manual -> CSV format -> Tips -> Setting amounts)"+        ]+-- | Figure out the expected balance (assertion or assignment) specified for posting N,+-- if any (and its parse position).+getBalance :: CsvRules -> CsvRecord -> Text -> Int -> Maybe (Amount, SourcePos)+getBalance rules record currency n = do+  v <- (fieldval ("balance"<> T.pack (show n))+        -- for posting 1, also recognise the old field name+        <|> if n==1 then fieldval "balance" else Nothing)+  case v of+    "" -> Nothing+    s  -> Just (+            parseBalanceAmount rules record currency n s+           ,initialPos ""  -- parse position to show when assertion fails,+           )               -- XXX the csv record's line number would be good+  where+    fieldval = fmap T.strip . hledgerFieldValue rules record :: HledgerFieldName -> Maybe Text++-- | Given a non-empty amount string (from CSV) to parse, along with a+-- possibly non-empty currency symbol to prepend,+-- parse as a hledger MixedAmount (as in journal format), or raise an error.+-- The whole CSV record is provided for the error message.+parseAmount :: CsvRules -> CsvRecord -> Text -> Text -> MixedAmount+parseAmount rules record currency s =+    either mkerror mixedAmount $  -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+      -- PARTIAL:+    runParser (evalStateT (amountp <* eof) journalparsestate) "" $+    currency <> simplifySign s+  where+    journalparsestate = nulljournal{jparsedecimalmark=parseDecimalMark rules}+    mkerror e = error' . T.unpack $ T.unlines+      ["error: could not parse \"" <> s <> "\" as an amount"+      ,showRecord record+      ,showRules rules record+      -- ,"the default-currency is: "++fromMaybe "unspecified" (getDirective "default-currency" rules)+      ,"the parse error is:      " <> T.pack (customErrorBundlePretty e)+      ,"you may need to \+        \change your amount*, balance*, or currency* rules, \+        \or add or change your skip rule"+      ]++-- | Show the values assigned to each journal field.+showRules rules record = T.unlines $ catMaybes+  [ (("the "<>fld<>" rule is: ")<>) <$>+    getEffectiveAssignment rules record fld | fld <- journalfieldnames ]++-- | Show a (approximate) recreation of the original CSV record.+showRecord :: CsvRecord -> Text+showRecord r = "CSV record: "<>T.intercalate "," (map (wrap "\"" "\"") r)++-- XXX unify these ^v++-- | Almost but not quite the same as parseAmount.+-- Given a non-empty amount string (from CSV) to parse, along with a+-- possibly non-empty currency symbol to prepend,+-- parse as a hledger Amount (as in journal format), or raise an error.+-- The CSV record and the field's numeric suffix are provided for the error message.+parseBalanceAmount :: CsvRules -> CsvRecord -> Text -> Int -> Text -> Amount+parseBalanceAmount rules record currency n s =+  either (mkerror n s) id $+    runParser (evalStateT (amountp <* eof) journalparsestate) "" $+    currency <> simplifySign s+                  -- the csv record's line number would be good+  where+    journalparsestate = nulljournal{jparsedecimalmark=parseDecimalMark rules}+    mkerror n' s' e = error' . T.unpack $ T.unlines+      ["error: could not parse \"" <> s' <> "\" as balance"<> T.pack (show n') <> " amount"+      ,showRecord record+      ,showRules rules record+      -- ,"the default-currency is: "++fromMaybe "unspecified" mdefaultcurrency+      ,"the parse error is:      "<> T.pack (customErrorBundlePretty e)+      ]++-- Read a valid decimal mark from the decimal-mark rule, if any.+-- If the rule is present with an invalid argument, raise an error.+parseDecimalMark :: CsvRules -> Maybe DecimalMark+parseDecimalMark rules = do+    s <- rules `csvRule` "decimal-mark"+    case T.uncons s of+        Just (c, rest) | T.null rest && isDecimalMark c -> return c+        _ -> error' . T.unpack $ "decimal-mark's argument should be \".\" or \",\" (not \""<>s<>"\")"++-- | Make a balance assertion for the given amount, with the given parse+-- position (to be shown in assertion failures), with the assertion type+-- possibly set by a balance-type rule.+-- The CSV rules and current record are also provided, to be shown in case+-- balance-type's argument is bad (XXX refactor).+mkBalanceAssertion :: CsvRules -> CsvRecord -> (Amount, SourcePos) -> BalanceAssertion+mkBalanceAssertion rules record (amt, pos) = assrt{baamount=amt, baposition=pos}+  where+    assrt =+      case getDirective "balance-type" rules of+        Nothing    -> nullassertion+        Just "="   -> nullassertion+        Just "=="  -> nullassertion{batotal=True}+        Just "=*"  -> nullassertion{bainclusive=True}+        Just "==*" -> nullassertion{batotal=True, bainclusive=True}+        Just x     -> error' . T.unpack $ T.unlines  -- PARTIAL:+          [ "balance-type \"" <> x <>"\" is invalid. Use =, ==, =* or ==*."+          , showRecord record+          , showRules rules record+          ]++-- | Figure out the account name specified for posting N, if any.+-- And whether it is the default unknown account (which may be+-- improved later) or an explicitly set account (which may not).+getAccount :: CsvRules -> CsvRecord -> Maybe MixedAmount -> Maybe (Amount, SourcePos) -> Int -> Maybe (AccountName, Bool)+getAccount rules record mamount mbalance n =+  let+    fieldval = hledgerFieldValue rules record :: HledgerFieldName -> Maybe Text+    maccount = T.strip <$> fieldval ("account"<> T.pack (show n))+  in case maccount of+    -- accountN is set to the empty string - no posting will be generated+    Just "" -> Nothing+    -- accountN is set (possibly to "expenses:unknown"! #1192) - mark it final+    Just a  ->+      -- Check it and reject if invalid.. sometimes people try+      -- to set an amount or comment along with the account name.+      case parsewith (accountnamep >> eof) a of+        Left e  -> usageError $ errorBundlePretty e+        Right _ -> Just (a, True)+    -- accountN is unset+    Nothing ->+      case (mamount, mbalance) of+        -- amountN is set, or implied by balanceN - set accountN to+        -- the default unknown account ("expenses:unknown") and+        -- allow it to be improved later+        (Just _, _) -> Just (unknownExpenseAccount, False)+        (_, Just _) -> Just (unknownExpenseAccount, False)+        -- amountN is also unset - no posting will be generated+        (Nothing, Nothing) -> Nothing++-- | Default account names to use when needed.+unknownExpenseAccount = "expenses:unknown"+unknownIncomeAccount  = "income:unknown"++type CsvAmountString = Text++-- | Canonicalise the sign in a CSV amount string.+-- Such strings can have a minus sign, parentheses (equivalent to minus),+-- or any two of these (which cancel out),+-- or a plus sign (which is removed),+-- or any sign by itself with no following number (which is removed).+-- See hledger > CSV FORMAT > Tips > Setting amounts.+--+-- These are supported (note, not every possibile combination):+--+-- >>> simplifySign "1"+-- "1"+-- >>> simplifySign "+1"+-- "1"+-- >>> simplifySign "-1"+-- "-1"+-- >>> simplifySign "(1)"+-- "-1"+-- >>> simplifySign "--1"+-- "1"+-- >>> simplifySign "-(1)"+-- "1"+-- >>> simplifySign "-+1"+-- "-1"+-- >>> simplifySign "(-1)"+-- "1"+-- >>> simplifySign "((1))"+-- "1"+-- >>> simplifySign "-"+-- ""+-- >>> simplifySign "()"+-- ""+-- >>> simplifySign "+"+-- ""+simplifySign :: CsvAmountString -> CsvAmountString+simplifySign amtstr+  | Just (' ',t) <- T.uncons amtstr = simplifySign t+  | Just (t,' ') <- T.unsnoc amtstr = simplifySign t+  | Just ('(',t) <- T.uncons amtstr, Just (amt,')') <- T.unsnoc t = simplifySign $ negateStr amt+  | Just ('-',b) <- T.uncons amtstr, Just ('(',t) <- T.uncons b, Just (amt,')') <- T.unsnoc t = simplifySign amt+  | Just ('-',m) <- T.uncons amtstr, Just ('-',amt) <- T.uncons m = amt+  | Just ('-',m) <- T.uncons amtstr, Just ('+',amt) <- T.uncons m = negateStr amt+  | amtstr `elem` ["-","+","()"] = ""+  | Just ('+',amt) <- T.uncons amtstr = simplifySign amt+  | otherwise = amtstr++negateStr :: Text -> Text+negateStr amtstr = case T.uncons amtstr of+    Just ('-',s) -> s+    _            -> T.cons '-' amtstr++--- ** tests+_TESTS__________________________________________ = undefined++tests_RulesReader = testGroup "RulesReader" [+   testGroup "parseCsvRules" [+     testCase "empty file" $+      parseCsvRules "unknown" "" @?= Right (mkrules defrules)+   ]+  ,testGroup "rulesp" [+     testCase "trailing comments" $+      parseWithState' defrules rulesp "skip\n# \n#\n" @?= Right (mkrules $ defrules{rdirectives = [("skip","")]})++    ,testCase "trailing blank lines" $+      parseWithState' defrules rulesp "skip\n\n  \n" @?= (Right (mkrules $ defrules{rdirectives = [("skip","")]}))++    ,testCase "no final newline" $+      parseWithState' defrules rulesp "skip" @?= (Right (mkrules $ defrules{rdirectives=[("skip","")]}))++    ,testCase "assignment with empty value" $+      parseWithState' defrules rulesp "account1 \nif foo\n  account2 foo\n" @?=+        (Right (mkrules $ defrules{rassignments = [("account1","")], rconditionalblocks = [CB{cbMatchers=[RecordMatcher None (toRegex' "foo")],cbAssignments=[("account2","foo")]}]}))+   ]+  ,testGroup "conditionalblockp" [+    testCase "space after conditional" $ -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+       -- #1120+      parseWithState' defrules conditionalblockp "if a\n account2 b\n \n" @?=+        (Right $ CB{cbMatchers=[RecordMatcher None $ toRegexCI' "a"],cbAssignments=[("account2","b")]})++  ],++  testGroup "csvfieldreferencep" [+    testCase "number" $ parseWithState' defrules csvfieldreferencep "%1" @?= (Right "%1")+   ,testCase "name" $ parseWithState' defrules csvfieldreferencep "%date" @?= (Right "%date")+   ,testCase "quoted name" $ parseWithState' defrules csvfieldreferencep "%\"csv date\"" @?= (Right "%\"csv date\"")+   ]++  ,testGroup "matcherp" [++    testCase "recordmatcherp" $+      parseWithState' defrules matcherp "A A\n" @?= (Right $ RecordMatcher None $ toRegexCI' "A A")++   ,testCase "recordmatcherp.starts-with-&" $+      parseWithState' defrules matcherp "& A A\n" @?= (Right $ RecordMatcher And $ toRegexCI' "A A")++   ,testCase "fieldmatcherp.starts-with-%" $+      parseWithState' defrules matcherp "description A A\n" @?= (Right $ RecordMatcher None $ toRegexCI' "description A A")++   ,testCase "fieldmatcherp" $+      parseWithState' defrules matcherp "%description A A\n" @?= (Right $ FieldMatcher None "%description" $ toRegexCI' "A A")++   ,testCase "fieldmatcherp.starts-with-&" $+      parseWithState' defrules matcherp "& %description A A\n" @?= (Right $ FieldMatcher And "%description" $ toRegexCI' "A A")++   -- ,testCase "fieldmatcherp with operator" $+   --    parseWithState' defrules matcherp "%description ~ A A\n" @?= (Right $ FieldMatcher "%description" "A A")++   ]++ ,testGroup "getEffectiveAssignment" [+    let rules = mkrules $ defrules {rcsvfieldindexes=[("csvdate",1)],rassignments=[("date","%csvdate")]}++    in testCase "toplevel" $ getEffectiveAssignment rules ["a","b"] "date" @?= (Just "%csvdate")++   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a"] [("date","%csvdate")]]}+    in testCase "conditional" $ getEffectiveAssignment rules ["a","b"] "date" @?= (Just "%csvdate")++   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1),("description",2)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a", FieldMatcher None "%description" $ toRegex' "b"] [("date","%csvdate")]]}+    in testCase "conditional-with-or-a" $ getEffectiveAssignment rules ["a"] "date" @?= (Just "%csvdate")++   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1),("description",2)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a", FieldMatcher None "%description" $ toRegex' "b"] [("date","%csvdate")]]}+    in testCase "conditional-with-or-b" $ getEffectiveAssignment rules ["_", "b"] "date" @?= (Just "%csvdate")++   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1),("description",2)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a", FieldMatcher And "%description" $ toRegex' "b"] [("date","%csvdate")]]}+    in testCase "conditional.with-and" $ getEffectiveAssignment rules ["a", "b"] "date" @?= (Just "%csvdate")++   ,let rules = mkrules $ defrules{rcsvfieldindexes=[("csvdate",1),("description",2)], rconditionalblocks=[CB [FieldMatcher None "%csvdate" $ toRegex' "a", FieldMatcher And "%description" $ toRegex' "b", FieldMatcher None "%description" $ toRegex' "c"] [("date","%csvdate")]]}+    in testCase "conditional.with-and-or" $ getEffectiveAssignment rules ["_", "c"] "date" @?= (Just "%csvdate")++   ]++ ]
Hledger/Read/TimeclockReader.hs view
@@ -45,7 +45,6 @@  --- ** language {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE PackageImports #-}  --- ** exports module Hledger.Read.TimeclockReader (@@ -62,13 +61,13 @@ import           Control.Monad.State.Strict import           Data.Maybe (fromMaybe) import           Data.Text (Text)-import qualified Data.Text as T import           Text.Megaparsec hiding (parse)  import           Hledger.Data -- XXX too much reuse ? import           Hledger.Read.Common import           Hledger.Utils+import Data.Text as T (strip)  --- ** doctest setup -- $setup@@ -119,10 +118,11 @@ -- | Parse a timeclock entry. timeclockentryp :: JournalParser m TimeclockEntry timeclockentryp = do-  sourcepos <- getSourcePos+  pos <- getSourcePos   code <- oneOf ("bhioO" :: [Char])   lift skipNonNewlineSpaces1   datetime <- datetimep-  account <- fromMaybe "" <$> optional (lift skipNonNewlineSpaces1 >> modifiedaccountnamep)-  description <- T.pack . fromMaybe "" <$> lift (optional (skipNonNewlineSpaces1 >> restofline))-  return $ TimeclockEntry sourcepos (read [code]) datetime account description+  account     <- fmap (fromMaybe "") $ optional $ lift skipNonNewlineSpaces1 >> modifiedaccountnamep+  description <- fmap (maybe "" T.strip) $ optional $ lift $ skipNonNewlineSpaces1 >> descriptionp+  (comment, tags) <- lift transactioncommentp+  return $ TimeclockEntry pos (read [code]) datetime account description comment tags
Hledger/Read/TimedotReader.hs view
@@ -43,7 +43,6 @@ import Control.Monad.Except (ExceptT, liftEither) import Control.Monad.State.Strict import Data.Char (isSpace)-import Data.List (foldl') import Data.Text (Text) import qualified Data.Text as T import Data.Time (Day)@@ -113,7 +112,7 @@   many $ notFollowedBy datelinep >> (lift $ emptyorcommentlinep "#;*")   lift $ traceparse' "preamblep" --- | Parse timedot day entries to zero or more time transactions for that day.+-- | Parse timedot day entries to multi-posting time transactions for that day. -- @ -- 2020/2/1 optional day description -- fos.haskell  .... ..@@ -123,23 +122,32 @@ dayp :: JournalParser m () dayp = label "timedot day entry" $ do   lift $ traceparse "dayp"-  (d,desc) <- datelinep+  pos <- getSourcePos+  (date,desc,comment,tags) <- datelinep   commentlinesp-  ts <- many $ entryp <* commentlinesp-  modify' $ addTransactions $ map (\t -> t{tdate=d, tdescription=desc}) ts-  lift $ traceparse' "dayp"-  where-    addTransactions :: [Transaction] -> Journal -> Journal-    addTransactions ts j = foldl' (flip ($)) j (map addTransaction ts)+  ps <- many $ timedotentryp <* commentlinesp+  endpos <- getSourcePos+  -- lift $ traceparse' "dayp end"+  let t = txnTieKnot $ nulltransaction{+    tsourcepos   = (pos, endpos),+    tdate        = date,+    tstatus      = Cleared,+    tdescription = desc,+    tcomment     = comment,+    ttags        = tags,+    tpostings    = ps+    }+  modify' $ addTransaction t -datelinep :: JournalParser m (Day,Text)+datelinep :: JournalParser m (Day,Text,Text,[Tag]) datelinep = do   lift $ traceparse "datelinep"   lift $ optional orgheadingprefixp-  d <- datep-  desc <- strip <$> lift restofline-  lift $ traceparse' "datelinep"-  return (d, T.pack desc)+  date <- datep+  desc <- T.strip <$> lift descriptionp+  (comment, tags) <- lift transactioncommentp+  -- lift $ traceparse' "datelinep end"+  return (date, desc, comment, tags)  -- | Zero or more empty lines or hash/semicolon comment lines -- or org headlines which do not start a new day.@@ -164,38 +172,39 @@ -- @ -- fos.haskell  .... .. -- @-entryp :: JournalParser m Transaction-entryp = do-  lift $ traceparse "entryp"-  pos <- getSourcePos+timedotentryp :: JournalParser m Posting+timedotentryp = do+  lift $ traceparse "timedotentryp"   notFollowedBy datelinep   lift $ optional $ choice [orgheadingprefixp, skipNonNewlineSpaces1]   a <- modifiedaccountnamep   lift skipNonNewlineSpaces-  hours <--    try (lift followingcommentp >> return 0)-    <|> (lift durationp <*-         (try (lift followingcommentp) <|> (newline >> return "")))+  (hours, comment, tags) <-+    try (do+      (c,ts) <- lift transactioncommentp  -- or postingp, but let's not bother supporting date:/date2:+      return (0, c, ts)+    )+    <|> (do+      h <- lift durationp+      (c,ts) <- try (lift transactioncommentp) <|> (newline >> return ("",[]))+      return (h,c,ts)+    )   mcs <- getDefaultCommodityAndStyle   let      (c,s) = case mcs of       Just (defc,defs) -> (defc, defs{asprecision=max (asprecision defs) (Precision 2)})       _ -> ("", amountstyle{asprecision=Precision 2})-    t = nulltransaction{-          tsourcepos = (pos, pos),-          tstatus    = Cleared,-          tpostings  = [-            nullposting{paccount=a+  -- lift $ traceparse' "timedotentryp end"+  return $ nullposting{paccount=a                       ,pamount=mixedAmount $ nullamt{acommodity=c, aquantity=hours, astyle=s}                       ,ptype=VirtualPosting-                      ,ptransaction=Just t+                      ,pcomment=comment+                      ,ptags=tags                       }-            ]-          }-  lift $ traceparse' "entryp"-  return t -durationp :: TextParser m Quantity+type Hours = Quantity++durationp :: TextParser m Hours durationp = do   traceparse "durationp"   try numericquantityp <|> dotquantityp@@ -210,7 +219,7 @@ -- 1.5h -- 90m -- @-numericquantityp :: TextParser m Quantity+numericquantityp :: TextParser m Hours numericquantityp = do   -- lift $ traceparse "numericquantityp"   (q, _, _, _) <- numberp Nothing
Hledger/Reports/BudgetReport.hs view
@@ -81,7 +81,7 @@       expandAccountNames $       accountNamesFromPostings $       concatMap tpostings $-      concatMap (`runPeriodicTransaction` reportspan) $+      concatMap (\pt -> runPeriodicTransaction False pt reportspan) $       jperiodictxns j     actualj = journalWithBudgetAccountNames budgetedaccts showunbudgeted j     budgetj = journalAddBudgetGoalTransactions bopts ropts reportspan j@@ -156,7 +156,7 @@       dbg5 "budget goal txns" $       [makeBudgetTxn t       | pt <- budgetpts-      , t <- runPeriodicTransaction pt budgetspan+      , t <- runPeriodicTransaction False pt budgetspan       ]     makeBudgetTxn t = txnTieKnot $ t { tdescription = T.pack "Budget transaction" } 
Hledger/Reports/MultiBalanceReport.hs view
@@ -322,10 +322,11 @@         -- changes to report on: usually just the valued changes themselves, but use the         -- differences in the valued historical amount for CalcValueChange and CalcGain.         changes = case balancecalc_ ropts of-            CalcChange      -> M.mapWithKey avalue unvaluedChanges-            CalcBudget      -> M.mapWithKey avalue unvaluedChanges-            CalcValueChange -> periodChanges valuedStart historical-            CalcGain        -> periodChanges valuedStart historical+            CalcChange        -> M.mapWithKey avalue unvaluedChanges+            CalcBudget        -> M.mapWithKey avalue unvaluedChanges+            CalcValueChange   -> periodChanges valuedStart historical+            CalcGain          -> periodChanges valuedStart historical+            CalcPostingsCount -> M.mapWithKey avalue unvaluedChanges         -- the historical balance is the valued cumulative sum of all unvalued changes         historical = M.mapWithKey avalue $ cumulativeSum startingBalance unvaluedChanges         -- since this is a cumulative sum of valued amounts, it should not be valued again@@ -353,9 +354,15 @@ generateMultiBalanceReport :: ReportSpec -> Journal -> PriceOracle -> Set AccountName                            -> [(DateSpan, [Posting])] -> HashMap AccountName Account                            -> MultiBalanceReport-generateMultiBalanceReport rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle unelidableaccts colps startbals =+generateMultiBalanceReport rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle unelidableaccts colps0 startbals =     report   where+    -- If doing --count, set all posting amounts to "1".+    colps =+      if balancecalc_ ropts == CalcPostingsCount+      then map (second (map (postingTransformAmount (const $ mixed [num 1])))) colps0+      else colps0+     -- Process changes into normal, cumulative, or historical amounts, plus value them     matrix = calculateReportMatrix rspec j priceoracle startbals colps 
Hledger/Reports/ReportOptions.hs view
@@ -88,10 +88,11 @@ -- | What to calculate for each cell in a balance report. -- "Balance report types -> Calculation type" in the hledger manual. data BalanceCalculation =-    CalcChange      -- ^ Sum of posting amounts in the period.-  | CalcBudget      -- ^ Sum of posting amounts and the goal for the period.-  | CalcValueChange -- ^ Change from previous period's historical end value to this period's historical end value.-  | CalcGain        -- ^ Change from previous period's gain, i.e. valuation minus cost basis.+    CalcChange        -- ^ Sum of posting amounts in the period.+  | CalcBudget        -- ^ Sum of posting amounts and the goal for the period.+  | CalcValueChange   -- ^ Change from previous period's historical end value to this period's historical end value.+  | CalcGain          -- ^ Change from previous period's gain, i.e. valuation minus cost basis.+  | CalcPostingsCount -- ^ Number of postings in the period.   deriving (Eq, Show)  instance Default BalanceCalculation where def = CalcChange@@ -318,6 +319,7 @@       "valuechange" -> Just CalcValueChange       "gain"        -> Just CalcGain       "budget"      -> Just CalcBudget+      "count"       -> Just CalcPostingsCount       _             -> Nothing  balanceaccumopt :: RawOpts -> BalanceAccumulation@@ -804,7 +806,7 @@ -- >>> _rsQuery <$> setEither querystring ["assets"] defreportspec -- Right (Acct (RegexpCI "assets")) -- >>> _rsQuery <$> setEither querystring ["(assets"] defreportspec--- Left "This regular expression is malformed...+-- Left "This regular expression is malformed, please correct it:\n(assets" -- >>> _rsQuery $ set querystring ["assets"] defreportspec -- Acct (RegexpCI "assets") -- >>> _rsQuery $ set querystring ["(assets"] defreportspec
Hledger/Utils.hs view
@@ -47,6 +47,7 @@   sixth6,    -- * Misc+  multicol,   numDigitsInt,   makeHledgerClassyLenses, @@ -66,8 +67,10 @@ where  import Data.Char (toLower)-import Data.List.Extra (foldl', foldl1', uncons, unsnoc)+import Data.List (intersperse)+import Data.List.Extra (chunksOf, foldl', foldl1', uncons, unsnoc) import qualified Data.Set as Set+import qualified Data.Text as T (pack, unpack) import Data.Tree (foldTree, Tree (Node, subForest)) import Language.Haskell.TH (DecsQ, Name, mkName, nameBase) import Lens.Micro ((&), (.~))@@ -198,6 +201,21 @@ sixth6  (_,_,_,_,_,x) = x  -- Misc++-- | Convert a list of strings to a multi-line multi-column list+-- fitting within the given width. Not wide character aware.+multicol :: Int -> [String] -> String+multicol _ [] = []+multicol width strs =+  let+    maxwidth = maximum' $ map length strs+    numcols = min (length strs) (width `div` (maxwidth+2))+    itemspercol = length strs `div` numcols+    colitems = chunksOf itemspercol strs+    cols = map unlines colitems+    sep = " "+  in+    T.unpack $ textConcatBottomPadded $ map T.pack $ intersperse sep cols  -- | Find the number of digits of an 'Int'. {-# INLINE numDigitsInt #-}
Hledger/Utils/IO.hs view
@@ -23,6 +23,11 @@   pager,   setupPager, +  -- * Terminal size+  getTerminalHeightWidth,+  getTerminalHeight,+  getTerminalWidth,+   -- * Command line arguments   progArgs,   outputFileOption,@@ -32,10 +37,33 @@   colorOption,   useColorOnStdout,   useColorOnStderr,+  -- XXX needed for using color/bgColor/colorB/bgColorB, but clashing with UIUtils:+  -- Color(..),+  -- ColorIntensity(..),   color,   bgColor,   colorB,   bgColorB,+  --+  bold',+  faint',+  black',+  red',+  green',+  yellow',+  blue',+  magenta',+  cyan',+  white',+  brightBlack',+  brightRed',+  brightGreen',+  brightYellow',+  brightBlue',+  brightMagenta',+  brightCyan',+  brightWhite',+  rgb',   terminalIsLight,   terminalLightness,   terminalFgColor,@@ -49,6 +77,8 @@   embedFileRelative,   expandHomePath,   expandPath,+  expandGlob,+  sortByModTime,   readFileOrStdinPortably,   readFilePortably,   readHandlePortably,@@ -61,7 +91,7 @@   ) where -import           Control.Monad (when)+import           Control.Monad (when, forM) import           Data.Colour.RGBSpace (RGB(RGB)) import           Data.Colour.RGBSpace.HSL (lightness) import           Data.FileEmbed (makeRelativeToProject, embedStringFile)@@ -74,11 +104,13 @@ import           Data.Time.Clock (getCurrentTime) import           Data.Time.LocalTime   (LocalTime, ZonedTime, getCurrentTimeZone, utcToLocalTime, utcToZonedTime)-import           Data.Word (Word16)+import           Data.Word (Word8, Word16) import           Language.Haskell.TH.Syntax (Q, Exp)-import           System.Console.ANSI-  (Color,ColorIntensity,ConsoleLayer(..), SGR(..), hSupportsANSIColor, setSGRCode, getLayerColor)-import           System.Directory (getHomeDirectory)+import           String.ANSI+import           System.Console.ANSI (Color(..),ColorIntensity(..),+  ConsoleLayer(..), SGR(..), hSupportsANSIColor, setSGRCode, getLayerColor)+import           System.Console.Terminal.Size (Window (Window), size)+import           System.Directory (getHomeDirectory, getModificationTime) import           System.Environment (getArgs, lookupEnv, setEnv) import           System.FilePath (isRelative, (</>)) import           System.IO@@ -89,42 +121,48 @@ import           System.Pager (printOrPage) #endif import           Text.Pretty.Simple-  (CheckColorTty(CheckColorTty), OutputOptions(..), +  (CheckColorTty(..), OutputOptions(..),   defaultOutputOptionsDarkBg, defaultOutputOptionsNoColor, pShowOpt, pPrintOpt)  import Hledger.Utils.Text (WideBuilder(WideBuilder))+import System.FilePath.Glob (glob)+import Data.Functor ((<&>))  -- Pretty showing/printing with pretty-simple +-- https://hackage.haskell.org/package/pretty-simple/docs/Text-Pretty-Simple.html#t:OutputOptions+ -- | pretty-simple options with colour enabled if allowed. prettyopts =    (if useColorOnStderr then defaultOutputOptionsDarkBg else defaultOutputOptionsNoColor)-    { outputOptionsIndentAmount=2-    , outputOptionsCompact=True+    { outputOptionsIndentAmount = 2+    -- , outputOptionsCompact      = True  -- fills lines, but does not respect page width (https://github.com/cdepillabout/pretty-simple/issues/126)+    -- , outputOptionsPageWidth    = fromMaybe 80 $ unsafePerformIO getTerminalWidth     }  -- | pretty-simple options with colour disabled.-prettyopts' =+prettyoptsNoColor =   defaultOutputOptionsNoColor     { outputOptionsIndentAmount=2-    , outputOptionsCompact=True     } --- | Pretty show. Easier alias for pretty-simple's pShow.+-- | Pretty show. An easier alias for pretty-simple's pShow.+-- This will probably show in colour if useColorOnStderr is true. pshow :: Show a => a -> String pshow = TL.unpack . pShowOpt prettyopts --- | Monochrome version of pshow.+-- | Monochrome version of pshow. This will never show in colour. pshow' :: Show a => a -> String-pshow' = TL.unpack . pShowOpt prettyopts'+pshow' = TL.unpack . pShowOpt prettyoptsNoColor --- | Pretty print. Easier alias for pretty-simple's pPrint.+-- | Pretty print a showable value. An easier alias for pretty-simple's pPrint.+-- This will print in colour if useColorOnStderr is true. pprint :: Show a => a -> IO ()-pprint = pPrintOpt CheckColorTty prettyopts+pprint = pPrintOpt (if useColorOnStderr then CheckColorTty else NoCheckColorTty) prettyopts --- | Monochrome version of pprint.+-- | Monochrome version of pprint. This will never print in colour. pprint' :: Show a => a -> IO ()-pprint' = pPrintOpt CheckColorTty prettyopts'+pprint' = pPrintOpt NoCheckColorTty prettyoptsNoColor  -- "Avoid using pshow, pprint, dbg* in the code below to prevent infinite loops." (?) @@ -145,6 +183,19 @@ pager = printOrPage' #endif +-- | An alternative to ansi-terminal's getTerminalSize, based on+-- the more robust-looking terminal-size package.+-- Tries to get stdout's terminal's current height and width.+getTerminalHeightWidth :: IO (Maybe (Int,Int))+getTerminalHeightWidth = fmap (fmap unwindow) size+  where unwindow (Window h w) = (h,w)++getTerminalHeight :: IO (Maybe Int)+getTerminalHeight = fmap fst <$> getTerminalHeightWidth++getTerminalWidth :: IO (Maybe Int)+getTerminalWidth  = fmap snd <$> getTerminalHeightWidth+ -- | Make sure our $LESS and $MORE environment variables contain R, -- to help ensure the common pager `less` will show our ANSI output properly. -- less uses $LESS by default, and $MORE when it is invoked as `more`.@@ -198,6 +249,67 @@  -- ANSI colour +ifAnsi f = if useColorOnStdout then f else id++-- | Versions of some of text-ansi's string colors/styles which are more careful+-- to not print junk onscreen. These use our useColorOnStdout.+bold' :: String -> String+bold'  = ifAnsi bold++faint' :: String -> String+faint'  = ifAnsi faint++black' :: String -> String+black'  = ifAnsi black++red' :: String -> String+red'  = ifAnsi red++green' :: String -> String+green'  = ifAnsi green++yellow' :: String -> String+yellow'  = ifAnsi yellow++blue' :: String -> String+blue'  = ifAnsi blue++magenta' :: String -> String+magenta'  = ifAnsi magenta++cyan' :: String -> String+cyan'  = ifAnsi cyan++white' :: String -> String+white'  = ifAnsi white++brightBlack' :: String -> String+brightBlack'  = ifAnsi brightBlack++brightRed' :: String -> String+brightRed'  = ifAnsi brightRed++brightGreen' :: String -> String+brightGreen'  = ifAnsi brightGreen++brightYellow' :: String -> String+brightYellow'  = ifAnsi brightYellow++brightBlue' :: String -> String+brightBlue'  = ifAnsi brightBlue++brightMagenta' :: String -> String+brightMagenta'  = ifAnsi brightMagenta++brightCyan' :: String -> String+brightCyan'  = ifAnsi brightCyan++brightWhite' :: String -> String+brightWhite'  = ifAnsi brightWhite++rgb' :: Word8 -> Word8 -> Word8 -> String -> String+rgb' r g b  = ifAnsi (rgb r g b)+ -- | Read the value of the --color or --colour command line option provided at program startup -- using unsafePerformIO. If this option was not provided, returns the empty string. colorOption :: String@@ -250,6 +362,9 @@        || (coloroption `notElem` ["never","no"] && not no_color && supports_color)  -- | Wrap a string in ANSI codes to set and reset foreground colour.+-- ColorIntensity is @Dull@ or @Vivid@ (ie normal and bold).+-- Color is one of @Black@, @Red@, @Green@, @Yellow@, @Blue@, @Magenta@, @Cyan@, @White@.+-- Eg: @color Dull Red "text"@. color :: ColorIntensity -> Color -> String -> String color int col s = setSGRCode [SetColor Foreground int col] ++ s ++ setSGRCode [] @@ -319,15 +434,8 @@  -- Files --- | Convert a possibly relative, possibly tilde-containing file path to an absolute one,--- given the current directory. ~username is not supported. Leave "-" unchanged.--- Can raise an error.-expandPath :: FilePath -> FilePath -> IO FilePath -- general type sig for use in reader parsers-expandPath _ "-" = return "-"-expandPath curdir p = (if isRelative p then (curdir </>) else id) <$> expandHomePath p--- PARTIAL:---- | Expand user home path indicated by tilde prefix+-- | Expand a tilde (representing home directory) at the start of a file path.+-- ~username is not supported. Can raise an error. expandHomePath :: FilePath -> IO FilePath expandHomePath = \case     ('~':'/':p)  -> (</> p) <$> getHomeDirectory@@ -335,6 +443,24 @@     ('~':_)      -> ioError $ userError "~USERNAME in paths is not supported"     p            -> return p +-- | Given a current directory, convert a possibly relative, possibly tilde-containing+-- file path to an absolute one.+-- ~username is not supported. Leaves "-" unchanged. Can raise an error.+expandPath :: FilePath -> FilePath -> IO FilePath -- general type sig for use in reader parsers+expandPath _ "-" = return "-"+expandPath curdir p = (if isRelative p then (curdir </>) else id) <$> expandHomePath p++-- | Like expandPath, but treats the expanded path as a glob, and returns+-- zero or more matched absolute file paths, alphabetically sorted.+expandGlob :: FilePath -> FilePath -> IO [FilePath]+expandGlob curdir p = expandPath curdir p >>= glob <&> sort++-- | Given a list of existing file paths, sort them by modification time, most recent first.+sortByModTime :: [FilePath] -> IO [FilePath]+sortByModTime fs = do+  ftimes <- forM fs $ \f -> do {t <- getModificationTime f; return (t,f)}+  return $ map snd $ reverse $ sort ftimes+ -- | Read text from a file, -- converting any \r\n line endings to \n,, -- using the system locale's text encoding,@@ -359,7 +485,6 @@   T.hGetContents h  -- | Like embedFile, but takes a path relative to the package directory.--- Similar to embedFileRelative ? embedFileRelative :: FilePath -> Q Exp embedFileRelative f = makeRelativeToProject f >>= embedStringFile 
hledger-lib.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           hledger-lib-version:        1.29.2+version:        1.30 synopsis:       A reusable library providing the core functionality of hledger description:    A reusable library containing hledger's core functionality.                 This is used by most hledger* packages so that they support the same@@ -68,8 +68,10 @@       Hledger.Read       Hledger.Read.Common       Hledger.Read.CsvReader+      Hledger.Read.CsvUtils       Hledger.Read.InputOptions       Hledger.Read.JournalReader+      Hledger.Read.RulesReader       Hledger.Read.TimedotReader       Hledger.Read.TimeclockReader       Hledger.Reports@@ -134,6 +136,7 @@     , tasty >=1.2.3     , tasty-hunit >=0.10.0.2     , template-haskell+    , terminal-size >=0.3.3     , text >=1.2     , text-ansi >=0.2.1     , time >=1.5@@ -192,6 +195,7 @@     , tasty >=1.2.3     , tasty-hunit >=0.10.0.2     , template-haskell+    , terminal-size >=0.3.3     , text >=1.2     , text-ansi >=0.2.1     , time >=1.5@@ -252,6 +256,7 @@     , tasty >=1.2.3     , tasty-hunit >=0.10.0.2     , template-haskell+    , terminal-size >=0.3.3     , text >=1.2     , text-ansi >=0.2.1     , time >=1.5