1 {-# LANGUAGE DeriveDataTypeable #-}
2 module Hcompta.Format.Ledger.Read where
4 import Control.Applicative ((<*))
5 import qualified Control.Exception as Exn
7 -- import Control.Monad.Error
10 -- import Data.List.Split (wordsBy)
11 import qualified Data.Map
13 import Data.Typeable ()
14 import Safe (headDef, lastDef)
16 import qualified Data.Time.Clock as Time
17 import qualified Data.Time.Calendar as Time
18 import qualified Data.Time.LocalTime as Time
19 import Text.Parsec hiding (parse)
21 import qualified Hcompta.Model as Model
22 import qualified Hcompta.Model.Account as Account
23 import Hcompta.Model.Account (Account)
24 import qualified Hcompta.Model.Amount as Amount
25 import Hcompta.Model.Amount (Amount)
26 import qualified Hcompta.Model.Date as Date
27 import Hcompta.Format.Ledger.Journal as Journal
31 { account_prefix :: !Account
32 --, context_aliases :: ![AccountAlias]
33 , unit_and_style :: !(Maybe (Amount.Unit, Amount.Style))
36 } deriving (Data, Eq, Read, Show, Typeable)
42 , unit_and_style = Nothing
43 , journal = Journal.nil
44 , year = (\(year, _ , _) -> year) $
45 Time.toGregorian $ Time.utctDay $
46 Journal.last_read_time Journal.nil
52 reader = Reader format detect parse
57 detect :: FilePath -> String -> Bool
59 | file /= "-" = takeExtension file `elem` ['.':format, ".j"] -- from a file: yes if the extension is .journal or .j
60 -- from stdin: yes if we can see something that looks like a journal entry (digits in column 0 with the next line indented)
61 | otherwise = regexMatches "^[0-9]+.*\n[ \t]+" s
63 parse :: Maybe FilePath -> Bool -> FilePath -> String -> ErrorT String IO Journal
64 parse _ = parseJournal journal
67 :: ParsecT [Char] Context (ErrorT String IO) Context
68 -> Bool -> FilePath -> String -> ErrorT String IO Journal
69 parseJournal parser filePath fileData = do
70 currentUTC <- liftIO Time.getCurrentTime
71 currentTimeZone <- liftIO Time.getCurrentTimeZone
72 let currentLocalTime = Time.utcToLocalTime currentTimeZone currentUTC
73 let (currentLocalYear, _, _) = Time.toGregorian $ Time.localDay currentLocalTime
74 parserResult <- runParserT parser
75 contextNull{context_year=Just currentLocalYear}
78 Left error -> throwError $ show error
80 let journal = context_journal context
81 journalBalanceTransactions $
83 { journal_unit_styles=
84 , journal_file=filePath
85 , journal_includes=reverse $ journal_includes journal
86 -- , journal_historical_prices=reverse $ journal_historical_prices journal
87 , journal_last_read_time=currentUTC
88 , journal_transactions=reverse $ journal_transactions journal
89 -- , journal_transaction_modifiers=reverse $ journal_transaction_modifiers journal
90 -- , journal_transaction_periodics=reverse $ journal_transaction_periodics journal
93 -- | Fill in any missing amounts and check that all journal transactions
94 -- balance, or return an error message. This is done after parsing all
95 -- amounts and working out the canonical commodities, since balancing
96 -- depends on display precision. Reports only the first error encountered.
97 journalBalanceTransactions :: Journal -> Either String Journal
98 journalBalanceTransactions journal =
99 let transactions = journal_transactions journal
100 let unit_and_style = journal_unit_styles journal
101 case sequence $ map balance transactions of
102 Right ts' -> Right journal{journal_transactions=map txnTieKnot ts'}
104 where balance = balanceTransaction (Just unit_and_style)
106 -- | Convert all the journal's posting amounts (not price amounts) to
107 -- their canonical display settings. Ie, all amounts in a given
108 -- unit will use (a) the display settings of the first, and (b)
109 -- the greatest precision, of the posting amounts in that unit.
110 journalCanonicaliseAmounts :: Journal -> Journal
111 journalCanonicaliseAmounts j@Journal{journal_transactions=ts} =
114 j'' = j'{journal_transactions=map fixtransaction ts}
115 j' = j{context_unit_and_style = canonicalStyles $ dbgAt 8 "journalAmounts" $ journalAmounts j}
116 fixtransaction t@Transaction{tpostings=ps} = t{tpostings=map fixposting ps}
117 fixposting p@Posting{pamount=a} = p{pamount=fixmixedamount a}
118 fixmixedamount (Mixed as) = Mixed $ map fixamount as
119 fixamount a@Amount{aunit=c} = a{astyle=journalCommodityStyle j' c}
121 -- | Given a list of amounts in parse order, build a map from commodities
122 -- to canonical display styles for amounts in that unit.
123 canonicalStyles :: [Amount] -> M.Map Amount.Unit Amount.Style
124 canonicalStyles amts =
125 M.fromList commstyles
127 samecomm = \a1 a2 -> aunit a1 == aunit a2
128 commamts = [(aunit $ head as, as) | as <- groupBy samecomm $ sortBy (comparing aunit) amts]
129 commstyles = [(c, canonicalStyleFrom $ map astyle as) | (c,as) <- commamts]
131 -- | Get all this journal's component amounts, roughly in the order parsed.
132 journalAmounts :: Journal -> [Amount]
134 concatMap flatten . journalMixedAmounts
135 where flatten (Mixed as) = as
137 amountStyleFromCommodity :: Context -> Amount.Unit -> Amount.Style
138 amountStyleFromCommodity context unit =
139 Data.Map.findWithDefault
140 (context_unit_and_style context)
142 journal_unit_styles $
143 context_journal context
148 setYear :: Stream [Char] m Char => Integer -> ParsecT [Char] Context m ()
149 setYear y = modifyState (\ctx -> ctx{context_year=Just y})
151 getYear :: Stream [Char] m Char => ParsecT s Context m (Maybe Integer)
152 getYear = liftM context_year getState
154 setCoA :: Stream [Char] m Char => CoA -> ParsecT [Char] Context m ()
155 setCoA coa = modifyState (\ctx -> ctx{ctxCoA=coa})
157 getCoA :: Stream [Char] m Char => ParsecT [Char] Context m CoA
158 getCoA = liftM ctxCoA getState
160 setDefaultCommodityAndStyle :: Stream [Char] m Char => (Amount.Unit,Amount.Style) -> ParsecT [Char] Context m ()
161 setDefaultCommodityAndStyle cs = modifyState (\ctx -> ctx{context_unit_and_style=Just cs})
163 getDefaultCommodityAndStyle :: Stream [Char] m Char => ParsecT [Char] Context m (Maybe (Amount.Unit,Amount.Style))
164 getDefaultCommodityAndStyle = context_unit_and_style `fmap` getState
166 pushParentAccount :: Stream [Char] m Char => String -> ParsecT [Char] Context m ()
167 pushParentAccount parent = modifyState addParentAccount
168 where addParentAccount ctx0 = ctx0 { context_account_prefix = parent : context_account_prefix ctx0 }
170 popParentAccount :: Stream [Char] m Char => ParsecT [Char] Context m ()
171 popParentAccount = do
173 case context_account_prefix ctx0 of
174 [] -> unexpected "End of account block with no beginning"
175 (_:rest) -> setState $ ctx0 { context_account_prefix = rest }
177 getParentAccount :: Stream [Char] m Char => ParsecT [Char] Context m String
178 getParentAccount = liftM (concatAccountNames . reverse . context_account_prefix) getState
180 addAccountAlias :: Stream [Char] m Char => AccountAlias -> ParsecT [Char] Context m ()
181 addAccountAlias a = modifyState (\(ctx@Ctx{..}) -> ctx{context_aliases=a:context_aliases})
183 getAccountAliases :: Stream [Char] m Char => ParsecT [Char] Context m [AccountAlias]
184 getAccountAliases = liftM context_aliases getState
186 clearAccountAliases :: Stream [Char] m Char => ParsecT [Char] Context m ()
187 clearAccountAliases = modifyState (\(ctx@Ctx{..}) -> ctx{context_aliases=[]})
191 parseJournal :: ParsecT [Char] Context (ErrorT String IO) (JournalUpdate, Context)
193 journalUpdates <- many journalItem
195 finalContext <- getState
196 return $ (combineJournalUpdates journalUpdates, finalContext)
198 -- As all journal line types can be distinguished by the first
199 -- character, excepting transactions versus empty (blank or
200 -- comment-only) lines, can use choice w/o try
204 , liftM (return . addTransaction) parseTransaction
205 , liftM (return . addModifierTransaction) parseTransactionModifier
206 , liftM (return . addPeriodicTransaction) periodictransaction
207 , liftM (return . addHistoricalPrice) historicalpricedirective
208 , emptyorcommentlinep >> return (return id)
209 , multilinecommentp >> return (return id)
210 ] <?> "journal transaction or directive"
212 parseDirective :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
216 [ parseDirectiveInclude
217 , parseDirectiveAlias
218 , parseDirectiveEndAlias
219 , parseDirectiveAccount
222 , parseDirectiveEndTag
224 , parseDirectiveCommodity
225 , parseDirectiveCommodityConversion
226 , parseDirectiveIgnoredPriceCommodity
230 parseDirectiveInclude :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
231 parseDirectiveInclude = do
234 filename <- restofline
235 outerState <- getState
236 outerPos <- getPosition
237 let curdir = takeDirectory (sourceName outerPos)
238 let (u::ErrorT String IO (Journal -> Journal, Context)) = do
239 filepath <- expandPath curdir filename
240 txt <- readFileOrError outerPos filepath
241 let inIncluded = show outerPos ++ " in included file " ++ show filename ++ ":\n"
242 r <- runParserT parseJournal outerState filepath txt
244 Right (ju, ctx) -> do
245 u <- combineJournalUpdates [ return $ journalAddFile (filepath,txt)
247 ] `catchError` (throwError . (inIncluded ++))
249 Left err -> throwError $ inIncluded ++ show err
250 where readFileOrError pos fp =
251 ErrorT $ liftM Right (readFile' fp) `Exn.catch`
252 \e -> return $ Left $ printf "%s reading %s:\n%s" (show pos) fp (show (e::Exn.IOException))
253 r <- liftIO $ runErrorT u
255 Left err -> return $ throwError err
256 Right (ju, ctx) -> do
258 return $ ErrorT $ return $ Right ju
260 journalAddFile :: (FilePath,String) -> Journal -> Journal
261 journalAddFile f j@Journal{journal_files=fs} = j{journal_files=fs++[f]}
262 -- NOTE: first encountered file to left, to avoid a reverse
264 parseDirectiveAccount :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
265 parseDirectiveAccount = do
268 parent <- parseAccountName
270 pushParentAccount parent
271 -- return $ return id
272 return $ ErrorT $ return $ Right id
274 parseDirectiveEnd :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
275 parseDirectiveEnd = do
278 -- return (return id)
279 return $ ErrorT $ return $ Right id
281 parseDirectiveAlias :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
282 parseDirectiveAlias = do
285 orig <- many1 $ noneOf "="
288 addAccountAlias (accountNameWithoutPostingType $ strip orig
289 ,accountNameWithoutPostingType $ strip alias)
292 parseDirectiveEndAlias :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
293 parseDirectiveEndAlias = do
298 parseDirectiveTag :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
299 parseDirectiveTag = do
300 string "tag" <?> "tag directive"
306 parseDirectiveEndTag :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
307 parseDirectiveEndTag = do
308 (string "end tag" <|> string "pop") <?> "end tag or pop directive"
312 parseDirectiveYear :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
313 parseDirectiveYear = do
314 char 'Y' <?> "default year"
322 parseDirectiveCommodity :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
323 parseDirectiveCommodity = do
324 char 'D' <?> "default unit"
326 Amount{..} <- getDefaultCommodityAndStyle >>= parseAmount
327 setDefaultCommodityAndStyle (aunit, astyle)
331 parseDirectiveHistoricalPrice :: ParsecT [Char] Context (ErrorT String IO) HistoricalPrice
332 parseDirectiveHistoricalPrice = do
333 char 'P' <?> "historical price"
335 date <- try (do {LocalTime d _ <- parseDateTime; return d}) <|> parseDate -- a time is ignored
337 symbol <- parseCommodity
339 price <- getDefaultCommodityAndStyle >>= parseAmount
341 return $ HistoricalPrice date symbol price
343 parseDirectiveIgnoredPriceCommodity :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
344 parseDirectiveIgnoredPriceCommodity = do
345 char 'N' <?> "ignored-price unit"
351 parseDirectiveCommodityConversion :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
352 parseDirectiveCommodityConversion = do
353 char 'C' <?> "unit conversion"
355 default_cs <- getDefaultCommodityAndStyle
356 parseAmount default_cs
360 parseAmount default_cs
364 parseTransactionModifier :: ParsecT [Char] Context (ErrorT String IO) ModifierTransaction
365 parseTransactionModifier = do
366 char '=' <?> "modifier transaction"
368 valueexpr <- restofline
369 parsePostings <- parsePostings
370 return $ ModifierTransaction valueexpr parsePostings
372 parseTransactionPeriodic :: ParsecT [Char] Context (ErrorT String IO) PeriodicTransaction
373 parseTransactionPeriodic = do
374 char '~' <?> "periodic transaction"
376 periodexpr <- restofline
377 parsePostings <- parsePostings
378 return $ PeriodicTransaction periodexpr parsePostings
380 -- | Parse a (possibly unbalanced) transaction.
381 parseTransaction :: ParsecT [Char] Context (ErrorT String IO) Transaction
382 parseTransaction = do
383 -- ptrace "transaction"
384 sourcepos <- getPosition
385 date <- parseDate <?> "transaction"
386 edate <- optionMaybe (parseDate2 date) <?> "secondary date"
387 lookAhead (spacenonewline <|> newline) <?> "whitespace or newline"
388 status <- parseStatus <?> "cleared flag"
389 code <- parseCode <?> "transaction code"
390 description <- descriptionp >>= return . strip
391 comment <- try followingcommentp <|> (newline >> return "")
392 let tags = tagsInComment comment
393 parsePostings <- parsePostings
394 return $ txnTieKnot $ Transaction sourcepos date edate status code description comment tags parsePostings ""
396 descriptionp = many (noneOf ";\n")
398 -- | Parse a date in YYYY/MM/DD format. Fewer digits are allowed. The year
399 -- may be omitted if a default year has already been set.
400 parseDate :: Stream [Char] m t => ParsecT [Char] Context m Day
402 -- hacky: try to ensure precise errors for invalid dates
403 -- XXX reported error position is not too good
404 -- pos <- getPosition
405 datestr <- many1 $ choice' [digit, datesepchar]
406 let sepchars = nub $ sort $ filter (`elem` datesepchars) datestr
407 when (length sepchars /= 1) $ fail $ "bad date, different separators used: " ++ datestr
408 let dateparts = wordsBy (`elem` datesepchars) datestr
409 currentyear <- getYear
411 case (dateparts,currentyear) of
412 ([m,d],Just y) -> return [show y,m,d]
413 ([_,_],Nothing) -> fail $ "partial date "++datestr++" found, but the current year is unknown"
414 ([y,m,d],_) -> return [y,m,d]
415 _ -> fail $ "bad date: " ++ datestr
416 let maybedate = fromGregorianValid (read y) (read m) (read d)
418 Nothing -> fail $ "bad date: " ++ datestr
419 Just date -> return date
420 <?> "full or partial date"
422 -- | Parse a date and time in YYYY/MM/DD HH:MM[:SS][+-ZZZZ] format. Any
423 -- timezone will be ignored; the time is treated as local time. Fewer
424 -- digits are allowed, except in the timezone. The year may be omitted if
425 -- a default year has already been set.
426 parseDateTime :: Stream [Char] m Char => ParsecT [Char] Context m LocalTime
432 guard $ h' >= 0 && h' <= 23
436 guard $ m' >= 0 && m' <= 59
437 s <- optionMaybe $ char ':' >> many1 digit
438 let s' = case s of Just sstr -> read sstr
440 guard $ s' >= 0 && s' <= 59
443 plusminus <- oneOf "-+"
448 return $ plusminus:d1:d2:d3:d4:""
449 -- ltz <- liftIO $ getCurrentTimeZone
450 -- let tz' = maybe ltz (fromMaybe ltz . parseTime defaultTimeLocale "%z") tz
451 -- return $ localTimeToUTC tz' $ LocalTime day $ TimeOfDay h' m' (fromIntegral s')
452 return $ LocalTime day $ TimeOfDay h' m' (fromIntegral s')
454 parseDate2 :: Stream [Char] m Char => Day -> ParsecT [Char] Context m Day
455 parseDate2 primarydate = do
457 -- kludgy way to use primary date for default year
458 let withDefaultYear d p = do
460 let (y',_,_) = toGregorian d in setYear y'
462 when (isJust y) $ setYear $ fromJust y
464 edate <- withDefaultYear primarydate parseDate
467 parseStatus :: Stream [Char] m Char => ParsecT [Char] Context m Bool
468 parseStatus = try (do { many spacenonewline; (char '*' <|> char '!') <?> "status"; return True } ) <|> return False
470 parseCode :: Stream [Char] m Char => ParsecT [Char] Context m String
471 parseCode = try (do { many1 spacenonewline; char '(' <?> "parseCode"; code <- anyChar `manyTill` char ')'; return code } ) <|> return ""
473 -- Parse the following whitespace-beginning lines as parsePostings, posting tags, and/or comments.
474 parsePostings :: Stream [Char] m Char => ParsecT [Char] Context m [Posting]
475 parsePostings = many1 (try parsePosting) <?> "parsePostings"
477 parsePosting :: Stream [Char] m Char => ParsecT [Char] Context m Posting
480 status <- parseStatus
482 account <- modifiedaccountname
483 let (ptype, account') = (accountNamePostingType account, unbracket account)
484 amount <- spaceandamountormissing
485 massertion <- partialbalanceassertion
489 comment <- try followingcommentp <|> (newline >> return "")
490 let tags = tagsInComment comment
493 if isZeroMixedAmount amount
495 let coa_ = coaAdd coa (accountNameComponents account) tags
500 case dateValueFromTags tags of
501 Nothing -> return Nothing
503 case runParser (parseDate <* eof) ctx "" v of
504 Right d -> return $ Just d
505 Left err -> parserFail $ show err
507 case date2ValueFromTags tags of
508 Nothing -> return Nothing
510 case runParser (parseDate <* eof) ctx "" v of
511 Right d -> return $ Just d
512 Left err -> parserFail $ show err
515 , Posting.date2=date2
516 , Posting.status=status
517 , Posting.account=account'
518 , Posting.amount=amount
519 , Posting.comment=comment
523 , Posting.balanceassertion=massertion
527 -- | Parse an account name, then apply any parent account prefix and/or account aliases currently in effect.
528 modifiedaccountname :: Stream [Char] m Char => ParsecT [Char] Context m AccountName
529 modifiedaccountname = do
530 a <- parseAccountName
531 prefix <- getParentAccount
532 let prefixed = prefix `joinAccountNames` a
533 aliases <- getAccountAliases
534 return $ accountNameApplyAliases aliases prefixed
536 -- accountnamechar = notFollowedBy (oneOf "()[]") >> nonspace
537 -- <?> "account name character (non-bracket, non-parenthesis, non-whitespace)"
539 -- | Parse whitespace then an amount, with an optional left or right
540 -- currency symbol and optional price, or return the special
541 -- "missing" marker amount.
542 spaceandamountormissing :: Stream [Char] m Char => ParsecT [Char] Context m MixedAmount
543 spaceandamountormissing = do
544 default_cs <- getDefaultCommodityAndStyle
547 (Mixed . (:[])) `fmap` parseAmount default_cs <|> return missingmixedamt
548 ) <|> return missingmixedamt
550 partialbalanceassertion :: Stream [Char] m t => ParsecT [Char] Context m (Maybe MixedAmount)
551 partialbalanceassertion = do
552 default_cs <- getDefaultCommodityAndStyle
557 a <- parseAmount default_cs -- XXX should restrict to a simple amount
558 return $ Just $ Mixed [a])
561 -- balanceassertion :: Stream [Char] m Char => ParsecT [Char] Context m (Maybe MixedAmount)
562 -- balanceassertion =
563 -- default_cs <- getDefaultCommodityAndStyle
565 -- many spacenonewline
567 -- many spacenonewline
568 -- a <- parseAmount default_cs -- XXX should restrict to a simple amount
569 -- return $ Just $ Mixed [a])
570 -- <|> return Nothing
572 -- http://ledger-cli.org/3.0/doc/ledger3.html#Fixing-Lot-Prices
573 fixedlotprice :: Stream [Char] m Char => ParsecT [Char] Context m (Maybe Amount)
575 default_cs <- getDefaultCommodityAndStyle
582 a <- parseAmount default_cs -- XXX should restrict to a simple amount
590 multilinecommentp :: Stream [Char] m Char => ParsecT [Char] Context m ()
591 multilinecommentp = do
592 string "comment" >> newline
595 go = try (string "end comment" >> newline >> return ())
597 anyLine = anyChar `manyTill` newline
599 emptyorcommentlinep :: Stream [Char] m Char => ParsecT [Char] Context m ()
600 emptyorcommentlinep = do
601 many spacenonewline >> (parseComment <|> (many spacenonewline >> newline >> return ""))
604 followingcommentp :: Stream [Char] m Char => ParsecT [Char] Context m String
606 -- ptrace "followingcommentp"
607 do samelinecomment <- many spacenonewline >> (try semicoloncomment <|> (newline >> return ""))
608 newlinecomments <- many (try (many1 spacenonewline >> semicoloncomment))
609 return $ unlines $ samelinecomment:newlinecomments
611 parseComment :: Stream [Char] m Char => ParsecT [Char] Context m String
612 parseComment = commentStartingWith commentchars
614 commentchars :: [Char]
617 semicoloncomment :: Stream [Char] m Char => ParsecT [Char] Context m String
618 semicoloncomment = commentStartingWith ";"
620 commentStartingWith :: Stream [Char] m Char => String -> ParsecT [Char] Context m String
621 commentStartingWith cs = do
622 -- ptrace "commentStartingWith"
625 l <- anyChar `manyTill` eolof
629 tagsInComment :: String -> [Tag]
630 tagsInComment c = concatMap tagsInCommentLine $ lines c'
632 c' = ledgerDateSyntaxToTags c
634 tagsInCommentLine :: String -> [Tag]
635 tagsInCommentLine = catMaybes . map maybetag . map strip . splitAtElement ','
637 maybetag s = case runParser (parseTag <* eof) contextNull "" s of
648 -- ptrace "parseTagName"
649 n <- many1 $ noneOf ": \t"
654 -- ptrace "parseTagValue"
655 v <- anyChar `manyTill` ((char ',' >> return ()) <|> eolof)
656 return $ strip $ reverse $ dropWhile (==',') $ reverse $ strip v
658 ledgerDateSyntaxToTags :: String -> String
659 ledgerDateSyntaxToTags =
660 regexReplaceBy "\\[[-.\\/0-9=]+\\]" replace
662 replace ('[':s) | lastDef ' ' s == ']' = replace' $ init s
665 replace' s | isdate s = datetag s
666 replace' ('=':s) | isdate s = date2tag s
667 replace' s | last s =='=' && isdate (init s) = datetag (init s)
668 replace' s | length ds == 2 && isdate d1 && isdate d1 = datetag d1 ++ date2tag d2
670 ds = splitAtElement '=' s
675 isdate = isJust . parsedateM
676 datetag s = "date:"++s++", "
677 date2tag s = "date2:"++s++", "
679 dateValueFromTags, date2ValueFromTags :: [Tag] -> Maybe String
680 dateValueFromTags ts = maybe Nothing (Just . snd) $ find ((=="date") . fst) ts
681 date2ValueFromTags ts = maybe Nothing (Just . snd) $ find ((=="date2") . fst) ts