librarian-0.2.0.0: 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 qualified Data.Text as Text
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 qualified Text.Regex.Pcre2 as Pcre2
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}
| Bundle {subAction :: [Action]}
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 {..} ->
guard (Pcre2.matches (Text.pack inputPattern) (Text.pack p))
$> ResolvedRemove {original = p, rule = rule}
newPath :: String -> String -> Maybe FilePath
newPath inputPattern' newName' =
mfilter (/= p) $ Just $ Text.unpack $ Pcre2.sub (Text.pack inputPattern') (Text.pack newName') (Text.pack 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 <> ")"