hledger-flow-0.17.0: src/Hledger/Flow/Import/ImportHelpersTurtle.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Generate journal include files and their relative include directives.
module Hledger.Flow.Import.ImportHelpersTurtle
( allYearIncludeFiles,
extractImportDirs,
extraIncludesForFile,
groupIncludeFiles,
groupAndWriteIncludeFiles,
includePreamble,
toIncludeFiles,
toIncludeLine,
writeIncludesUpTo,
writeToplevelAllYearsInclude,
yearsIncludeMap,
)
where
import Control.Concurrent.STM (TChan)
import Control.Monad (unless)
import Data.Either (isRight)
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 Hledger.Flow.BaseDir (relativeToBase, relativeToBase', turtleBaseDir)
import Hledger.Flow.Common (allYearsFileName, directivesFile, filterPaths, groupValuesBy, writeFiles, writeFiles')
import Hledger.Flow.Import.ImportHelpers (extractImportDirs, groupIncludesUpTo)
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 ((</>))
groupIncludeFiles :: [FilePath] -> (TurtleFileBundle, TurtleFileBundle)
groupIncludeFiles = allYearIncludeFiles . groupIncludeFilesPerYear . filter isJournalFile
isJournalFile :: FilePath -> Bool
isJournalFile f = FilePath.takeExtension f == ".journal"
allYearIncludeFiles :: TurtleFileBundle -> (TurtleFileBundle, TurtleFileBundle)
allYearIncludeFiles m = (m, yearsIncludeMap $ Map.keys m)
yearsIncludeMap :: [FilePath] -> TurtleFileBundle
yearsIncludeMap = groupValuesBy allYearsPath
allYearsPath :: FilePath -> FilePath
allYearsPath journal = fst (FilePath.splitFileName journal) </> allYearsFileName
includeFileName :: FilePath -> FilePath
includeFileName journal = FilePath.takeFileName (FilePath.takeDirectory journal) ++ "-include.journal"
groupIncludeFilesPerYear :: [FilePath] -> TurtleFileBundle
groupIncludeFilesPerYear [] = Map.empty
groupIncludeFilesPerYear ps@(p : _)
| maybe False (Path.prjSomeBase (isRight . extractImportDirs)) (Path.parseSomeFile p) =
groupValuesBy initialIncludeFilePath ps
| otherwise = groupValuesBy parentIncludeFilePath ps
initialIncludeFilePath :: FilePath -> FilePath
initialIncludeFilePath p = ancestorDir 3 p </> includeFileName p
parentIncludeFilePath :: FilePath -> FilePath
parentIncludeFilePath p = ancestorDir 2 p </> FilePath.takeFileName p
-- Keep the original path components (including separators) when constructing
-- include-map keys. Relative keys have historically been rooted at "./".
ancestorDir :: Int -> FilePath -> FilePath
ancestorDir levels file = prefix </> FilePath.joinPath parents
where
(drive, rest) = FilePath.splitDrive file
parts = FilePath.splitPath rest
parents = take (length parts - levels) parts
prefix = case parents of
first : _ | first == "./" || first == "../" -> drive
_ | null drive -> "./"
_ -> drive
toIncludeFiles :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> TurtleFileBundle -> IO (Map.Map FilePath T.Text)
toIncludeFiles opts ch m = do
preMap <- extraIncludes opts ch (Map.keys m) ["opening.journal"] ["pre-import.journal"] []
postMap <- extraIncludes opts ch (Map.keys m) ["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"
groupAndWriteIncludeFiles :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> [FilePath] -> IO [FilePath]
groupAndWriteIncludeFiles opts ch = writeFileMap opts ch . groupIncludeFiles
writeFileMap :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> (TurtleFileBundle, TurtleFileBundle) -> IO [FilePath]
writeFileMap opts ch (m, allYears) = do
_ <- writeFiles' $ (addPreamble . toIncludeFiles' Map.empty Map.empty) allYears
writeFiles . (toIncludeFiles opts ch) $ m
writeIncludesUpTo :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> AbsDir -> [AbsFile] -> IO [AbsFile]
writeIncludesUpTo opts ch stopAt journals = do
let base = baseDir opts
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
let groups = groupIncludesUpTo boundary inputs
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
_ <- writeFileMap opts ch (includes, Map.insertWith (++) allAtStop [] years)
return [base Path.</> f | f <- Map.keys groups, Path.parent f == boundary]
writeToplevelAllYearsInclude :: (HasBaseDir o, HasVerbosity o) => o -> IO [FilePath]
writeToplevelAllYearsInclude opts = do
let directives = directivesFile opts
directivesExists <- PathIO.doesFileExist directives
let preMap = if directivesExists then Map.singleton (turtleBaseDir opts </> allYearsFileName) [Path.toFilePath directives] else Map.empty
let allTop = Map.singleton (turtleBaseDir opts </> allYearsFileName) ["import" </> allYearsFileName]
writeFiles' $ (addPreamble . toIncludeFiles' preMap Map.empty) allTop
extraIncludes :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> [FilePath] -> [T.Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle
extraIncludes opts ch = extraIncludes' opts ch Map.empty
extraIncludes' :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> TurtleFileBundle -> [FilePath] -> [T.Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle
extraIncludes' _ _ acc [] _ _ _ = return acc
extraIncludes' opts ch acc (file : files) extraSuffixes manualFiles prices = do
extra <- extraIncludesForFile opts ch file extraSuffixes manualFiles prices
extraIncludes' opts ch (Map.unionWith (++) acc extra) files extraSuffixes manualFiles prices
extraIncludesForFile :: (HasVerbosity o, HasBaseDir o) => o -> TChan LogMessage -> FilePath -> [T.Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle
extraIncludesForFile opts ch 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 logMsg =
"Looking for possible extra include files for '" <> T.pack (relativeToBase opts file)
<> "' among these " <> T.pack (show (length extraFiles)) <> " options: "
<> T.pack (show (relativeFilesAsText opts extraFiles))
<> ". Found " <> T.pack (show (length filtered)) <> ": "
<> T.pack (show (relativeFilesAsText opts filtered))
logVerbose opts ch logMsg
return $ Map.fromList [(file, filtered)]
relativeFilesAsText :: (HasBaseDir o) => o -> [FilePath] -> [T.Text]
relativeFilesAsText opts = map (T.pack . relativeToBase opts)