packages feed

photoname-5.5: src/lib/Photoname/Exiv2.hs

module Photoname.Exiv2
  ( getExifDateWithExiv2
  , setArtist
  , setExifDate
  )
  where

import Colog.Simple (logDebug, logInfo, logNotice)
import Control.Exception
import Control.Monad (void)
import Data.Char (isSpace)
import Data.Monoid (First (..))
import Data.Text (Text)
import Data.Text qualified as TS
import Formatting ((%), (%+), formatToString, string, text)
import GHC.IO.Exception
import System.Process hiding (proc)
import qualified System.Process as Proc

import Photoname.Common (Artist (Artist), DestPath (DestPath),
  NoActionSwitch (NoActionSwitch), Options (artist, noAction),
  SrcPath (SrcPath))
import Photoname.Date (PhDate (FilenameDate), formatDateForExif)
import Photoname.Monad (Env (envOptions), Ph, asks, liftIO)


type LogFunction = Text -> Ph ()

data Reading
data Writing

-- For logging purposes we keep the program name separate from its arguments
-- until we need to build a CreateProcess data structure
data Command rw = Command LogFunction FilePath [String]

-- Construct a human-readable command-line from a Command data structure. This
-- is purely for logging.
-- Arguments may contain spaces but be quoted properly when this is used by
-- System.Process.proc BUT they look odd when logged by our code. This function
-- will put quotes around any space-containing arguments purely for human
-- readability.
commandToText :: Command rw -> Text
commandToText (Command _ program' arguments) =
  TS.pack . unwords $ program' : map quoteAsNeeded arguments
  where
    quoteAsNeeded str = if ' ' `elem` str
      then "'" <> str <> "'"
      else str

proc :: Command rw -> CreateProcess
proc (Command _ program' arguments) = Proc.proc program' arguments


logCommand :: Command rw -> Ph ()
logCommand command@(Command logFunction _ _) =
  logFunction . commandToText $ command


-- For Writing (or "destructive") commands, we need to check if the user has
-- chosen no-action behavior before executing
execWritingCommand :: Command Writing -> Ph (Maybe String)
execWritingCommand command = do
  logCommand command
  (NoActionSwitch noAction') <- asks $ noAction . envOptions
  if noAction'
    then pure Nothing
    else execCommand command


-- For Reading (or "non-destructive") commands, we just log it and do it
execReadingCommand :: Command Reading -> Ph (Maybe String)
execReadingCommand command = logCommand command >> execCommand command


stripTrailingWhitespace :: String -> String
stripTrailingWhitespace = reverse . dropWhile isSpace . reverse


execCommand :: Command rw -> Ph (Maybe String)
execCommand command = do
  eResult <- liftIO $ postProcess =<< try (readCreateProcessWithExitCode (proc command) "")
  either handleFailure handleSuccess eResult
  where
    handleFailure msg = do
      let msg' = formatToString (text %+ string) "** Command failed:" (stripTrailingWhitespace msg)
      logDebug . TS.pack $ msg'
      pure Nothing
    handleSuccess output = do
      let output' = formatToString (text %+ string) "Command succeeded, output:" (stripTrailingWhitespace output)
      logDebug . TS.pack $ output'
      pure . Just $ output'


program :: FilePath
program = "exiv2"


postProcess :: Either IOException (ExitCode, String, String) -> IO (Either String String)
postProcess (Left   e                             ) =
  pure . Left $ formatToString (string % ":" %+ string) program (ioe_description e)
postProcess (Right (ExitSuccess  , stdOut, _     )) = pure . Right $ stdOut
postProcess (Right (ExitFailure 1, _     , ""    )) = pure . Left $ "EXIF tag not found"
postProcess (Right (ExitFailure _, _     , stdErr)) = pure . Left $ stdErr


setArtist :: DestPath -> Ph ()
setArtist (DestPath destFp) = do
  artist' <- asks $ artist . envOptions

  case artist' of
    Nothing -> pure ()
    Just (Artist "") -> void $ execWritingCommand $
      Command logNotice program ["--Modify", "del Exif.Image.Artist", destFp]
    Just (Artist artistInfo) -> void $ execWritingCommand $
      Command logNotice program ["--Modify", "set Exif.Image.Artist " <> artistInfo, destFp]


getExifDateWithExiv2 :: SrcPath -> Ph (Maybe String)
getExifDateWithExiv2 (SrcPath srcFp) =
  getFirst  -- Remove the First wrapper
  . mconcat  -- Collapse these to the first not-Nothing
  . map First -- Wrap in First data structures
  -- Look up all of them (resulting in Ph [Maybe ExifValue])
  <$> mapM mbResult
  -- EXIF tags we're intersted in, in the order we want them left-to-right
  ["Exif.Photo.DateTimeOriginal", "Exif.Photo.DateTimeDigitized", "Exif.Image.DateTime"]

  where
    mbResult tag =
      execReadingCommand $ Command logInfo program ["--Print", "v", "--grep", tag, srcFp]


setExifDate :: PhDate -> DestPath -> Ph ()

setExifDate (FilenameDate lt) (DestPath destFp) =
  void $ execWritingCommand . Command logNotice program $
    [ "--Modify", "set Exif.Image.DateTime Ascii " <> formatDateForExif lt
    , "--Modify", "set Exif.Photo.UserComment charset=Ascii DateTime is a guess", destFp
    ]

setExifDate _ _ = pure ()