librarian-0.1.0.1: src/Librarian.hs
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TupleSections #-}
{-# OPTIONS_GHC -Wno-partial-fields #-}
module Librarian
( Rule (..),
RuleName (..),
Grouping (..),
Filtering (..),
Source (..),
SourceDate (..),
TimeSpec (..),
SortingOrder (..),
GroupSelection (..),
GroupingBucket (..),
-- * Collecting
Matcher (..),
Action (..),
CollectedFiles,
fetchRulesOn,
-- * Planning
ResolvedAction (..),
planActions,
displayPlan,
-- * Runner
FsAction (..),
ActionResult (..),
RunResult,
runPlan,
displayResult,
)
where
import Control.Exception (catch)
import Control.Monad
import Data.Foldable (Foldable (toList))
import Data.Functor (($>), (<&>))
import Data.Kind (Type)
import Data.List (sortOn)
import qualified Data.Map.Strict as Map
import Data.Maybe (catMaybes, mapMaybe)
import Data.Ord (Down (Down))
import Data.Sequence (Seq)
import Data.String (IsString)
import Data.Time
( UTCTime,
addUTCTime,
defaultTimeLocale,
formatTime,
getCurrentTime,
nominalDay,
secondsToNominalDiffTime,
)
import GHC.Generics (Generic)
import System.Directory
( copyFile,
copyFileWithMetadata,
createDirectoryIfMissing,
doesFileExist,
getAccessTime,
getModificationTime,
removeFile,
renameFile,
)
import System.EasyFile (splitFileName)
import System.FilePath.Glob (compile, globDir)
import Text.RegexPR
data Rule = Rule
{ name :: RuleName,
match :: Matcher,
grouping :: Grouping,
filtering :: Filtering,
actions :: [Action]
}
deriving stock (Show, Generic)
newtype RuleName = RuleName {getRuleName :: String}
deriving stock (Generic)
deriving newtype (Eq, Ord, Show, IsString)
newtype Matcher = Matcher {matchPattern :: String}
deriving stock (Generic)
deriving newtype (Eq, Ord, Show, IsString)
data Action
= Move {inputPattern :: String, newName :: String}
| Copy {inputPattern :: String, newName :: String}
| Remove {inputPattern :: String}
deriving stock (Eq, Show, Generic)
data Grouping
= FileGroup
| forall a.
Ord a =>
Group
{ groupSource :: Source a,
groupBucket :: GroupingBucket a,
groupSelection :: GroupSelection a
}
deriving stock instance Show Grouping
data Filtering
= AllF
| AndF Filtering Filtering
| OrF Filtering Filtering
| forall a. Ord a => GtF (Source a) (Source a)
| forall a. Ord a => LtF (Source a) (Source a)
deriving stock instance Show Filtering
data Source :: Type -> Type where
SourceDate :: SourceDate -> Source UTCTime
SourceTime :: TimeSpec -> Source UTCTime
deriving stock instance Eq (Source a)
deriving stock instance Show (Source a)
data SourceDate
= ModificationTime
| AccessTime
deriving stock (Eq, Show, Generic)
data TimeSpec
= HoursAgo Integer
| DaysAgo Integer
| AbsoluteTime UTCTime
deriving stock (Eq, Show, Generic)
data SortingOrder
= SortingAsc
| SortingDesc
deriving stock (Eq, Show, Generic)
data GroupSelection a
= After Int SortingOrder (Source a)
| Before Int SortingOrder (Source a)
deriving stock (Eq, Show, Generic)
data GroupingBucket :: Type -> Type where
Daily :: GroupingBucket UTCTime
Weekly :: GroupingBucket UTCTime
Monthly :: GroupingBucket UTCTime
deriving stock instance Eq (GroupingBucket a)
deriving stock instance Show (GroupingBucket a)
type CollectedFiles = Map.Map FilePath (Seq Rule)
fetchRulesOn :: FilePath -> [Rule] -> IO CollectedFiles
fetchRulesOn root rules = do
matches <- globDir (compile . matchPattern . match <$> rules) root
let applyRule :: [FilePath] -> Rule -> IO [(FilePath, Seq Rule)]
applyRule files rule =
filterM (applyFiltering rule.filtering) files
>>= applyGrouping rule
applyFiltering :: Filtering -> FilePath -> IO Bool
applyFiltering filteringRule file =
case filteringRule of
AllF -> return True
AndF x y -> (&&) <$> applyFiltering x file <*> applyFiltering y file
OrF x y -> (||) <$> applyFiltering x file <*> applyFiltering y file
GtF x y -> (>) <$> fetchSource x file <*> fetchSource y file
LtF x y -> (<) <$> fetchSource x file <*> fetchSource y file
applyGrouping :: Rule -> [FilePath] -> IO [(FilePath, Seq Rule)]
applyGrouping rule files =
case rule.grouping of
FileGroup ->
return $ map (\f -> (f, [rule])) files
Group {..} -> do
let bucketUtc :: UTCTime -> String
bucketUtc x =
case groupBucket of
Daily -> formatTime defaultTimeLocale "%Y-%m-%d" x
Weekly -> formatTime defaultTimeLocale "%Y-%V" x
Monthly -> formatTime defaultTimeLocale "%Y-%m" x
bucket :: Source x -> x -> String
bucket source x =
case source of
SourceDate _ -> bucketUtc x
SourceTime _ -> bucketUtc x
fetchBucket :: Source x -> FilePath -> IO String
fetchBucket source file =
bucket source <$> fetchSource source file
sorting :: Ord x => SortingOrder -> [(x, FilePath)] -> [(x, FilePath)]
sorting =
\case
SortingAsc -> sortOn fst
SortingDesc -> sortOn $ Down . fst
applySelection :: [FilePath] -> IO [FilePath]
applySelection bucketedFiles =
case groupSelection of
After n order source ->
drop (n + 1) . map snd . sorting order
<$> mapM (\file -> (,file) <$> fetchSource source file) bucketedFiles
Before n order source ->
take n . map snd . sorting order
<$> mapM (\file -> (,file) <$> fetchSource source file) bucketedFiles
buckets <-
Map.fromListWith @String @(Seq FilePath) (<>)
<$> forM files (\file -> (,[file]) <$> fetchBucket groupSource file)
concatMap (map (,[rule]))
<$> mapM applySelection (toList <$> Map.elems buckets)
fetchSource :: Source a -> FilePath -> IO a
fetchSource source file =
case source of
SourceDate sourceDate ->
case sourceDate of
ModificationTime -> getModificationTime file
AccessTime -> getAccessTime file
SourceTime timeSpec ->
case timeSpec of
HoursAgo d ->
addUTCTime (secondsToNominalDiffTime $ (-1) * fromInteger d * 60 * 60) <$> getCurrentTime
DaysAgo d ->
addUTCTime ((-1) * fromInteger d * nominalDay) <$> getCurrentTime
AbsoluteTime x ->
return x
files <- mapM (filterM doesFileExist) matches
Map.unionsWith (<>) . map Map.fromList <$> zipWithM applyRule files rules
data ResolvedAction
= ResolvedMove {original :: FilePath, new :: FilePath, rule :: Rule}
| ResolvedCopy {original :: FilePath, new :: FilePath, rule :: Rule}
| ResolvedRemove {original :: FilePath, rule :: Rule}
deriving stock (Show, Generic)
planActions :: CollectedFiles -> [ResolvedAction]
planActions = concatMap (take 1 . uncurry planAction) . concatMap (traverse toList) . Map.toList
where
planAction :: FilePath -> Rule -> [ResolvedAction]
planAction p rule = mapMaybe go $ actions rule
where
go :: Action -> Maybe ResolvedAction
go =
\case
Move {..} ->
newPath inputPattern newName
<&> \newPath' -> ResolvedMove {original = p, new = newPath', rule = rule}
Copy {..} ->
newPath inputPattern newName
<&> \newPath' -> ResolvedCopy {original = p, new = newPath', rule = rule}
Remove {..} ->
matchRegexPR inputPattern p
$> ResolvedRemove {original = p, rule = rule}
newPath :: String -> String -> Maybe FilePath
newPath inputPattern' newName' =
mfilter (/= p) $ Just $ subRegexPR inputPattern' newName' p
displayPlan :: [ResolvedAction] -> IO ()
displayPlan =
mapM_ $ \case
ResolvedMove {..} ->
putStrLn $ "Move [" <> getRuleName (name rule) <> "] '" <> original <> "' -> '" <> new <> "'"
ResolvedCopy {..} ->
putStrLn $ "Copy [" <> getRuleName (name rule) <> "] '" <> original <> "' -> '" <> new <> "'"
ResolvedRemove {..} ->
putStrLn $ "Remove [" <> getRuleName (name rule) <> "] '" <> original <> "'"
data FsAction
= FsMove {from :: FilePath, to :: FilePath}
| FsCopy {from :: FilePath, to :: FilePath}
| FsRemove {from :: FilePath}
deriving stock (Eq, Show, Generic)
data ActionResult
= Done
| Existing
| Missing
| IOException IOError
deriving stock (Eq, Show, Generic)
type RunResult = [(FsAction, ActionResult)]
runPlan :: [ResolvedAction] -> IO RunResult
runPlan = fmap catMaybes . run
where
run =
mapM $
\case
ResolvedMove {..} -> do
let fsAction = FsMove original new
originalPresent <- doesFileExist original
if not originalPresent
then return $ Just (fsAction, Missing)
else do
newPresent <- doesFileExist new
if newPresent
then return $ Just (fsAction, Existing)
else do
prepareDirectory new
let resillientMove from to = renameFile from to `catch` const @_ @IOError (copyFileWithMetadata from to >> removeFile from)
Just . (,) fsAction <$> ((resillientMove original new $> Done) `catch` (return . IOException))
ResolvedCopy {..} -> do
let fsAction = FsCopy original new
originalPresent <- doesFileExist original
if not originalPresent
then return $ Just (fsAction, Missing)
else do
newPresent <- doesFileExist new
if newPresent
then return $ Just (fsAction, Existing)
else do
prepareDirectory new
Just . (,) fsAction <$> ((copyFile original new $> Done) `catch` (return . IOException))
ResolvedRemove {..} -> do
let fsAction = FsRemove original
originalPresent <- doesFileExist original
if not originalPresent
then return $ Just (fsAction, Missing)
else Just . (,) fsAction <$> ((removeFile original $> Done) `catch` (return . IOException))
prepareDirectory = createDirectoryIfMissing True . fst . splitFileName
displayResult :: RunResult -> IO ()
displayResult =
mapM_ $ \(action, result) ->
case action of
FsMove {..} ->
case result of
Done -> return ()
Existing -> putStrLn $ "'" <> from <> "' -> '" <> to <> "' ALREADY EXISTING"
Missing -> putStrLn $ "'" <> from <> "' -> '" <> to <> "' MISSING"
IOException e -> putStrLn $ "'" <> from <> "' -> '" <> to <> "' IOError (" <> show e <> ")"
FsCopy {..} ->
case result of
Done -> return ()
Existing -> putStrLn $ "'" <> from <> "' -> '" <> to <> "' ALREADY EXISTING"
Missing -> putStrLn $ "'" <> from <> "' -> '" <> to <> "' MISSING"
IOException e -> putStrLn $ "'" <> from <> "' -> '" <> to <> "' IOError (" <> show e <> ")"
FsRemove {..} ->
case result of
Done -> return ()
Existing -> putStrLn $ "'" <> from <> "' ALREADY EXISTING (BUG)"
Missing -> putStrLn $ "'" <> from <> "' MISSING"
IOException e -> putStrLn $ "'" <> from <> "' IOError (" <> show e <> ")"