mirror of
https://git.sr.ht/~magic_rb/dotfiles
synced 2024-11-26 01:56:13 +01:00
Publish photo-hs
Signed-off-by: magic_rb <magic_rb@redalder.org>
This commit is contained in:
parent
f747427f5c
commit
62f4255e6f
1
overlays/photo-hs/.gitignore
vendored
Normal file
1
overlays/photo-hs/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
percept
|
5
overlays/photo-hs/CHANGELOG.md
Normal file
5
overlays/photo-hs/CHANGELOG.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Revision history for photo-hs
|
||||
|
||||
## 0.1.0.0 -- YYYY-mm-dd
|
||||
|
||||
* First version. Released on an unsuspecting world.
|
165
overlays/photo-hs/LICENSE
Normal file
165
overlays/photo-hs/LICENSE
Normal file
|
@ -0,0 +1,165 @@
|
|||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
6
overlays/photo-hs/exe/Main.hs
Normal file
6
overlays/photo-hs/exe/Main.hs
Normal file
|
@ -0,0 +1,6 @@
|
|||
module Main where
|
||||
|
||||
import MyLib (libMain)
|
||||
|
||||
main :: IO ()
|
||||
main = libMain
|
4
overlays/photo-hs/lib/.dir-locals.el
Normal file
4
overlays/photo-hs/lib/.dir-locals.el
Normal file
|
@ -0,0 +1,4 @@
|
|||
;;; Directory Local Variables -*- no-byte-compile: t -*-
|
||||
;;; For more information see (info "(emacs) Directory Variables")
|
||||
|
||||
((haskell-mode . ((lsp-lens-enable . nil))))
|
33
overlays/photo-hs/lib/AppData.hs
Normal file
33
overlays/photo-hs/lib/AppData.hs
Normal file
|
@ -0,0 +1,33 @@
|
|||
{-# LANGUAGE UndecidableInstances #-}
|
||||
module AppData
|
||||
( AppData(..)
|
||||
, askPhotoDir
|
||||
, askStoreDir
|
||||
) where
|
||||
|
||||
import Options (Options)
|
||||
import Constants qualified
|
||||
import Data.Text qualified as T
|
||||
import System.FilePath ((</>))
|
||||
import Data.Functor ((<&>))
|
||||
import Control.Monad.Reader.Class (MonadReader(..), asks)
|
||||
|
||||
data AppData
|
||||
= AppData
|
||||
{ options :: Options
|
||||
, photoDir :: FilePath
|
||||
}
|
||||
|
||||
-- instance MonadReader AppData m => MonadReader SqlBackend m where
|
||||
-- ask :: MonadReader AppData m => m SqlBackend
|
||||
-- ask = ask @AppData <&> \appdata -> appdata.conn
|
||||
-- local :: MonadReader AppData m => (SqlBackend -> SqlBackend) -> m a -> m a
|
||||
-- local f = local @AppData (\appdata -> appdata { conn = f appdata.conn })
|
||||
|
||||
askPhotoDir :: (MonadReader AppData m) => m FilePath
|
||||
askPhotoDir = asks (\s -> s.photoDir)
|
||||
|
||||
askStoreDir :: (MonadReader AppData m) => m FilePath
|
||||
askStoreDir = askPhotoDir <&> (</> T.unpack Constants.storeDirectory)
|
||||
|
||||
|
11
overlays/photo-hs/lib/Commands.hs
Normal file
11
overlays/photo-hs/lib/Commands.hs
Normal file
|
@ -0,0 +1,11 @@
|
|||
module Commands
|
||||
( module Commands.AddPhoto
|
||||
, module Commands.Init
|
||||
, module Commands.List
|
||||
, module Commands.EditMeta
|
||||
) where
|
||||
|
||||
import Commands.AddPhoto
|
||||
import Commands.Init
|
||||
import Commands.List
|
||||
import Commands.EditMeta
|
75
overlays/photo-hs/lib/Commands/AddPhoto.hs
Normal file
75
overlays/photo-hs/lib/Commands/AddPhoto.hs
Normal file
|
@ -0,0 +1,75 @@
|
|||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE UndecidableInstances #-}
|
||||
{-# LANGUAGE UndecidableSuperClasses #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
|
||||
module Commands.AddPhoto
|
||||
( commandAddPhoto
|
||||
) where
|
||||
|
||||
import Control.Monad.IO.Class (MonadIO, liftIO)
|
||||
import Control.Monad.Logger (MonadLogger)
|
||||
import Control.Monad.Reader (MonadReader)
|
||||
import AppData (AppData, askStoreDir)
|
||||
import Data.Text (Text)
|
||||
import Data.Time (ZonedTime)
|
||||
import Crypto.Hash (Digest, SHA256)
|
||||
import Photo (getPhotoExtension, Photo (..))
|
||||
import Data.Functor ((<&>))
|
||||
import Control.Applicative ((<|>))
|
||||
import System.FilePath (takeBaseName)
|
||||
import qualified Data.Text as T
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Time.LocalTime (zonedTimeToUTC)
|
||||
import Git.Annex (gitAnnexAdd)
|
||||
import Control.Exception (throw)
|
||||
import Percept.Error (PhotoException(..))
|
||||
import qualified Schema as S
|
||||
import Control.Monad.Extra (forM_, whenM)
|
||||
import Database.Persist.Monad (MonadSqlQuery)
|
||||
import qualified System.Directory as D
|
||||
import Git (gitCommit)
|
||||
import qualified Photo as P
|
||||
import Percept.Operations (calculateImageDigest, readImageCreationUTCTime, inStorePathForPhoto, savePhotoFile, saveMetadataFile, cachePhoto)
|
||||
import Database.Esqueleto.Experimental.Monad
|
||||
|
||||
commandAddPhoto
|
||||
:: (MonadIO m, MonadLogger m, MonadSqlQuery m, MonadReader AppData m)
|
||||
=> FilePath
|
||||
-> Maybe Text
|
||||
-> [P.Tag]
|
||||
-> Maybe ZonedTime
|
||||
-> Maybe Text
|
||||
-> m (Digest SHA256)
|
||||
commandAddPhoto filePath name tags cmdlineCreationTime description = do
|
||||
digest <- calculateImageDigest filePath
|
||||
creationTime <- readImageCreationUTCTime filePath <&> (<|> cmdlineCreationTime)
|
||||
|
||||
case creationTime of
|
||||
Just imageCreationTime -> do
|
||||
let
|
||||
photo
|
||||
= Photo
|
||||
{ hash = P.Hash . T.pack . show $ digest
|
||||
, name = fromMaybe (T.pack $ takeBaseName filePath) name
|
||||
, tags = tags
|
||||
, date = zonedTimeToUTC imageCreationTime
|
||||
, description = fromMaybe "" description
|
||||
, imageType = T.drop 1 $ getPhotoExtension filePath
|
||||
}
|
||||
storeDir <- askStoreDir
|
||||
photoStorePath <- inStorePathForPhoto photo
|
||||
|
||||
whenM (liftIO $ D.doesFileExist photoStorePath) (throw (PhotoAlreadyExists photo))
|
||||
|
||||
savePhotoFile filePath photo
|
||||
saveMetadataFile photo
|
||||
|
||||
cachePhoto photo
|
||||
|
||||
gitAnnexAdd storeDir photoStorePath
|
||||
_ <- gitCommit (T.pack storeDir) ("Add " <> photo.name)
|
||||
|
||||
pure digest
|
||||
Nothing -> throw UnknownCreationTime
|
||||
|
3
overlays/photo-hs/lib/Commands/Annex.hs
Normal file
3
overlays/photo-hs/lib/Commands/Annex.hs
Normal file
|
@ -0,0 +1,3 @@
|
|||
module Commands.Annex
|
||||
(
|
||||
) where
|
4
overlays/photo-hs/lib/Commands/Debug.hs
Normal file
4
overlays/photo-hs/lib/Commands/Debug.hs
Normal file
|
@ -0,0 +1,4 @@
|
|||
module Commands.Debug
|
||||
(
|
||||
) where
|
||||
|
38
overlays/photo-hs/lib/Commands/EditMeta.hs
Normal file
38
overlays/photo-hs/lib/Commands/EditMeta.hs
Normal file
|
@ -0,0 +1,38 @@
|
|||
module Commands.EditMeta
|
||||
( commandEditMeta
|
||||
) where
|
||||
import Control.Monad.IO.Class (MonadIO)
|
||||
import Database.Persist.Monad.Class (MonadSqlQuery)
|
||||
import qualified Options as O
|
||||
import qualified Photo as P
|
||||
import Percept.Operations (readMetadataFile, saveMetadataFile, cachePhoto, inStorePathForPhoto, inStorePathForMetadata)
|
||||
import AppData (AppData, askStoreDir)
|
||||
import Control.Monad.Reader.Class (MonadReader)
|
||||
import qualified Data.List as L
|
||||
import Git (gitAdd, gitCommit)
|
||||
import qualified Data.Text as T
|
||||
import Data.Functor ((<&>))
|
||||
import Percept.Util (handleGitError)
|
||||
|
||||
commandEditMeta :: (MonadIO m, MonadReader AppData m, MonadSqlQuery m) => P.Hash -> O.EditOperation -> m P.Photo
|
||||
commandEditMeta hash editOp = do
|
||||
photo <- readMetadataFile hash
|
||||
|
||||
let
|
||||
newPhoto =
|
||||
case editOp of
|
||||
O.AddTags tags -> photo { P.tags = photo.tags `L.union` tags }
|
||||
O.RemoveTags tags -> photo { P.tags = photo.tags L.\\ tags }
|
||||
O.ClearTags -> photo { P.tags = [] }
|
||||
O.SetTags tags -> photo { P.tags = tags }
|
||||
O.SetName name -> photo { P.name = name }
|
||||
O.SetDescription description -> photo { P.description = description }
|
||||
|
||||
saveMetadataFile newPhoto
|
||||
cachePhoto newPhoto
|
||||
|
||||
storeDir <- askStoreDir <&> T.pack
|
||||
inStorePathForMetadata photo.hash >>= gitAdd storeDir . T.pack >>= handleGitError
|
||||
gitCommit storeDir ("Edit metadata of " <> photo.name ) >>= handleGitError
|
||||
|
||||
pure newPhoto
|
3
overlays/photo-hs/lib/Commands/Git.hs
Normal file
3
overlays/photo-hs/lib/Commands/Git.hs
Normal file
|
@ -0,0 +1,3 @@
|
|||
module Commands.Git
|
||||
(
|
||||
) where
|
39
overlays/photo-hs/lib/Commands/Init.hs
Normal file
39
overlays/photo-hs/lib/Commands/Init.hs
Normal file
|
@ -0,0 +1,39 @@
|
|||
module Commands.Init
|
||||
( commandInit
|
||||
) where
|
||||
import Control.Monad.IO.Class (MonadIO (..))
|
||||
import Options (Options (..))
|
||||
import Constants qualified
|
||||
import qualified Data.Text as T
|
||||
import System.FilePath ((</>))
|
||||
import System.Process.Typed (ExitCode (..))
|
||||
import qualified System.Directory as D
|
||||
import Percept.Util (handleExitCode, (<$/>), handleGitError)
|
||||
import Control.Exception (throw, bracket)
|
||||
import Percept.Error (PhotoException(..))
|
||||
import Git.Annex (gitAnnexInit)
|
||||
import Git (gitInit, gitAdd, gitCommit)
|
||||
import qualified Database.Sqlite as SqlLite
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text.IO as T
|
||||
|
||||
commandInit :: (MonadIO m) => Options -> Text -> m ()
|
||||
commandInit opts photoDir = do
|
||||
let
|
||||
cmd = ("git", ["init"])
|
||||
storePath = photoDir <$/> Constants.storeDirectory
|
||||
dryRun = opts.dryRun
|
||||
|
||||
if not dryRun then do
|
||||
liftIO $ D.createDirectoryIfMissing True (T.unpack photoDir)
|
||||
gitInit (T.unpack storePath) >>= handleGitError
|
||||
gitAnnexInit (T.unpack storePath)
|
||||
|
||||
-- initialize database
|
||||
liftIO $ bracket (SqlLite.open (storePath <$/> Constants.sqlFile)) SqlLite.close (const $ pure ())
|
||||
|
||||
liftIO $ T.writeFile (T.unpack $ storePath <$/> Constants.gitignoreFile) Constants.gitignoreText
|
||||
gitAdd storePath Constants.gitignoreFile >>= handleGitError
|
||||
gitCommit storePath "Initial commit" >>= handleGitError
|
||||
else
|
||||
liftIO . putStrLn $ "Would execute `" ++ unwords (uncurry (:) cmd) ++ "`"
|
81
overlays/photo-hs/lib/Commands/List.hs
Normal file
81
overlays/photo-hs/lib/Commands/List.hs
Normal file
|
@ -0,0 +1,81 @@
|
|||
module Commands.List
|
||||
( commandList
|
||||
) where
|
||||
|
||||
import Control.Monad.IO.Class (MonadIO, liftIO)
|
||||
import qualified Photo as P
|
||||
import qualified Options as O
|
||||
import Database.Esqueleto.Experimental (where_, (^.), (==.), table, from, Entity (..), val, Value (..), SqlExpr, SqlString, notExists, valList, (&&.), notIn, with, (/=.), except_, (!=.), selectQuery, SqlQuery, in_, distinct, union_, toSqlSetOperation, exists)
|
||||
import Database.Esqueleto.Experimental.Monad (select)
|
||||
import Database.Esqueleto.Internal.Internal (unsafeSqlBinOp, unsafeSqlFunctionParens)
|
||||
import qualified Schema as S
|
||||
import Database.Persist.Monad.Class (MonadSqlQuery)
|
||||
import Data.Text (Text)
|
||||
import Control.Monad (void, forM)
|
||||
|
||||
-- | @LIKE@ operator.
|
||||
regexp :: SqlString s => SqlExpr (Value s) -> SqlExpr (Value s) -> SqlExpr (Value Bool)
|
||||
regexp a b = unsafeSqlFunctionParens "REGEXP" (a, b)
|
||||
|
||||
commandList :: (MonadIO m, MonadSqlQuery m) => O.Filter -> m [P.Photo]
|
||||
commandList filter = do
|
||||
photos <- select do
|
||||
photos <- from $ table @S.Photo
|
||||
case filter.name of
|
||||
Just name ->
|
||||
if filter.useRegex then
|
||||
where_ (photos ^. S.PhotoName `regexp` val name)
|
||||
else
|
||||
where_ (photos ^. S.PhotoName ==. val name)
|
||||
Nothing -> pure ()
|
||||
|
||||
case filter.tags of
|
||||
Just (O.TagsAll (tag:tags)) ->
|
||||
where_ $ notExists $ void $ from $
|
||||
( let
|
||||
tagToSet = toSqlSetOperation . pure @(SqlQuery) . val . P.unTag
|
||||
in
|
||||
foldr union_ (tagToSet tag) (map tagToSet tags)
|
||||
)
|
||||
`except_`
|
||||
(do
|
||||
taggedPhotos <- from $ table @S.TaggedPhoto
|
||||
|
||||
where_ ( taggedPhotos ^. S.TaggedPhotoPhotoHash ==. photos ^. S.PhotoHash
|
||||
&&. taggedPhotos ^. S.TaggedPhotoTagText `in_` (valList $ map P.unTag (tag:tags))
|
||||
)
|
||||
|
||||
pure (taggedPhotos ^. S.TaggedPhotoTagText)
|
||||
)
|
||||
|
||||
Just (O.TagsSome tags) ->
|
||||
where_ $ exists $ void $ from $
|
||||
do
|
||||
taggedPhotos <- from $ table @S.TaggedPhoto
|
||||
|
||||
where_ ( taggedPhotos ^. S.TaggedPhotoPhotoHash ==. photos ^. S.PhotoHash
|
||||
&&. taggedPhotos ^. S.TaggedPhotoTagText `in_` (valList $ map P.unTag tags)
|
||||
)
|
||||
|
||||
pure (val (1 :: Int))
|
||||
Just (O.TagsAll []) -> pure ()
|
||||
Nothing -> pure ()
|
||||
|
||||
pure photos
|
||||
|
||||
forM photos \(Entity { entityVal }) -> do
|
||||
tags <- map (P.Tag . unValue) <$> select do
|
||||
taggedPhotos <- from $ table @S.TaggedPhoto
|
||||
|
||||
where_ ( taggedPhotos ^. S.TaggedPhotoPhotoHash ==. val entityVal.hash )
|
||||
|
||||
pure ( taggedPhotos ^. S.TaggedPhotoTagText )
|
||||
pure
|
||||
$ P.Photo
|
||||
{ hash = entityVal.hash
|
||||
, name = entityVal.name
|
||||
, date = entityVal.date
|
||||
, description = entityVal.description
|
||||
, imageType = entityVal.imageType
|
||||
, tags = tags
|
||||
}
|
30
overlays/photo-hs/lib/Constants.hs
Normal file
30
overlays/photo-hs/lib/Constants.hs
Normal file
|
@ -0,0 +1,30 @@
|
|||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Constants
|
||||
( sqlFile
|
||||
, storeDirectory
|
||||
, gitignoreFile
|
||||
, gitignoreText
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Percept.Util ((<$/>))
|
||||
|
||||
sqlFile :: Text
|
||||
sqlFile = "photos.sqlite3"
|
||||
|
||||
storeDirectory :: Text
|
||||
storeDirectory = ".store"
|
||||
|
||||
gitignoreFile :: Text
|
||||
gitignoreFile = ".gitignore"
|
||||
|
||||
gitignoreText :: Text
|
||||
gitignoreText
|
||||
= T.unlines
|
||||
[ sqlFile
|
||||
, sqlFile <> "-shm"
|
||||
, sqlFile <> "-wal"
|
||||
]
|
|
@ -0,0 +1,93 @@
|
|||
module Database.Esqueleto.Experimental.Monad
|
||||
( select
|
||||
, insertMany_
|
||||
, insert
|
||||
, insert_
|
||||
, insertUnique
|
||||
, upsert
|
||||
, repsert
|
||||
, repsertMany
|
||||
, delete
|
||||
) where
|
||||
|
||||
import Database.Persist.Monad.Class (MonadSqlQuery)
|
||||
import qualified Database.Esqueleto.Experimental as E
|
||||
import Database.Esqueleto.Internal.Internal qualified as E (SqlSelect, Update)
|
||||
import Database.Persist.Monad (unsafeLiftSql)
|
||||
import Database.Persist.Class.PersistEntity (Update)
|
||||
|
||||
select :: (MonadSqlQuery m, E.SqlSelect a r) => E.SqlQuery a -> m [r]
|
||||
select q = unsafeLiftSql "esqueleto-select" (E.select q)
|
||||
|
||||
type InsertConstraints record m
|
||||
= ( MonadSqlQuery m
|
||||
, E.PersistRecordBackend record E.SqlBackend
|
||||
, E.SafeToInsert record
|
||||
)
|
||||
|
||||
insertMany_
|
||||
:: forall record m
|
||||
. InsertConstraints record m
|
||||
=> [record]
|
||||
-> m ()
|
||||
insertMany_ entities = unsafeLiftSql "esqueleto-insert-many_" (E.insertMany_ entities)
|
||||
|
||||
insert
|
||||
:: forall record m
|
||||
. InsertConstraints record m
|
||||
=> record
|
||||
-> m (E.Key record)
|
||||
insert entity = unsafeLiftSql "esqueleto-insert" (E.insert entity)
|
||||
|
||||
insert_
|
||||
:: forall record m
|
||||
. InsertConstraints record m
|
||||
=> record
|
||||
-> m ()
|
||||
insert_ entity = unsafeLiftSql "esqueleto-insert_" (E.insert_ entity)
|
||||
|
||||
insertUnique
|
||||
:: forall record m
|
||||
. InsertConstraints record m
|
||||
=> record
|
||||
-> m (Maybe (E.Key record))
|
||||
insertUnique entity = unsafeLiftSql "esqueleto-insert-unique" (E.insertUnique entity)
|
||||
|
||||
repsert
|
||||
:: forall record m
|
||||
. ( MonadSqlQuery m
|
||||
, E.PersistRecordBackend record E.SqlBackend
|
||||
)
|
||||
=> E.Key record
|
||||
-> record
|
||||
-> m ()
|
||||
repsert key entity = unsafeLiftSql "esqueleto-repsert" (E.repsert key entity)
|
||||
|
||||
upsert
|
||||
:: forall record m
|
||||
. ( MonadSqlQuery m
|
||||
, E.PersistRecordBackend record E.SqlBackend
|
||||
, E.OnlyOneUniqueKey record
|
||||
, E.SafeToInsert record
|
||||
)
|
||||
=> record
|
||||
-> [Update record]
|
||||
-> m (E.Entity record)
|
||||
upsert entity updates = unsafeLiftSql "esqueleto-upsert" (E.upsert entity updates)
|
||||
|
||||
repsertMany
|
||||
:: forall record m
|
||||
. ( MonadSqlQuery m
|
||||
, E.PersistRecordBackend record E.SqlBackend
|
||||
)
|
||||
=> [(E.Key record, record)]
|
||||
-> m ()
|
||||
repsertMany entities = unsafeLiftSql "esqueleto-repsert-many" (E.repsertMany entities)
|
||||
|
||||
delete
|
||||
:: forall record m
|
||||
. ( MonadSqlQuery m
|
||||
)
|
||||
=> E.SqlQuery ()
|
||||
-> m ()
|
||||
delete query = unsafeLiftSql "esqueleto-repsert-many" (E.delete query)
|
41
overlays/photo-hs/lib/Exif/Tool.hs
Normal file
41
overlays/photo-hs/lib/Exif/Tool.hs
Normal file
|
@ -0,0 +1,41 @@
|
|||
module Exif.Tool
|
||||
( exiftoolWrite
|
||||
, exiftoolRead
|
||||
) where
|
||||
import Control.Monad.IO.Class (MonadIO (..))
|
||||
import Data.Text (Text)
|
||||
import Data.HashMap.Strict (HashMap)
|
||||
import Control.Monad.Logger (MonadLogger, logWarnN)
|
||||
import GHC.IO.Exception (ExitCode(..))
|
||||
import System.Process.Typed (readProcess, runProcess, proc)
|
||||
import Data.Text qualified as T
|
||||
import Data.HashMap.Strict qualified as HM
|
||||
import Data.Aeson qualified as A
|
||||
import Data.Functor ((<&>))
|
||||
|
||||
exiftoolWrite :: (MonadIO m) => FilePath -> HashMap Text Text -> m ()
|
||||
exiftoolWrite imagePath fields = do
|
||||
let
|
||||
toFlag (field, value) = "-" <> field <> "=" <> value <> ""
|
||||
flags = map toFlag . filter ((/= "SourceFile") . fst) $ HM.toList fields
|
||||
|
||||
pc = proc "/nix/store/6scrhdz42mq882wnlz71rz8yq1a5h6gk-perl5.38.2-Image-ExifTool-12.84/bin/exiftool" (["-j", imagePath] ++ map T.unpack flags)
|
||||
|
||||
liftIO $ print flags
|
||||
runProcess pc
|
||||
pure ()
|
||||
|
||||
exiftoolRead :: (MonadIO m, MonadLogger m) => FilePath -> [Text] -> m (Maybe (HashMap Text Text))
|
||||
exiftoolRead imagePath fields =
|
||||
let
|
||||
toFlag field = "-" <> field
|
||||
flags = map toFlag fields
|
||||
|
||||
pc = proc "/nix/store/6scrhdz42mq882wnlz71rz8yq1a5h6gk-perl5.38.2-Image-ExifTool-12.84/bin/exiftool" (["-j", imagePath] ++ map T.unpack flags)
|
||||
in
|
||||
readProcess pc >>= \case
|
||||
(ExitFailure exitCode, _, _stderr) -> do
|
||||
logWarnN ("exiftool exited with exit code: " <> T.pack (show exitCode) <> "\n")
|
||||
pure Nothing
|
||||
(ExitSuccess, stdout, _) -> do
|
||||
pure $ A.decode stdout <&> head
|
44
overlays/photo-hs/lib/Git.hs
Normal file
44
overlays/photo-hs/lib/Git.hs
Normal file
|
@ -0,0 +1,44 @@
|
|||
module Git (gitCommit, gitInit, gitAdd) where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Control.Monad.IO.Class (MonadIO, liftIO)
|
||||
import System.Process.Typed (proc, runProcess, ExitCode, setWorkingDir)
|
||||
import qualified Data.Text as T
|
||||
import Data.Function ((&))
|
||||
import qualified System.Directory as D
|
||||
|
||||
gitCommit
|
||||
:: ( MonadIO m
|
||||
)
|
||||
=> Text
|
||||
-> Text
|
||||
-> m ExitCode
|
||||
gitCommit repoPath message = runProcess pc
|
||||
where pc = proc "git" ["commit", "-m", T.unpack message]
|
||||
& setWorkingDir (T.unpack repoPath)
|
||||
|
||||
gitInit
|
||||
:: ( MonadIO m
|
||||
)
|
||||
=> FilePath
|
||||
-> m ExitCode
|
||||
gitInit repoPath = do
|
||||
let
|
||||
pc = proc "git" ["init"]
|
||||
& setWorkingDir repoPath
|
||||
|
||||
liftIO $ D.createDirectoryIfMissing True repoPath
|
||||
runProcess pc
|
||||
|
||||
gitAdd
|
||||
:: ( MonadIO m
|
||||
)
|
||||
=> Text
|
||||
-> Text
|
||||
-> m ExitCode
|
||||
gitAdd repoPath filePath = do
|
||||
let
|
||||
pc = proc "git" ["add", T.unpack filePath]
|
||||
& setWorkingDir (T.unpack repoPath)
|
||||
|
||||
runProcess pc
|
30
overlays/photo-hs/lib/Git/Annex.hs
Normal file
30
overlays/photo-hs/lib/Git/Annex.hs
Normal file
|
@ -0,0 +1,30 @@
|
|||
module Git.Annex
|
||||
( gitAnnexAdd
|
||||
, gitAnnexInit
|
||||
) where
|
||||
|
||||
import Control.Monad.IO.Class (MonadIO)
|
||||
import System.Process.Typed (proc, runProcess, ExitCode (..), setWorkingDir)
|
||||
import Data.Function ((&))
|
||||
|
||||
gitAnnexAdd :: (MonadIO m) => FilePath -> FilePath -> m ()
|
||||
gitAnnexAdd repositoryPath filePath = do
|
||||
let
|
||||
pc
|
||||
= proc "git" [ "annex", "add", filePath ]
|
||||
& setWorkingDir repositoryPath
|
||||
|
||||
runProcess pc >>= \case
|
||||
ExitSuccess -> pure ()
|
||||
ExitFailure code -> undefined
|
||||
|
||||
gitAnnexInit :: (MonadIO m) => FilePath -> m ()
|
||||
gitAnnexInit repositoryPath = do
|
||||
let
|
||||
pc
|
||||
= proc "git" [ "annex", "init" ]
|
||||
& setWorkingDir repositoryPath
|
||||
|
||||
runProcess pc >>= \case
|
||||
ExitSuccess -> pure ()
|
||||
ExitFailure code -> undefined
|
148
overlays/photo-hs/lib/MyLib.hs
Normal file
148
overlays/photo-hs/lib/MyLib.hs
Normal file
|
@ -0,0 +1,148 @@
|
|||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE UndecidableInstances #-}
|
||||
|
||||
module MyLib (libMain) where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Control.Monad.IO.Class (MonadIO (liftIO))
|
||||
import System.Process.Typed
|
||||
import Data.Function ((&))
|
||||
import System.FilePath ((</>))
|
||||
import Control.Exception (throw, SomeException)
|
||||
import Options.Applicative
|
||||
import Options.Applicative qualified as OA
|
||||
import Data.Functor ((<&>))
|
||||
import Control.Monad.Reader (MonadReader (..), ReaderT (..), asks)
|
||||
import Database.Persist.Sqlite (withSqlitePool)
|
||||
import Data.Text qualified as T
|
||||
import Control.Monad.Logger (LoggingT, MonadLogger (..), runStdoutLoggingT)
|
||||
import UnliftIO (MonadUnliftIO, catch)
|
||||
import qualified System.Directory as D
|
||||
import qualified Constants
|
||||
import Control.Monad.Extra (findM)
|
||||
import Options (Command (..), Options (..), SomeCommand (..), parseOptions)
|
||||
import AppData (AppData (..), askStoreDir)
|
||||
import Commands (commandAddPhoto, commandInit, commandList, commandEditMeta)
|
||||
import Percept.Error (PhotoException(..))
|
||||
import Percept.Util (handleExitCode, (<$/>))
|
||||
import Database.Persist.Monad (SqlQueryT, runSqlQueryT, MonadSqlQuery, runMigration)
|
||||
import qualified Schema as S
|
||||
|
||||
executeCommand :: (MonadIO m, MonadLogger m, MonadSqlQuery m, MonadReader AppData m) => Command a -> m ()
|
||||
executeCommand = \case
|
||||
Init storeDir -> do
|
||||
appData <- asks (\s -> s.options)
|
||||
commandInit appData storeDir
|
||||
|
||||
AddPhoto filePath name tags creationTime description -> do
|
||||
_ <- commandAddPhoto filePath name tags creationTime description
|
||||
pure ()
|
||||
|
||||
List filter -> do
|
||||
photos <- commandList filter
|
||||
liftIO $ print photos
|
||||
|
||||
EditMeta hash editOp -> do
|
||||
_ <- commandEditMeta hash editOp
|
||||
pure ()
|
||||
|
||||
Debug -> do
|
||||
commandDebug
|
||||
|
||||
Annex arguments -> do
|
||||
commandAnnex arguments
|
||||
|
||||
Git arguments -> do
|
||||
commandGit arguments
|
||||
|
||||
commandDebug :: (MonadIO m, MonadReader AppData m) => m ()
|
||||
commandDebug = do
|
||||
photoDir <- asks (\s -> s.photoDir)
|
||||
liftIO $ putStrLn ("photo directory: " ++ photoDir)
|
||||
pure ()
|
||||
|
||||
commandPassthrough :: (MonadIO m, MonadReader AppData m) => Text -> [Text] -> [Text] -> m ()
|
||||
commandPassthrough command extraArgs arguments = do
|
||||
storeDir <- askStoreDir
|
||||
let
|
||||
pc
|
||||
= proc (T.unpack command) (map T.unpack extraArgs ++ map T.unpack arguments)
|
||||
& setWorkingDir storeDir
|
||||
|
||||
runProcess pc >>= handleExitCode (throw . GitInitFailed . ExitFailure) ()
|
||||
|
||||
commandAnnex :: (MonadIO m, MonadReader AppData m) => [Text] -> m ()
|
||||
commandAnnex = commandPassthrough "git" ["annex"]
|
||||
|
||||
commandGit :: (MonadIO m, MonadReader AppData m) => [Text] -> m ()
|
||||
commandGit = commandPassthrough "git" []
|
||||
|
||||
newtype AppM m a = AppM (SqlQueryT (ReaderT AppData (LoggingT m)) a)
|
||||
deriving newtype (Functor, Applicative, Monad, MonadIO, MonadSqlQuery)
|
||||
|
||||
unAppM :: AppM m a -> SqlQueryT (ReaderT AppData (LoggingT m)) a
|
||||
unAppM (AppM inner) = inner
|
||||
|
||||
instance Monad m => MonadReader AppData (AppM m) where
|
||||
ask = AppM ask
|
||||
local f (AppM m) = AppM (local f m)
|
||||
|
||||
instance (MonadIO m) => MonadLogger (AppM m) where
|
||||
monadLoggerLog loc logSource logLevel msg = AppM $ monadLoggerLog loc logSource logLevel msg
|
||||
|
||||
increments :: [FilePath] -> [[FilePath]]
|
||||
increments path = map reverse $ increments' (reverse path)
|
||||
|
||||
increments' :: [FilePath] -> [[FilePath]]
|
||||
increments' [] = [[]]
|
||||
increments' path = path : increments' (tail path)
|
||||
|
||||
split :: (Char -> Bool) -> String -> [String]
|
||||
split delim string = T.pack string & T.split delim & map T.unpack -- ugly hack
|
||||
|
||||
join :: Char -> [String] -> String
|
||||
join _ [] = ""
|
||||
join _ [x] = x
|
||||
join delim (x:xs) = x ++ [delim] ++ join delim xs
|
||||
|
||||
|
||||
detectPhotoDirectory :: (MonadIO m) => m (Maybe FilePath)
|
||||
detectPhotoDirectory = do
|
||||
cwd <- liftIO D.getCurrentDirectory <&> filter (/="") . split (=='/')
|
||||
let parentDirectories = increments cwd & map (('/' :) . join '/')
|
||||
|
||||
flip findM parentDirectories \directory ->
|
||||
liftIO $ D.doesDirectoryExist (directory </> T.unpack Constants.storeDirectory)
|
||||
|
||||
runAppM :: (Monad m, MonadIO m, MonadUnliftIO m) => Options -> AppM m a -> m a
|
||||
runAppM opts appM = do
|
||||
detectPhotoDirectory >>= \case
|
||||
Just photoDir -> do
|
||||
let
|
||||
sqlPath = T.pack photoDir <$/> Constants.storeDirectory <$/> Constants.sqlFile
|
||||
appData
|
||||
= AppData
|
||||
{ options = opts
|
||||
, photoDir = photoDir
|
||||
}
|
||||
|
||||
runStdoutLoggingT $ withSqlitePool sqlPath 1 \pool -> flip runReaderT appData . runSqlQueryT pool. unAppM $ do
|
||||
runMigration S.migrateAll
|
||||
appM
|
||||
Nothing -> throw PhotoDirNotFound
|
||||
|
||||
libMain :: IO ()
|
||||
libMain = do
|
||||
opts <- OA.execParser (info (parseOptions <**> helper)
|
||||
( fullDesc
|
||||
<> progDesc "Photo management in Haskell"
|
||||
<> header "photo-hs - photo manager"
|
||||
)
|
||||
)
|
||||
print opts
|
||||
|
||||
(\(SomeCommand cmd) ->
|
||||
case cmd of
|
||||
Init photoDir -> commandInit opts photoDir
|
||||
_ -> runAppM opts $ executeCommand cmd >> pure ()) opts.command
|
||||
pure ()
|
264
overlays/photo-hs/lib/Options.hs
Normal file
264
overlays/photo-hs/lib/Options.hs
Normal file
|
@ -0,0 +1,264 @@
|
|||
{-# LANGUAGE GADTs #-}
|
||||
|
||||
module Options
|
||||
( Command(..)
|
||||
, Filter(..)
|
||||
, TagFilter(..)
|
||||
, EditOperation(..)
|
||||
, SomeCommand(..)
|
||||
, Options(..)
|
||||
, parseOptions
|
||||
) where
|
||||
import Data.Text (Text)
|
||||
import Data.Time (ZonedTime)
|
||||
import Crypto.Hash (Digest, SHA256)
|
||||
import Options.Applicative
|
||||
import Options.Applicative qualified as OA
|
||||
import Data.Functor ((<&>))
|
||||
import qualified Data.Text as T
|
||||
import qualified Photo as P
|
||||
|
||||
data TagFilter
|
||||
= TagsAll [P.Tag]
|
||||
| TagsSome [P.Tag]
|
||||
deriving Show
|
||||
|
||||
data Filter
|
||||
= Filter
|
||||
{ name :: Maybe Text
|
||||
, tags :: Maybe TagFilter
|
||||
, useRegex :: Bool
|
||||
}
|
||||
deriving Show
|
||||
|
||||
data EditOperation
|
||||
= AddTags [P.Tag]
|
||||
| RemoveTags [P.Tag]
|
||||
| ClearTags
|
||||
| SetTags [P.Tag]
|
||||
| SetName Text
|
||||
| SetDescription Text
|
||||
deriving Show
|
||||
|
||||
data Command a where
|
||||
Init ::
|
||||
Text
|
||||
-- ^ path to new directory to create and initialize
|
||||
-> Command ()
|
||||
AddPhoto
|
||||
:: FilePath
|
||||
-- ^ path to image file
|
||||
-> Maybe Text
|
||||
-- ^ overriding name
|
||||
-> [P.Tag]
|
||||
-- ^ list of tags
|
||||
-> Maybe ZonedTime
|
||||
-- ^ creation time
|
||||
-> Maybe Text
|
||||
-- ^ description
|
||||
-> Command (Digest SHA256)
|
||||
List
|
||||
:: Filter
|
||||
-- ^ filter for the listing
|
||||
-> Command [P.Photo]
|
||||
EditMeta
|
||||
:: P.Hash
|
||||
-- ^ photo which to edit
|
||||
-> EditOperation
|
||||
-- ^ operation to carry out
|
||||
-> Command P.Photo
|
||||
Annex
|
||||
:: [Text]
|
||||
-> Command ()
|
||||
Git
|
||||
:: [Text]
|
||||
-> Command ()
|
||||
Debug
|
||||
:: Command ()
|
||||
|
||||
makePrettyList :: [T.Text] -> T.Text
|
||||
makePrettyList list = T.intercalate ", " (map (\mid -> "\"" <> mid <> "\"") list)
|
||||
|
||||
instance Show (Command a) where
|
||||
show :: Command a -> String
|
||||
show (Init photoDir) = "Init { storeDir = " ++ show photoDir ++ " }"
|
||||
show (AddPhoto photoPath name tags creationTime description)
|
||||
= "AddPhoto { "
|
||||
++ "photoPath = " ++ show photoPath
|
||||
++ ", name = " ++ show name
|
||||
++ ", tags = " ++ show tags
|
||||
++ ", creationTime = " ++ show creationTime
|
||||
++ ", description = " ++ show description
|
||||
++ " }"
|
||||
show (List filter) = "Lift { filter = " ++ show filter ++ " }"
|
||||
show (EditMeta hash operation) = "EditMeta { hash = " ++ show hash ++ ", operation = " ++ show operation ++ " }"
|
||||
show (Annex commands) = "Annex { commands = [" ++ T.unpack (makePrettyList commands) ++ "] }"
|
||||
show (Git commands) = "Git { commands = [" ++ T.unpack (makePrettyList commands) ++ "] }"
|
||||
show Debug = "Debug"
|
||||
|
||||
data SomeCommand where
|
||||
SomeCommand :: forall a . Command a -> SomeCommand
|
||||
|
||||
instance Show SomeCommand where
|
||||
show :: SomeCommand -> String
|
||||
show (SomeCommand cmd) = show cmd
|
||||
|
||||
data Options
|
||||
= Options
|
||||
{ dryRun :: Bool
|
||||
, verbose :: Bool
|
||||
, command :: SomeCommand
|
||||
}
|
||||
deriving Show
|
||||
|
||||
parseCommandInit :: Parser (Command ())
|
||||
parseCommandInit = Init <$> argument str (metavar "STORE_DIR")
|
||||
|
||||
parseCommandAddPhoto :: Parser (Command (Digest SHA256))
|
||||
parseCommandAddPhoto
|
||||
= AddPhoto
|
||||
<$> argument str (metavar "PHOTO")
|
||||
<*> optional (option str
|
||||
( long "name"
|
||||
<> short 'n'
|
||||
))
|
||||
<*> many
|
||||
(option str
|
||||
( long "tag"
|
||||
<> short 't'
|
||||
))
|
||||
<*> optional (option auto
|
||||
( long "creation-time"
|
||||
<> short 'C'
|
||||
<> help "Substitute creation time if missing, format: 2024-06-26 18:21:30 +0200"
|
||||
))
|
||||
<*> optional (option str
|
||||
( long "description"
|
||||
<> short 'D'
|
||||
))
|
||||
|
||||
parseCommandList :: Parser (Command [P.Photo])
|
||||
parseCommandList
|
||||
= List
|
||||
<$> ( Filter
|
||||
<$> optional (option str
|
||||
( long "name"
|
||||
<> short 'n'
|
||||
)
|
||||
)
|
||||
<*> optional (parseTagFilterAll <|> parseTagFilterSome)
|
||||
<*> switch
|
||||
( long "regex"
|
||||
<> short 'r'
|
||||
)
|
||||
)
|
||||
where
|
||||
parseTagFilterAll :: Parser TagFilter
|
||||
parseTagFilterAll = flag' TagsAll
|
||||
( long "all"
|
||||
<> help "All of the specified tags have to match" ) <*> many (option str ( long "tag" <> short 't'))
|
||||
|
||||
|
||||
parseTagFilterSome :: Parser TagFilter
|
||||
parseTagFilterSome = flag' TagsSome
|
||||
( long "some"
|
||||
<> help "Only some of the specified tags have to match" ) <*> many (option str ( long "tag" <> short 't'))
|
||||
|
||||
parseCommandEditMeta :: Parser (Command P.Photo)
|
||||
parseCommandEditMeta
|
||||
= EditMeta
|
||||
<$> argument str (metavar "HASH")
|
||||
<*> ( parseAddTags
|
||||
<|> parseRemoveTags
|
||||
<|> parseClearTags
|
||||
<|> parseSetTags
|
||||
<|> parseSetName
|
||||
<|> parseSetDescription
|
||||
)
|
||||
where
|
||||
parseAddTags :: Parser EditOperation
|
||||
parseAddTags
|
||||
= flag' AddTags
|
||||
( long "add-tags"
|
||||
<> help "Add the specified tags" )
|
||||
<*> many (option str
|
||||
( long "tag"
|
||||
<> short 't'
|
||||
) )
|
||||
parseRemoveTags :: Parser EditOperation
|
||||
parseRemoveTags
|
||||
= flag' RemoveTags
|
||||
( long "remove-tags"
|
||||
<> help "Removes the specified tags" )
|
||||
<*> many (option str
|
||||
( long "tag"
|
||||
<> short 't'
|
||||
) )
|
||||
parseClearTags :: Parser EditOperation
|
||||
parseClearTags
|
||||
= flag' ClearTags
|
||||
( long "clear-tags"
|
||||
<> help "Clears all existing tags" )
|
||||
parseSetTags :: Parser EditOperation
|
||||
parseSetTags
|
||||
= flag' SetTags
|
||||
( long "set-tags"
|
||||
<> help "Overrides existing tags with the specified tags" )
|
||||
<*> many (option str
|
||||
( long "tag"
|
||||
<> short 't'
|
||||
) )
|
||||
parseSetName :: Parser EditOperation
|
||||
parseSetName
|
||||
= SetName
|
||||
<$> option str
|
||||
( long "set-name"
|
||||
<> help "Overrides the existing name withe the specified one" )
|
||||
parseSetDescription :: Parser EditOperation
|
||||
parseSetDescription
|
||||
= SetDescription
|
||||
<$> option str
|
||||
( long "set-description"
|
||||
<> help "Overrides the existing description with the specified one" )
|
||||
|
||||
parseCommandDebug :: Parser (Command ())
|
||||
parseCommandDebug = pure Debug
|
||||
|
||||
parseCommandAnnex :: Parser (Command ())
|
||||
parseCommandAnnex = Annex <$> many (argument str (metavar "REST"))
|
||||
|
||||
parseCommandGit :: Parser (Command ())
|
||||
parseCommandGit = Git <$> many (argument str (metavar "REST"))
|
||||
|
||||
parseCommand :: Parser SomeCommand
|
||||
parseCommand =
|
||||
subparser (
|
||||
OA.command "init" (info (parseCommandInit <**> helper <&> SomeCommand) (progDesc descInit))
|
||||
<> OA.command "addphoto" (info (parseCommandAddPhoto <**> helper <&> SomeCommand) (progDesc descAddPhoto))
|
||||
<> OA.command "list" (info (parseCommandList <**> helper <&> SomeCommand) (progDesc descList))
|
||||
<> OA.command "editmeta" (info (parseCommandEditMeta <**> helper <&> SomeCommand) (progDesc descEditMeta))
|
||||
<> OA.command "debug" (info (parseCommandDebug <**> helper <&> SomeCommand) (progDesc descDebug))
|
||||
<> OA.command "annex" (info (parseCommandAnnex <**> helper <&> SomeCommand) (progDesc descAnnex))
|
||||
<> OA.command "git" (info (parseCommandGit <**> helper <&> SomeCommand) (progDesc descGit))
|
||||
)
|
||||
|
||||
where
|
||||
descInit = "Init a new photo repository"
|
||||
descList = "List all photos in the store, optionally according to a filter"
|
||||
descEditMeta = "Edit the metadata of an existing photo"
|
||||
descAddPhoto = "Add a photo to the database"
|
||||
descDebug = "Debug command"
|
||||
descAnnex = "Passes through all arguments to `git annex` in the store directory"
|
||||
descGit = "Passes through all arguments to `git` in the store directory"
|
||||
|
||||
parseOptions :: Parser Options
|
||||
parseOptions = Options
|
||||
<$> switch
|
||||
( long "dry-run"
|
||||
<> short 'n'
|
||||
)
|
||||
<*> switch
|
||||
( long "verbose"
|
||||
<> short 'v'
|
||||
)
|
||||
<*> parseCommand
|
28
overlays/photo-hs/lib/Percept/Error.hs
Normal file
28
overlays/photo-hs/lib/Percept/Error.hs
Normal file
|
@ -0,0 +1,28 @@
|
|||
module Percept.Error
|
||||
( PhotoException(..)
|
||||
) where
|
||||
|
||||
import System.Process.Typed (ExitCode)
|
||||
import GHC.Exception (Exception(..))
|
||||
import Photo qualified as P
|
||||
import qualified Toml
|
||||
|
||||
data PhotoException where
|
||||
GitInitFailed :: ExitCode -> PhotoException
|
||||
PhotoDirNotFound :: PhotoException
|
||||
UnknownCreationTime :: PhotoException
|
||||
InconsistentDatabaseState :: PhotoException
|
||||
PhotoAlreadyExists :: P.Photo -> PhotoException
|
||||
CorruptMetadata :: P.Hash -> [Toml.TomlDecodeError] -> PhotoException
|
||||
deriving (Show, Eq)
|
||||
|
||||
|
||||
instance Exception PhotoException where
|
||||
backtraceDesired _ = False
|
||||
displayException :: PhotoException -> String
|
||||
displayException (GitInitFailed exitCode) = "Failed to initialize new percept store, git failed with exit code: " ++ show exitCode
|
||||
displayException PhotoDirNotFound = "Couldn't find an initialize percept store"
|
||||
displayException UnknownCreationTime = "Couldn't read a photos creation time, specify it with `--creation-time`"
|
||||
displayException InconsistentDatabaseState = "The database has been found to be in an inconsistent state, recreate it using `rebuild-cache`"
|
||||
displayException (PhotoAlreadyExists (P.Photo { hash, name })) = "Photo with hash " ++ show hash ++ " already exists with name: " ++ show name
|
||||
displayException (CorruptMetadata hash errors) = "Failed to decode metadata file for hash " ++ show hash ++ ", got errors: " ++ show errors
|
119
overlays/photo-hs/lib/Percept/Operations.hs
Normal file
119
overlays/photo-hs/lib/Percept/Operations.hs
Normal file
|
@ -0,0 +1,119 @@
|
|||
module Percept.Operations
|
||||
( calculateImageDigest
|
||||
, readImageCreationUTCTime
|
||||
, saveMetadataFile
|
||||
, readMetadataFile
|
||||
, inStorePathForMetadata
|
||||
, inStorePathForPhoto
|
||||
, savePhotoFile
|
||||
, cachePhoto
|
||||
) where
|
||||
import Control.Monad.IO.Class (MonadIO, liftIO)
|
||||
import Control.Monad.Logger (MonadLogger)
|
||||
import Crypto.Hash (SHA256, Digest, hash)
|
||||
import Data.Time (ZonedTime, parseTimeM, defaultTimeLocale)
|
||||
import Control.Monad.Reader.Class (MonadReader)
|
||||
import AppData (AppData, askPhotoDir, askStoreDir)
|
||||
import qualified Photo as P
|
||||
import qualified Data.ByteString as BS
|
||||
import Data.Functor ((<&>))
|
||||
import Exif.Tool (exiftoolRead)
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Data.Text as T
|
||||
import qualified Toml
|
||||
import qualified Data.Text.IO as T
|
||||
import qualified Constants
|
||||
import qualified System.Directory as D
|
||||
import Database.Persist.Monad.Class (MonadSqlQuery)
|
||||
import qualified Schema as S
|
||||
import Control.Monad.Extra (forM_)
|
||||
import Database.Esqueleto.Experimental.Monad (insertUnique, insert_, insertMany_, repsert, upsert, insert, delete, select, repsertMany)
|
||||
import Database.Esqueleto.Experimental (toSqlKey, keyFromValues, keyFromRecordM, Key, PersistEntity, notExists, where_, from, (^.), (==.), table, val)
|
||||
import Schema (EntityField(PhotoHash))
|
||||
import Data.Maybe (fromJust)
|
||||
import Percept.Error (PhotoException(CorruptMetadata))
|
||||
import Control.Exception (throw)
|
||||
|
||||
calculateImageDigest :: (MonadIO m) => FilePath -> m (Digest SHA256)
|
||||
calculateImageDigest imagePath = liftIO $ BS.readFile imagePath <&> hash
|
||||
|
||||
readImageCreationUTCTime :: (MonadLogger m, MonadIO m) => FilePath -> m (Maybe ZonedTime)
|
||||
readImageCreationUTCTime imagePath = do
|
||||
hashmap <- exiftoolRead imagePath ["DateTimeOriginal", "OffsetTimeOriginal"]
|
||||
|
||||
case hashmap of
|
||||
Nothing -> pure Nothing
|
||||
Just hashmap' -> case (hashmap' HM.!? "DateTimeOriginal", hashmap' HM.!? "OffsetTimeOriginal") of
|
||||
(Nothing, _) -> pure Nothing
|
||||
(Just time, Just offset) ->
|
||||
pure . parseTimeM True defaultTimeLocale "%Y:%m:%d %H:%M:%S%z" . T.unpack $ (time <> offset)
|
||||
(Just time, Nothing) ->
|
||||
pure . parseTimeM True defaultTimeLocale "%Y:%m:%d %H:%M:%S" . T.unpack $ time
|
||||
|
||||
inStorePathForMetadata :: (MonadReader AppData m) => P.Hash -> m FilePath
|
||||
inStorePathForMetadata hash = askStoreDir <&> \storeDir ->
|
||||
storeDir <> "/" <> T.unpack (P.unHash hash) <> ".meta.toml"
|
||||
|
||||
saveMetadataFile :: (MonadReader AppData m, MonadIO m) => P.Photo -> m ()
|
||||
saveMetadataFile photo = do
|
||||
let
|
||||
content = Toml.encode P.photoCodec photo
|
||||
metadataPath <- inStorePathForMetadata photo.hash
|
||||
|
||||
liftIO $ T.writeFile metadataPath content
|
||||
|
||||
inStorePathForPhoto :: (MonadReader AppData m) => P.Photo -> m FilePath
|
||||
inStorePathForPhoto photo = askStoreDir <&> \storeDir ->
|
||||
storeDir <> "/" <> T.unpack (P.unHash photo.hash) <> "." <> T.unpack photo.imageType
|
||||
|
||||
readMetadataFile :: (MonadReader AppData m, MonadIO m) => P.Hash -> m P.Photo
|
||||
readMetadataFile hash = do
|
||||
metadataPath <- inStorePathForMetadata hash
|
||||
|
||||
Toml.decodeFileEither P.photoCodec metadataPath >>= \case
|
||||
Left errors -> throw (CorruptMetadata hash errors)
|
||||
Right photo -> pure photo
|
||||
|
||||
savePhotoFile :: (MonadReader AppData m, MonadIO m) => FilePath -> P.Photo -> m ()
|
||||
savePhotoFile photoPath photo = do
|
||||
inStorePhotoPath <- inStorePathForPhoto photo
|
||||
liftIO $ D.copyFile photoPath inStorePhotoPath
|
||||
|
||||
cachePhoto :: (MonadSqlQuery m) => P.Photo -> m ()
|
||||
cachePhoto photo = do
|
||||
let
|
||||
keyFromRecord :: (PersistEntity record) => record -> Key record
|
||||
keyFromRecord = fromJust keyFromRecordM
|
||||
schemaPhoto
|
||||
= S.Photo
|
||||
{ hash = photo.hash
|
||||
, name = photo.name
|
||||
, date = photo.date
|
||||
, imageType = photo.imageType
|
||||
, description = photo.description
|
||||
}
|
||||
|
||||
-- repsert all required tags
|
||||
repsertMany $ flip map photo.tags \(P.Tag tag) ->
|
||||
((keyFromRecord (S.Tag tag)), (S.Tag tag))
|
||||
|
||||
-- repsert the photo itself
|
||||
repsert (keyFromRecord schemaPhoto) schemaPhoto
|
||||
|
||||
-- delete all existing TaggedPhoto entities
|
||||
delete do
|
||||
taggedPhotos <- from $ table @S.TaggedPhoto
|
||||
where_ (taggedPhotos ^. S.TaggedPhotoPhotoHash ==. val photo.hash)
|
||||
|
||||
-- insert only the ones that are supposed to be there
|
||||
insertMany_ $ flip map photo.tags \(P.Tag tag) ->
|
||||
S.TaggedPhoto { tagText = tag, photoHash = photo.hash }
|
||||
|
||||
-- delete any non-referenced tag entities
|
||||
delete do
|
||||
tags <- from $ table @S.Tag
|
||||
where_ $ notExists $ do
|
||||
taggedPhotos <- from $ table @S.TaggedPhoto
|
||||
where_ (tags ^. S.TagText ==. taggedPhotos ^. S.TaggedPhotoTagText)
|
||||
pure ()
|
||||
pure ()
|
21
overlays/photo-hs/lib/Percept/Util.hs
Normal file
21
overlays/photo-hs/lib/Percept/Util.hs
Normal file
|
@ -0,0 +1,21 @@
|
|||
module Percept.Util
|
||||
( handleExitCode
|
||||
, handleGitError
|
||||
, (<$/>)
|
||||
) where
|
||||
|
||||
import System.Process.Typed (ExitCode (..))
|
||||
import Data.Text (Text)
|
||||
import Control.Exception (throw)
|
||||
import Percept.Error (PhotoException(..))
|
||||
|
||||
(<$/>) :: Text -> Text -> Text
|
||||
(<$/>) a b = a <> "/" <> b
|
||||
|
||||
handleExitCode :: (Monad m) => (Int -> m a) -> a -> ExitCode -> m a
|
||||
handleExitCode failureM success = \case
|
||||
ExitSuccess -> pure success
|
||||
ExitFailure code -> failureM code
|
||||
|
||||
handleGitError :: (Monad m) => ExitCode -> m ()
|
||||
handleGitError = handleExitCode (throw . GitInitFailed . ExitFailure) ()
|
78
overlays/photo-hs/lib/Photo.hs
Normal file
78
overlays/photo-hs/lib/Photo.hs
Normal file
|
@ -0,0 +1,78 @@
|
|||
module Photo
|
||||
( Photo(..)
|
||||
, photoCodec
|
||||
, Tag(..)
|
||||
, unTag
|
||||
, Hash(..)
|
||||
, unHash
|
||||
, getPhotoExtension
|
||||
) where
|
||||
|
||||
import Data.Functor ((<&>))
|
||||
import Data.Time (UTCTime, utc, utcToZonedTime, zonedTimeToUTC)
|
||||
import Data.Text (Text)
|
||||
import Data.Text qualified as T
|
||||
import Toml qualified
|
||||
import System.FilePath (takeExtension)
|
||||
import GHC.Generics (Generic)
|
||||
import Data.Aeson qualified as A
|
||||
import Database.Esqueleto.Experimental (PersistField, PersistFieldSql)
|
||||
import Web.HttpApiData (FromHttpApiData, ToHttpApiData)
|
||||
import Web.PathPieces (PathPiece)
|
||||
import Data.String (IsString)
|
||||
|
||||
newtype Tag = Tag Text
|
||||
deriving newtype (PersistField, Eq, Show, A.FromJSON, A.ToJSON, IsString, Toml.HasItemCodec)
|
||||
|
||||
unTag :: Tag -> Text
|
||||
unTag (Tag text) = text
|
||||
|
||||
newtype Hash = Hash Text
|
||||
deriving newtype
|
||||
( PersistField
|
||||
, PersistFieldSql
|
||||
, FromHttpApiData
|
||||
, PathPiece
|
||||
, ToHttpApiData
|
||||
, Read
|
||||
, Ord
|
||||
, Eq
|
||||
, Show
|
||||
, A.FromJSON
|
||||
, A.ToJSON
|
||||
, Toml.HasCodec
|
||||
, IsString
|
||||
)
|
||||
|
||||
unHash :: Hash -> Text
|
||||
unHash (Hash hash) = hash
|
||||
|
||||
data Photo
|
||||
= Photo
|
||||
{ hash :: Hash
|
||||
, name :: Text
|
||||
, date :: UTCTime
|
||||
, description :: Text
|
||||
, imageType :: Text
|
||||
, tags :: [Tag]
|
||||
}
|
||||
deriving (Show, Eq, A.FromJSON, A.ToJSON, Generic)
|
||||
|
||||
matchUTCTime :: Toml.Value t -> Either Toml.MatchError UTCTime
|
||||
matchUTCTime v = Toml.matchZoned v <&> zonedTimeToUTC
|
||||
|
||||
_UTCTimeToValue :: UTCTime -> Toml.Value 'Toml.TZoned
|
||||
_UTCTimeToValue utcTime = Toml.Zoned (utcToZonedTime utc utcTime)
|
||||
|
||||
_UTCTime :: Toml.TomlBiMap UTCTime Toml.AnyValue
|
||||
_UTCTime = Toml.mkAnyValueBiMap matchUTCTime _UTCTimeToValue
|
||||
|
||||
instance Toml.HasCodec UTCTime where
|
||||
hasCodec = Toml.match _UTCTime
|
||||
|
||||
photoCodec :: Toml.TomlCodec Photo
|
||||
photoCodec = Toml.genericCodec
|
||||
|
||||
getPhotoExtension :: FilePath -> Text
|
||||
getPhotoExtension photoPath = T.pack $ takeExtension photoPath
|
||||
|
46
overlays/photo-hs/lib/Schema.hs
Normal file
46
overlays/photo-hs/lib/Schema.hs
Normal file
|
@ -0,0 +1,46 @@
|
|||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE UndecidableInstances #-}
|
||||
{-# LANGUAGE UndecidableSuperClasses #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
|
||||
module Schema
|
||||
( Photo(..)
|
||||
, EntityField(..)
|
||||
, Tag(..)
|
||||
, TaggedPhoto(..)
|
||||
, entityDefListFormigrateAll
|
||||
, migrateAll
|
||||
) where
|
||||
|
||||
import Database.Persist.TH
|
||||
import TH (customSqlSettings)
|
||||
import Data.Text (Text)
|
||||
import Data.Time (UTCTime)
|
||||
|
||||
import Photo qualified as P (Hash)
|
||||
import Database.Esqueleto.Experimental (EntityField, Key)
|
||||
|
||||
share [mkPersist customSqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
|
||||
Photo
|
||||
hash P.Hash
|
||||
name Text
|
||||
date UTCTime
|
||||
description Text
|
||||
imageType Text
|
||||
Primary hash
|
||||
deriving Show
|
||||
Tag
|
||||
text Text
|
||||
Primary text
|
||||
deriving Show
|
||||
TaggedPhoto
|
||||
photoHash P.Hash
|
||||
tagText Text
|
||||
Primary photoHash tagText
|
||||
Foreign Photo fk_tagged_photo_photo photoHash References hash
|
||||
Foreign Tag fk_tagged_photo_tag tagText References text
|
||||
deriving Show
|
||||
|]
|
||||
|
6
overlays/photo-hs/lib/TH.hs
Normal file
6
overlays/photo-hs/lib/TH.hs
Normal file
|
@ -0,0 +1,6 @@
|
|||
module TH(customSqlSettings) where
|
||||
|
||||
import Database.Persist.TH (MkPersistSettings (..), sqlSettings)
|
||||
|
||||
customSqlSettings :: MkPersistSettings
|
||||
customSqlSettings = sqlSettings { mpsFieldLabelModifier = \_ field -> field }
|
184
overlays/photo-hs/photo-hs.cabal
Normal file
184
overlays/photo-hs/photo-hs.cabal
Normal file
|
@ -0,0 +1,184 @@
|
|||
cabal-version: 3.4
|
||||
-- The cabal-version field refers to the version of the .cabal specification,
|
||||
-- and can be different from the cabal-install (the tool) version and the
|
||||
-- Cabal (the library) version you are using. As such, the Cabal (the library)
|
||||
-- version used must be equal or greater than the version stated in this field.
|
||||
-- Starting from the specification version 2.2, the cabal-version field must be
|
||||
-- the first thing in the cabal file.
|
||||
|
||||
-- Initial package description 'photo-hs' generated by
|
||||
-- 'cabal init'. For further documentation, see:
|
||||
-- http://haskell.org/cabal/users-guide/
|
||||
--
|
||||
-- The name of the package.
|
||||
name: photo-hs
|
||||
|
||||
-- The package version.
|
||||
-- See the Haskell package versioning policy (PVP) for standards
|
||||
-- guiding when and how versions should be incremented.
|
||||
-- https://pvp.haskell.org
|
||||
-- PVP summary: +-+------- breaking API changes
|
||||
-- | | +----- non-breaking API additions
|
||||
-- | | | +--- code changes with no API change
|
||||
version: 0.1.0.0
|
||||
|
||||
-- A short (one-line) description of the package.
|
||||
-- synopsis:
|
||||
|
||||
-- A longer description of the package.
|
||||
-- description:
|
||||
|
||||
-- URL for the project homepage or repository.
|
||||
homepage: redalder.org
|
||||
|
||||
-- The license under which the package is released.
|
||||
license: LGPL-3.0-or-later
|
||||
|
||||
-- The file containing the license text.
|
||||
license-file: LICENSE
|
||||
|
||||
-- The package author(s).
|
||||
author: magic_rb
|
||||
|
||||
-- An email address to which users can send suggestions, bug reports, and patches.
|
||||
maintainer: magic_rb@redalder.org
|
||||
|
||||
-- A copyright notice.
|
||||
-- copyright:
|
||||
category: Data
|
||||
build-type: Simple
|
||||
|
||||
-- Extra doc files to be distributed with the package, such as a CHANGELOG or a README.
|
||||
extra-doc-files: CHANGELOG.md
|
||||
|
||||
-- Extra source files to be distributed with the package, such as examples, or a tutorial module.
|
||||
-- extra-source-files:
|
||||
|
||||
common warnings
|
||||
ghc-options: -Wall
|
||||
|
||||
library
|
||||
-- Import common warning flags.
|
||||
import: warnings
|
||||
|
||||
-- Modules exported by the library.
|
||||
exposed-modules: MyLib
|
||||
|
||||
-- Modules included in this library but not exported.
|
||||
other-modules:
|
||||
Constants,
|
||||
TH,
|
||||
Git,
|
||||
Git.Annex,
|
||||
AppData,
|
||||
Options,
|
||||
Schema,
|
||||
Commands,
|
||||
Commands.Init,
|
||||
Commands.Debug,
|
||||
Commands.Annex,
|
||||
Commands.Git,
|
||||
Commands.AddPhoto,
|
||||
Commands.List,
|
||||
Commands.EditMeta,
|
||||
Photo,
|
||||
Exif.Tool,
|
||||
Percept.Error,
|
||||
Percept.Util,
|
||||
Percept.Operations,
|
||||
Database.Esqueleto.Experimental.Monad,
|
||||
|
||||
-- LANGUAGE extensions used by modules in this package.
|
||||
default-extensions:
|
||||
DerivingStrategies,
|
||||
DataKinds,
|
||||
DeriveAnyClass,
|
||||
LambdaCase,
|
||||
OverloadedStrings,
|
||||
BlockArguments,
|
||||
DuplicateRecordFields,
|
||||
OverloadedRecordDot,
|
||||
NoFieldSelectors,
|
||||
|
||||
-- Other library packages from which modules are imported.
|
||||
build-depends:
|
||||
base,
|
||||
persistent,
|
||||
persistent-sqlite,
|
||||
persistent-mtl,
|
||||
esqueleto,
|
||||
bytestring,
|
||||
text,
|
||||
time,
|
||||
aeson,
|
||||
http-api-data,
|
||||
path-pieces,
|
||||
typed-process,
|
||||
filepath,
|
||||
optparse-applicative,
|
||||
mtl,
|
||||
monad-logger,
|
||||
unliftio,
|
||||
directory,
|
||||
extra,
|
||||
crypton,
|
||||
unordered-containers,
|
||||
tomland,
|
||||
|
||||
|
||||
-- Directories containing source files.
|
||||
hs-source-dirs: lib
|
||||
|
||||
-- Base language which the package is written in.
|
||||
default-language: GHC2021
|
||||
|
||||
executable photo-hs
|
||||
-- Import common warning flags.
|
||||
import: warnings
|
||||
|
||||
-- .hs or .lhs file containing the Main module.
|
||||
main-is: Main.hs
|
||||
|
||||
-- Modules included in this executable, other than Main.
|
||||
-- other-modules:
|
||||
|
||||
-- LANGUAGE extensions used by modules in this package.
|
||||
-- other-extensions:
|
||||
|
||||
-- Other library packages from which modules are imported.
|
||||
build-depends:
|
||||
base,
|
||||
photo-hs
|
||||
|
||||
-- Directories containing source files.
|
||||
hs-source-dirs: exe
|
||||
|
||||
-- Base language which the package is written in.
|
||||
default-language: GHC2021
|
||||
|
||||
test-suite photo-hs-test
|
||||
-- Import common warning flags.
|
||||
import: warnings
|
||||
|
||||
-- Base language which the package is written in.
|
||||
default-language: GHC2021
|
||||
|
||||
-- Modules included in this executable, other than Main.
|
||||
-- other-modules:
|
||||
|
||||
-- LANGUAGE extensions used by modules in this package.
|
||||
-- other-extensions:
|
||||
|
||||
-- The interface type and version of the test suite.
|
||||
type: exitcode-stdio-1.0
|
||||
|
||||
-- Directories containing source files.
|
||||
hs-source-dirs: test
|
||||
|
||||
-- The entrypoint to the test suite.
|
||||
main-is: Main.hs
|
||||
|
||||
-- Test dependencies.
|
||||
build-depends:
|
||||
base ^>=4.17.2.0,
|
||||
photo-hs
|
4
overlays/photo-hs/test/Main.hs
Normal file
4
overlays/photo-hs/test/Main.hs
Normal file
|
@ -0,0 +1,4 @@
|
|||
module Main (main) where
|
||||
|
||||
main :: IO ()
|
||||
main = putStrLn "Test suite not yet implemented."
|
Loading…
Reference in a new issue