hledger-flow-0.20.0: src/Hledger/Flow/Import/Inventory.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
-- | Discover first, then classify paths relative to the known import root.
module Hledger.Flow.Import.Inventory
( findImportFiles,
stageFiles,
inImportScope,
generatedOutput,
classifyGeneratedOutputs,
reconciliationInventory,
conflictingOutputs,
cannotAlias,
canonicalFile,
manualIncludeFiles,
isManualJournal,
managedIncludeFiles,
moveToTrash,
)
where
import Control.Exception (bracket_)
import Control.Monad (filterM, forM)
import Data.Char (isAscii, isDigit, isPrint, toLower)
import qualified Data.List as List
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Hledger.Flow.Common (groupValuesBy, homeDirs, tildePath)
import Hledger.Flow.PathHelpers (AbsDir, AbsFile, RelDir)
import Path
import qualified Path.IO as PathIO
import qualified System.Directory as Directory
import qualified System.FilePath as FilePath
import System.IO.Error (catchIOError, isAlreadyExistsError, isDoesNotExistError, tryIOError)
findImportFiles :: AbsDir -> IO [AbsFile]
findImportFiles root = List.sort <$> PathIO.walkDirAccum (Just exclude) collect root
where
exclude _ dirs _ = return $ PathIO.WalkExclude $
filter (ignored . FilePath.dropTrailingPathSeparator . toFilePath . dirname) dirs
collect _ _ files = return $ filter (not . hidden . toFilePath . filename) files
ignored name = hidden name || name == "__pycache__"
hidden ('.' : _) = True
hidden _ = False
relativeParts :: AbsDir -> AbsFile -> [String]
relativeParts root file = maybe [] (FilePath.splitDirectories . toFilePath) (stripProperPrefix root file)
isYear :: String -> Bool
isYear year = length year == 4 && all isDigit year
stageFiles :: AbsDir -> RelDir -> [AbsFile] -> [AbsFile]
stageFiles root stage = filter matches
where
name = FilePath.dropTrailingPathSeparator (toFilePath stage)
matches file = case relativeParts root file of
[_, _, _, actualStage, year, _] -> actualStage == name && isYear year
_ -> False
-- Call only on classified stage files, which have a numeric year parent.
inImportScope :: AbsDir -> Integer -> AbsFile -> Bool
inImportScope scope startYear file =
isProperPrefixOf scope file && read year >= startYear
where
year = FilePath.dropTrailingPathSeparator (toFilePath (dirname (parent file)))
generatedOutput :: RelDir -> String -> AbsFile -> IO AbsFile
generatedOutput stage extension source =
-- One parse instead of chained Path.parent calls, which each renormalise the whole path.
parseAbsFile (account FilePath.</> toFilePath stage FilePath.</> FilePath.takeFileName yearDir FilePath.</> name)
where
file = toFilePath source
yearDir = FilePath.takeDirectory file
account = FilePath.takeDirectory (FilePath.takeDirectory yearDir)
name = FilePath.replaceExtension (FilePath.takeFileName file) extension
-- | Orphaned generated files and the source-backed journals to include.
classifyGeneratedOutputs :: AbsDir -> [AbsFile] -> [AbsFile] -> IO ([AbsFile], [AbsFile])
classifyGeneratedOutputs root inventory sources = do
pairs <- outputPairs sources
let expected = Set.fromList (map (toFilePath . fst) pairs)
staged stage ext = filter ((== ext) . FilePath.takeExtension . toFilePath) (stageFiles root stage inventory)
cached = staged [reldir|2-preprocessed|] ".csv"
journals = staged [reldir|3-journal|] ".journal"
unmatched = filter ((`Set.notMember` expected) . toFilePath) (cached ++ journals)
-- A differently spelled output can alias any expected file, even one which
-- also matched literally (for example, through a symlink).
orphans <- if null unmatched then return [] else do
resolved <- Set.fromList <$> mapM (canonicalFile . fst) pairs
filterM (fmap (`Set.notMember` resolved) . canonicalFile) unmatched
let retired = Set.fromList orphans
active = filter (`Set.notMember` retired) journals
preferExpected a b = if rank a <= rank b then a else b
rank file = (Set.notMember (toFilePath file) expected, file)
-- Resolve year directories once, including symlinked directories. Names
-- came from listing the directory, so only symlinks need a full lookup.
groups <- forM (Map.toList (groupValuesBy (FilePath.takeDirectory . toFilePath) active)) $ \(dir, files) -> do
resolvedDir <- Directory.canonicalizePath dir
forM files $ \file -> do
link <- PathIO.isSymlink file
key <- if link then Directory.canonicalizePath (toFilePath file)
else return (resolvedDir FilePath.</> FilePath.takeFileName (toFilePath file))
return (key, file)
let chosen = Map.fromListWith preferExpected (concat groups)
return (orphans, List.sort (Map.elems chosen))
-- | Inventory, orphaned outputs and source-backed journals for reconciliation.
-- Includes cover every year and account, even when orphan moves are scoped.
reconciliationInventory :: AbsDir -> Maybe [AbsFile] -> IO ([AbsFile], [AbsFile], [AbsFile])
reconciliationInventory root cached = case cached of
Nothing -> refresh
Just files -> do
result@(_, orphans, _) <- classify files
if null orphans then return result else refresh
where
-- A source may have been restored since discovery.
refresh = findImportFiles root >>= classify
classify files = do
(orphans, journals) <- classifyGeneratedOutputs root files (stageFiles root [reldir|1-in|] files)
return (files, orphans, journals)
canonicalFile :: AbsFile -> IO AbsFile
canonicalFile file = Directory.canonicalizePath (toFilePath file) >>= parseAbsFile
outputPairs :: [AbsFile] -> IO [(AbsFile, AbsFile)]
outputPairs sources = concat <$> mapM outputs sources
where
outputs source = do
csv <- generatedOutput [reldir|2-preprocessed|] ".csv" source
journal <- generatedOutput [reldir|3-journal|] ".journal" source
return [(csv, source), (journal, source)]
-- Nonexistent outputs cannot be canonicalized for case or Unicode aliases.
-- Probe their names in a temporary directory on the destination filesystem.
conflictingOutputs :: [AbsFile] -> IO [(AbsFile, [AbsFile])]
conflictingOutputs sources = do
pairs <- outputPairs sources
-- Resolve each parent once, including links shared by different accounts.
let byParent = groupValuesBy (parent . fst) pairs
groups <- forM (Map.toList byParent) $ \(dir, outputs) -> do
resolved <- Directory.canonicalizePath (toFilePath dir) >>= parseAbsDir
return (resolved, outputs)
concat <$> mapM checkDirectory (Map.toList (Map.fromListWith (++) groups))
where
checkDirectory (_, [_]) = return []
checkDirectory (_, outputs) | cannotAlias (outputNames outputs) = return []
checkDirectory (directory, outputs) = do
let names = outputNames outputs
distinct = Set.fromList names
listed <- tryIOError (Directory.listDirectory (toFilePath directory))
case listed of
Right entries
| Set.size distinct == length names && distinct `Set.isSubsetOf` Set.fromList entries -> return []
_ -> do
existing <- nearestDirectory directory
PathIO.withTempDir existing ".hledger-flow-destinations-" $ \tmp -> do
let probes = map (toFilePath tmp FilePath.</>) names
mapM_ (Directory.createDirectoryIfMissing False) probes
entries <- Directory.listDirectory (toFilePath tmp)
if length entries == length names then return [] else do
-- Resolve individual probes only when some names actually alias.
keys <- mapM Directory.canonicalizePath probes
let groups = Map.fromListWith (++) (zip keys (map (:[]) outputs))
return [(output, source : map snd rest)
| (output, source) : rest <- Map.elems groups, not (null rest)]
outputNames = map (toFilePath . filename . fst)
nearestDirectory directory = do
exists <- PathIO.doesDirExist directory
if exists then return directory else nearestDirectory (parent directory)
-- | Generated .csv and .journal names stay distinct when they differ even
-- ignoring case, are plain ASCII and contain no Windows short-name marker (~).
cannotAlias :: [String] -> Bool
cannotAlias names = all plain names && Set.size (Set.fromList (map (map toLower) names)) == length names
where
plain = all (\c -> isAscii c && isPrint c && c /= '~')
-- Seed the include at the manual file's own level; its contents are added by
-- the existing pre/post renderer, keeping ordering and avoiding duplicates.
manualIncludeFiles :: AbsDir -> [AbsFile] -> IO [AbsFile]
manualIncludeFiles root files = Set.toAscList . Set.fromList <$> mapM includePath (filter (isManualJournal root) files)
where
includePath file = do
let year = FilePath.dropTrailingPathSeparator (toFilePath (dirname (parent file)))
name <- parseRelFile (year ++ "-include.journal")
return (parent (parent (parent file)) </> name)
-- | A @_manual_@ pre- or post-import journal that import includes.
isManualJournal :: AbsDir -> AbsFile -> Bool
isManualJournal root file = case reverse (relativeParts root file) of
name : year : "_manual_" : ancestors ->
length ancestors <= 3 && isYear year
&& name `elem` ["pre-import.journal", "post-import.journal"]
_ -> False
managedIncludeFiles :: AbsDir -> [AbsFile] -> [AbsFile]
managedIncludeFiles root = filter supported
where
supported file = case reverse (relativeParts root file) of
name : ancestors -> length ancestors <= 3
&& (name == "all-years.journal" || (isYear (take 4 name) && drop 4 name == "-include.journal"))
_ -> False
moveToTrash :: AbsFile -> IO AbsFile
moveToTrash file = do
let year = dirname (parent file)
stage = dirname (parent (parent file))
account = parent (parent (parent file))
trash = account </> [reldir|.trash|]
lock = trash </> [reldir|.move-lock|]
destination = trash </> year </> stage
acquire = Directory.createDirectory (toFilePath lock) `catchIOError` \err ->
if isAlreadyExistsError err
then homeDirs >>= \homes -> ioError (userError ("Trash is in use: " ++ tildePath homes (toFilePath lock)
++ ". If no import is running, remove this empty lock directory and retry."))
else ioError err
PathIO.createDirIfMissing True trash
-- Serialize destination selection and rename so two imports cannot replace
-- each other's recovery files. An interrupted move leaves a recoverable lock.
bracket_ acquire (PathIO.removeDir lock) $ do
PathIO.createDirIfMissing True destination
target <- available destination (0 :: Integer)
PathIO.renameFile file target
return target
where
available destination n = do
name <- parseRelFile (toFilePath (filename file) ++ if n == 0 then "" else "." ++ show n)
let target = destination </> name
exists <- Directory.doesPathExist (toFilePath target)
-- A dangling symlink also occupies the destination name.
link <- PathIO.isSymlink target `catchIOError` \err ->
if isDoesNotExistError err then return False else ioError err
if exists || link then available destination (n + 1) else return target