1 {-# LANGUAGE LambdaCase #-}
2 {-# LANGUAGE NamedFieldPuns #-}
3 {-# LANGUAGE OverloadedStrings #-}
4 {-# LANGUAGE ScopedTypeVariables #-}
5 module Hcompta.Format.Ledger.Write where
7 import Control.Arrow ((***))
8 import Data.Decimal (DecimalRaw(..))
9 import qualified Data.Char (isSpace)
10 import Data.Fixed (showFixed)
11 import qualified Data.List
12 import qualified Data.Map.Strict as Data.Map
13 import Data.Maybe (fromMaybe)
14 import qualified Data.Text.Lazy as TL
15 import qualified Data.Text as Text
16 import Data.Text (Text)
17 import qualified Data.Time.Calendar as Time (toGregorian)
18 import qualified Data.Time.LocalTime as Time (LocalTime(..), TimeOfDay(..), TimeZone(..), timeZoneOffsetString, ZonedTime(..))
19 import qualified Text.PrettyPrint.Leijen.Text as P
20 import Text.PrettyPrint.Leijen.Text (Doc, (<>))
21 import System.IO (Handle)
23 import qualified Hcompta.Model.Account as Account
24 import Hcompta.Model.Account (Account)
25 import qualified Hcompta.Model.Amount as Amount
26 import Hcompta.Model.Amount (Amount)
27 import qualified Hcompta.Model.Amount.Quantity as Quantity
28 import Hcompta.Model.Amount.Quantity (Quantity)
29 import qualified Hcompta.Model.Amount.Style as Style
30 import Hcompta.Model.Amount.Style (Style)
31 -- import qualified Hcompta.Model.Amount.Unit as Unit
32 import Hcompta.Model.Amount.Unit (Unit)
33 import qualified Hcompta.Model.Transaction as Transaction
34 import Hcompta.Model.Transaction (Comment, Tag, Transaction)
35 import qualified Hcompta.Model.Transaction.Posting as Posting
36 import Hcompta.Model.Transaction (Posting)
37 import qualified Hcompta.Model.Journal as Journal
38 import Hcompta.Model.Journal (Journal)
39 -- import qualified Hcompta.Model.Transaction.Tag as Tag
40 -- import Hcompta.Model.Transaction (Tag)
41 -- import qualified Hcompta.Model.Date as Date
42 import Hcompta.Model.Date (Date)
43 -- import Hcompta.Format.Ledger.Journal as Journal
44 import qualified Hcompta.Format.Ledger.Read as Read
50 show :: Doc -> TL.Text
51 show = P.displayT . P.renderPretty 1.0 maxBound
53 showIO :: Handle -> Doc -> IO ()
54 showIO handle = P.displayIO handle . P.renderPretty 1.0 maxBound
58 -- | Return a 'Doc' from a strict 'Text'
60 text = P.text . TL.fromStrict
62 -- | Return a 'Doc' concatenating converted values of a 'Map'
63 -- separated by a given 'Doc'
66 -> Data.Map.Map k a -> Doc
69 (\(first, doc) x -> case first of
71 False -> (False, doc <> sep <> f x))
72 (True, P.empty) -- NOTE: public API gives no way to test for P.empty
74 -- * Printing 'Account'
76 account :: Posting.Type -> Account -> Doc
79 Posting.Type_Regular -> account_
80 Posting.Type_Virtual -> \acct ->
81 P.char Read.posting_type_virtual_begin <> do
83 P.char Read.posting_type_virtual_end
84 Posting.Type_Virtual_Balanced -> \acct ->
85 P.char Read.posting_type_virtual_balanced_begin <> do
87 P.char Read.posting_type_virtual_balanced_end
89 account_ :: Account -> Doc
93 (P.char Read.account_name_sep)
94 (Data.List.map account_name acct)
96 account_name :: Account.Name -> Doc
99 -- ** Mesuring 'Account'
101 account_length :: Posting.Type -> Account -> Int
102 account_length type_ acct =
104 (\acc -> (1 +) . (acc +) . Text.length)
105 (if acct == [] then 0 else (- 1)) acct +
107 Posting.Type_Regular -> 0
108 Posting.Type_Virtual -> 2
109 Posting.Type_Virtual_Balanced -> 2
111 -- * Printing 'Amount'
113 amount :: Amount -> Doc
115 { Amount.quantity=qty
116 , Amount.style = style@(Style.Style
123 Just Style.Side_Left ->
125 <> (case unit_spaced of { Just True | unit_ /= "" -> P.space; _ -> P.empty })
127 <> quantity style qty
129 (Just Style.Side_Right) ->
130 (case unit_spaced of { Just True | unit_ /= "" -> P.space; _ -> P.empty })
133 (case unit_spaced of { Just True | unit_ /= "" -> P.space; _ -> P.empty })
140 quantity :: Style -> Quantity -> Doc
143 , Style.grouping_integral
144 , Style.grouping_fractional
147 let Decimal e n = Quantity.round precision qty
148 let num = Prelude.show $ abs $ n
149 let sign = text (if n < 0 then "-" else "")
150 case e == 0 || precision == 0 of
151 True -> sign <> (text $ Text.pack num)
153 let num_len = length num
156 [ replicate (fromIntegral e + 1 - num_len) '0'
158 , replicate (fromIntegral precision - fromIntegral e) '0'
160 let (int, frac) = Data.List.splitAt (max 1 (num_len - fromIntegral precision)) padded
161 let default_fractioning =
163 del_grouping_sep grouping_integral $
164 del_grouping_sep grouping_fractional $
167 P.text (TL.pack $ maybe id (\g -> reverse . group g . reverse) grouping_integral $ int) <> do
168 P.char (fromMaybe default_fractioning fractioning) <> do
169 P.text (TL.pack $ maybe id group grouping_fractional frac)
171 group :: Style.Grouping -> [Char] -> [Char]
172 group (Style.Grouping sep sizes_) =
173 Data.List.concat . reverse .
174 Data.List.map reverse . fst .
176 (flip (\digit -> \case
177 ([], sizes) -> ([[digit]], sizes)
178 (digits:groups, []) -> ((digit:digits):groups, [])
179 (digits:groups, curr_sizes@(size:sizes)) ->
180 if length digits < size
181 then ( (digit:digits):groups, curr_sizes)
182 else ([digit]:[sep]:digits:groups, if sizes == [] then curr_sizes else sizes)
185 del_grouping_sep grouping =
187 Just (Style.Grouping sep _) -> Data.List.delete sep
190 -- ** Mesuring 'Amount'
192 amount_length :: Amount -> Int
193 amount_length Amount.Amount
194 { Amount.quantity=qty
195 , Amount.style = style@(Style.Style
201 + (case unit_spaced of { Just True | unit_ /= "" -> 1; _ -> 0 })
202 + quantity_length style qty
204 amounts_length :: Amount.By_Unit -> Int
205 amounts_length amts =
206 if Data.Map.null amts
210 (\n -> (3 +) . (+) (amount_length n))
213 quantity_length :: Style -> Quantity -> Int
214 quantity_length Style.Style
215 { Style.grouping_integral
216 , Style.grouping_fractional
219 let Decimal e n = Quantity.round precision qty in
220 let sign_len = if n < 0 then 1 else 0 in
221 let fractioning_len = if e > 0 then 1 else 0 in
222 let num_len = if n == 0 then 0 else (1 +) $ truncate $ logBase 10 $ (fromIntegral (abs n)::Double) in
223 let pad_left_len = max 0 (fromIntegral e + 1 - num_len) in
224 let pad_right_len = max 0 (fromIntegral precision - fromIntegral e) in
225 let padded_len = pad_left_len + num_len + pad_right_len in
226 let int_len = max 1 (num_len - fromIntegral precision) in
227 let frac_len = max 0 (padded_len - int_len) in
231 + maybe 0 (group int_len) grouping_integral
232 + maybe 0 (group frac_len) grouping_fractional
235 group :: Int -> Style.Grouping -> Int
236 group num_len (Style.Grouping _sep sizes_) =
239 else loop 0 num_len sizes_
241 loop :: Int -> Int -> [Int] -> Int
246 let l = len - size in
248 else loop (pad + 1) l sizes
250 let l = len - size in
252 else loop (pad + 1) l sizes
258 (Time.LocalTime day tod)
259 tz@(Time.TimeZone tz_min _ tz_name)) = do
260 let (y, mo, d) = Time.toGregorian day
261 (if y == 0 then P.empty else P.integer y <> P.char '/') <> do
263 P.char '/' <> int2 d <> do
265 Time.TimeOfDay 0 0 0 -> P.empty
266 Time.TimeOfDay h m s ->
267 P.space <> int2 h <> do
268 P.char ':' <> int2 m <> do
271 _ -> P.char ':' <> do
272 (if s < 10 then P.char '0' else P.empty) <> do
273 text $ Text.pack $ showFixed True s)) <> do
276 _ | tz_name /= "" -> P.space <> do text $ Text.pack tz_name
277 _ -> P.space <> do text $ Text.pack $ Time.timeZoneOffsetString tz)
280 int2 i = if i < 10 then P.char '0' <> P.int i else P.int i
282 -- * Printing 'Comment'
284 comment :: Comment -> Doc
286 P.char Read.comment_begin
287 <> (case Text.uncons com of
288 Just (c, _) | not $ Data.Char.isSpace c -> P.space
292 comments :: Doc -> [Comment] -> Doc
295 Data.List.intersperse P.line .
296 Data.List.map (\c -> prefix <> comment c)
301 tag (n, v) = text n <> P.char Read.tag_value_sep <> text v
303 -- * Printing 'Posting'
305 posting :: Posting_Lengths -> Posting.Type -> Posting -> Doc
312 { Posting.account=acct
314 , Posting.comments=cmts
316 , Posting.status=status_
322 (case Data.Map.null amounts of
323 True -> account type_ acct
325 P.fill (max_account_length + 2)
326 (account type_ acct) <> do
327 P.fill (max 0 (max_amount_length - amounts_length amounts)) P.empty <> do
328 -- NOTE: AFAICS Text.PrettyPrint.Leijen gives no way
329 -- to get the column size of a Doc
330 -- before printing it, hence the call to amounts_length here again.
332 (P.space <> P.char Read.amount_sep <> P.space)
336 [c] -> P.space <> comment c
337 _ -> P.line <> do comments (P.text "\t\t") cmts)
339 status :: Transaction.Status -> Doc
344 -- ** Mesuring 'Posting'
346 type Posting_Lengths = (Int, Int)
348 nil_Posting_Lengths :: Posting_Lengths
349 nil_Posting_Lengths = (0, 0)
351 postings_lengths :: Posting.Type -> Posting.By_Account -> Posting_Lengths -> Posting_Lengths
352 postings_lengths type_ =
353 flip $ Data.Map.foldl $ Data.List.foldl $
355 (max (account_length type_ (Posting.account p)))
357 (max (amounts_length (Posting.amounts p)))
359 -- * Printing 'Transaction'
361 transaction :: Transaction -> Doc
362 transaction t = transaction_with_lengths (transaction_lengths t nil_Transaction_Lengths) t
364 transaction_with_lengths :: Transaction_Lengths -> Transaction -> Doc
365 transaction_with_lengths
367 Transaction.Transaction
368 { Transaction.code=code_
369 , Transaction.comments_before
370 , Transaction.comments_after
371 , Transaction.dates=(first_date, dates)
372 , Transaction.description
373 , Transaction.postings
374 , Transaction.virtual_postings
375 , Transaction.balanced_virtual_postings
376 , Transaction.status=status_
377 -- , Transaction.tags
379 (case comments_before of
381 _ -> comments (P.text "\t") comments_before <> P.line) <> do
383 Data.List.intersperse
384 (P.char Read.date_sep)
385 (Data.List.map date (first_date:dates))) <> do
387 True -> P.space <> status status_
388 False -> P.empty) <> do
392 _ -> P.space <> text description) <> do
394 (case comments_after of
396 _ -> comments (P.text "\t") comments_after <> P.line) <> do
397 P.vsep $ Data.List.map
401 (P.vsep . Data.List.map
402 (posting posting_lengths_ type_)))
403 (Posting.by_signs_and_account ps))
404 [ (Posting.Type_Regular, postings)
405 , (Posting.Type_Virtual, virtual_postings)
406 , (Posting.Type_Virtual_Balanced, balanced_virtual_postings)
409 code :: Transaction.Code -> Doc
412 t -> P.space <> P.char '(' <> text t <> P.char ')'
414 -- ** Mesuring 'Transaction'
416 type Transaction_Lengths = Posting_Lengths
418 nil_Transaction_Lengths :: Posting_Lengths
419 nil_Transaction_Lengths = nil_Posting_Lengths
421 transaction_lengths :: Transaction -> Posting_Lengths -> Posting_Lengths
423 Transaction.Transaction
424 { Transaction.postings
425 , Transaction.virtual_postings
426 , Transaction.balanced_virtual_postings
427 } posting_lengths_ = do
429 (flip (\(type_, ps) -> postings_lengths type_ ps))
431 [ (Posting.Type_Regular, postings)
432 , (Posting.Type_Virtual, virtual_postings)
433 , (Posting.Type_Virtual_Balanced, balanced_virtual_postings)
436 -- * Printing 'Journal'
438 journal :: Journal -> Doc
439 journal Journal.Journal
440 { Journal.transactions
442 let transaction_lengths_ =
444 (Data.List.foldl (flip transaction_lengths))
445 nil_Transaction_Lengths
448 (Data.List.foldl (\(first, doc) t ->
450 , (if first then P.empty else doc <> P.line)
451 <> transaction_with_lengths transaction_lengths_ t <> P.line