cli: Using Text Builder for posting reports.

This commit is contained in:
Stephen Morgan 2020-10-27 13:04:00 +11:00
parent ac39d59016
commit 646ee0bce5
6 changed files with 74 additions and 67 deletions

View File

@ -44,7 +44,7 @@ import Data.Decimal
import Data.Maybe import Data.Maybe
import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.IO as TL import qualified Data.Text.Lazy.IO as TL
import Data.Text.Lazy.Builder (toLazyText) import qualified Data.Text.Lazy.Builder as TB
import GHC.Generics (Generic) import GHC.Generics (Generic)
import System.Time (ClockTime) import System.Time (ClockTime)
@ -232,7 +232,7 @@ instance FromJSON (DecimalRaw Integer)
-- | Show a JSON-convertible haskell value as pretty-printed JSON text. -- | Show a JSON-convertible haskell value as pretty-printed JSON text.
toJsonText :: ToJSON a => a -> TL.Text toJsonText :: ToJSON a => a -> TL.Text
toJsonText = (<>"\n") . toLazyText . encodePrettyToTextBuilder toJsonText = TB.toLazyText . (<> TB.fromText "\n") . encodePrettyToTextBuilder
-- | Write a JSON-convertible haskell value to a pretty-printed JSON file. -- | Write a JSON-convertible haskell value to a pretty-printed JSON file.
-- Eg: writeJsonFile "a.json" nulltransaction -- Eg: writeJsonFile "a.json" nulltransaction

View File

