1 {-# LANGUAGE DeriveDataTypeable #-}
2 {-# LANGUAGE FlexibleContexts #-}
3 {-# LANGUAGE NamedFieldPuns #-}
4 {-# LANGUAGE OverloadedStrings #-}
5 {-# LANGUAGE ScopedTypeVariables #-}
6 {-# LANGUAGE TupleSections #-}
7 module Hcompta.Format.Ledger.Read where
9 import Control.Applicative ((<$>), (<*>), (<*))
10 import qualified Control.Exception as Exception
11 import Control.Arrow ((***))
12 import Control.Monad (guard, join, liftM, (>=>))
13 import Control.Monad.IO.Class (liftIO)
14 import Control.Monad.Trans.Except (ExceptT(..), throwE)
15 import qualified Data.Char
16 import qualified Data.Decimal
17 import qualified Data.Either
18 import qualified Data.List
19 import Data.List.NonEmpty (NonEmpty(..))
20 import qualified Data.Map.Strict as Data.Map
21 import Data.Maybe (fromMaybe)
22 import qualified Data.Time.Calendar as Time
23 import qualified Data.Time.Clock as Time
24 import qualified Data.Time.LocalTime as Time
25 import Data.Time.LocalTime (TimeZone(..))
26 import Data.Typeable ()
27 import qualified Text.Parsec as R
28 import Text.Parsec (Stream, ParsecT, (<|>), (<?>))
29 import qualified Data.Text.IO as Text.IO (readFile)
30 import qualified Data.Text as Text
31 import qualified System.FilePath.Posix as Path
33 import qualified Hcompta.Model.Account as Account
34 import Hcompta.Model.Account (Account)
35 import qualified Hcompta.Model.Amount as Amount
36 import Hcompta.Model.Amount (Amount)
37 import qualified Hcompta.Model.Amount.Style as Style
38 import qualified Hcompta.Model.Amount.Unit as Unit
39 import Hcompta.Model.Amount.Unit (Unit)
40 import qualified Hcompta.Model.Transaction as Transaction
41 import Hcompta.Model.Transaction (Transaction, Comment)
42 import qualified Hcompta.Model.Transaction.Posting as Posting
43 import Hcompta.Model.Transaction (Posting)
44 import qualified Hcompta.Model.Transaction.Tag as Tag
45 import Hcompta.Model.Transaction (Tag)
46 import qualified Hcompta.Model.Date as Date
47 import Hcompta.Model.Date (Date)
48 import Hcompta.Format.Ledger.Journal as Journal
49 import qualified Hcompta.Lib.Regex as Regex
50 import Hcompta.Lib.Regex (Regex)
51 import qualified Hcompta.Lib.Parsec as R
52 import qualified Hcompta.Lib.Path as Path
56 { context_account_prefix :: !(Maybe Account)
57 , context_aliases_exact :: !(Data.Map.Map Account Account)
58 , context_aliases_joker :: ![(Account.Joker, Account)]
59 , context_aliases_regex :: ![(Regex, Account)]
60 , context_date :: !Date
61 , context_unit_and_style :: !(Maybe (Amount.Unit, Amount.Style))
62 , context_journal :: !Journal
63 , context_year :: !Date.Year
66 nil_Context :: Context
69 { context_account_prefix = Nothing
70 , context_aliases_exact = Data.Map.empty
71 , context_aliases_joker = []
72 , context_aliases_regex = []
73 , context_date = Date.nil
74 , context_unit_and_style = Nothing
75 , context_journal = Journal.nil
76 , context_year = (\(year, _ , _) -> year) $
77 Time.toGregorian $ Time.utctDay $
78 Journal.last_read_time Journal.nil
81 -- | Parse either '-' into 'negate', or '+' or '' into 'id'.
82 sign :: (Stream s m Char, Num i) => ParsecT s u m (i -> i)
84 (R.char '-' >> return negate)
85 <|> (R.char '+' >> return id)
88 -- * Parsing 'Account'
90 account_name_sep :: Char
91 account_name_sep = ':'
93 -- | Parse an 'Account'.
94 account :: Stream s m Char => ParsecT s u m Account
96 R.notFollowedBy $ R.space_horizontal
97 Account.from_List <$> do
98 R.many1_separated account_name $ R.char account_name_sep
100 -- | Parse an Account.'Account.Name'.
101 account_name :: Stream s m Char => ParsecT s u m Account.Name
104 R.many1 $ R.try account_name_char
106 account_name_char :: Stream s m Char => ParsecT s u m Char
107 account_name_char = do
110 _ | c == comment_begin -> R.parserZero
111 _ | c == account_name_sep -> R.parserZero
112 _ | R.is_space_horizontal c -> do
113 _ <- R.notFollowedBy $ R.space_horizontal
114 return c <* (R.lookAhead $ R.try $
115 ( R.try (R.char account_name_sep)
116 <|> account_name_char
118 _ | not (Data.Char.isSpace c) -> return c
121 -- | Parse an Account.'Account.Joker_Name'.
122 account_joker_name :: Stream s m Char => ParsecT s u m Account.Joker_Name
123 account_joker_name = do
124 n <- R.option Nothing $ (Just <$> account_name)
126 Nothing -> R.char account_name_sep >> (return $ Account.Joker_Any)
127 Just n' -> return $ Account.Joker_Name n'
129 -- | Parse an Account.'Account.Joker'.
130 account_joker :: Stream s m Char => ParsecT s u m Account.Joker
132 R.notFollowedBy $ R.space_horizontal
133 R.many1_separated account_joker_name $ R.char account_name_sep
135 -- | Parse a 'Regex'.
136 account_regex :: Stream s m Char => ParsecT s u m Regex
138 re <- R.many1 $ R.satisfy (not . R.is_space_horizontal)
141 -- | Parse an Account.'Account.Filter'.
142 account_pattern :: Stream s m Char => ParsecT s u m Account.Pattern
145 [ Account.Pattern_Exact <$> (R.char '=' >> account)
146 , Account.Pattern_Joker <$> (R.char '*' >> account_joker)
147 , Account.Pattern_Regex <$> (R.option '~' (R.char '~') >> account_regex)
150 -- * Parsing 'Amount'
152 -- | Parse an 'Amount'.
153 amount :: Stream s m Char => ParsecT s u m Amount
157 R.option Nothing $ do
159 s <- R.many $ R.space_horizontal
160 return $ Just $ (u, not $ null s)
161 (quantity_, style) <- do
168 , grouping_fractional
171 [ quantity '_' ',' '_' <* (R.notFollowedBy $ R.oneOf ",._")
172 , quantity '_' '.' '_' <* (R.notFollowedBy $ R.oneOf ",._")
173 , quantity ',' '.' '_' <* (R.notFollowedBy $ R.oneOf ",._")
174 , quantity '.' ',' '_' <* (R.notFollowedBy $ R.oneOf ",._")
176 let int = Data.List.concat integral
177 let frac_flat = Data.List.concat fractional
178 let frac = reverse $ Data.List.dropWhile (== '0') $ reverse $ frac_flat
179 let place = length frac
181 let mantissa = R.integer_of_digits 10 $ int ++ frac
183 ( Data.Decimal.Decimal
188 , Style.grouping_integral
189 , Style.grouping_fractional
190 , Style.precision = fromIntegral $ length frac_flat
193 (unit_, unit_side, unit_spaced) <-
196 return (u, Just Style.Side_Left, Just s)
198 R.option (Unit.nil, Nothing, Nothing) $ R.try $ do
199 s <- R.many $ R.space_horizontal
201 return $ (u, Just Style.Side_Right, Just $ not $ null s)
204 { Amount.quantity = left_signing $ quantity_
205 , Amount.style = style
209 , Amount.unit = unit_
214 { integral :: [String]
215 , fractional :: [String]
216 , fractioning :: Maybe Style.Fractioning
217 , grouping_integral :: Maybe Style.Grouping
218 , grouping_fractional :: Maybe Style.Grouping
221 -- | Parse a 'Quantity'.
224 => Char -- ^ Integral grouping separator.
225 -> Char -- ^ Fractioning separator.
226 -> Char -- ^ Fractional grouping separator.
227 -> ParsecT s u m Quantity
228 quantity int_group_sep frac_sep frac_group_sep = do
229 (integral, grouping_integral) <- do
232 [] -> return ([], Nothing)
234 t <- R.many $ R.char int_group_sep >> R.many1 R.digit
236 return (digits, grouping_of_digits int_group_sep digits)
237 (fractional, fractioning, grouping_fractional) <-
240 _ -> R.option ([], Nothing, Nothing)) $ do
241 fractioning <- R.char frac_sep
243 t <- R.many $ R.char frac_group_sep >> R.many1 R.digit
245 return (digits, Just fractioning
246 , grouping_of_digits frac_group_sep $ reverse digits)
253 , grouping_fractional
256 grouping_of_digits :: Char -> [String] -> Maybe Style.Grouping
257 grouping_of_digits group_sep digits =
262 Style.Grouping group_sep $
263 canonicalize_grouping $
265 canonicalize_grouping :: [Int] -> [Int]
266 canonicalize_grouping groups =
267 Data.List.foldl -- NOTE: remove duplicates at beginning and reverse.
268 (\acc l0 -> case acc of
269 l1:_ -> if l0 == l1 then acc else l0:acc
271 case groups of -- NOTE: keep only longer at beginning.
272 l0:l1:t -> if l0 > l1 then groups else l1:t
275 -- | Parse an 'Unit'.
276 unit :: Stream s m Char => ParsecT s u m Unit
278 (quoted <|> unquoted) <?> "unit"
280 unquoted :: Stream s m Char => ParsecT s u m Unit
285 case Data.Char.generalCategory c of
286 Data.Char.CurrencySymbol -> True
287 Data.Char.LowercaseLetter -> True
288 Data.Char.ModifierLetter -> True
289 Data.Char.OtherLetter -> True
290 Data.Char.TitlecaseLetter -> True
291 Data.Char.UppercaseLetter -> True
293 quoted :: Stream s m Char => ParsecT s u m Unit
296 R.between (R.char '"') (R.char '"') $
302 directive_alias :: Stream s m Char => ParsecT s Context m ()
304 _ <- R.string "alias"
305 R.skipMany1 $ R.space_horizontal
306 pattern <- account_pattern
307 R.skipMany $ R.space_horizontal
309 R.skipMany $ R.space_horizontal
311 R.skipMany $ R.space_horizontal
313 Account.Pattern_Exact acct -> R.modifyState $ \ctx -> ctx{context_aliases_exact=
314 Data.Map.insert acct repl $ context_aliases_exact ctx}
315 Account.Pattern_Joker jokr -> R.modifyState $ \ctx -> ctx{context_aliases_joker=
316 (jokr, repl):context_aliases_joker ctx}
317 Account.Pattern_Regex regx -> R.modifyState $ \ctx -> ctx{context_aliases_regex=
318 (regx, repl):context_aliases_regex ctx}
321 -- | Parse the year, month and day separator: '/' or '-'.
322 date_separator :: Stream s m Char => ParsecT s u m Char
323 date_separator = R.satisfy (\c -> c == '/' || c == '-')
325 -- | Parse the hour, minute and second separator: ':'.
326 hour_separator :: Stream s m Char => ParsecT s u m Char
327 hour_separator = R.char ':'
331 -- | Parse a 'Date' in [YYYY/]MM/DD [HH:MM[:SS][TZ]] format.
332 date :: Stream s m Char => Maybe Integer -> ParsecT s u m Date
334 n0 <- R.many1 R.digit
335 day_sep <- date_separator
336 n1 <- R.try (R.count 2 R.digit) <|> R.count 1 R.digit
337 n2 <- R.option Nothing $ R.try $ do
339 Just <$> do R.try (R.count 2 R.digit) <|> R.count 1 R.digit
341 case (n2, def_year) of
342 (Nothing, Nothing) -> fail "year or day is missing"
343 (Nothing, Just year) -> return (year, n0, n1)
344 (Just d, _) -> return (R.integer_of_digits 10 n0, n1, d)
345 let month = fromInteger $ R.integer_of_digits 10 m
346 let day = fromInteger $ R.integer_of_digits 10 d
347 guard $ month >= 1 && month <= 12
348 guard $ day >= 1 && day <= 31
349 day_ <- case Time.fromGregorianValid year month day of
350 Nothing -> fail "invalid day"
351 Just day_ -> return day_
352 (hour, minu, sec, tz) <-
353 R.option (0, 0, 0, Time.utc) $ R.try $ do
354 R.skipMany1 $ R.space_horizontal
355 hour <- R.try (R.count 2 R.digit) <|> R.count 1 R.digit
356 sep <- hour_separator
357 minu <- R.try (R.count 2 R.digit) <|> R.count 1 R.digit
358 sec <- R.option Nothing $ R.try $ do
360 Just <$> (R.try (R.count 2 R.digit) <|> R.count 1 R.digit)
362 tz <- R.option Time.utc $ R.try $ do
363 R.skipMany $ R.space_horizontal
366 ( R.integer_of_digits 10 hour
367 , R.integer_of_digits 10 minu
368 , maybe 0 (R.integer_of_digits 10) sec
370 guard $ hour >= 0 && hour <= 23
371 guard $ minu >= 0 && minu <= 59
372 guard $ sec >= 0 && sec <= 60 -- NOTE: allow leap second
373 tod <- case Time.makeTimeOfDayValid
377 Nothing -> fail "invalid time of day"
378 Just tod -> return tod
381 (Time.LocalTime day_ tod)
385 time_zone :: Stream s m Char => ParsecT s u m TimeZone
387 -- DOC: http://www.timeanddate.com/time/zones/
388 -- TODO: only a few time zones are suported below.
389 -- TODO: check the timeZoneSummerOnly values
391 [ R.char 'A' >> R.choice
392 [ R.string "ST" >> return (TimeZone ((-4) * 60) False "AST")
393 , R.string "DT" >> return (TimeZone ((-3) * 60) True "ADT")
394 , return (TimeZone ((-1) * 60) False "A")
396 , R.char 'B' >> R.choice
397 [ R.string "ST" >> return (TimeZone ((-11) * 60) False "BST")
398 , R.string "DT" >> return (TimeZone ((-10) * 60) True "BDT")
400 , R.char 'C' >> R.choice
401 [ R.char 'E' >> R.choice
402 [ R.string "T" >> return (TimeZone ((1) * 60) True "CET")
403 , R.string "ST" >> return (TimeZone ((2) * 60) False "CEST")
405 , R.string "ST" >> return (TimeZone ((-6) * 60) False "CST")
406 , R.string "DT" >> return (TimeZone ((-5) * 60) True "CDT")
408 , R.char 'E' >> R.choice
409 [ R.string "ST" >> return (TimeZone ((-5) * 60) False "EST")
410 , R.string "DT" >> return (TimeZone ((-4) * 60) True "EDT")
412 , R.string "GMT" >> return (TimeZone 0 False "GMT")
413 , R.char 'H' >> R.choice
414 [ R.string "ST" >> return (TimeZone ((-10) * 60) False "HST")
415 , R.string "DT" >> return (TimeZone (( -9) * 60) True "HDT")
417 , R.char 'M' >> R.choice
418 [ R.string "ST" >> return (TimeZone ((-7) * 60) False "MST")
419 , R.string "DT" >> return (TimeZone ((-6) * 60) True "MDT")
420 , return (TimeZone ((-12) * 60) False "M")
422 , R.char 'N' >> R.choice
423 [ R.string "ST" >> return (TimeZone ((-3) * 60 - 30) False "NST")
424 , return (TimeZone (1 * 60) False "N")
426 , R.char 'P' >> R.choice
427 [ R.string "ST" >> return (TimeZone ((-8) * 60) False "PST")
428 , R.string "DT" >> return (TimeZone ((-7) * 60) True "PDT")
430 , R.char 'Y' >> R.choice
431 [ R.string "ST" >> return (TimeZone ((-9) * 60) False "YST")
432 , R.string "DT" >> return (TimeZone ((-8) * 60) True "YDT")
433 , return (TimeZone (12 * 60) False "Y")
435 , R.char 'Z' >> return (TimeZone 0 False "Z")
439 time_zone_digits :: Stream s m Char => ParsecT s u m TimeZone
440 {-# INLINEABLE time_zone_digits #-}
441 time_zone_digits = do
443 hour <- R.integer_of_digits 10 <$> R.count 2 R.digit
444 _ <- R.option ':' (R.char ':')
445 minute <- R.integer_of_digits 10 <$> R.count 2 R.digit
447 { timeZoneMinutes = sign_ (fromInteger hour * 60 + fromInteger minute)
448 , timeZoneSummerOnly = False
449 , timeZoneName = Time.timeZoneOffsetString tz
453 -- * Parsing 'Comment'
455 comment_begin :: Char
458 comment :: Stream s m Char => ParsecT s u m Comment
460 _ <- R.char comment_begin
462 R.manyTill R.anyChar (R.lookAhead R.new_line <|> R.eof)
465 comments :: Stream s m Char => ParsecT s u m [Comment]
468 R.many1_separated comment (R.new_line >> R.skipMany R.space_horizontal)
474 tag_value_sep :: Char
481 tag :: Stream s m Char => ParsecT s u m Tag
484 _ <- R.char tag_value_sep
489 tag_name :: Stream s m Char => ParsecT s u m Tag.Name
492 R.many1 $ R.satisfy (\c -> c /= tag_value_sep && not (Data.Char.isSpace c))
494 tag_value :: Stream s m Char => ParsecT s u m Tag.Value
497 R.manyTill R.anyChar $ do
499 R.try (R.char tag_sep >> R.many R.space_horizontal >> tag_name >> R.char tag_value_sep >> return ())
503 tags :: Stream s m Char => ParsecT s u m Tag.By_Name
506 R.many_separated tag $ do
508 R.skipMany $ R.space_horizontal
511 not_tag :: Stream s m Char => ParsecT s u m ()
513 R.skipMany $ R.try $ do
514 R.skipMany $ R.satisfy
515 (\c -> c /= tag_value_sep
516 && not (Data.Char.isSpace c))
519 -- * Parsing 'Posting'
521 -- | Parse a 'Posting'.
522 posting :: Stream s m Char => ParsecT s Context m (Posting, Posting.Type)
525 sourcepos <- R.getPosition
526 comments_ <- comments
527 R.skipMany1 $ R.space_horizontal
529 R.skipMany $ R.space_horizontal
531 let (type_, account_) = posting_type acct
535 _ <- R.count 2 R.space_horizontal
536 R.skipMany $ R.space_horizontal
538 (\(u, s) -> Data.Map.adjust (\a -> a{Amount.style=s, Amount.unit=u}) Unit.nil)
539 (context_unit_and_style ctx) .
540 Amount.from_List <$> do
541 R.many_separated amount $ do
542 R.skipMany $ R.space_horizontal
543 _ <- R.char amount_sep
544 R.skipMany $ R.space_horizontal
546 , return Data.Map.empty
548 R.skipMany $ R.space_horizontal
549 -- TODO: balance assertion
551 comments__ <- (comments_ ++) <$> comments
552 let tags_ = tags_of_comments comments__
554 case Data.Map.lookup "date" tags_ of
557 let date2s = Data.Map.lookup "date2" tags_ -- NOTE: support hledger's date2
558 dates_ <- (flip mapM) (dates ++ fromMaybe [] date2s) $
559 R.runParserT (date (Just $ context_year ctx) <* R.eof) () ""
561 Left ko -> fail $ show ko
562 Right ok -> return ok
563 case (dates, date2s) of -- NOTE: put hledger's date2 at least in second position
565 return $ context_date ctx:dates_
567 return (Posting.Posting
568 { Posting.account=account_
569 , Posting.amounts=amounts_
570 , Posting.comments=comments__
571 , Posting.dates=dates_
572 , Posting.sourcepos=sourcepos
573 , Posting.status=status_
581 tags_of_comments :: [Comment] -> Tag.By_Name
583 Data.Map.unionsWith (++)
585 ( Data.Either.either (const Data.Map.empty) id
586 . R.runParser (not_tag >> tags <* R.eof) () "" )
588 status :: Stream s m Char => ParsecT s u m Transaction.Status
591 R.skipMany $ R.space_horizontal
592 _ <- (R.char '*' <|> R.char '!')
597 -- | Return the Posting.'Posting.Type' and stripped 'Account' of the given 'Account'.
598 posting_type :: Account -> (Posting.Type, Account)
600 fromMaybe (Posting.Type_Regular, acct) $ do
603 case Text.stripPrefix virtual_begin name of
606 Text.stripSuffix virtual_end name'
607 >>= return . Text.strip
608 guard $ not $ Text.null name''
609 Just (Posting.Type_Virtual, name'':|[])
612 Text.stripPrefix virtual_balanced_begin name
613 >>= Text.stripSuffix virtual_balanced_end
614 >>= return . Text.strip
615 guard $ not $ Text.null name'
616 Just (Posting.Type_Virtual_Balanced, name':|[])
617 first_name:|acct' -> do
618 let rev_acct' = Data.List.reverse acct'
619 let last_name = Data.List.head rev_acct'
620 case Text.stripPrefix virtual_begin first_name
621 >>= return . Text.stripStart of
622 Just first_name' -> do
624 Text.stripSuffix virtual_end last_name
625 >>= return . Text.stripEnd
626 guard $ not $ Text.null first_name'
627 guard $ not $ Text.null last_name'
629 ( Posting.Type_Virtual
631 Data.List.reverse (last_name':Data.List.tail rev_acct')
635 Text.stripPrefix virtual_balanced_begin first_name
636 >>= return . Text.stripStart
638 Text.stripSuffix virtual_balanced_end last_name
639 >>= return . Text.stripEnd
640 guard $ not $ Text.null first_name'
641 guard $ not $ Text.null last_name'
643 ( Posting.Type_Virtual_Balanced
645 Data.List.reverse (last_name':Data.List.tail rev_acct')
648 virtual_begin = Text.singleton posting_type_virtual_begin
649 virtual_end = Text.singleton posting_type_virtual_end
650 virtual_balanced_begin = Text.singleton posting_type_virtual_balanced_begin
651 virtual_balanced_end = Text.singleton posting_type_virtual_balanced_end
653 posting_type_virtual_begin :: Char
654 posting_type_virtual_begin = '('
655 posting_type_virtual_balanced_begin :: Char
656 posting_type_virtual_balanced_begin = '['
657 posting_type_virtual_end :: Char
658 posting_type_virtual_end = ')'
659 posting_type_virtual_balanced_end :: Char
660 posting_type_virtual_balanced_end = ']'
662 -- * Parsing 'Transaction'
664 transaction :: Stream s m Char => ParsecT s Context m Transaction
666 sourcepos <- R.getPosition
668 comments_before <- comments
669 date_ <- date (Just $ context_year ctx)
671 R.option [] $ R.try $ do
672 R.skipMany $ R.space_horizontal
674 R.skipMany $ R.space_horizontal
676 (date (Just $ context_year ctx)) $
678 R.many $ R.space_horizontal
680 >> (R.many $ R.space_horizontal)
681 R.skipMany $ R.space_horizontal
683 code_ <- R.option "" $ R.try code
684 R.skipMany $ R.space_horizontal
685 description_ <- description
686 R.skipMany $ R.space_horizontal
687 comments_after <- comments
689 Data.Map.unionWith (++)
690 (tags_of_comments comments_before)
691 (tags_of_comments comments_after)
693 postings_ <- R.many1_separated posting R.new_line
694 let (postings, postings__) =
695 (Posting.from_List . Data.List.map fst) *** id $
697 ((Posting.Type_Regular ==) . snd)
699 let (virtual_postings, balanced_virtual_postings) =
700 join (***) (Posting.from_List . Data.List.map fst) $
702 ((Posting.Type_Virtual ==) . snd)
705 Transaction.Transaction
706 { Transaction.code=code_
707 , Transaction.comments_before
708 , Transaction.comments_after
709 , Transaction.dates=(date_, dates_)
710 , Transaction.description=description_
711 , Transaction.postings
712 , Transaction.virtual_postings
713 , Transaction.balanced_virtual_postings
714 , Transaction.sourcepos
715 , Transaction.status=status_
716 , Transaction.tags=tags_
723 code :: Stream s m Char => ParsecT s Context m Transaction.Code
726 R.skipMany $ R.space_horizontal
727 R.between (R.char '(') (R.char ')') $
728 R.many $ R.satisfy (\c -> c /= ')' && not (R.is_space_horizontal c))
731 description :: Stream s m Char => ParsecT s u m Transaction.Description
734 R.many $ R.try description_char
737 description_char :: Stream s m Char => ParsecT s u m Char
738 description_char = do
741 _ | c == comment_begin -> R.parserZero
742 _ | R.is_space_horizontal c -> return c <* (R.lookAhead $ R.try $ description_char)
743 _ | not (Data.Char.isSpace c) -> return c
746 -- * Parsing directives
748 default_year :: Stream s m Char => ParsecT s Context m ()
750 year <- R.integer_of_digits 10 <$> R.many1 R.digit
751 R.skipMany R.space_horizontal >> R.new_line
752 context_ <- R.getState
753 R.setState context_{context_year=year}
756 default_unit_and_style :: Stream s m Char => ParsecT s Context m ()
757 default_unit_and_style = (do
759 R.skipMany R.space_horizontal >> R.new_line
760 context_ <- R.getState
761 R.setState context_{context_unit_and_style =
763 ( Amount.unit amount_
764 , Amount.style amount_ )}
765 ) <?> "default unit and style"
767 include :: Stream s IO Char => ParsecT s Context IO ()
769 sourcepos <- R.getPosition
770 filename <- R.manyTill R.anyChar (R.lookAhead R.new_line <|> R.eof)
771 context_ <- R.getState
772 let journal_ = context_journal context_
773 let cwd = Path.takeDirectory (R.sourceName sourcepos)
774 file_ <- liftIO $ Path.abs cwd filename
775 (journal_included, context_included) <- liftIO $
778 (\ko -> fail $ concat -- TODO: i18n by using a custom data type
782 , ":\n", show (ko::Exception.IOException)
784 >>= R.runParserT (R.and_state $ journal_rec file_)
785 context_{context_journal = Journal.nil}
788 Left ko -> fail $ show ko
789 Right ok -> return ok
791 context_included{context_journal=
792 journal_{Journal.includes=
793 journal_included{Journal.file=file_}
794 : Journal.includes journal_}}
797 -- * Parsing 'Journal'
799 journal :: Stream s IO Char => FilePath -> ParsecT s Context IO Journal
801 currentLocalTime <- liftIO $
803 <$> Time.getCurrentTimeZone
804 <*> Time.getCurrentTime
805 let (currentLocalYear, _, _) = Time.toGregorian $ Time.localDay currentLocalTime
806 context_ <- R.getState
807 R.setState $ context_{context_year=currentLocalYear}
811 journal_rec :: Stream s IO Char => FilePath -> ParsecT s Context IO Journal
812 journal_rec file_ = do
813 last_read_time <- liftIO $ Time.getCurrentTime
816 [ R.skipMany1 R.space
818 [ R.string "Y" >> return default_year
819 , R.string "D" >> return default_unit_and_style
820 , R.string "!include" >> return include
822 >>= \r -> R.skipMany1 R.space_horizontal >> r)
825 context_' <- R.getState
826 let j = context_journal context_'
827 R.setState $ context_'{context_journal=
828 j{Journal.transactions=
829 Data.Map.insertWith (flip (++))
830 -- NOTE: flip-ing preserves order but slows down
831 -- when many transactions have the very same date.
832 (Date.to_UTC $ fst $ Transaction.dates t) [t]
833 (Journal.transactions j)}}
834 R.new_line <|> R.eof))
835 , R.try (comment >> return ())
838 journal_ <- context_journal <$> R.getState
841 { Journal.file = file_
842 , Journal.last_read_time
843 , Journal.includes = reverse $ Journal.includes journal_
846 -- ** Parsing 'Journal' from a file
848 file :: FilePath -> ExceptT String IO Journal
852 (liftM Right $ Text.IO.readFile path) $
853 \ko -> return $ Left $ show (ko::Exception.IOException)
854 >>= liftIO . R.runParserT (journal path) nil_Context path
856 Left ko -> throwE $ show ko
857 Right ok -> ExceptT $ return $ Right ok