hledger-flow-0.19.0: src/Hledger/Flow/Import/ImportHelpersTurtle.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
-- | Generate journal include files and their relative include directives.
module Hledger.Flow.Import.ImportHelpersTurtle
( extraIncludesForFile,
includePreamble,
includesNeedUpdate,
toIncludeFiles,
toIncludeLine,
writeIncludesFromInventory,
writeToplevelAllYearsInclude,
yearsIncludeMap,
)
where
import Control.Concurrent.STM (TChan)
import Control.Monad (filterM, unless)
import qualified Data.List as List (isPrefixOf, nub, sort)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Hledger.Flow.BaseDir (relativeToBase')
import Hledger.Flow.Common (allYearsFileName, filterPaths, groupValuesBy, textFileNeedsUpdate, writeFiles')
import Hledger.Flow.Import.ImportHelpers (groupIncludesUpTo)
import Hledger.Flow.Import.Inventory (manualIncludeFiles, managedIncludeFiles)
import Hledger.Flow.Import.Types
import Hledger.Flow.Logging (logVerbose)
import Hledger.Flow.PathHelpers (AbsDir, AbsFile)
import Hledger.Flow.Types
import qualified Path
import qualified Path.IO as PathIO
import System.Directory (doesFileExist)
import qualified System.FilePath as FilePath
import System.FilePath ((</>))
isJournalFile :: FilePath -> Bool
isJournalFile f = FilePath.takeExtension f == ".journal"
yearsIncludeMap :: [FilePath] -> TurtleFileBundle
yearsIncludeMap = groupValuesBy allYearsPath
allYearsPath :: FilePath -> FilePath
allYearsPath journal = fst (FilePath.splitFileName journal) </> allYearsFileName
toIncludeFiles :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> TurtleFileBundle -> IO (Map.Map FilePath T.Text)
toIncludeFiles opts ch = renderIncludeFiles (baseDir opts) (logVerbose opts ch)
renderIncludeFiles :: BaseDir -> (T.Text -> IO ()) -> TurtleFileBundle -> IO (Map.Map FilePath T.Text)
renderIncludeFiles base logMessage m = do
let extras suffixes manual prices = Map.unions <$> mapM
(\file -> findExtraIncludes base logMessage file suffixes manual prices) (Map.keys m)
preMap <- extras ["opening.journal"] ["pre-import.journal"] []
postMap <- extras ["closing.journal"] ["post-import.journal"] ["prices.journal"]
return $ (addPreamble . toIncludeFiles' preMap postMap) m
toIncludeFiles' :: TurtleFileBundle -> TurtleFileBundle -> TurtleFileBundle -> Map.Map FilePath T.Text
toIncludeFiles' preMap postMap = Map.mapWithKey $ generatedIncludeText preMap postMap
addPreamble :: Map.Map FilePath T.Text -> Map.Map FilePath T.Text
addPreamble = Map.map (\txt -> includePreamble <> "\n" <> txt)
toIncludeLine :: FilePath -> FilePath -> T.Text
toIncludeLine base file = "include " <> T.pack (map forwardSlash (relativeToBase' base file))
where
forwardSlash c = if FilePath.isPathSeparator c then '/' else c
generatedIncludeText :: TurtleFileBundle -> TurtleFileBundle -> FilePath -> [FilePath] -> T.Text
generatedIncludeText preMap postMap outputFile fs = do
let preFiles = fromMaybe [] $ Map.lookup outputFile preMap
let files = List.nub . List.sort $ fs
let postFiles = fromMaybe [] $ Map.lookup outputFile postMap
let lns = map (toIncludeLine $ FilePath.takeDirectory outputFile) $ preFiles ++ files ++ postFiles
T.intercalate "\n" $ lns ++ [""]
includePreamble :: T.Text
includePreamble = "### Generated by hledger-flow - DO NOT EDIT ###\n"
writeIncludesFromInventory :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> AbsDir -> [AbsFile] -> [AbsFile] -> IO [AbsFile]
writeIncludesFromInventory opts ch stopAt inventory journals = do
(files, roots) <- expectedIncludes (baseDir opts) (logVerbose opts ch) stopAt inventory journals
_ <- writeFiles' files
return roots
-- | Check the same contents import would write, including obsolete generated indexes.
includesNeedUpdate :: BaseDir -> AbsDir -> [AbsFile] -> [AbsFile] -> IO Bool
includesNeedUpdate base root inventory journals = do
(includes, _) <- expectedIncludes base (const (return ())) root inventory journals
top <- toplevelAllYearsInclude base
anyChanged (Map.toList (Map.union top includes))
where
anyChanged [] = return False
anyChanged ((file, contents) : rest) = do
changed <- textFileNeedsUpdate file contents
if changed then return True else anyChanged rest
expectedIncludes :: BaseDir -> (T.Text -> IO ()) -> AbsDir -> [AbsFile] -> [AbsFile] -> IO (Map.Map FilePath T.Text, [AbsFile])
expectedIncludes base logMessage stopAt inventory journals = do
let importRoot = base Path.</> [Path.reldir|import|]
journalFiles = filter (isJournalFile . Path.toFilePath) journals
boundary <- Path.stripProperPrefix base stopAt
unless (all (Path.isProperPrefixOf stopAt) journalFiles) $
ioError (userError "Journal file outside the requested include directory")
inputs <- mapM (Path.stripProperPrefix base) journalFiles
manual <- manualIncludeFiles importRoot (filter (Path.isProperPrefixOf stopAt) inventory)
seeds <- mapM (Path.stripProperPrefix base) manual
let groups = Map.unionsWith (++)
[ groupIncludesUpTo boundary inputs,
groupIncludesUpTo boundary seeds,
Map.fromList [(seed, []) | seed <- seeds]
]
absolute file = Path.toFilePath (base Path.</> file)
includes = Map.mapKeys absolute (Map.map (map absolute) groups)
years = yearsIncludeMap (Map.keys includes)
allAtStop = Path.toFilePath stopAt </> allYearsFileName
indexes = Map.insertWith (++) allAtStop [] years
old = managedIncludeFiles importRoot (filter (Path.isProperPrefixOf stopAt) inventory)
rendered <- renderIncludeFiles base logMessage includes
let current = Map.union rendered ((addPreamble . toIncludeFiles' Map.empty Map.empty) indexes)
emptyIndex = includePreamble <> "\n"
-- Users may still open obsolete generated indexes directly. Leave hand-written files alone.
obsolete <- filterM (needsClearing emptyIndex) [file | file <- map Path.toFilePath old, Map.notMember file current]
let files = Map.union current (Map.fromList [(file, emptyIndex) | file <- obsolete])
return (files, [base Path.</> f | f <- Map.keys groups, Path.parent f == boundary])
where
needsClearing emptyIndex file = do
text <- T.readFile file
return (includePreamble `T.isPrefixOf` text && text /= emptyIndex)
writeToplevelAllYearsInclude :: (HasBaseDir o, HasVerbosity o) => o -> IO [FilePath]
writeToplevelAllYearsInclude opts = toplevelAllYearsInclude (baseDir opts) >>= writeFiles'
toplevelAllYearsInclude :: BaseDir -> IO (Map.Map FilePath T.Text)
toplevelAllYearsInclude base = do
let directives = base Path.</> [Path.relfile|directives.journal|]
top = Path.toFilePath base </> allYearsFileName
directivesExists <- PathIO.doesFileExist directives
let preMap = if directivesExists then Map.singleton top [Path.toFilePath directives] else Map.empty
allTop = Map.singleton top ["import" </> allYearsFileName]
return $ (addPreamble . toIncludeFiles' preMap Map.empty) allTop
extraIncludesForFile :: (HasVerbosity o, HasBaseDir o) => o -> TChan LogMessage -> FilePath -> [T.Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle
extraIncludesForFile opts ch = findExtraIncludes (baseDir opts) (logVerbose opts ch)
findExtraIncludes :: BaseDir -> (T.Text -> IO ()) -> FilePath -> [T.Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle
findExtraIncludes base logMessage file extraSuffixes manualFiles prices = do
let name = FilePath.takeFileName file
stem = if "." `List.isPrefixOf` name then name else FilePath.dropExtensions name
dirprefix = takeWhile (/= '-') stem
fileNames = [dirprefix ++ "-" ++ T.unpack suffix | suffix <- extraSuffixes]
let suffixFiles = map (FilePath.takeDirectory file </>) fileNames
let suffixDirFiles = map (((FilePath.takeDirectory file </> "_manual_") </> dirprefix) </>) manualFiles
let priceFiles = map ((((FilePath.takeDirectory file </> "..") </> "prices") </> dirprefix) </>) prices
let extraFiles = suffixFiles ++ suffixDirFiles ++ priceFiles
filtered <- filterPaths doesFileExist extraFiles
let relative = relativeToBase' (Path.toFilePath base)
logMsg =
"Looking for possible extra include files for '" <> T.pack (relative file)
<> "' among these " <> T.pack (show (length extraFiles)) <> " options: "
<> T.pack (show (map (T.pack . relative) extraFiles))
<> ". Found " <> T.pack (show (length filtered)) <> ": "
<> T.pack (show (map (T.pack . relative) filtered))
logMessage logMsg
return $ Map.fromList [(file, filtered)]