@ -24,8 +24,7 @@ where
import Data.List import Data.List
import Data.List.Extra (nubSort) import Data.List.Extra (nubSort)
import Data.Maybe import Data.Maybe
-- import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Calendar import Data.Time.Calendar
import Safe (headMay, lastMay) import Safe (headMay, lastMay)
@ -38,9 +37,7 @@ import Hledger.Reports.ReportOptions
-- | A postings report is a list of postings with a running total, a label -- | A postings report is a list of postings with a running total, a label
-- for the total field, and a little extra transaction info to help with rendering. -- for the total field, and a little extra transaction info to help with rendering.
-- This is used eg for the register command. -- This is used eg for the register command.
type PostingsReport = (String -- label for the running balance column XXX remove type PostingsReport = [PostingsReportItem] -- line items, one per posting
,[PostingsReportItem] -- line items, one per posting
)
type PostingsReportItem = (Maybe Day -- The posting date, if this is the first posting in a type PostingsReportItem = (Maybe Day -- The posting date, if this is the first posting in a
-- transaction or if it's different from the previous -- transaction or if it's different from the previous
-- posting's date. Or if this a summary posting, the -- posting's date. Or if this a summary posting, the
@ -49,7 +46,7 @@ type PostingsReportItem = (Maybe Day -- The posting date, if this is the firs
,Maybe Day -- If this is a summary posting, the report interval's ,Maybe Day -- If this is a summary posting, the report interval's
-- end date if this is the first summary posting in -- end date if this is the first summary posting in
-- the interval. -- the interval.
,Maybe String -- The posting's transaction's description, if this is the first posting in the transaction. ,Maybe Text -- The posting's transaction's description, if this is the first posting in the transaction.
,Posting -- The posting, possibly with the account name depth-clipped. ,Posting -- The posting, possibly with the account name depth-clipped.
,MixedAmount -- The running total after this posting, or with --average, ,MixedAmount -- The running total after this posting, or with --average,
-- the running average posting amount. With --historical, -- the running average posting amount. With --historical,
@ -66,8 +63,7 @@ type SummaryPosting = (Posting, Day)
-- | Select postings from the journal and add running balance and other -- | Select postings from the journal and add running balance and other
-- information to make a postings report. Used by eg hledger's register command. -- information to make a postings report. Used by eg hledger's register command.
postingsReport :: ReportSpec -> Journal -> PostingsReport postingsReport :: ReportSpec -> Journal -> PostingsReport
postingsReport rspec@ReportSpec{rsOpts=ropts@ReportOpts{..}} j = postingsReport rspec@ReportSpec{rsOpts=ropts@ReportOpts{..}} j = items
(totallabel, items)
where where
reportspan = adjustReportDates rspec j reportspan = adjustReportDates rspec j
whichdate = whichDateFromOpts ropts whichdate = whichDateFromOpts ropts
@ -130,8 +126,6 @@ registerRunningCalculationFn ropts
| average_ ropts = \i avg amt -> avg + divideMixedAmount (fromIntegral i) (amt - avg) | average_ ropts = \i avg amt -> avg + divideMixedAmount (fromIntegral i) (amt - avg)
| otherwise = \_ bal amt -> bal + amt | otherwise = \_ bal amt -> bal + amt
totallabel = "Total"
-- | Adjust report start/end dates to more useful ones based on -- | Adjust report start/end dates to more useful ones based on
-- journal data and report intervals. Ie: -- journal data and report intervals. Ie:
-- 1. If the start date is unspecified, use the earliest date in the journal (if any) -- 1. If the start date is unspecified, use the earliest date in the journal (if any)
@ -206,14 +200,13 @@ mkpostingsReportItem :: Bool -> Bool -> WhichDate -> Maybe Day -> Posting -> Mix
mkpostingsReportItem showdate showdesc wd menddate p b = mkpostingsReportItem showdate showdesc wd menddate p b =
(if showdate then Just date else Nothing (if showdate then Just date else Nothing
,menddate ,menddate
,if showdesc then Just desc else Nothing ,if showdesc then tdescription <$> ptransaction p else Nothing
,p ,p
,b ,b
) )
where where
date = case wd of PrimaryDate -> postingDate p date = case wd of PrimaryDate -> postingDate p
SecondaryDate -> postingDate2 p SecondaryDate -> postingDate2 p
desc = T.unpack $ maybe "" tdescription $ ptransaction p
-- | Convert a list of postings into summary postings, one per interval, -- | Convert a list of postings into summary postings, one per interval,
-- aggregated to the specified depth if any. -- aggregated to the specified depth if any.
@ -267,7 +260,7 @@ negatePostingAmount p = p { pamount = negate $ pamount p }
tests_PostingsReport = tests "PostingsReport" [ tests_PostingsReport = tests "PostingsReport" [
test "postingsReport" $ do test "postingsReport" $ do
let (query, journal) `gives` n = (length $ snd $ postingsReport defreportspec{rsQuery=query} journal) @?= n let (query, journal) `gives` n = (length $ postingsReport defreportspec{rsQuery=query} journal) @?= n
-- with the query specified explicitly -- with the query specified explicitly
(Any, nulljournal) `gives` 0 (Any, nulljournal) `gives` 0
(Any, samplejournal) `gives` 13 (Any, samplejournal) `gives` 13
@ -276,10 +269,10 @@ tests_PostingsReport = tests "PostingsReport" [
(And [Depth 1, StatusQ Cleared, Acct (toRegex' "expenses")], samplejournal) `gives` 2 (And [Depth 1, StatusQ Cleared, Acct (toRegex' "expenses")], samplejournal) `gives` 2
(And [And [Depth 1, StatusQ Cleared], Acct (toRegex' "expenses")], samplejournal) `gives` 2 (And [And [Depth 1, StatusQ Cleared], Acct (toRegex' "expenses")], samplejournal) `gives` 2
-- with query and/or command-line options -- with query and/or command-line options
(length $ snd $ postingsReport defreportspec samplejournal) @?= 13 (length $ postingsReport defreportspec samplejournal) @?= 13
(length $ snd $ postingsReport defreportspec{rsOpts=defreportopts{interval_=Months 1}} samplejournal) @?= 11 (length $ postingsReport defreportspec{rsOpts=defreportopts{interval_=Months 1}} samplejournal) @?= 11
(length $ snd $ postingsReport defreportspec{rsOpts=defreportopts{interval_=Months 1, empty_=True}} samplejournal) @?= 20 (length $ postingsReport defreportspec{rsOpts=defreportopts{interval_=Months 1, empty_=True}} samplejournal) @?= 20
(length $ snd $ postingsReport defreportspec{rsQuery=Acct $ toRegex' "assets:bank:checking"} samplejournal) @?= 5 (length $ postingsReport defreportspec{rsQuery=Acct $ toRegex' "assets:bank:checking"} samplejournal) @?= 5
-- (defreportopts, And [Acct "a a", Acct "'b"], samplejournal2) `gives` 0 -- (defreportopts, And [Acct "a a", Acct "'b"], samplejournal2) `gives` 0
-- [(Just (fromGregorian 2008 01 01,"income"),assets:bank:checking $1,$1) -- [(Just (fromGregorian 2008 01 01,"income"),assets:bank:checking $1,$1)

View File

@ -30,6 +30,8 @@ import qualified Data.Set as S
import Data.Maybe import Data.Maybe
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.IO as TL
import Data.Time.Calendar (Day) import Data.Time.Calendar (Day)
import Data.Time.Format (formatTime, defaultTimeLocale, iso8601DateFormat) import Data.Time.Format (formatTime, defaultTimeLocale, iso8601DateFormat)
import Safe (headDef, headMay, atMay) import Safe (headDef, headMay, atMay)
@ -442,7 +444,7 @@ journalAddTransaction j@Journal{jtxns=ts} opts t = do
-- unelided shows all amounts explicitly, in case there's a price, cf #283 -- unelided shows all amounts explicitly, in case there's a price, cf #283
when (debug_ opts > 0) $ do when (debug_ opts > 0) $ do
putStrLn $ printf "\nAdded transaction to %s:" f putStrLn $ printf "\nAdded transaction to %s:" f
putStrLn =<< registerFromString (showTransaction t) TL.putStrLn =<< registerFromString (T.pack $ showTransaction t)
return j{jtxns=ts++[t]} return j{jtxns=ts++[t]}
-- | Append a string, typically one or more transactions, to a journal -- | Append a string, typically one or more transactions, to a journal
@ -464,9 +466,9 @@ ensureOneNewlineTerminated :: String -> String
ensureOneNewlineTerminated = (++"\n") . reverse . dropWhile (=='\n') . reverse ensureOneNewlineTerminated = (++"\n") . reverse . dropWhile (=='\n') . reverse
-- | Convert a string of journal data into a register report. -- | Convert a string of journal data into a register report.
registerFromString :: String -> IO String registerFromString :: Text -> IO TL.Text
registerFromString s = do registerFromString s = do
j <- readJournal' $ T.pack s j <- readJournal' s
return . postingsReportAsText opts $ postingsReport rspec j return . postingsReportAsText opts $ postingsReport rspec j
where where
ropts = defreportopts{empty_=True} ropts = defreportopts{empty_=True}

View File

@ -23,6 +23,7 @@ import Data.Maybe
-- import Data.Text (Text) -- import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Builder as TB
import System.Console.CmdArgs.Explicit import System.Console.CmdArgs.Explicit
import Hledger.Read.CsvReader (CSV, CsvRecord, printCSV) import Hledger.Read.CsvReader (CSV, CsvRecord, printCSV)
@ -58,16 +59,17 @@ registermode = hledgerCommandMode
-- | Print a (posting) register report. -- | Print a (posting) register report.
register :: CliOpts -> Journal -> IO () register :: CliOpts -> Journal -> IO ()
register opts@CliOpts{reportspec_=rspec} j = do register opts@CliOpts{reportspec_=rspec} j =
let fmt = outputFormatFromOpts opts writeOutputLazyText opts . render $ postingsReport rspec j
render | fmt=="txt" = postingsReportAsText where
| fmt=="csv" = const ((++"\n") . printCSV . postingsReportAsCsv) fmt = outputFormatFromOpts opts
| fmt=="json" = const ((++"\n") . TL.unpack . toJsonText) render | fmt=="txt" = postingsReportAsText opts
| otherwise = const $ error' $ unsupportedOutputFormatError fmt -- PARTIAL: | fmt=="csv" = TL.pack . printCSV . postingsReportAsCsv
writeOutput opts . render opts $ postingsReport rspec j | fmt=="json" = toJsonText
| otherwise = error' $ unsupportedOutputFormatError fmt -- PARTIAL:
postingsReportAsCsv :: PostingsReport -> CSV postingsReportAsCsv :: PostingsReport -> CSV
postingsReportAsCsv (_,is) = postingsReportAsCsv is =
["txnidx","date","code","description","account","amount","total"] ["txnidx","date","code","description","account","amount","total"]
: :
map postingsReportItemAsCsvRecord is map postingsReportItemAsCsvRecord is
@ -89,13 +91,17 @@ postingsReportItemAsCsvRecord (_, _, _, p, b) = [idx,date,code,desc,acct,amt,bal
bal = showMixedAmountOneLineWithoutPrice False b bal = showMixedAmountOneLineWithoutPrice False b
-- | Render a register report as plain text suitable for console output. -- | Render a register report as plain text suitable for console output.
postingsReportAsText :: CliOpts -> PostingsReport -> String postingsReportAsText :: CliOpts -> PostingsReport -> TL.Text
postingsReportAsText opts (_,items) = unlines $ map (postingsReportItemAsText opts amtwidth balwidth) items postingsReportAsText opts items =
TB.toLazyText . unlinesB $
map (postingsReportItemAsText opts amtwidth balwidth) items
where where
amtwidth = maximumStrict $ map (snd . showMixed showAmount (Just 12) Nothing False . itemamt) items amtwidth = maximumStrict $ map (snd . showMixed showAmount (Just 12) Nothing False . itemamt) items
balwidth = maximumStrict $ map (snd . showMixed showAmount (Just 12) Nothing False . itembal) items balwidth = maximumStrict $ map (snd . showMixed showAmount (Just 12) Nothing False . itembal) items
itemamt (_,_,_,Posting{pamount=a},_) = a itemamt (_,_,_,Posting{pamount=a},_) = a
itembal (_,_,_,_,a) = a itembal (_,_,_,_,a) = a
unlinesB [] = mempty
unlinesB xs = mconcat (intersperse (TB.fromText "\n") xs) <> TB.fromText "\n"
-- | Render one register report line item as plain text. Layout is like so: -- | Render one register report line item as plain text. Layout is like so:
-- @ -- @
@ -119,36 +125,30 @@ postingsReportAsText opts (_,items) = unlines $ map (postingsReportItemAsText op
-- has multiple commodities. Does not yet support formatting control -- has multiple commodities. Does not yet support formatting control
-- like balance reports. -- like balance reports.
-- --
postingsReportItemAsText :: CliOpts -> Int -> Int -> PostingsReportItem -> String postingsReportItemAsText :: CliOpts -> Int -> Int -> PostingsReportItem -> TB.Builder
postingsReportItemAsText opts preferredamtwidth preferredbalwidth (mdate, menddate, mdesc, p, b) = postingsReportItemAsText opts preferredamtwidth preferredbalwidth (mdate, menddate, mdesc, p, b) =
-- use elide*Width to be wide-char-aware -- use elide*Width to be wide-char-aware
-- trace (show (totalwidth, datewidth, descwidth, acctwidth, amtwidth, balwidth)) $ -- trace (show (totalwidth, datewidth, descwidth, acctwidth, amtwidth, balwidth)) $
intercalate "\n" $ foldMap mconcat . intersperse ([TB.fromText "\n"]) . map (map TB.fromText) $
concat [fitString (Just datewidth) (Just datewidth) True True date [ fitText (Just datewidth) (Just datewidth) True True date
," " , " "
,fitString (Just descwidth) (Just descwidth) True True desc , fitText (Just descwidth) (Just descwidth) True True desc
," " , " "
,fitString (Just acctwidth) (Just acctwidth) True True acct , fitText (Just acctwidth) (Just acctwidth) True True acct
," " , " "
,amtfirstline , amtfirstline
," " , " "
,balfirstline , balfirstline
] ]
: :
[concat [spacer [ [ spacer, a, " ", b ] | (a,b) <- zip amtrest balrest ]
,a
," "
,b
]
| (a,b) <- zip amtrest balrest
]
where where
-- calculate widths -- calculate widths
(totalwidth,mdescwidth) = registerWidthsFromOpts opts (totalwidth,mdescwidth) = registerWidthsFromOpts opts
(datewidth, date) = case (mdate,menddate) of (datewidth, date) = case (mdate,menddate) of
(Just _, Just _) -> (21, showDateSpan (DateSpan mdate menddate)) (Just _, Just _) -> (21, T.pack $ showDateSpan (DateSpan mdate menddate))
(Nothing, Just _) -> (21, "") (Nothing, Just _) -> (21, "")
(Just d, Nothing) -> (10, showDate d) (Just d, Nothing) -> (10, T.pack $ showDate d)
_ -> (10, "") _ -> (10, "")
(amtwidth, balwidth) (amtwidth, balwidth)
| shortfall <= 0 = (preferredamtwidth, preferredbalwidth) | shortfall <= 0 = (preferredamtwidth, preferredbalwidth)
@ -171,24 +171,25 @@ postingsReportItemAsText opts preferredamtwidth preferredbalwidth (mdate, mendda
-- gather content -- gather content
desc = fromMaybe "" mdesc desc = fromMaybe "" mdesc
acct = parenthesise $ T.unpack $ elideAccountName awidth $ paccount p acct = parenthesise . elideAccountName awidth $ paccount p
where where
(parenthesise, awidth) = (parenthesise, awidth) =
case ptype p of case ptype p of
BalancedVirtualPosting -> (\s -> "["++s++"]", acctwidth-2) BalancedVirtualPosting -> (\s -> wrap "[" "]" s, acctwidth-2)
VirtualPosting -> (\s -> "("++s++")", acctwidth-2) VirtualPosting -> (\s -> wrap "(" ")" s, acctwidth-2)
_ -> (id,acctwidth) _ -> (id,acctwidth)
amt = fst $ showMixed showAmountWithoutPrice (Just amtwidth) (Just amtwidth) (color_ . rsOpts $ reportspec_ opts) $ pamount p wrap a b x = a <> x <> b
bal = fst $ showMixed showAmountWithoutPrice (Just balwidth) (Just balwidth) (color_ . rsOpts $ reportspec_ opts) b amt = T.pack . fst $ showMixed showAmountWithoutPrice (Just amtwidth) (Just amtwidth) (color_ . rsOpts $ reportspec_ opts) $ pamount p
bal = T.pack . fst $ showMixed showAmountWithoutPrice (Just balwidth) (Just balwidth) (color_ . rsOpts $ reportspec_ opts) b
-- alternate behaviour, show null amounts as 0 instead of blank -- alternate behaviour, show null amounts as 0 instead of blank
-- amt = if null amt' then "0" else amt' -- amt = if null amt' then "0" else amt'
-- bal = if null bal' then "0" else bal' -- bal = if null bal' then "0" else bal'
(amtlines, ballines) = (lines amt, lines bal) (amtlines, ballines) = (T.lines amt, T.lines bal)
(amtlen, ballen) = (length amtlines, length ballines) (amtlen, ballen) = (length amtlines, length ballines)
numlines = max 1 (max amtlen ballen) numlines = max 1 (max amtlen ballen)
(amtfirstline:amtrest) = take numlines $ amtlines ++ repeat (replicate amtwidth ' ') -- posting amount is top-aligned (amtfirstline:amtrest) = take numlines $ amtlines ++ repeat (T.replicate amtwidth " ") -- posting amount is top-aligned
(balfirstline:balrest) = take numlines $ replicate (numlines - ballen) (replicate balwidth ' ') ++ ballines -- balance amount is bottom-aligned (balfirstline:balrest) = take numlines $ replicate (numlines - ballen) (T.replicate balwidth " ") ++ ballines -- balance amount is bottom-aligned
spacer = replicate (totalwidth - (amtwidth + 2 + balwidth)) ' ' spacer = T.replicate (totalwidth - (amtwidth + 2 + balwidth)) " "
-- tests -- tests
@ -198,7 +199,7 @@ tests_Register = tests "Register" [
test "unicode in register layout" $ do test "unicode in register layout" $ do
j <- readJournal' "2009/01/01 * медвежья шкура\n расходы:покупки 100\n актив:наличные\n" j <- readJournal' "2009/01/01 * медвежья шкура\n расходы:покупки 100\n актив:наличные\n"
let rspec = defreportspec let rspec = defreportspec
(postingsReportAsText defcliopts $ postingsReport rspec j) (TL.unpack . postingsReportAsText defcliopts $ postingsReport rspec j)
@?= @?=
unlines unlines
["2009-01-01 медвежья шкура расходы:покупки 100 100" ["2009-01-01 медвежья шкура расходы:покупки 100 100"

View File

@ -10,6 +10,7 @@ where
import Data.Char (toUpper) import Data.Char (toUpper)
import Data.List import Data.List
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Lazy.IO as TL
import Hledger import Hledger
import Hledger.Cli.CliOptions import Hledger.Cli.CliOptions
import Hledger.Cli.Commands.Register import Hledger.Cli.Commands.Register
@ -25,14 +26,13 @@ registermatch :: CliOpts -> Journal -> IO ()
registermatch opts@CliOpts{rawopts_=rawopts,reportspec_=rspec} j = registermatch opts@CliOpts{rawopts_=rawopts,reportspec_=rspec} j =
case listofstringopt "args" rawopts of case listofstringopt "args" rawopts of
[desc] -> do [desc] -> do
let (_,pris) = postingsReport rspec j let ps = [p | (_,_,_,p,_) <- postingsReport rspec j]
ps = [p | (_,_,_,p,_) <- pris]
case similarPosting ps desc of case similarPosting ps desc of
Nothing -> putStrLn "no matches found." Nothing -> putStrLn "no matches found."
Just p -> putStr $ postingsReportAsText opts ("",[pri]) Just p -> TL.putStr $ postingsReportAsText opts [pri]
where pri = (Just (postingDate p) where pri = (Just (postingDate p)
,Nothing ,Nothing
,Just $ T.unpack (maybe "" tdescription $ ptransaction p) ,tdescription <$> ptransaction p
,p ,p
,0) ,0)
_ -> putStrLn "please provide one description argument." _ -> putStrLn "please provide one description argument."

View File

@ -13,6 +13,7 @@ module Hledger.Cli.Utils
unsupportedOutputFormatError, unsupportedOutputFormatError,
withJournalDo, withJournalDo,
writeOutput, writeOutput,
writeOutputLazyText,
journalTransform, journalTransform,
journalAddForecast, journalAddForecast,
journalReload, journalReload,
@ -34,6 +35,8 @@ import Data.List
import Data.Maybe import Data.Maybe
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.IO as T import qualified Data.Text.IO as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.IO as TL
import Data.Time (UTCTime, Day, addDays) import Data.Time (UTCTime, Day, addDays)
import Safe (readMay) import Safe (readMay)
import System.Console.CmdArgs import System.Console.CmdArgs
@ -159,6 +162,14 @@ writeOutput opts s = do
f <- outputFileFromOpts opts f <- outputFileFromOpts opts
(if f == "-" then putStr else writeFile f) s (if f == "-" then putStr else writeFile f) s
-- | Write some output to stdout or to a file selected by --output-file.
-- If the file exists it will be overwritten. This function operates on Lazy
-- Text values.
writeOutputLazyText :: CliOpts -> TL.Text -> IO ()
writeOutputLazyText opts s = do
f <- outputFileFromOpts opts
(if f == "-" then TL.putStr else TL.writeFile f) s
-- -- | Get a journal from the given string and options, or throw an error. -- -- | Get a journal from the given string and options, or throw an error.
-- readJournal :: CliOpts -> String -> IO Journal -- readJournal :: CliOpts -> String -> IO Journal
-- readJournal opts s = readJournal def Nothing s >>= either error' return -- readJournal opts s = readJournal def Nothing s >>= either error' return