dev: extract beancount output helpers to Hledger.Write.Beancount
This commit is contained in:
parent
a6a1b2c28e
commit
66fce53c0b
@ -53,8 +53,6 @@ module Hledger.Data.AccountName (
|
|||||||
,concatAccountNames
|
,concatAccountNames
|
||||||
,accountNameApplyAliases
|
,accountNameApplyAliases
|
||||||
,accountNameApplyAliasesMemo
|
,accountNameApplyAliasesMemo
|
||||||
,accountNameToBeancount
|
|
||||||
,beancountTopLevelAccounts
|
|
||||||
,tests_AccountName
|
,tests_AccountName
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
@ -74,7 +72,6 @@ import Text.DocLayout (realLength)
|
|||||||
|
|
||||||
import Hledger.Data.Types hiding (asubs)
|
import Hledger.Data.Types hiding (asubs)
|
||||||
import Hledger.Utils
|
import Hledger.Utils
|
||||||
import Data.Char (isDigit, isLetter, isUpperCase)
|
|
||||||
import Data.List (partition)
|
import Data.List (partition)
|
||||||
|
|
||||||
-- $setup
|
-- $setup
|
||||||
@ -367,70 +364,6 @@ accountNameToAccountOnlyRegexCI a = toRegexCI' $ "^" <> escapeName a <> "$" -- P
|
|||||||
--isAccountRegex :: String -> Bool
|
--isAccountRegex :: String -> Bool
|
||||||
--isAccountRegex s = take 1 s == "^" && take 5 (reverse s) == ")$|:("
|
--isAccountRegex s = take 1 s == "^" && take 5 (reverse s) == ")$|:("
|
||||||
|
|
||||||
type BeancountAccountName = AccountName
|
|
||||||
type BeancountAccountNameComponent = AccountName
|
|
||||||
|
|
||||||
-- | Convert a hledger account name to a valid Beancount account name.
|
|
||||||
-- It replaces non-supported characters with @-@ (warning: in extreme cases
|
|
||||||
-- separate accounts could end up with the same name), it prepends the letter B
|
|
||||||
-- to any part which doesn't begin with a letter or number, and it capitalises
|
|
||||||
-- each part. It also checks that the first part is one of the required english
|
|
||||||
-- account names Assets, Liabilities, Equity, Income, or Expenses, and if not
|
|
||||||
-- it raises an informative error suggesting --alias.
|
|
||||||
-- Ref: https://beancount.github.io/docs/beancount_language_syntax.html#accounts
|
|
||||||
accountNameToBeancount :: AccountName -> BeancountAccountName
|
|
||||||
accountNameToBeancount a =
|
|
||||||
dbg9 "beancount account name" $
|
|
||||||
accountNameFromComponents bs'
|
|
||||||
where
|
|
||||||
bs =
|
|
||||||
map accountNameComponentToBeancount $ accountNameComponents $
|
|
||||||
dbg9 "hledger account name " $
|
|
||||||
a
|
|
||||||
bs' =
|
|
||||||
case bs of
|
|
||||||
b:_ | b `notElem` beancountTopLevelAccounts -> error' e
|
|
||||||
where
|
|
||||||
e = T.unpack $ T.unlines [
|
|
||||||
"bad top-level account: " <> b
|
|
||||||
,"in beancount account name: " <> accountNameFromComponents bs
|
|
||||||
,"converted from hledger account name: " <> a
|
|
||||||
,"For Beancount, top-level accounts must be (or be --alias'ed to)"
|
|
||||||
,"one of " <> T.intercalate ", " beancountTopLevelAccounts <> "."
|
|
||||||
-- ,"and not: " <> b
|
|
||||||
]
|
|
||||||
cs -> cs
|
|
||||||
|
|
||||||
accountNameComponentToBeancount :: AccountName -> BeancountAccountNameComponent
|
|
||||||
accountNameComponentToBeancount acctpart =
|
|
||||||
prependStartCharIfNeeded $
|
|
||||||
case T.uncons acctpart of
|
|
||||||
Nothing -> ""
|
|
||||||
Just (c,cs) ->
|
|
||||||
textCapitalise $
|
|
||||||
T.map (\d -> if isBeancountAccountChar d then d else '-') $ T.cons c cs
|
|
||||||
where
|
|
||||||
prependStartCharIfNeeded t =
|
|
||||||
case T.uncons t of
|
|
||||||
Just (c,_) | not $ isBeancountAccountStartChar c -> T.cons beancountAccountDummyStartChar t
|
|
||||||
_ -> t
|
|
||||||
|
|
||||||
-- | Dummy valid starting character to prepend to Beancount account name parts if needed (B).
|
|
||||||
beancountAccountDummyStartChar :: Char
|
|
||||||
beancountAccountDummyStartChar = 'B'
|
|
||||||
|
|
||||||
-- XXX these probably allow too much unicode:
|
|
||||||
|
|
||||||
-- | Is this a valid character to start a Beancount account name part (capital letter or digit) ?
|
|
||||||
isBeancountAccountStartChar :: Char -> Bool
|
|
||||||
isBeancountAccountStartChar c = (isLetter c && isUpperCase c) || isDigit c
|
|
||||||
|
|
||||||
-- | Is this a valid character to appear elsewhere in a Beancount account name part (letter, digit, or -) ?
|
|
||||||
isBeancountAccountChar :: Char -> Bool
|
|
||||||
isBeancountAccountChar c = isLetter c || isDigit c || c=='-'
|
|
||||||
|
|
||||||
beancountTopLevelAccounts = ["Assets", "Liabilities", "Equity", "Income", "Expenses"]
|
|
||||||
|
|
||||||
tests_AccountName = testGroup "AccountName" [
|
tests_AccountName = testGroup "AccountName" [
|
||||||
testCase "accountNameTreeFrom" $ do
|
testCase "accountNameTreeFrom" $ do
|
||||||
accountNameTreeFrom ["a"] @?= Node "root" [Node "a" []]
|
accountNameTreeFrom ["a"] @?= Node "root" [Node "a" []]
|
||||||
|
|||||||
@ -62,10 +62,8 @@ module Hledger.Data.Posting (
|
|||||||
showPostingLines,
|
showPostingLines,
|
||||||
postingAsLines,
|
postingAsLines,
|
||||||
postingsAsLines,
|
postingsAsLines,
|
||||||
postingsAsLinesBeancount,
|
postingIndent,
|
||||||
postingAsLinesBeancount,
|
|
||||||
showAccountName,
|
showAccountName,
|
||||||
showAccountNameBeancount,
|
|
||||||
renderCommentLines,
|
renderCommentLines,
|
||||||
showBalanceAssertion,
|
showBalanceAssertion,
|
||||||
-- * misc.
|
-- * misc.
|
||||||
@ -309,7 +307,7 @@ postingAsLines elideamount onelineamounts acctwidth amtwidth p =
|
|||||||
assertion = maybe mempty ((WideBuilder (TB.singleton ' ') 1 <>).showBalanceAssertion) $ pbalanceassertion p
|
assertion = maybe mempty ((WideBuilder (TB.singleton ' ') 1 <>).showBalanceAssertion) $ pbalanceassertion p
|
||||||
|
|
||||||
-- pad to the maximum account name width, plus 2 to leave room for status flags, to keep amounts aligned
|
-- pad to the maximum account name width, plus 2 to leave room for status flags, to keep amounts aligned
|
||||||
statusandaccount = lineIndent . fitText (Just $ 2 + acctwidth) Nothing False True $ pstatusandacct p
|
statusandaccount = postingIndent . fitText (Just $ 2 + acctwidth) Nothing False True $ pstatusandacct p
|
||||||
thisacctwidth = realLength $ pacctstr p
|
thisacctwidth = realLength $ pacctstr p
|
||||||
|
|
||||||
(samelinecomment, newlinecomments) =
|
(samelinecomment, newlinecomments) =
|
||||||
@ -325,91 +323,6 @@ showAccountName w = fmt
|
|||||||
fmt VirtualPosting = wrap "(" ")" . maybe id (T.takeEnd . subtract 2) w
|
fmt VirtualPosting = wrap "(" ")" . maybe id (T.takeEnd . subtract 2) w
|
||||||
fmt BalancedVirtualPosting = wrap "[" "]" . maybe id (T.takeEnd . subtract 2) w
|
fmt BalancedVirtualPosting = wrap "[" "]" . maybe id (T.takeEnd . subtract 2) w
|
||||||
|
|
||||||
-- | Like postingsAsLines but generates Beancount journal format.
|
|
||||||
postingsAsLinesBeancount :: [Posting] -> [Text]
|
|
||||||
postingsAsLinesBeancount ps = concatMap first3 linesWithWidths
|
|
||||||
where
|
|
||||||
linesWithWidths = map (postingAsLinesBeancount False maxacctwidth maxamtwidth) ps
|
|
||||||
maxacctwidth = maximumBound 0 $ map second3 linesWithWidths
|
|
||||||
maxamtwidth = maximumBound 0 $ map third3 linesWithWidths
|
|
||||||
|
|
||||||
-- | Like postingAsLines but generates Beancount journal format.
|
|
||||||
postingAsLinesBeancount :: Bool -> Int -> Int -> Posting -> ([Text], Int, Int)
|
|
||||||
postingAsLinesBeancount elideamount acctwidth amtwidth p =
|
|
||||||
(concatMap (++ newlinecomments) postingblocks, thisacctwidth, thisamtwidth)
|
|
||||||
where
|
|
||||||
-- This needs to be converted to strict Text in order to strip trailing
|
|
||||||
-- spaces. This adds a small amount of inefficiency, and the only difference
|
|
||||||
-- is whether there are trailing spaces in print (and related) reports. This
|
|
||||||
-- could be removed and we could just keep everything as a Text Builder, but
|
|
||||||
-- would require adding trailing spaces to 42 failing tests.
|
|
||||||
postingblocks = [map T.stripEnd . T.lines . TL.toStrict $
|
|
||||||
render [ textCell BottomLeft statusandaccount
|
|
||||||
, textCell BottomLeft " "
|
|
||||||
, Cell BottomLeft [pad amt]
|
|
||||||
, textCell BottomLeft samelinecomment
|
|
||||||
]
|
|
||||||
| (amt,_assertion) <- shownAmountsAssertions]
|
|
||||||
render = renderRow def{tableBorders=False, borderSpaces=False} . Group NoLine . map Header
|
|
||||||
pad amt = WideBuilder (TB.fromText $ T.replicate w " ") w <> amt
|
|
||||||
where w = max 12 amtwidth - wbWidth amt -- min. 12 for backwards compatibility
|
|
||||||
|
|
||||||
pacct = showAccountNameBeancount Nothing $ paccount p
|
|
||||||
pstatusandacct p' = if pstatus p' == Pending then "! " else "" <> pacct
|
|
||||||
|
|
||||||
-- currently prices are considered part of the amount string when right-aligning amounts
|
|
||||||
-- Since we will usually be calling this function with the knot tied between
|
|
||||||
-- amtwidth and thisamtwidth, make sure thisamtwidth does not depend on
|
|
||||||
-- amtwidth at all.
|
|
||||||
shownAmounts
|
|
||||||
| elideamount = [mempty]
|
|
||||||
| otherwise = showMixedAmountLinesB displayopts a'
|
|
||||||
where
|
|
||||||
displayopts = defaultFmt{ displayZeroCommodity=True, displayForceDecimalMark=True }
|
|
||||||
a' = mapMixedAmount amountToBeancount $ pamount p
|
|
||||||
thisamtwidth = maximumBound 0 $ map wbWidth shownAmounts
|
|
||||||
|
|
||||||
-- when there is a balance assertion, show it only on the last posting line
|
|
||||||
shownAmountsAssertions = zip shownAmounts shownAssertions
|
|
||||||
where
|
|
||||||
shownAssertions = replicate (length shownAmounts - 1) mempty ++ [assertion]
|
|
||||||
where
|
|
||||||
assertion = maybe mempty ((WideBuilder (TB.singleton ' ') 1 <>).showBalanceAssertion) $ pbalanceassertion p
|
|
||||||
|
|
||||||
-- pad to the maximum account name width, plus 2 to leave room for status flags, to keep amounts aligned
|
|
||||||
statusandaccount = lineIndent . fitText (Just $ 2 + acctwidth) Nothing False True $ pstatusandacct p
|
|
||||||
thisacctwidth = realLength pacct
|
|
||||||
|
|
||||||
(samelinecomment, newlinecomments) =
|
|
||||||
case renderCommentLines (pcomment p) of [] -> ("",[])
|
|
||||||
c:cs -> (c,cs)
|
|
||||||
|
|
||||||
type BeancountAmount = Amount
|
|
||||||
|
|
||||||
-- | Do some best effort adjustments to make an amount that renders
|
|
||||||
-- in a way that Beancount can read: forces the commodity symbol to the right,
|
|
||||||
-- converts a few currency symbols to names, capitalises all letters.
|
|
||||||
amountToBeancount :: Amount -> BeancountAmount
|
|
||||||
amountToBeancount a@Amount{acommodity=c,astyle=s,acost=mp} = a{acommodity=c', astyle=s', acost=mp'}
|
|
||||||
-- https://beancount.github.io/docs/beancount_language_syntax.html#commodities-currencies
|
|
||||||
where
|
|
||||||
c' = T.toUpper $
|
|
||||||
T.replace "$" "USD" $
|
|
||||||
T.replace "€" "EUR" $
|
|
||||||
T.replace "¥" "JPY" $
|
|
||||||
T.replace "£" "GBP" $
|
|
||||||
c
|
|
||||||
s' = s{ascommodityside=R, ascommodityspaced=True}
|
|
||||||
mp' = costToBeancount <$> mp
|
|
||||||
where
|
|
||||||
costToBeancount (TotalCost amt) = TotalCost $ amountToBeancount amt
|
|
||||||
costToBeancount (UnitCost amt) = UnitCost $ amountToBeancount amt
|
|
||||||
|
|
||||||
-- | Like showAccountName for Beancount journal format.
|
|
||||||
-- Calls accountNameToBeancount first.
|
|
||||||
showAccountNameBeancount :: Maybe Int -> AccountName -> Text
|
|
||||||
showAccountNameBeancount w = maybe id T.take w . accountNameToBeancount
|
|
||||||
|
|
||||||
-- | Render a transaction or posting's comment as indented, semicolon-prefixed comment lines.
|
-- | Render a transaction or posting's comment as indented, semicolon-prefixed comment lines.
|
||||||
-- The first line (unless empty) will have leading space, subsequent lines will have a larger indent.
|
-- The first line (unless empty) will have leading space, subsequent lines will have a larger indent.
|
||||||
renderCommentLines :: Text -> [Text]
|
renderCommentLines :: Text -> [Text]
|
||||||
@ -417,14 +330,14 @@ renderCommentLines t =
|
|||||||
case T.lines t of
|
case T.lines t of
|
||||||
[] -> []
|
[] -> []
|
||||||
[l] -> [commentSpace $ comment l] -- single-line comment
|
[l] -> [commentSpace $ comment l] -- single-line comment
|
||||||
("":ls) -> "" : map (lineIndent . comment) ls -- multi-line comment with empty first line
|
("":ls) -> "" : map (postingIndent . comment) ls -- multi-line comment with empty first line
|
||||||
(l:ls) -> commentSpace (comment l) : map (lineIndent . comment) ls
|
(l:ls) -> commentSpace (comment l) : map (postingIndent . comment) ls
|
||||||
where
|
where
|
||||||
comment = ("; "<>)
|
comment = ("; "<>)
|
||||||
|
|
||||||
-- | Prepend a suitable indent for a posting (or transaction/posting comment) line.
|
-- | Prepend a suitable indent for a posting (or transaction/posting comment) line.
|
||||||
lineIndent :: Text -> Text
|
postingIndent :: Text -> Text
|
||||||
lineIndent = (" "<>)
|
postingIndent = (" "<>)
|
||||||
|
|
||||||
-- | Prepend the space required before a same-line comment.
|
-- | Prepend the space required before a same-line comment.
|
||||||
commentSpace :: Text -> Text
|
commentSpace :: Text -> Text
|
||||||
|
|||||||
@ -34,6 +34,9 @@ module Hledger.Data.Transaction
|
|||||||
, transactionMapPostingAmounts
|
, transactionMapPostingAmounts
|
||||||
, transactionAmounts
|
, transactionAmounts
|
||||||
, partitionAndCheckConversionPostings
|
, partitionAndCheckConversionPostings
|
||||||
|
-- * helpers
|
||||||
|
, payeeAndNoteFromDescription
|
||||||
|
, payeeAndNoteFromDescription'
|
||||||
-- nonzerobalanceerror
|
-- nonzerobalanceerror
|
||||||
-- * date operations
|
-- * date operations
|
||||||
, transactionDate2
|
, transactionDate2
|
||||||
@ -46,7 +49,6 @@ module Hledger.Data.Transaction
|
|||||||
, showTransaction
|
, showTransaction
|
||||||
, showTransactionOneLineAmounts
|
, showTransactionOneLineAmounts
|
||||||
, showTransactionLineFirstPart
|
, showTransactionLineFirstPart
|
||||||
, showTransactionBeancount
|
|
||||||
, transactionFile
|
, transactionFile
|
||||||
-- * transaction errors
|
-- * transaction errors
|
||||||
, annotateErrorWithTransaction
|
, annotateErrorWithTransaction
|
||||||
@ -176,33 +178,6 @@ showTransactionLineFirstPart t = T.concat [date, status, code]
|
|||||||
| otherwise = ""
|
| otherwise = ""
|
||||||
code = if T.null (tcode t) then "" else wrap " (" ")" $ tcode t
|
code = if T.null (tcode t) then "" else wrap " (" ")" $ tcode t
|
||||||
|
|
||||||
-- | Like showTransaction, but generates Beancount journal format.
|
|
||||||
showTransactionBeancount :: Transaction -> Text
|
|
||||||
showTransactionBeancount t =
|
|
||||||
-- https://beancount.github.io/docs/beancount_language_syntax.html
|
|
||||||
-- similar to showTransactionHelper, but I haven't bothered with Builder
|
|
||||||
firstline <> nl
|
|
||||||
<> foldMap ((<> nl)) newlinecomments
|
|
||||||
<> foldMap ((<> nl)) (postingsAsLinesBeancount $ tpostings t)
|
|
||||||
<> nl
|
|
||||||
where
|
|
||||||
nl = "\n"
|
|
||||||
firstline = T.concat [date, status, payee, note, tags, samelinecomment]
|
|
||||||
date = showDate $ tdate t
|
|
||||||
status = if tstatus t == Pending then " !" else " *"
|
|
||||||
(payee,note) =
|
|
||||||
case payeeAndNoteFromDescription' $ tdescription t of
|
|
||||||
("","") -> ("", "" )
|
|
||||||
("",n ) -> ("" , wrapq n )
|
|
||||||
(p ,"") -> (wrapq p, wrapq "")
|
|
||||||
(p ,n ) -> (wrapq p, wrapq n )
|
|
||||||
where
|
|
||||||
wrapq = wrap " \"" "\"" . escapeDoubleQuotes . escapeBackslash
|
|
||||||
tags = T.concat $ map ((" #"<>).fst) $ ttags t
|
|
||||||
(samelinecomment, newlinecomments) =
|
|
||||||
case renderCommentLines (tcomment t) of [] -> ("",[])
|
|
||||||
c:cs -> (c,cs)
|
|
||||||
|
|
||||||
hasRealPostings :: Transaction -> Bool
|
hasRealPostings :: Transaction -> Bool
|
||||||
hasRealPostings = not . null . realPostings
|
hasRealPostings = not . null . realPostings
|
||||||
|
|
||||||
|
|||||||
224
hledger-lib/Hledger/Write/Beancount.hs
Normal file
224
hledger-lib/Hledger/Write/Beancount.hs
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
{-|
|
||||||
|
Helpers for beancount output.
|
||||||
|
-}
|
||||||
|
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module Hledger.Write.Beancount (
|
||||||
|
showTransactionBeancount,
|
||||||
|
-- postingsAsLinesBeancount,
|
||||||
|
-- postingAsLinesBeancount,
|
||||||
|
-- showAccountNameBeancount,
|
||||||
|
accountNameToBeancount,
|
||||||
|
-- beancountTopLevelAccounts,
|
||||||
|
|
||||||
|
-- * Tests
|
||||||
|
tests_WriteBeancount
|
||||||
|
)
|
||||||
|
where
|
||||||
|
|
||||||
|
-- import Prelude hiding (Applicative(..))
|
||||||
|
import Data.Char
|
||||||
|
import Data.Default (def)
|
||||||
|
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 Safe (maximumBound)
|
||||||
|
import Text.DocLayout (realLength)
|
||||||
|
import Text.Tabular.AsciiWide hiding (render)
|
||||||
|
|
||||||
|
import Hledger.Utils
|
||||||
|
import Hledger.Data.Types
|
||||||
|
import Hledger.Data.AccountName
|
||||||
|
import Hledger.Data.Amount
|
||||||
|
import Hledger.Data.Dates (showDate)
|
||||||
|
import Hledger.Data.Posting (renderCommentLines, showBalanceAssertion, postingIndent)
|
||||||
|
import Hledger.Data.Transaction (payeeAndNoteFromDescription')
|
||||||
|
|
||||||
|
--- ** doctest setup
|
||||||
|
-- $setup
|
||||||
|
-- >>> :set -XOverloadedStrings
|
||||||
|
|
||||||
|
-- | Like showTransaction, but generates Beancount journal format.
|
||||||
|
showTransactionBeancount :: Transaction -> Text
|
||||||
|
showTransactionBeancount t =
|
||||||
|
-- https://beancount.github.io/docs/beancount_language_syntax.html
|
||||||
|
-- similar to showTransactionHelper, but I haven't bothered with Builder
|
||||||
|
firstline <> nl
|
||||||
|
<> foldMap ((<> nl)) newlinecomments
|
||||||
|
<> foldMap ((<> nl)) (postingsAsLinesBeancount $ tpostings t)
|
||||||
|
<> nl
|
||||||
|
where
|
||||||
|
nl = "\n"
|
||||||
|
firstline = T.concat [date, status, payee, note, tags, samelinecomment]
|
||||||
|
date = showDate $ tdate t
|
||||||
|
status = if tstatus t == Pending then " !" else " *"
|
||||||
|
(payee,note) =
|
||||||
|
case payeeAndNoteFromDescription' $ tdescription t of
|
||||||
|
("","") -> ("", "" )
|
||||||
|
("",n ) -> ("" , wrapq n )
|
||||||
|
(p ,"") -> (wrapq p, wrapq "")
|
||||||
|
(p ,n ) -> (wrapq p, wrapq n )
|
||||||
|
where
|
||||||
|
wrapq = wrap " \"" "\"" . escapeDoubleQuotes . escapeBackslash
|
||||||
|
tags = T.concat $ map ((" #"<>).fst) $ ttags t
|
||||||
|
(samelinecomment, newlinecomments) =
|
||||||
|
case renderCommentLines (tcomment t) of [] -> ("",[])
|
||||||
|
c:cs -> (c,cs)
|
||||||
|
|
||||||
|
-- | Like postingsAsLines but generates Beancount journal format.
|
||||||
|
postingsAsLinesBeancount :: [Posting] -> [Text]
|
||||||
|
postingsAsLinesBeancount ps = concatMap first3 linesWithWidths
|
||||||
|
where
|
||||||
|
linesWithWidths = map (postingAsLinesBeancount False maxacctwidth maxamtwidth) ps
|
||||||
|
maxacctwidth = maximumBound 0 $ map second3 linesWithWidths
|
||||||
|
maxamtwidth = maximumBound 0 $ map third3 linesWithWidths
|
||||||
|
|
||||||
|
-- | Like postingAsLines but generates Beancount journal format.
|
||||||
|
postingAsLinesBeancount :: Bool -> Int -> Int -> Posting -> ([Text], Int, Int)
|
||||||
|
postingAsLinesBeancount elideamount acctwidth amtwidth p =
|
||||||
|
(concatMap (++ newlinecomments) postingblocks, thisacctwidth, thisamtwidth)
|
||||||
|
where
|
||||||
|
-- This needs to be converted to strict Text in order to strip trailing
|
||||||
|
-- spaces. This adds a small amount of inefficiency, and the only difference
|
||||||
|
-- is whether there are trailing spaces in print (and related) reports. This
|
||||||
|
-- could be removed and we could just keep everything as a Text Builder, but
|
||||||
|
-- would require adding trailing spaces to 42 failing tests.
|
||||||
|
postingblocks = [map T.stripEnd . T.lines . TL.toStrict $
|
||||||
|
render [ textCell BottomLeft statusandaccount
|
||||||
|
, textCell BottomLeft " "
|
||||||
|
, Cell BottomLeft [pad amt]
|
||||||
|
, textCell BottomLeft samelinecomment
|
||||||
|
]
|
||||||
|
| (amt,_assertion) <- shownAmountsAssertions]
|
||||||
|
render = renderRow def{tableBorders=False, borderSpaces=False} . Group NoLine . map Header
|
||||||
|
pad amt = WideBuilder (TB.fromText $ T.replicate w " ") w <> amt
|
||||||
|
where w = max 12 amtwidth - wbWidth amt -- min. 12 for backwards compatibility
|
||||||
|
|
||||||
|
pacct = showAccountNameBeancount Nothing $ paccount p
|
||||||
|
pstatusandacct p' = if pstatus p' == Pending then "! " else "" <> pacct
|
||||||
|
|
||||||
|
-- currently prices are considered part of the amount string when right-aligning amounts
|
||||||
|
-- Since we will usually be calling this function with the knot tied between
|
||||||
|
-- amtwidth and thisamtwidth, make sure thisamtwidth does not depend on
|
||||||
|
-- amtwidth at all.
|
||||||
|
shownAmounts
|
||||||
|
| elideamount = [mempty]
|
||||||
|
| otherwise = showMixedAmountLinesB displayopts a'
|
||||||
|
where
|
||||||
|
displayopts = defaultFmt{ displayZeroCommodity=True, displayForceDecimalMark=True }
|
||||||
|
a' = mapMixedAmount amountToBeancount $ pamount p
|
||||||
|
thisamtwidth = maximumBound 0 $ map wbWidth shownAmounts
|
||||||
|
|
||||||
|
-- when there is a balance assertion, show it only on the last posting line
|
||||||
|
shownAmountsAssertions = zip shownAmounts shownAssertions
|
||||||
|
where
|
||||||
|
shownAssertions = replicate (length shownAmounts - 1) mempty ++ [assertion]
|
||||||
|
where
|
||||||
|
assertion = maybe mempty ((WideBuilder (TB.singleton ' ') 1 <>).showBalanceAssertion) $ pbalanceassertion p
|
||||||
|
|
||||||
|
-- pad to the maximum account name width, plus 2 to leave room for status flags, to keep amounts aligned
|
||||||
|
statusandaccount = postingIndent . fitText (Just $ 2 + acctwidth) Nothing False True $ pstatusandacct p
|
||||||
|
thisacctwidth = realLength pacct
|
||||||
|
|
||||||
|
(samelinecomment, newlinecomments) =
|
||||||
|
case renderCommentLines (pcomment p) of [] -> ("",[])
|
||||||
|
c:cs -> (c,cs)
|
||||||
|
|
||||||
|
-- | Like showAccountName for Beancount journal format.
|
||||||
|
-- Calls accountNameToBeancount first.
|
||||||
|
showAccountNameBeancount :: Maybe Int -> AccountName -> Text
|
||||||
|
showAccountNameBeancount w = maybe id T.take w . accountNameToBeancount
|
||||||
|
|
||||||
|
type BeancountAccountName = AccountName
|
||||||
|
type BeancountAccountNameComponent = AccountName
|
||||||
|
|
||||||
|
-- | Convert a hledger account name to a valid Beancount account name.
|
||||||
|
-- It replaces non-supported characters with @-@ (warning: in extreme cases
|
||||||
|
-- separate accounts could end up with the same name), it prepends the letter B
|
||||||
|
-- to any part which doesn't begin with a letter or number, and it capitalises
|
||||||
|
-- each part. It also checks that the first part is one of the required english
|
||||||
|
-- account names Assets, Liabilities, Equity, Income, or Expenses, and if not
|
||||||
|
-- it raises an informative error suggesting --alias.
|
||||||
|
-- Ref: https://beancount.github.io/docs/beancount_language_syntax.html#accounts
|
||||||
|
accountNameToBeancount :: AccountName -> BeancountAccountName
|
||||||
|
accountNameToBeancount a =
|
||||||
|
dbg9 "beancount account name" $
|
||||||
|
accountNameFromComponents bs'
|
||||||
|
where
|
||||||
|
bs =
|
||||||
|
map accountNameComponentToBeancount $ accountNameComponents $
|
||||||
|
dbg9 "hledger account name " $
|
||||||
|
a
|
||||||
|
bs' =
|
||||||
|
case bs of
|
||||||
|
b:_ | b `notElem` beancountTopLevelAccounts -> error' e
|
||||||
|
where
|
||||||
|
e = T.unpack $ T.unlines [
|
||||||
|
"bad top-level account: " <> b
|
||||||
|
,"in beancount account name: " <> accountNameFromComponents bs
|
||||||
|
,"converted from hledger account name: " <> a
|
||||||
|
,"For Beancount, top-level accounts must be (or be --alias'ed to)"
|
||||||
|
,"one of " <> T.intercalate ", " beancountTopLevelAccounts <> "."
|
||||||
|
-- ,"and not: " <> b
|
||||||
|
]
|
||||||
|
cs -> cs
|
||||||
|
|
||||||
|
accountNameComponentToBeancount :: AccountName -> BeancountAccountNameComponent
|
||||||
|
accountNameComponentToBeancount acctpart =
|
||||||
|
prependStartCharIfNeeded $
|
||||||
|
case T.uncons acctpart of
|
||||||
|
Nothing -> ""
|
||||||
|
Just (c,cs) ->
|
||||||
|
textCapitalise $
|
||||||
|
T.map (\d -> if isBeancountAccountChar d then d else '-') $ T.cons c cs
|
||||||
|
where
|
||||||
|
prependStartCharIfNeeded t =
|
||||||
|
case T.uncons t of
|
||||||
|
Just (c,_) | not $ isBeancountAccountStartChar c -> T.cons beancountAccountDummyStartChar t
|
||||||
|
_ -> t
|
||||||
|
|
||||||
|
-- | Dummy valid starting character to prepend to Beancount account name parts if needed (B).
|
||||||
|
beancountAccountDummyStartChar :: Char
|
||||||
|
beancountAccountDummyStartChar = 'B'
|
||||||
|
|
||||||
|
-- XXX these probably allow too much unicode:
|
||||||
|
|
||||||
|
-- | Is this a valid character to start a Beancount account name part (capital letter or digit) ?
|
||||||
|
isBeancountAccountStartChar :: Char -> Bool
|
||||||
|
isBeancountAccountStartChar c = (isLetter c && isUpperCase c) || isDigit c
|
||||||
|
|
||||||
|
-- | Is this a valid character to appear elsewhere in a Beancount account name part (letter, digit, or -) ?
|
||||||
|
isBeancountAccountChar :: Char -> Bool
|
||||||
|
isBeancountAccountChar c = isLetter c || isDigit c || c=='-'
|
||||||
|
|
||||||
|
beancountTopLevelAccounts = ["Assets", "Liabilities", "Equity", "Income", "Expenses"]
|
||||||
|
|
||||||
|
type BeancountAmount = Amount
|
||||||
|
|
||||||
|
-- | Do some best effort adjustments to make an amount that renders
|
||||||
|
-- in a way that Beancount can read: forces the commodity symbol to the right,
|
||||||
|
-- converts a few currency symbols to names, capitalises all letters.
|
||||||
|
amountToBeancount :: Amount -> BeancountAmount
|
||||||
|
amountToBeancount a@Amount{acommodity=c,astyle=s,acost=mp} = a{acommodity=c', astyle=s', acost=mp'}
|
||||||
|
-- https://beancount.github.io/docs/beancount_language_syntax.html#commodities-currencies
|
||||||
|
where
|
||||||
|
c' = T.toUpper $
|
||||||
|
T.replace "$" "USD" $
|
||||||
|
T.replace "€" "EUR" $
|
||||||
|
T.replace "¥" "JPY" $
|
||||||
|
T.replace "£" "GBP" $
|
||||||
|
c
|
||||||
|
s' = s{ascommodityside=R, ascommodityspaced=True}
|
||||||
|
mp' = costToBeancount <$> mp
|
||||||
|
where
|
||||||
|
costToBeancount (TotalCost amt) = TotalCost $ amountToBeancount amt
|
||||||
|
costToBeancount (UnitCost amt) = UnitCost $ amountToBeancount amt
|
||||||
|
|
||||||
|
|
||||||
|
--- ** tests
|
||||||
|
|
||||||
|
tests_WriteBeancount :: TestTree
|
||||||
|
tests_WriteBeancount = testGroup "Write.Beancount" [
|
||||||
|
]
|
||||||
@ -150,6 +150,7 @@ library:
|
|||||||
# - Hledger.Read.LedgerReader
|
# - Hledger.Read.LedgerReader
|
||||||
- Hledger.Read.TimedotReader
|
- Hledger.Read.TimedotReader
|
||||||
- Hledger.Read.TimeclockReader
|
- Hledger.Read.TimeclockReader
|
||||||
|
- Hledger.Write.Beancount
|
||||||
- Hledger.Write.Csv
|
- Hledger.Write.Csv
|
||||||
- Hledger.Write.Ods
|
- Hledger.Write.Ods
|
||||||
- Hledger.Write.Html
|
- Hledger.Write.Html
|
||||||
|
|||||||
@ -19,22 +19,23 @@ module Hledger.Cli.Commands.Print (
|
|||||||
where
|
where
|
||||||
|
|
||||||
|
|
||||||
|
import Data.Function ((&))
|
||||||
import Data.List (intersperse, intercalate)
|
import Data.List (intersperse, intercalate)
|
||||||
|
import Data.List.Extra (nubSort)
|
||||||
|
import qualified Data.Map as M
|
||||||
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 qualified Data.Text.Lazy.Builder as TB
|
||||||
import Lens.Micro ((^.), _Just, has)
|
import Lens.Micro ((^.), _Just, has)
|
||||||
|
import Safe (lastMay, minimumDef)
|
||||||
import System.Console.CmdArgs.Explicit
|
import System.Console.CmdArgs.Explicit
|
||||||
|
import System.Exit (exitFailure)
|
||||||
|
|
||||||
import Hledger
|
import Hledger
|
||||||
|
import Hledger.Write.Beancount (accountNameToBeancount, showTransactionBeancount)
|
||||||
import Hledger.Write.Csv (CSV, printCSV, printTSV)
|
import Hledger.Write.Csv (CSV, printCSV, printTSV)
|
||||||
import Hledger.Cli.CliOptions
|
import Hledger.Cli.CliOptions
|
||||||
import Hledger.Cli.Utils
|
import Hledger.Cli.Utils
|
||||||
import System.Exit (exitFailure)
|
|
||||||
import Safe (lastMay, minimumDef)
|
|
||||||
import Data.Function ((&))
|
|
||||||
import Data.List.Extra (nubSort)
|
|
||||||
import qualified Data.Map as M
|
|
||||||
|
|
||||||
printmode = hledgerCommandMode
|
printmode = hledgerCommandMode
|
||||||
$(embedFileRelative "Hledger/Cli/Commands/Print.txt")
|
$(embedFileRelative "Hledger/Cli/Commands/Print.txt")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user