diff --git a/hledger-lib/Hledger/Reports/ReportOptions.hs b/hledger-lib/Hledger/Reports/ReportOptions.hs index a5dce589c0e..257f442f2c7 100644 --- a/hledger-lib/Hledger/Reports/ReportOptions.hs +++ b/hledger-lib/Hledger/Reports/ReportOptions.hs @@ -124,6 +124,7 @@ instance Default AccountListMode where def = ALFlat data Layout = LayoutWide (Maybe Int) | LayoutTall | LayoutBare + | LayoutBareWide | LayoutTidy deriving (Eq, Show) @@ -432,6 +433,7 @@ layoutopt rawopts = fromMaybe (LayoutWide Nothing) $ layout <|> column , ("tall", LayoutTall) , ("bare", LayoutBare) , ("tidy", LayoutTidy) + , ("barewide", LayoutBareWide) ] -- For `--layout=elided,n`, elide to the given width (s,n) = break (==',') $ map toLower opt @@ -440,7 +442,7 @@ layoutopt rawopts = fromMaybe (LayoutWide Nothing) $ layout <|> column c | Just w' <- readMay c -> Just w' _ -> usageError "width in --layout=wide,WIDTH must be an integer" - err = usageError "--layout's argument should be \"wide[,WIDTH]\", \"tall\", \"bare\", or \"tidy\"" + err = usageError "--layout's argument should be \"wide[,WIDTH]\", \"tall\", \"bare\", \"barewide\", or \"tidy\"" -- Get the period specified by any -b/--begin, -e/--end and/or -p/--period -- options appearing in the command line. diff --git a/hledger-lib/Hledger/Write/Html/Blaze.hs b/hledger-lib/Hledger/Write/Html/Blaze.hs index 099bf11fb17..53f76e6e852 100644 --- a/hledger-lib/Hledger/Write/Html/Blaze.hs +++ b/hledger-lib/Hledger/Write/Html/Blaze.hs @@ -50,16 +50,18 @@ formatCell cell = let class_ = map (HtmlAttr.class_ . Html.textValue) $ filter (not . Text.null) [Spr.textFromClass $ cellClass cell] in + let addSpan spanAttr n attrs = + if n==1 + then attrs + else spanAttr (Html.stringValue $ show n) : attrs in let span_ makeCell attrs = case Spr.cellSpan cell of Spr.NoSpan -> foldl (!) makeCell attrs Spr.Covered -> pure () Spr.SpanHorizontal n -> - foldl (!) makeCell - (HtmlAttr.colspan (Html.stringValue $ show n) : attrs) + foldl (!) makeCell (addSpan HtmlAttr.colspan n attrs) Spr.SpanVertical n -> - foldl (!) makeCell - (HtmlAttr.rowspan (Html.stringValue $ show n) : attrs) + foldl (!) makeCell (addSpan HtmlAttr.rowspan n attrs) in case cellStyle cell of Head -> span_ (Html.th content) (style++class_) diff --git a/hledger-lib/Hledger/Write/Html/Lucid.hs b/hledger-lib/Hledger/Write/Html/Lucid.hs index 3b2dca3bb39..4cc6f166e98 100644 --- a/hledger-lib/Hledger/Write/Html/Lucid.hs +++ b/hledger-lib/Hledger/Write/Html/Lucid.hs @@ -88,14 +88,18 @@ formatCell cell = [filter (not . Text.null) $ Spr.textFromClass (cellClass cell) : ["date" | cellType cell == TypeDate]] in + let addSpan spanAttr n attrs = + if n==1 + then attrs + else spanAttr (Text.pack $ show n) : attrs in let span_ makeCell attrs cont = case Spr.cellSpan cell of Spr.NoSpan -> makeCell attrs cont Spr.Covered -> pure () Spr.SpanHorizontal n -> - makeCell (L.colspan_ (Text.pack $ show n) : attrs) cont + makeCell (addSpan L.colspan_ n attrs) cont Spr.SpanVertical n -> - makeCell (L.rowspan_ (Text.pack $ show n) : attrs) cont + makeCell (addSpan L.rowspan_ n attrs) cont in case cellStyle cell of Head -> span_ L.th_ (style++class_) content diff --git a/hledger-lib/Hledger/Write/Spreadsheet.hs b/hledger-lib/Hledger/Write/Spreadsheet.hs index d60d8d82034..8fc6cb0fa9b 100644 --- a/hledger-lib/Hledger/Write/Spreadsheet.hs +++ b/hledger-lib/Hledger/Write/Spreadsheet.hs @@ -21,6 +21,7 @@ module Hledger.Write.Spreadsheet ( horizontalSpan, addHeaderBorders, addRowSpanHeader, + addRowSpanHeaderNE, rawTableContent, cellFromMixedAmount, cellsFromMixedAmount, @@ -32,6 +33,8 @@ import Hledger.Data.Amount qualified as Amt import Hledger.Data.Types (Amount, MixedAmount, acommodity) import Hledger.Data.Amount (AmountFormat) +import Data.Foldable qualified as Fold +import Data.List.NonEmpty (NonEmpty((:|))) import Data.List qualified as List import Data.Maybe (isNothing) import Data.Text qualified as Text @@ -189,9 +192,9 @@ transpose :: [[Cell border text]] -> [[Cell border text]] transpose = List.transpose . map (map transposeCell) -addHeaderBorders :: [Cell () text] -> [Cell NumLines text] +addHeaderBorders :: (Functor f) => f (Cell () text) -> f (Cell NumLines text) addHeaderBorders = - map (\c -> c {cellBorder = noBorder {borderBottom = DoubleLine}}) + fmap (\c -> c {cellBorder = noBorder {borderBottom = DoubleLine}}) horizontalSpan :: (Lines border, Monoid text) => @@ -205,18 +208,23 @@ horizontalSpan subCells cell = addRowSpanHeader :: Cell border text -> [[Cell border text]] -> [[Cell border text]] -addRowSpanHeader header rows = +addRowSpanHeader header = map Fold.toList . addRowSpanHeaderNE header + +addRowSpanHeaderNE :: + Cell border text -> + [[Cell border text]] -> [NonEmpty (Cell border text)] +addRowSpanHeaderNE header rows = case rows of [] -> [] - [row] -> [header:row] + [row] -> [header:|row] _ -> - zipWith (:) + zipWith (:|) (header{cellSpan = SpanVertical (length rows)} : repeat header{cellSpan = Covered}) rows -rawTableContent :: [[Cell border text]] -> [[text]] -rawTableContent = map (map cellContent) +rawTableContent :: (Functor f) => [f (Cell border text)] -> [f text] +rawTableContent = map (fmap cellContent) diff --git a/hledger/Hledger/Cli/Commands/Balance.hs b/hledger/Hledger/Cli/Commands/Balance.hs index 197da216b0d..b784d11e6f9 100644 --- a/hledger/Hledger/Cli/Commands/Balance.hs +++ b/hledger/Hledger/Cli/Commands/Balance.hs @@ -261,6 +261,7 @@ module Hledger.Cli.Commands.Balance ( ,multiBalanceReportAsCsv ,multiBalanceReportAsHtml ,multiBalanceReportAsTable + ,multiBalanceReportAsPartTable ,multiBalanceReportTableAsText ,multiBalanceReportAsSpreadsheet ,multiBalanceReportAsSpreadsheetParts @@ -280,13 +281,16 @@ import Control.Monad (guard) import Data.Decimal (roundTo) import Data.Default (def) import Data.Function (on) +import Data.Foldable qualified as Fold +import Data.List.NonEmpty qualified as NonEmpty +import Data.List.NonEmpty (NonEmpty((:|))) import Data.List (find, transpose) #if !MIN_VERSION_base(4,20,0) import Data.List (foldl') #endif import Data.Map qualified as Map import Data.Set qualified as S -import Data.Maybe (mapMaybe, fromMaybe) +import Data.Maybe (mapMaybe, fromMaybe, maybeToList) import Data.Tuple (swap) import Data.Text (Text) import Data.Text qualified as T @@ -297,7 +301,7 @@ import System.Console.CmdArgs.Explicit as C (flagNone, flagReq, flagOpt) import Safe (headMay, maximumMay) import Text.Tabular.AsciiWide (Header(..), Align(..), Properties(..), Cell(..), Table(..), TableOpts(..), - cellWidth, concatTables, renderColumns, renderRowB, renderTableByRowsB, textCell) + cellWidth, concatTables, renderColumns, renderRowB, renderTableB, renderTableByRowsB, textCell) import System.IO qualified as IO @@ -309,8 +313,8 @@ import Hledger.Write.Csv (CSV, printCSV, printTSV) import Hledger.Write.Ods (printFods) import Hledger.Write.Html (Html, titledTableHtml, htmlAsLazyText, toHtml) import Hledger.Write.Spreadsheet (rawTableContent, headerCell, - addHeaderBorders, addRowSpanHeader, - cellFromMixedAmount, cellsFromMixedAmount) + addHeaderBorders, addRowSpanHeader, addRowSpanHeaderNE, + cellFromMixedAmount, cellsFromMixedAmount, cellFromAmount) import Hledger.Write.Spreadsheet qualified as Ods @@ -401,7 +405,7 @@ balance opts@CliOpts{reportspec_=rspec} j = case balancecalc_ ropts of "tsv" -> printTSV . budgetReportAsCsv ropts "html" -> (<>"\n") . htmlAsLazyText . budgetReportAsHtml ropts "fods" -> printFods IO.localeEncoding . - Map.singleton "Budget Report" . (,) (1,0) . budgetReportAsSpreadsheet oneLineNoCostFmt ropts + Map.singleton "Budget Report" . budgetReportAsSpreadsheet oneLineNoCostFmt ropts _ -> error' $ unsupportedOutputFormatError fmt writeOutputLazyText opts $ render budgetreport @@ -496,6 +500,7 @@ balanceReportAsText :: ReportOpts -> BalanceReport -> TB.Builder balanceReportAsText originalopts report@((items, total)) = case layout_ originalopts of LayoutBare | iscustom -> error' "Custom format not supported with commodity columns" -- PARTIAL: LayoutBare -> bareLayoutBalanceReportAsText originalopts report + LayoutBareWide -> bareWideLayoutBalanceReportAsText originalopts report _ -> unlinesB ls <> unlinesB (if no_total_ opts then [] else [overline, totalLines]) where opts = widenDefaultBalanceLineFormat originalopts report @@ -568,6 +573,26 @@ bareLayoutBalanceReportAsText opts (items, total) = singleColumnTableOuterBorder = pretty_ opts singleColumnTableInterColumnBorder = if pretty_ opts then SingleLine else NoLine +-- | Render a single-column balance report as plain text with a separate commodity column (--layout=barewide) +bareWideLayoutBalanceReportAsText :: ReportOpts -> BalanceReport -> TB.Builder +bareWideLayoutBalanceReportAsText opts br = + renderTableB tableopts (textCell TopLeft) (textCell TopLeft) (textCell TopRight) $ + Table + (if null totalheadings + then Group NoLine rowheadings + else Group SingleLine $ map (Group NoLine) [rowheadings, totalheadings]) + (Group NoLine $ map (Header . Ods.cellContent) $ NonEmpty.tail header) + (rows++totaldata) + where + tableopts = def{tableBorders=singleColumnTableOuterBorder, prettyTable=pretty_ opts} + (header, body, totals) = balanceReportAsSpreadsheetParts fmt opts br + fmt = oneLineNoCostFmt{displayColour=color_ opts} + separateHeaders = + unzip . map (\(hd:|tl) -> (Header hd, tl)) . rawTableContent + (rowheadings,rows) = separateHeaders body + (totalheadings,totaldata) = separateHeaders totals + singleColumnTableOuterBorder = pretty_ opts + {- This implementation turned out to be a bit convoluted but implements the following algorithm for formatting: @@ -635,14 +660,19 @@ renderComponent topaligned oneline opts (acctname, dep, total) (FormatField ljus } +headerWithoutBorders :: [Ods.Cell () text] -> [Ods.Cell Ods.NumLines text] +headerWithoutBorders = map (\c -> c {Ods.cellBorder = Ods.noBorder}) + simpleDateSpanCell :: PeriodTitles -> DateSpan -> Ods.Cell Ods.NumLines Text simpleDateSpanCell ph = Ods.defaultCell . renderPeriodHeading ph -addTotalBorders :: [[Ods.Cell border text]] -> [[Ods.Cell Ods.NumLines text]] +addTotalBorders :: + (Functor f) => + [f (Ods.Cell border text)] -> [f (Ods.Cell Ods.NumLines text)] addTotalBorders = zipWith (\border -> - map (\c -> c { + fmap (\c -> c { Ods.cellStyle = Ods.Body Ods.Total, Ods.cellBorder = Ods.noBorder {Ods.borderTop = border}})) (Ods.DoubleLine : repeat Ods.NoLine) @@ -658,23 +688,39 @@ balanceReportAsHtml ropts br = -- | Render a single-column balance report as FODS. balanceReportAsSpreadsheet :: AmountFormat -> ReportOpts -> BalanceReport -> [[Ods.Cell Ods.NumLines Text]] -balanceReportAsSpreadsheet fmt opts (items, total) = +balanceReportAsSpreadsheet fmt opts mbr = (if transpose_ opts then Ods.transpose else id) $ - headers : - concatMap (rows Value) items ++ - if no_total_ opts then [] - else addTotalBorders $ - rows Total (totalRowHeadingSpreadsheet, totalRowHeadingSpreadsheet, 0, total) + map Fold.toList $ header : body ++ totals + where (header, body, totals) = + balanceReportAsSpreadsheetParts fmt opts mbr + +-- | Render the ODS table rows for a BalanceReport. +-- Returns the heading row, 0 or more body rows, and the totals row if enabled. +balanceReportAsSpreadsheetParts :: + AmountFormat -> ReportOpts -> BalanceReport -> + (NonEmpty (Ods.Cell Ods.NumLines Text), + [NonEmpty (Ods.Cell Ods.NumLines Text)], + [NonEmpty (Ods.Cell Ods.NumLines Text)]) +balanceReportAsSpreadsheetParts fmt opts (items, total) = + (headers, + concatMap (rows Value) items, + if no_total_ opts + then [] + else addTotalBorders $ + rows Total (totalRowHeadingSpreadsheet, totalRowHeadingSpreadsheet, 0, total)) where cell = Ods.defaultCell headers = - addHeaderBorders $ map headerCell $ - "account" : case layout_ opts of + addHeaderBorders $ fmap headerCell $ + "account" :| case layout_ opts of + LayoutBareWide -> allCommodities LayoutBare -> ["commodity", "balance"] _ -> ["balance"] + allCommodities = + S.toAscList $ foldMap (\(_,_,_,ma) -> maCommodities ma) items rows :: RowClass -> BalanceReportItem -> - [[Ods.Cell Ods.NumLines Text]] + [NonEmpty (Ods.Cell Ods.NumLines Text)] rows rc (name, dispName, dep, ma) = let accountCell = setAccountAnchor @@ -683,8 +729,17 @@ balanceReportAsSpreadsheet fmt opts (items, total) = cell $ case rc of Total -> dispName -- show the total row heading as is; --drop etc. don't apply (#2688) Value -> renderBalanceAcct opts nbsp (name, dispName, dep) in - addRowSpanHeader accountCell $ + addRowSpanHeaderNE accountCell $ case layout_ opts of + LayoutBareWide -> + let bopts = + machineFmt { + displayCommodity = False, + displayCommodityOrder = Just allCommodities + } in + [map (\bldAmt -> + fmap wbToText $ cellFromAmount bopts (amountClass rc, bldAmt)) $ + showMixedAmountLinesPartsB bopts ma] LayoutBare -> map (\a -> [cell $ acommodity a, renderAmount rc $ mixedAmount a]) . amounts $ mixedAmountStripCosts ma @@ -708,29 +763,41 @@ balanceReportAsSpreadsheet fmt opts (items, total) = multiBalanceReportAsCsv :: ReportOpts -> MultiBalanceReport -> CSV multiBalanceReportAsCsv opts@ReportOpts{..} report = (if transpose_ then transpose else id) $ - rawTableContent $ header : body ++ totals + rawTableContent $ header ++ body ++ totals where (header, body, totals) = - multiBalanceReportAsSpreadsheetParts machineFmt opts report + multiBalanceReportAsSpreadsheetParts machineFmt opts + (allCommoditiesFromPeriodicReport $ prRows report) report -- | Render the Spreadsheet table rows (CSV, ODS, HTML) for a MultiBalanceReport. -- Returns the heading row, 0 or more body rows, and the totals row if enabled. multiBalanceReportAsSpreadsheetParts :: - AmountFormat -> ReportOpts -> MultiBalanceReport -> - ([Ods.Cell Ods.NumLines Text], + AmountFormat -> ReportOpts -> + [CommoditySymbol] -> MultiBalanceReport -> + ([[Ods.Cell Ods.NumLines Text]], [[Ods.Cell Ods.NumLines Text]], [[Ods.Cell Ods.NumLines Text]]) -multiBalanceReportAsSpreadsheetParts fmt opts@ReportOpts{..} (PeriodicReport colspans items tr) = - (headers, concatMap fullRowAsTexts items, addTotalBorders totalrows) +multiBalanceReportAsSpreadsheetParts fmt opts@ReportOpts{..} + allCommodities (PeriodicReport colspans items tr) = + (allHeaders, concatMap fullRowAsTexts items, addTotalBorders totalrows) where accountCell label = (Ods.defaultCell label) {Ods.cellClass = Ods.Class "account"} hCell cls label = (headerCell label) {Ods.cellClass = Ods.Class cls} + allHeaders = + case layout_ of + LayoutBareWide -> + [headerWithoutBorders $ + Ods.emptyCell : + concatMap (Ods.horizontalSpan allCommodities) dateHeaders, + headers] + _ -> [headers] headers = addHeaderBorders $ hCell "account" "account" : case layout_ of LayoutTidy -> map headerCell tidyColumnLabels + LayoutBareWide -> dateHeaders >> map headerCell allCommodities LayoutBare -> headerCell "commodity" : dateHeaders _ -> dateHeaders dateHeaders = @@ -751,7 +818,7 @@ multiBalanceReportAsSpreadsheetParts fmt opts@ReportOpts{..} (PeriodicReport col rowAsText Total (simpleDateSpanCell period_titles_) tr rowAsText rc dsCell = map (map (fmap wbToText)) . - multiBalanceRowAsCellBuilders fmt opts colspans rc dsCell + multiBalanceRowAsCellBuilders fmt opts colspans allCommodities rc dsCell tidyColumnLabels :: [Text] tidyColumnLabels = @@ -771,10 +838,12 @@ multiBalanceReportAsSpreadsheet :: ((Int, Int), [[Ods.Cell Ods.NumLines Text]]) multiBalanceReportAsSpreadsheet ropts mbr = let (header,body,total) = - multiBalanceReportAsSpreadsheetParts oneLineNoCostFmt ropts mbr + multiBalanceReportAsSpreadsheetParts oneLineNoCostFmt ropts + (allCommoditiesFromPeriodicReport $ prRows mbr) mbr in (if transpose_ ropts then swap *** Ods.transpose else id) $ - ((1, case layout_ ropts of LayoutWide _ -> 1; _ -> 0), - header : body ++ total) + ((case layout_ ropts of LayoutBareWide -> 2; _ -> 1, + case layout_ ropts of LayoutWide _ -> 1; _ -> 0), + header ++ body ++ total) -- | Render a report title as a text block to prefix to a text report, @@ -846,7 +915,17 @@ multiBalanceReportTableAsText ReportOpts{..} = renderTableByRowsB tableopts rend -- | Build a 'Table' from a multi-column balance report. multiBalanceReportAsTable :: ReportOpts -> MultiBalanceReport -> Table T.Text T.Text WideBuilder -multiBalanceReportAsTable opts@ReportOpts{summary_only_, average_, balanceaccum_} +multiBalanceReportAsTable opts report@(PeriodicReport _spans items _tr) = + multiBalanceReportAsPartTable opts + (allCommoditiesFromPeriodicReport items) + report + +multiBalanceReportAsPartTable :: + ReportOpts -> [CommoditySymbol] -> MultiBalanceReport -> + Table T.Text T.Text WideBuilder +multiBalanceReportAsPartTable + opts@ReportOpts{summary_only_, average_, balanceaccum_} + allCommodities (PeriodicReport spans items tr) = maybetranspose $ addtotalrow $ @@ -855,10 +934,19 @@ multiBalanceReportAsTable opts@ReportOpts{summary_only_, average_, balanceaccum_ (Group multiColumnTableInterColumnBorder $ map Header colheadings) (concat rows) where - colheadings = ["Commodity" | layout_ opts == LayoutBare] - ++ (if not summary_only_ then map (reportPeriodName (period_titles_ opts) balanceaccum_ spans) spans else []) - ++ [" Total" | multiBalanceHasTotalsColumn opts] - ++ ["Average" | average_] + colheadings = + ["Commodity" | layout_ opts == LayoutBare] + ++ + case layout_ opts of + LayoutBareWide -> + liftA2 (\s c -> T.concat [s, " (", c, ")"]) + spanNames allCommodities + _ -> spanNames + spanNames = + (guard (not summary_only_) >> + map (reportPeriodName (period_titles_ opts) balanceaccum_ spans) spans) + ++ [" Total" | multiBalanceHasTotalsColumn opts] + ++ ["Average" | average_] (accts, rows) = unzip $ fmap fullRowAsTexts items' where isLeaf rs row = not $ any (\r -> T.isPrefixOf (displayFull (prrName row) <> ":") (displayFull (prrName r))) rs @@ -867,12 +955,12 @@ multiBalanceReportAsTable opts@ReportOpts{summary_only_, average_, balanceaccum_ else items fullRowAsTexts row = (replicate (length rs) (renderacct row), rs) where - rs = multiBalanceRowAsText opts row + rs = multiBalanceRowAsText opts allCommodities row renderacct row' = renderPeriodicAcct opts " " row' addtotalrow | no_total_ opts = id | otherwise = - let totalrows = multiBalanceRowAsText opts tr + let totalrows = multiBalanceRowAsText opts allCommodities tr rowhdrs = Group NoLine $ map Header $ totalRowHeadingText : replicate (length totalrows - 1) "" colhdrs = Header [] -- unused, concatTables will discard in (flip (concatTables SingleLine) $ Table rowhdrs colhdrs totalrows) @@ -881,12 +969,17 @@ multiBalanceReportAsTable opts@ReportOpts{summary_only_, average_, balanceaccum_ multiColumnTableInterRowBorder = NoLine multiColumnTableInterColumnBorder = if pretty_ opts then SingleLine else NoLine +allCommoditiesFromPeriodicReport :: + [PeriodicReportRow a MixedAmount] -> [CommoditySymbol] +allCommoditiesFromPeriodicReport = + S.toAscList . foldMap (foldMap maCommodities . prrAmounts) + multiBalanceRowAsCellBuilders :: - AmountFormat -> ReportOpts -> [DateSpan] -> + AmountFormat -> ReportOpts -> [DateSpan] -> [CommoditySymbol] -> RowClass -> (DateSpan -> Ods.Cell Ods.NumLines Text) -> PeriodicReportRow a MixedAmount -> [[Ods.Cell Ods.NumLines WideBuilder]] -multiBalanceRowAsCellBuilders bopts ropts@ReportOpts{..} colspans +multiBalanceRowAsCellBuilders bopts ropts@ReportOpts{..} colspans allCommodities rc renderDateSpanCell (PeriodicReportRow _acct as rowtot rowavg) = case layout_ of LayoutWide width -> [fmap (cellFromMixedAmount bopts{displayMaxWidth=width}) clsamts] @@ -895,12 +988,14 @@ multiBalanceRowAsCellBuilders bopts ropts@ReportOpts{..} colspans $ clsamts LayoutBare -> zipWith (:) (map wbCell cs) -- add symbols . transpose -- each row becomes a list of Text quantities - . map (cellsFromMixedAmount bopts{displayCommodity=False, displayCommodityOrder=Just cs, displayMinWidth=Nothing}) + . map (cellsFromMixedAmount (setDisplayCommodityBare bopts cs)) $ clsamts + LayoutBareWide -> [concatMap (cellsFromMixedAmount (setDisplayCommodityBare bopts allCommodities)) + $ clsamts] LayoutTidy -> concat . zipWith (map . addDateColumns) colspans . map ( zipWith (\c a -> [wbCell c, a]) cs - . cellsFromMixedAmount bopts{displayCommodity=False, displayCommodityOrder=Just cs, displayMinWidth=Nothing}) + . cellsFromMixedAmount (setDisplayCommodityBare bopts cs)) $ classified -- Do not include totals column or average for tidy output, as this -- complicates the data representation and can be easily calculated @@ -939,16 +1034,20 @@ multiBalanceHasTotalsColumn :: ReportOpts -> Bool multiBalanceHasTotalsColumn ropts = row_total_ ropts && balanceaccum_ ropts `notElem` [Cumulative, Historical] -multiBalanceRowAsText :: ReportOpts -> PeriodicReportRow a MixedAmount -> [[WideBuilder]] -multiBalanceRowAsText opts = +multiBalanceRowAsText :: + ReportOpts -> [CommoditySymbol] -> PeriodicReportRow a MixedAmount -> [[WideBuilder]] +multiBalanceRowAsText opts allCommodities = rawTableContent . - multiBalanceRowAsCellBuilders oneLineNoCostFmt{displayColour=color_ opts} opts [] + multiBalanceRowAsCellBuilders oneLineNoCostFmt{displayColour=color_ opts} + opts [] allCommodities Value (simpleDateSpanCell $ period_titles_ opts) -multiBalanceRowAsCsvText :: ReportOpts -> [DateSpan] -> PeriodicReportRow a MixedAmount -> [[T.Text]] -multiBalanceRowAsCsvText opts colspans = +multiBalanceRowAsCsvText :: + ReportOpts -> [DateSpan] -> [CommoditySymbol] -> + PeriodicReportRow a MixedAmount -> [[T.Text]] +multiBalanceRowAsCsvText opts colspans allCommodities = map (map (wbToText . Ods.cellContent)) . - multiBalanceRowAsCellBuilders machineFmt opts colspans + multiBalanceRowAsCellBuilders machineFmt opts colspans allCommodities Value (simpleDateSpanCell $ period_titles_ opts) @@ -1171,7 +1270,7 @@ budgetReportAsTable ropts@ReportOpts{..} (PeriodicReport spans items totrow) = LayoutWide width -> ( pure . showMixedAmountB oneLineNoCostFmt{displayMaxWidth=width, displayColour=color_} , \a -> pure . percentage a) - _ -> ( showMixedAmountLinesB noCostFmt{displayCommodity=layout_/=LayoutBare, displayCommodityOrder=Just cs, displayMinWidth=Nothing, displayColour=color_} + _ -> ( showMixedAmountLinesB (setDisplayCommodityBare noCostFmt cs){displayCommodity=layout_/=LayoutBare, displayColour=color_} , \a b -> map (percentage' a b) cs) where -- | Calculate the percentage of actual change to budget goal to show, if any. @@ -1205,38 +1304,35 @@ budgetReportAsTable ropts@ReportOpts{..} (PeriodicReport spans items totrow) = -- but includes alternating actual and budget amount columns. budgetReportAsCsv :: ReportOpts -> BudgetReport -> [[Text]] budgetReportAsCsv ropts report - = rawTableContent $ + = rawTableContent $ snd $ budgetReportAsSpreadsheet machineFmt ropts report -- | Render a budget report as HTML. budgetReportAsHtml :: ReportOpts -> BudgetReport -> Html budgetReportAsHtml ropts budgetr = - titledTableHtml (budgetReportTitle ropts budgetr) . map (map (fmap toHtml)) $ + titledTableHtml (budgetReportTitle ropts budgetr) . map (map (fmap toHtml)) . snd $ budgetReportAsSpreadsheet oneLineNoCostFmt ropts budgetr budgetReportAsSpreadsheet :: - AmountFormat -> ReportOpts -> BudgetReport -> [[Ods.Cell Ods.NumLines Text]] + AmountFormat -> ReportOpts -> BudgetReport -> + ((Int,Int), [[Ods.Cell Ods.NumLines Text]]) budgetReportAsSpreadsheet fmt ropts@ReportOpts{..} (PeriodicReport colspans items totrow) - = (if transpose_ then Ods.transpose else id) $ + = (if transpose_ then swap *** Ods.transpose else id) $ + ((length allHeaders, length leadingHeaders) + , + -- heading row + allHeaders ++ - -- heading row - (addHeaderBorders $ map headerCell $ - "Account" : - ["Commodity" | layout_ == LayoutBare ] - ++ (if not summary_only_ then concatMap (\spn -> [renderPeriodHeading period_titles_ spn, "budget"]) colspans else []) - ++ concat [["Total" ,"budget"] | row_total_] - ++ concat [["Average","budget"] | average_] - ) : + -- account rows + concatMap (\row -> rowAsTexts Value (accountCell row) row) items - -- account rows - concatMap (\row -> rowAsTexts Value (accountCell row) row) items - - -- totals row - ++ addTotalBorders - (concat [ rowAsTexts Total (cell totalRowHeadingBudgetCsv) totrow | not no_total_ ]) + -- totals row + ++ addTotalBorders + (concat [ rowAsTexts Total (cell totalRowHeadingBudgetCsv) totrow | not no_total_ ]) + ) where cell = Ods.defaultCell @@ -1244,6 +1340,29 @@ budgetReportAsSpreadsheet let name = prrFullName row in setAccountAnchor (balance_base_url_) querystring_ name $ cell $ renderPeriodicAcct ropts nbsp row + allHeaders = + case layout_ of + LayoutBareWide -> + [headerWithoutBorders $ + Ods.emptyCell : + concatMap + (Ods.horizontalSpan allCommodities . headerCell) + dateHeaders, + addHeaderBorders $ map headerCell $ + leadingHeaders ++ (dateHeaders >> allCommodities)] + _ -> [addHeaderBorders $ map headerCell $ leadingHeaders ++ dateHeaders] + leadingHeaders = + "Account" : ["Commodity" | layout_ == LayoutBare ] + dateHeaders = + (if not summary_only_ + then concatMap (\spn -> [renderPeriodHeading period_titles_ spn, "budget"]) colspans + else []) + ++ concat [["Total" ,"budget"] | row_total_] + ++ concat [["Average","budget"] | average_] + allCommodities = + S.toAscList $ + foldMap (foldMap maCommodities . concatMap (\(change,goal) -> maybeToList change ++ maybeToList goal) . prrAmounts) items + {- ToDo: The chosen HTML cell class names are not put in stone. If you find you need more systematic names, @@ -1264,12 +1383,16 @@ budgetReportAsSpreadsheet LayoutBare -> zipWith (:) (map cell cs) -- add symbols . transpose -- each row becomes a list of Text quantities - . map (map (fmap wbToText) . cellsFromMixedAmount dopts . second (fromMaybe nullmixedamt)) + . bareCells cs $ vals + LayoutBareWide -> [concat . bareCells allCommodities $ vals] _ -> [map showNorm vals] where + bareCells cs_ = + map (map (fmap wbToText) . + cellsFromMixedAmount (setDisplayCommodityBare fmt cs_) . + second (fromMaybe nullmixedamt)) cs = S.toList . mconcat . map maCommodities $ mapMaybe snd vals - dopts = fmt{displayCommodity=layout_ /= LayoutBare, displayCommodityOrder=Just cs, displayMinWidth=Nothing} vals = flattentuples rc (if not summary_only_ then as else []) ++ concat [[(rowTotalClass rc, rowtot), (budgetTotalClass rc, budgettot)] @@ -1278,6 +1401,14 @@ budgetReportAsSpreadsheet (budgetAverageClass rc, budgetavg)] | average_] +setDisplayCommodityBare :: AmountFormat -> [CommoditySymbol] -> AmountFormat +setDisplayCommodityBare fmt cs = + fmt{ + displayCommodity=False, + displayCommodityOrder=Just cs, + displayMinWidth=Nothing + } + nbsp :: Text nbsp = "\160" diff --git a/hledger/Hledger/Cli/Commands/Balance.md b/hledger/Hledger/Cli/Commands/Balance.md index 27661b12796..eddcced96be 100644 --- a/hledger/Hledger/Cli/Commands/Balance.md +++ b/hledger/Hledger/Cli/Commands/Balance.md @@ -822,18 +822,20 @@ It has four possible values: - `--layout=wide[,WIDTH]`: commodities are shown on a single line, optionally elided to WIDTH - `--layout=tall`: each commodity is shown on a separate line - `--layout=bare`: commodity symbols are in their own column, amounts are bare numbers +- `--layout=barewide`: commodities are shown on a single line, all in separate columns, amounts are bare numbers - `--layout=tidy`: data is normalised to easily-consumed "tidy" form, with one row per data value. (This one is currently supported only by the `balance` command.) Here are the `--layout` modes supported by each [output format](#output-format) Only CSV output supports all of them: -| - | txt | csv | html | json | sql | -|------|-----|-----|------|------|-----| -| wide | Y | Y | Y | | | -| tall | Y | Y | Y | | | -| bare | Y | Y | Y | | | -| tidy | | Y | | | | +| - | txt | csv | html | json | sql | +|----------|-----|-----|------|------|-----| +| wide | Y | Y | Y | | | +| tall | Y | Y | Y | | | +| bare | Y | Y | Y | | | +| barewide | Y | Y | Y | | | +| tidy | | Y | | | | Examples: @@ -927,6 +929,27 @@ because of zero amounts (hledger treats zeroes as commodity-less, usually). This can break `hledger-bar` confusingly (workaround: add a `cur:` query to exclude the no-symbol row). +#### Barewide layout +Commodity symbols are spread in the table headers, +each commodity has its own column, +all column groups share the same set of commodities +even if in one commodity column all values are zero. +For consistency you may think of layout `bare` as `baretall`. +```cli +$ hledger -f examples/bcexample.journal bal assets:us:etrade -3 -T -Y --layout=barewide +Balance changes in 2012-01-01..2014-12-31: + + || 2012 (GLD) 2012 (ITOT) 2012 (USD) 2012 (VEA) 2012 (VHT) 2013 (GLD) 2013 (ITOT) 2013 (USD) 2013 (VEA) 2013 (VHT) 2014 (GLD) 2014 (ITOT) 2014 (USD) 2014 (VEA) 2014 (VHT) Total (GLD) Total (ITOT) Total (USD) Total (VEA) Total (VHT) +==================++=================================================================================================================================================================================================================================================================== + Assets:US:ETrade || 0 10.00 337.18 12.00 106.00 70.00 18.00 -98.12 10.00 18.00 0 -11.00 4881.44 14.00 170.00 70.00 17.00 5120.50 36.00 294.00 +------------------++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + || 0 10.00 337.18 12.00 106.00 70.00 18.00 -98.12 10.00 18.00 0 -11.00 4881.44 14.00 170.00 70.00 17.00 5120.50 36.00 294.00 +``` + +Barewide layout is very useful for [FODS and CSV output](#output-format), +since you can easily use LibreOffice's `VLOOKUP` function +for accessing numbers in a table with mixed commodities. + #### Tidy layout This produces normalised "tidy data" (see ) where every variable has its own column and each row represents a single data point. diff --git a/hledger/Hledger/Cli/CompoundBalanceCommand.hs b/hledger/Hledger/Cli/CompoundBalanceCommand.hs index 9fc2b60b074..a649d5a5aec 100644 --- a/hledger/Hledger/Cli/CompoundBalanceCommand.hs +++ b/hledger/Hledger/Cli/CompoundBalanceCommand.hs @@ -14,7 +14,7 @@ module Hledger.Cli.CompoundBalanceCommand ( ,compoundBalanceCommand ) where -import Control.Monad (guard, unless) +import Control.Monad (guard, unless, void) import Data.Bifunctor (second) import Data.Foldable (traverse_) import Data.Function ((&)) @@ -22,6 +22,7 @@ import Data.List.NonEmpty (NonEmpty((:|))) import Data.Maybe (fromMaybe, mapMaybe, maybeToList) import Safe (atMay) import Data.Map qualified as Map +import Data.Set qualified as Set import Data.List qualified as List import Data.List.NonEmpty qualified as NonEmpty import Data.Text qualified as T @@ -310,18 +311,21 @@ compoundBalanceReportAsText ropts (CompoundPeriodicReport title _colspans subrep -- [COL1LINE1, COL2LINE1] -- [COL1LINE2, COL2LINE2] -- ] - coltotalslines = multiBalanceRowAsText ropts totalsrow + coltotalslines = multiBalanceRowAsText ropts allCommodities totalsrow totalstable = Table (Group NoLine $ map Header $ "Net:" : replicate (length coltotalslines - 1) "") -- row headers (Header []) -- column headers, concatTables will discard these coltotalslines -- cell values + allCommodities = allCommoditiesFromSubreports subreports + -- | Convert a named multi balance report to a table suitable for -- concatenating with others to make a compound balance report table. -- An empty subreport title is omitted entirely (no title row above the data). subreportAsTable ropts1 (title1, r, _) = tablewithtitle where - Table lefthdrs tophdrs cells = multiBalanceReportAsTable ropts1 r + Table lefthdrs tophdrs cells = + multiBalanceReportAsPartTable ropts1 allCommodities r tablewithtitle | T.null title1 = Table lefthdrs tophdrs cells | otherwise = Table @@ -387,14 +391,23 @@ compoundBalanceReportAsSpreadsheet fmt accountLabel maybeBlank ropts cbr = _ -> [] dataHeaders = (guard (layout_ ropts /= LayoutTidy) >>) $ - map (Spr.headerCell . reportPeriodName (period_titles_ ropts) (balanceaccum_ ropts) colspans) - (if not (summary_only_ ropts) then colspans else []) ++ - (guard (multiBalanceHasTotalsColumn ropts) >> [Spr.headerCell "Total"]) ++ - (guard (average_ ropts) >> [Spr.headerCell "Average"]) - headerrow = leadingHeaders ++ dataHeaders + map + (reportPeriodName + (period_titles_ ropts) (balanceaccum_ ropts) colspans) + (if not (summary_only_ ropts) then colspans else []) ++ + (guard (multiBalanceHasTotalsColumn ropts) >> ["Total"]) ++ + (guard (average_ ropts) >> ["Average"]) + headerrow = + leadingHeaders ++ + concatMap (Spr.horizontalSpan subColumns . Spr.headerCell) dataHeaders blankrow = fmap (Spr.horizontalSpan headerrow . Spr.defaultCell) maybeBlank + subColumns = + case layout_ ropts of + LayoutBareWide -> void allCommodities + _ -> [()] + allCommodities = allCommoditiesFromSubreports subreports -- Make rows for a subreport: its title row, not the headings row, -- the data rows, any totals row, and a blank row for whitespace. @@ -403,15 +416,20 @@ compoundBalanceReportAsSpreadsheet fmt accountLabel maybeBlank ropts cbr = subreportrows (subreporttitle, mbr, _increasestotal) = let (_, bodyrows, mtotalsrows) = - multiBalanceReportAsSpreadsheetParts fmt ropts mbr + multiBalanceReportAsSpreadsheetParts fmt ropts allCommodities mbr + accountCell = + (Spr.defaultCell subreporttitle) { + Spr.cellStyle = Spr.Body Spr.Total, + Spr.cellClass = Spr.Class "account" + } titleRows | T.null subreporttitle = [] | otherwise = - [Spr.horizontalSpan headerrow - ((Spr.defaultCell subreporttitle){ - Spr.cellStyle = Spr.Body Spr.Total, - Spr.cellClass = Spr.Class "account" - })] + [case layout_ ropts of + LayoutBareWide -> + accountCell : + map Spr.headerCell (dataHeaders >> allCommodities) + _ -> Spr.horizontalSpan headerrow accountCell] in titleRows ++ bodyrows ++ @@ -421,7 +439,7 @@ compoundBalanceReportAsSpreadsheet fmt accountLabel maybeBlank ropts cbr = totalrows = if no_total_ ropts || length subreports == 1 then [] else - multiBalanceRowAsCellBuilders fmt ropts colspans + multiBalanceRowAsCellBuilders fmt ropts colspans allCommodities Total (simpleDateSpanCell $ period_titles_ ropts) totalrow -- make a table of rendered lines of the report totals row & map (map (fmap wbToText)) @@ -433,3 +451,10 @@ compoundBalanceReportAsSpreadsheet fmt accountLabel maybeBlank ropts cbr = in (title, ((1,1), headerrow :| concatMap subreportrows subreports ++ totalrows)) + +allCommoditiesFromSubreports :: + [(text, PeriodicReport a MixedAmount, bool)] -> [CommoditySymbol] +allCommoditiesFromSubreports = + Set.toAscList . + foldMap (\(_,mbr,_) -> + foldMap (foldMap maCommodities . prrAmounts) $ prRows mbr) diff --git a/hledger/test/balance/layout.test b/hledger/test/balance/layout.test index 262cdb1d673..1d6c5c745d1 100644 --- a/hledger/test/balance/layout.test +++ b/hledger/test/balance/layout.test @@ -401,3 +401,24 @@ Balance changes in 2021-01: Equity:Opening || INR -1.00 ----------------++------------------ || 0 + +# ** 27. Balance report csv output with one column per commodity (--layout=barewide). +$ hledger -f bcexample.journal bal assets.*etrade -3 -O csv --layout=barewide +"account","GLD","ITOT","USD","VEA","VHT" +"Assets:US:ETrade"," 70.00"," 17.00","5120.50"," 36.00"," 294.00" +"Total:"," 70.00"," 17.00","5120.50"," 36.00"," 294.00" + +# ** 28. Multicolumn balance report csv output with one commodity per column. +$ hledger -f bcexample.journal bal -T -Y assets.*etrade -3 -O csv --layout=barewide +"","2012","","","","","2013","","","","","2014","","","","","total","","","","" +"account","GLD","ITOT","USD","VEA","VHT","GLD","ITOT","USD","VEA","VHT","GLD","ITOT","USD","VEA","VHT","GLD","ITOT","USD","VEA","VHT" +"Assets:US:ETrade","0","10.00","337.18","12.00","106.00","70.00","18.00","-98.12","10.00","18.00","0","-11.00","4881.44","14.00","170.00","70.00","17.00","5120.50","36.00","294.00" +"Total:","0","10.00","337.18","12.00","106.00","70.00","18.00","-98.12","10.00","18.00","0","-11.00","4881.44","14.00","170.00","70.00","17.00","5120.50","36.00","294.00" + +# ** 29. Balance report with individual commodity columns. +$ hledger -f bcexample.journal bal assets.*etrade -3 --layout=barewide + || GLD ITOT USD VEA VHT +==================++============================================= + Assets:US:ETrade || 70.00 17.00 5120.50 36.00 294.00 +------------------++--------------------------------------------- + Total: || 70.00 17.00 5120.50 36.00 294.00