{-# LANGUAGE OverloadedStrings #-} -- | Renders Penny data in a format that can be parsed by -- "Penny.Copper.Parsec". These functions render text that is -- compliant with the EBNF grammar which is at -- @doc\/ledger-grammar.org@. module Penny.Copper.Render where import Control.Monad (guard) import Control.Applicative ((<$>), (<|>), (<*>), pure) import Data.List (intersperse) import Data.Monoid ((<>)) import qualified Data.Text as X import Data.Text (Text, cons, snoc) import qualified Penny.Copper.Terminals as T import qualified Data.Time as Time import qualified Penny.Copper.Interface as I import qualified Penny.Lincoln as L import qualified Data.Sums as S -- * Helpers -- | Merges a list of words into one Text; however, if any given Text -- is empty, that Text is first dropped from the list. txtWords :: [X.Text] -> X.Text txtWords xs = case filter (not . X.null) xs of [] -> X.empty rs -> X.unwords rs -- | Takes a field that may or may not be present and a function that -- renders it. If the field is not present at all, returns an empty -- Text. Otherwise will succeed or fail depending upon whether the -- rendering function succeeds or fails. renMaybe :: Maybe a -> (a -> Maybe X.Text) -> Maybe X.Text renMaybe mx f = case mx of Nothing -> Just X.empty Just a -> f a -- * Accounts -- | Is True if a sub account can be rendered at Level 1; -- False otherwise. isSubAcctLvl1 :: L.SubAccount -> Bool isSubAcctLvl1 (L.SubAccount x) = X.all T.lvl1AcctChar x && not (X.null x) isAcctLvl1 :: L.Account -> Bool isAcctLvl1 (L.Account ls) = (not . null $ ls) && (all isSubAcctLvl1 ls) quotedLvl1Acct :: L.Account -> Maybe Text quotedLvl1Acct a@(L.Account ls) = do guard (isAcctLvl1 a) let txt = X.concat . intersperse (X.singleton ':') . map L.unSubAccount $ ls return $ '{' `X.cons` txt `X.snoc` '}' isFirstSubAcctLvl2 :: L.SubAccount -> Bool isFirstSubAcctLvl2 (L.SubAccount x) = case X.uncons x of Nothing -> False Just (c, r) -> T.letter c && (X.all T.lvl2AcctOtherChar r) isOtherSubAcctLvl2 :: L.SubAccount -> Bool isOtherSubAcctLvl2 (L.SubAccount x) = (not . X.null $ x) && (X.all T.lvl2AcctOtherChar x) isAcctLvl2 :: L.Account -> Bool isAcctLvl2 (L.Account ls) = case ls of [] -> False x:xs -> isFirstSubAcctLvl2 x && all isOtherSubAcctLvl2 xs lvl2Acct :: L.Account -> Maybe Text lvl2Acct a@(L.Account ls) = do guard $ isAcctLvl2 a return . X.concat . intersperse (X.singleton ':') . map L.unSubAccount $ ls -- | Shows an account, with the minimum level of quoting -- possible. Fails with an error if any one of the characters in the -- account name does not satisfy the 'lvl1Char' predicate. Otherwise -- returns a rendered account, quoted if necessary. ledgerAcct :: L.Account -> Maybe Text ledgerAcct a = lvl2Acct a <|> quotedLvl1Acct a -- * Commodities -- | Render a quoted Level 1 commodity. Fails if any character does -- not satisfy lvl1Char. quotedLvl1Cmdty :: L.Commodity -> Maybe Text quotedLvl1Cmdty (L.Commodity c) = if X.all T.lvl1CmdtyChar c then Just $ '"' `cons` c `snoc` '"' else Nothing -- | Render a Level 2 commodity. Fails if the first character is not a -- letter or a symbol, or if any other character is a space. lvl2Cmdty :: L.Commodity -> Maybe Text lvl2Cmdty (L.Commodity c) = do (f, rs) <- X.uncons c guard $ T.lvl2CmdtyFirstChar f guard . X.all T.lvl2CmdtyOtherChar $ rs return c -- | Render a Level 3 commodity. Fails if any character is not a -- letter or a symbol. lvl3Cmdty :: L.Commodity -> Maybe Text lvl3Cmdty (L.Commodity c) = if (not . X.null $ c) && (X.all T.lvl3CmdtyChar c) then return c else Nothing -- * Quantities -- | Gets the characters necessary to quote a qtyRep. quoteQtyRep :: L.QtyRep -> (Text, Text) quoteQtyRep q = case q of L.QNoGrouping _ r -> case r of L.Period -> ("", "") L.Comma -> ("[", "]") L.QGrouped ei -> case ei of Left wf -> if hasSpace wf then ("{", "}") else ("", "") Right _ -> ("[", "]") qtyRep :: L.QtyRep -> Text qtyRep q = b <> L.showQtyRep q <> e where (b, e) = quoteQtyRep q hasSpace :: L.WholeOrFrac (L.GroupedDigits L.PeriodGrp) -> Bool hasSpace (L.WholeOrFrac ei) = case ei of Left w -> grpHasSpace . L.unWholeOnly $ w Right wf -> grpHasSpace (L.whole wf) || grpHasSpace (L.frac wf) where grpHasSpace grp = L.PGSpace `elem` (map fst . L.dsNextParts $ grp) -- * Amounts -- | Render an Amount. The Format is required so that the commodity -- can be displayed in the right place. amount :: Maybe (L.Amount L.Qty -> S.S3 L.Radix L.PeriodGrp L.CommaGrp) -- ^ If Just, render entries that are NOT inferred and that do not -- have a QtyRep. If Nothing, fail if an entry is NOT inferred and -- does not have a QtyRep. (Inferred entries are always rendered -- without an entry.) -> Maybe L.Side -> Maybe L.SpaceBetween -> Either (L.Amount L.QtyRep) (L.Amount L.Qty) -> Maybe X.Text amount mayFmt maySd maySb ei = do (q, c) <- case ei of Left a -> return (qtyRep . L.qty $ a, L.commodity a) Right a -> case mayFmt of Nothing -> Nothing Just f -> return ( qtyRep . L.qtyToRep (f a) . L.qty $ a, L.commodity a) sd <- maySd sb <- maySb let ws = case sb of L.SpaceBetween -> X.singleton ' ' L.NoSpaceBetween -> X.empty (l, r) <- case sd of L.CommodityOnLeft -> do cx <- lvl3Cmdty c <|> quotedLvl1Cmdty c return (cx, q) L.CommodityOnRight -> do cx <- lvl2Cmdty c <|> quotedLvl1Cmdty c return (q, cx) return $ X.concat [l, ws, r] -- * Comments comment :: I.Comment -> Maybe X.Text comment (I.Comment x) = if (not . X.all T.nonNewline $ x) then Nothing else Just $ '#' `cons` x `snoc` '\n' -- * DateTime -- | Render a DateTime. The day is always printed. If the time zone -- offset is not zero, then the time and time zone offset are both -- printed. If the time zone offset is zero, then the hours and -- minutes are printed, but only if the time is not midnight. If the -- seconds are not zero, they are also printed. dateTime :: L.DateTime -> X.Text dateTime (L.DateTime d h m s z) = X.append xd xr where (iYr, iMo, iDy) = Time.toGregorian d xr = hoursMinsSecsZone h m s z dash = X.singleton '-' xd = X.concat [ showX iYr, dash, pad2 . showX $ iMo, dash, pad2 . showX $ iDy ] pad2 :: X.Text -> X.Text pad2 = X.justifyRight 2 '0' pad4 :: X.Text -> X.Text pad4 = X.justifyRight 4 '0' showX :: Show a => a -> X.Text showX = X.pack . show hoursMinsSecsZone :: L.Hours -> L.Minutes -> L.Seconds -> L.TimeZoneOffset -> X.Text hoursMinsSecsZone h m s z = if z == L.noOffset && (h, m, s) == L.midnight then X.empty else let xhms = X.concat [xh, colon, xm, xs] xh = pad2 . showX . L.unHours $ h xm = pad2 . showX . L.unMinutes $ m xs = let secs = L.unSeconds s in if secs == 0 then X.empty else ':' `X.cons` (pad2 . showX $ secs) off = L.offsetToMins z sign = X.singleton $ if off < 0 then '-' else '+' padded = pad4 . showX . abs $ off xz = if off == 0 then X.empty else ' ' `X.cons` sign `X.append` padded colon = X.singleton ':' in ' ' `X.cons` xhms `X.append` xz -- * Entries entry :: Maybe (L.Amount L.Qty -> S.S3 L.Radix L.PeriodGrp L.CommaGrp) -- ^ If Just, render entries that are NOT inferred and that do not -- have a QtyRep. If Nothing, fail if an entry is NOT inferred and -- does not have a QtyRep. (Inferred entries are always rendered -- without an entry.) -> Maybe L.Side -> Maybe L.SpaceBetween -> Either (L.Entry L.QtyRep) (L.Entry L.Qty) -> Maybe X.Text entry mayFmt sd sb ei = do amt <- amount mayFmt sd sb (either (Left . L.amount) (Right . L.amount) ei) let dc = either L.drCr L.drCr ei dcTxt = X.pack $ case dc of L.Debit -> "<" L.Credit -> ">" return $ X.append (X.snoc dcTxt ' ') amt -- * Flags flag :: L.Flag -> Maybe X.Text flag (L.Flag fl) = if X.all T.flagChar fl then Just $ '[' `cons` fl `snoc` ']' else Nothing -- * Memos -- | Renders a postingMemoLine, optionally with trailing -- whitespace. The trailing whitespace allows the next line to be -- indented properly if is also a postingMemoLine. This is handled -- using trailing whitespace rather than leading whitespace because -- leading whitespace is inconsistent with the grammar. postingMemoLine :: Int -- ^ Pad the end of the output with this many spaces -> X.Text -> Maybe X.Text postingMemoLine p x = if X.all T.nonNewline x then let trailing = X.replicate p (X.singleton ' ') ls = [X.singleton '\'', x, X.singleton '\n', trailing] in Just $ X.concat ls else Nothing -- | Renders a postingMemo. Fails if the postingMemo is empty, as the -- grammar requires that they have at least one line. -- -- If the boolean is True, inserts padding after the last -- postingMemoLine so that the next line is indented by four -- columns. Use this if the posting memo is followed by another -- posting. If the last boolean if False, there is no indenting after -- the last postingMemoLine. postingMemo :: Bool -> L.Memo -> Maybe X.Text postingMemo iLast (L.Memo ls) = if null ls then Nothing else let bs = replicate (length ls - 1) 8 ++ [if iLast then 4 else 0] in fmap X.concat . sequence $ zipWith postingMemoLine bs ls transactionMemoLine :: X.Text -> Maybe X.Text transactionMemoLine x = if X.all T.nonNewline x then Just $ ';' `cons` x `snoc` '\n' else Nothing transactionMemo :: L.Memo -> Maybe X.Text transactionMemo (L.Memo ls) = if null ls then Nothing else fmap X.concat . mapM transactionMemoLine $ ls -- * Numbers number :: L.Number -> Maybe Text number (L.Number t) = if X.all T.numberChar t then Just $ '(' `cons` t `snoc` ')' else Nothing -- * Payees quotedLvl1Payee :: L.Payee -> Maybe Text quotedLvl1Payee (L.Payee p) = do guard (X.all T.quotedPayeeChar p) return $ '~' `X.cons` p `X.snoc` '~' lvl2Payee :: L.Payee -> Maybe Text lvl2Payee (L.Payee p) = do (c1, cs) <- X.uncons p guard (T.letter c1) guard (X.all T.nonNewline cs) return p payee :: L.Payee -> Maybe Text payee p = lvl2Payee p <|> quotedLvl1Payee p -- * Prices price :: L.PricePoint -> Maybe X.Text price pp = let dateTxt = dateTime (L.dateTime pp) (L.From from) = L.from . L.price $ pp (L.To to) = L.to . L.price $ pp (L.CountPerUnit q) = L.countPerUnit . L.price $ pp mayFromTxt = lvl3Cmdty from <|> quotedLvl1Cmdty from in do amtTxt <- amount Nothing (L.ppSide pp) (L.ppSpaceBetween pp) (Left (L.Amount q to)) fromTxt <- mayFromTxt return $ (X.intercalate (X.singleton ' ') [X.singleton '@', dateTxt, fromTxt, amtTxt]) `snoc` '\n' -- * Tags tag :: L.Tag -> Maybe X.Text tag (L.Tag t) = if X.all T.tagChar t then Just $ X.cons '*' t else Nothing tags :: L.Tags -> Maybe X.Text tags (L.Tags ts) = X.intercalate (X.singleton ' ') <$> mapM tag ts -- * TopLine -- | Renders the TopLine. Emits trailing whitespace after the newline -- so that the first posting is properly indented. topLine :: L.TopLineCore -> Maybe X.Text topLine tl = f <$> pure (dateTime (L.tDateTime tl)) <*> renMaybe (L.tMemo tl) transactionMemo <*> renMaybe (L.tFlag tl) flag <*> renMaybe (L.tNumber tl) number <*> renMaybe (L.tPayee tl) payee where f dtX meX flX nuX paX = X.concat [ meX, txtWords [dtX, flX, nuX, paX], X.singleton '\n', X.replicate 4 (X.singleton ' ') ] -- * Posting -- | Renders a Posting. Fails if any of the components -- fail to render. In addition, if the unverified Posting has an -- Entry, a Format must be provided, otherwise render fails. -- -- The columns look like this. Column numbers begin with 0 (like they -- do in Emacs) rather than with column 1 (like they do in -- Vim). (Really Emacs is the strange one; most CLI utilities seem to -- start with column 1 too...) -- -- > ID COLUMN WIDTH WHAT -- > --------------------------------------------------- -- > A 0 4 Blank spaces for indentation -- > B 4 50 Flag, Number, Payee, Account, Tags -- > C 54 2 Blank spaces for padding -- > D 56 NA Entry -- -- Omit the padding after column B if there is no entry; also omit -- columns C and D entirely if there is no Entry. (It is annoying to -- have extraneous blank space in a file). -- -- This table is a bit of a lie, because the blank spaces for -- indentation are emitted either by the posting previous to this one -- (either after the posting itself or after its postingMemo) or by -- the TopLine. -- -- Also emits an additional eight spaces after the trailing newline if -- the posting has a memo. That way the memo will be indented -- properly. (There are trailing spaces here, as opposed to leading -- spaces in the posting memo, because the latter would be -- inconsistent with the grammar.) -- -- Emits an extra four spaces after the first line if the first -- paramter is True. However, this is overriden if there is a memo, in -- which case eight spaces will be emitted. (This allows the next -- posting to be indented properly.) posting :: Maybe (L.Amount L.Qty -> S.S3 L.Radix L.PeriodGrp L.CommaGrp) -- ^ If Just, render entries that are NOT inferred and that do not -- have a QtyRep. If Nothing, fail if an entry is NOT inferred and -- does not have a QtyRep. (Inferred entries are always rendered -- without an entry.) -> Bool -- ^ If True, emit four spaces after the trailing newline. -> L.Ent L.PostingCore -> Maybe X.Text posting maySpec pad ent = do let p = L.meta ent fl <- renMaybe (L.pFlag p) flag nu <- renMaybe (L.pNumber p) number pa <- renMaybe (L.pPayee p) quotedLvl1Payee ac <- ledgerAcct (L.pAccount p) ta <- tags (L.pTags p) me <- renMaybe (L.pMemo p) (postingMemo pad) let mayEn = if L.inferred ent then Nothing else Just $ L.entry ent en <- renMaybe mayEn (entry maySpec (L.pSide p) (L.pSpaceBetween p)) return $ formatter pad fl nu pa ac ta en me formatter :: Bool -- ^ If True, emit four trailing spaces if no memo or -- eight trailing spaces if there is a memo. -> X.Text -- ^ Flag -> X.Text -- ^ Number -> X.Text -- ^ Payee -> X.Text -- ^ Account -> X.Text -- ^ Tags -> X.Text -- ^ Entry -> X.Text -- ^ Memo -> X.Text formatter pad fl nu pa ac ta en me = let colBnoPad = txtWords [fl, nu, pa, ac, ta] colD = en colB = if X.null en then colBnoPad else X.justifyLeft 50 ' ' colBnoPad colC = if X.null en then X.empty else X.pack (replicate 2 ' ') rtn = '\n' `X.cons` trailingWhite trailingWhite = case (X.null me, pad) of (True, False) -> X.empty (True, True) -> X.replicate 4 (X.singleton ' ') (False, _) -> X.replicate 8 (X.singleton ' ') in X.concat [colB, colC, colD, rtn, me] -- * Transaction transaction :: Maybe (L.Amount L.Qty -> S.S3 L.Radix L.PeriodGrp L.CommaGrp) -- ^ If Just, render entries that are NOT inferred and that do not -- have a QtyRep. If Nothing, fail if an entry is NOT inferred and -- does not have a QtyRep. (Inferred entries are always rendered -- without an entry.) -> (L.TopLineCore, L.Ents L.PostingCore) -> Maybe X.Text transaction mayFmt txn = do tlX <- topLine . fst $ txn let (p1, p2, ps) = L.tupleEnts . snd $ txn p1X <- posting mayFmt True p1 p2X <- posting mayFmt (not . null $ ps) p2 psX <- if null ps then return X.empty else let bs = replicate (length ps - 1) True ++ [False] in fmap X.concat . sequence $ zipWith (posting mayFmt) bs ps return $ X.concat [tlX, p1X, p2X, psX] -- * Item item :: Maybe (L.Amount L.Qty -> S.S3 L.Radix L.PeriodGrp L.CommaGrp) -- ^ If Just, render entries that are NOT inferred and that do not -- have a QtyRep. If Nothing, fail if an entry is NOT inferred and -- does not have a QtyRep. (Inferred entries are always rendered -- without an entry.) -> S.S4 (L.TopLineCore, L.Ents L.PostingCore) L.PricePoint I.Comment I.BlankLine -> Maybe X.Text item mayFmt = S.caseS4 (transaction mayFmt) price comment (const (Just (X.pack "\n")))