imm 0.6.0.3 → 1.0.0.0
raw patch · 38 files changed
+1777/−1286 lines, 38 filesdep +HaskellNetdep +HaskellNet-SSLdep +aesondep −asyncdep −conddep −data-defaultdep ~directorydep ~networkdep ~time
Dependencies added: HaskellNet, HaskellNet-SSL, aeson, ansi-wl-pprint, atom-conduit, chunked-data, comonad, compdata, conduit, conduit-combinators, conduit-parse, connection, containers, exceptions, fast-logger, free, hashable, http-client, http-client-tls, mono-traversable, monoid-subclasses, opml-conduit, optparse-applicative, rainbow, rainbox, rss-conduit, uri-bytestring, xml-conduit
Dependencies removed: async, cond, data-default, feed, hslogger, http-conduit, lens, monad-control, mtl, network-uri, old-locale, opml, random, resourcet, text-icu, transformers-base, utf8-string, xdg-basedir
Dependency ranges changed: directory, network, time, timerep, tls
Files
- Imm.hs +0/−19
- Imm/Boot.hs +0/−77
- Imm/Config.hs +0/−171
- Imm/Core.hs +0/−100
- Imm/Database.hs +0/−113
- Imm/Dyre.hs +0/−66
- Imm/Error.hs +0/−97
- Imm/Executable.hs +0/−6
- Imm/Feed.hs +0/−134
- Imm/HTTP.hs +0/−64
- Imm/Mail.hs +0/−74
- Imm/Maildir.hs +0/−53
- Imm/OPML.hs +0/−18
- Imm/Options.hs +0/−113
- Imm/Util.hs +0/−29
- README +0/−58
- README.md +28/−0
- imm.cabal +48/−94
- src/bin/Executable.hs +30/−0
- src/lib/Imm.hs +12/−0
- src/lib/Imm/Aeson.hs +19/−0
- src/lib/Imm/Boot.hs +137/−0
- src/lib/Imm/Core.hs +186/−0
- src/lib/Imm/Database.hs +162/−0
- src/lib/Imm/Database/FeedTable.hs +131/−0
- src/lib/Imm/Database/JsonFile.hs +127/−0
- src/lib/Imm/Dyre.hs +70/−0
- src/lib/Imm/Error.hs +17/−0
- src/lib/Imm/Feed.hs +55/−0
- src/lib/Imm/HTTP.hs +48/−0
- src/lib/Imm/HTTP/Simple.hs +51/−0
- src/lib/Imm/Hooks.hs +42/−0
- src/lib/Imm/Hooks/SendMail.hs +149/−0
- src/lib/Imm/Logger.hs +73/−0
- src/lib/Imm/Logger/Simple.hs +41/−0
- src/lib/Imm/Options.hs +134/−0
- src/lib/Imm/Prelude.hs +122/−0
- src/lib/Imm/Pretty.hs +95/−0
@@ -1,19 +0,0 @@-module Imm (- module Imm.Boot,- module Imm.Config,- module Imm.Feed,- module Imm.HTTP,- module Imm.Mail,- module Imm.Maildir,- module Imm.OPML,- module Imm.Util-) where--import Imm.Boot-import Imm.Config-import Imm.Feed-import Imm.HTTP-import Imm.Mail hiding(formatFrom, formatSubject, formatBody)-import Imm.Maildir-import Imm.OPML-import Imm.Util
@@ -1,77 +0,0 @@-{-# LANGUAGE TupleSections #-}-module Imm.Boot (imm, ConfigFeed) where---- {{{ Imports-import qualified Imm.Core as Core-import Imm.Config-import Imm.Database-import Imm.Dyre as Dyre-import qualified Imm.Feed as Feed-import Imm.Options (Action(..))-import qualified Imm.Options as Options-import Imm.Util--import Control.Lens hiding (Action, (??))-import Control.Monad.Error hiding(mapM_, when)--- import Control.Monad.Reader hiding(mapM_, when)-import Control.Monad.Trans.Maybe--import Data.Foldable-import Data.Version--import Network.URI as N--import Paths_imm-import Prelude hiding (mapM_)--import System.Log.Logger-import System.Exit--- }}}--type ConfigFeed = (Config -> Config, String)----- | Main function to call in the configuration file.-imm :: [ConfigFeed] -> IO ()-imm feedsFromConfig = void . runMaybeT $ do- options <- Options.get- let dataDir = view Options.dataDirectory options- dyreMode = view Options.dyreMode options- feedsFromOptions = view Options.feedsList options- logLevel = view Options.logLevel options-- action <- handleSpecialActions $ view Options.action options-- io . updateGlobalLogger rootLoggerName $ setLevel logLevel- io . debugM "imm.options" $ "Commandline options: " ++ show options-- io $ Dyre.wrap dyreMode realMain (action, dataDir, feedsFromOptions, feedsFromConfig)---handleSpecialActions :: Options.Action -> MaybeT IO Feed.Action-handleSpecialActions Help = io (putStrLn Options.usage) >> mzero-handleSpecialActions ShowVersion = (io . putStrLn $ showVersion version) >> mzero-handleSpecialActions Recompile = (io $ Dyre.recompile >>= mapM_ putStrLn) >> mzero-handleSpecialActions Import = io getContents >>= Core.importOPML >> mzero-handleSpecialActions (Run action) = return action---realMain :: (Feed.Action, Maybe FilePath, [URI], [ConfigFeed]) -> IO ()-realMain (action, dataDir, feedsFromOptions, feedsFromConfig) = do- unless (null errors) . errorM "imm.boot" $ unlines errors- when (null feedsOK) $ warningM "imm.boot" "Nothing to process. Exiting..." >> exitFailure- -- io . debugM "imm.boot" . unlines $ "Feeds to be processed:":(map (show . snd) feedsOK)-- Core.dispatch baseConfig action feedsOK- where- (errors, feedsOK) = validateFeeds feedsFromConfig feedsFromOptions- baseConfig = maybe id (set (fileDatabase . directory)) dataDir---validateFeeds :: [ConfigFeed] -> [URI] -> ([String], Core.FeedList)-validateFeeds feedsFromConfig feedsFromOptions = (errors ++ errors', null feedsFromOptions ? feedsOK ?? feedsOK')- where- validateFromConfig (x, u) = maybe (Left ("Invalid feed URI: " ++ u)) (Right . (x,)) $ N.parseURI u- validateFromOptions uri = maybe (Left ("URI from commandline option has no configuration entry: " ++ show uri)) Right . listToMaybe . filter ((== uri) . snd) $ feedsOK- (errors, feedsOK) = partitionEithers $ map validateFromConfig feedsFromConfig- (errors', feedsOK') = partitionEithers $ map validateFromOptions feedsFromOptions
@@ -1,171 +0,0 @@-{-# LANGUAGE OverlappingInstances, TemplateHaskell #-}-module Imm.Config (--- * Types- FromFormat(FromFormat),- SubjectFormat(SubjectFormat),- BodyFormat(BodyFormat),- Config,- maildir,- fileDatabase,- dateParsers,- formatFrom,- formatSubject,- formatBody,- decoder,- withConfig,--- * Misc- addFeeds,-) where---- {{{ Imports-import Imm.Database-import Imm.Error-import Imm.Feed (FeedParser)-import qualified Imm.Feed as F-import Imm.Maildir (Maildir, MaildirWriter(..))-import Imm.HTTP (Decoder(..))-import qualified Imm.Mail as Mail-import Imm.Util--import Control.Lens hiding((??))-import Control.Monad.Error hiding(forM_, guard)-import Control.Monad.Reader hiding(forM_, guard)--import Data.Foldable hiding(concat)-import Data.Text.ICU.Convert-import qualified Data.Text.Lazy as TL-import Data.Time as T-import Data.Time.RFC2822-import Data.Time.RFC3339--import Prelude hiding(init)--import Text.Feed.Query as F--- import Text.Feed.Types as F--import System.Directory--- import System.Environment.XDG.BaseDir-import System.Locale--- }}}---- {{{ Types-newtype FromFormat = FromFormat { unFromFormat :: Mail.Format }-newtype SubjectFormat = SubjectFormat { unSubjectFormat :: Mail.Format }-newtype BodyFormat = BodyFormat { unBodyFormat :: Mail.Format }--instance Default FromFormat where- def = FromFormat $ \(item, feed) -> fromMaybe (getFeedTitle feed) $ getItemAuthor item--instance Default SubjectFormat where- def = SubjectFormat $ \(item, _feed) -> fromMaybe "Untitled" $ getItemTitle item--instance Default BodyFormat where- def = BodyFormat $ \(item, _feed) -> let- link = fromMaybe "No link found." $ getItemLink item- content = F.getItemContent item- description = fromMaybe "No description." $ getItemDescription item- in "<p>" ++ link ++ "</p><p>" ++ (null content ? description ?? content) ++ "</p>"----- | The only exported constructor is through 'Default' class.-data Config = Config {- _maildir :: Maildir, -- ^ Where mails will be written- _fileDatabase :: FileDatabase, -- ^ Database configuration, used to store resilient information (basically: last update time)- _dateParsers :: [String -> Maybe UTCTime], -- ^ List of date parsing functions, will be tried sequentially until one succeeds- _formatFrom :: FromFormat, -- ^ Called to write the From: header of feed mails- _formatSubject :: SubjectFormat, -- ^ Called to write the Subject: header of feed mails- _formatBody :: BodyFormat, -- ^ Called to write the body of feed mails (sic!)- _decoder :: String -- ^ 'Converter' name used to decode the HTTP response from a feed URI-}--makeLenses ''Config--instance Default (IO Config) where- def = do- theDatabase <- def- mailDir <- getHomeDirectory >/> "feeds"- return Config {- _maildir = mailDir,- _fileDatabase = theDatabase,- _dateParsers = [- return . zonedTimeToUTC <=< readRFC2822,- return . zonedTimeToUTC <=< readRFC3339,- T.parseTime defaultTimeLocale "%a, %d %b %G %T",- T.parseTime defaultTimeLocale "%Y-%m-%d",- T.parseTime defaultTimeLocale "%e %b %Y",- T.parseTime defaultTimeLocale "%a, %e %b %Y %k:%M:%S %z",- T.parseTime defaultTimeLocale "%a, %e %b %Y %T %Z"],- _formatFrom = def,- _formatSubject = def,- _formatBody = def,- _decoder = "UTF-8"- }--instance (Monad m) => FeedParser (ReaderT Config m) where- parseDate date = return . listToMaybe . {-map T.zonedTimeToUTC .-} catMaybes =<< tryParsers strippedDate- where- tryParsers string = return . map ($ string) =<< asks (view dateParsers)- strippedDate = TL.unpack . TL.strip . TL.pack $ date--instance (Applicative m, MonadBase IO m) => Decoder (ReaderT Config m) where- converter = io . (`open` Nothing) =<< asks (view decoder)--instance (MonadBase IO m) => DatabaseReader (ReaderT Config m) where- getLastCheck = withReaderT (view fileDatabase) . getLastCheck--instance (MonadError ImmError m, MonadBase IO m) => DatabaseWriter (ReaderT Config m) where- storeLastCheck uri = withReaderT (view fileDatabase) . storeLastCheck uri- forget = withReaderT (view fileDatabase) . forget--instance (MonadBase IO m, MonadError ImmError m) => MaildirWriter (ReaderT Config m) where- init = do- theMaildir <- asks $ view maildir- lift $ runReaderT init theMaildir- write mail = do- theMaildir <- asks $ view maildir- lift $ runReaderT (write mail) theMaildir--instance (Monad m) => Mail.MailFormatter (ReaderT Config m) where- formatFrom = asks $ unFromFormat . view formatFrom- formatSubject = asks $ unSubjectFormat . view formatSubject- formatBody = asks $ unBodyFormat . view formatBody---withConfig :: (MonadBase IO m) => (Config -> Config) -> ReaderT Config m a -> m a-withConfig f g = do- theConfig <- f <$> io def- runReaderT g theConfig--- }}}----- | Return the Haskell code to write in the configuration file to add feeds.-addFeeds :: (MonadBase IO m) => [(String, [String])] -> m ()-addFeeds feeds = do- io . putStrLn . unlines $- "import Imm":- "import Control.Lens":- "import System.FilePath":- "":- "main :: IO ()":- "main = imm myFeeds":- "":- "maildirRoot = \"/home/<user>/feeds\" -- TODO: fill <user>":- "":- ("myFeeds = concat $ " ++ intercalate ":" (map (map toLower . concat . words . fst) feeds) ++ ":[]"):- []-- forM_ feeds addFeedsGroup--addFeedsGroup :: (MonadBase IO m) => (String, [String]) -> m ()-addFeedsGroup (groupTitle, uris) = io $ do- -- guard (not $ null uris)- putStr . unlines $- ("-- Group " ++ groupTitle):- (groupID ++ "Config = set maildir (maildirRoot </> \"" ++ groupID ++ "\")"):- (groupID ++ " = zip (repeat " ++ groupID ++ "Config) $"):- []- putStr . unlines $ map (\u -> " " ++ show u ++ ":") uris- putStrLn " []"- putStrLn ""- where- groupID = map toLower . concat . words $ groupTitle
@@ -1,100 +0,0 @@-{-# LANGUAGE ScopedTypeVariables, TypeFamilies #-}-module Imm.Core (--- * Types- FeedConfig,- FeedList,--- * Actions- dispatch,- importOPML,- check,- showStatus,- markAsRead,- markAsUnread,- update,-) where---- {{{ Imports-import Imm.Config-import Imm.Database-import Imm.Error-import Imm.Feed (FeedParser(..))-import qualified Imm.Feed as Feed-import Imm.Maildir (MaildirWriter(..))-import qualified Imm.Maildir as Maildir-import Imm.Mail (MailFormatter(..))-import qualified Imm.Mail as Mail-import Imm.OPML as OPML-import Imm.Util--import Control.Concurrent.Async-import Control.Monad hiding(forM_, mapM_)-import Control.Monad.Error hiding(forM_, mapM_)--- import Control.Monad.Reader hiding(forM_, mapM_)--import Data.Foldable hiding(foldr)-import Data.Time as T--import Prelude hiding(log, mapM_, sum)--import System.Log.Logger--import Text.Feed.Query as F-import Text.Feed.Types as F--- }}}---- {{{ Types-type FeedConfig = (Config -> Config, FeedID)-type FeedList = [FeedConfig]--- }}}---dispatch :: (Config -> Config) -> Feed.Action -> FeedList -> IO ()-dispatch baseConfig Feed.Check feeds = void $ mapConcurrently (check baseConfig) feeds-dispatch baseConfig Feed.ShowStatus feeds = mapM_ (showStatus baseConfig) feeds-dispatch baseConfig Feed.MarkAsRead feeds = mapM_ (markAsRead baseConfig) feeds-dispatch baseConfig Feed.MarkAsUnread feeds = mapM_ (markAsUnread baseConfig) feeds-dispatch baseConfig Feed.Update feeds = void $ mapConcurrently (update baseConfig) feeds---importOPML :: (MonadBase IO m, MonadPlus m) => String -> m ()-importOPML = mapM_ addFeeds . OPML.read---check :: (Config -> Config) -> FeedConfig -> IO ()-check baseConfig (f, feedID) = withError "imm.core". withConfig (f . baseConfig) $ Feed.download feedID >>= Feed.check---showStatus :: (Config -> Config) -> FeedConfig -> IO ()-showStatus baseConfig (f, feedID) = withConfig (f . baseConfig) (io . noticeM "imm.core" =<< Feed.showStatus feedID)---markAsRead :: (Config -> Config) -> FeedConfig -> IO ()-markAsRead baseConfig (f, feedID) = withError "imm.core" . withConfig (f . baseConfig) $ Feed.markAsRead feedID---markAsUnread :: (Config -> Config) -> FeedConfig -> IO ()-markAsUnread baseConfig (f, feedID) = withError "imm.core" . withConfig (f . baseConfig) $ Feed.markAsUnread feedID----- | Write mails for each new item, and update the last check time in state file.-update :: (Config -> Config) -> FeedConfig -> IO ()-update baseConfig (f, feedID) = withError "imm.core" . withConfig (f . baseConfig) $ do- -- io . noticeM "imm.core" $ "Updating: " ++ show feedID- (uri, feed) <- Feed.download feedID-- Maildir.init-- io . debugM "imm.core" $ Feed.describe feed-- lastCheck <- getLastCheck uri- (results :: [Integer]) <- forM (feedItems feed) $ \item -> do- date <- Feed.getDate item- (date > lastCheck) ? (updateItem (item, feed) >> return 1) ?? return 0- io . noticeM "imm.core" $ "==> " ++ show (sum results) ++ " new item(s) for <" ++ show feedID ++ ">"- Feed.markAsRead uri---updateItem :: (Applicative m, FeedParser m, MaildirWriter m, MailFormatter m, MonadBase IO m, MonadError ImmError m) => (Item, Feed) -> m ()-updateItem (item, feed) = do- timeZone <- io getCurrentTimeZone- Maildir.write =<< Mail.build timeZone (item, feed)
@@ -1,113 +0,0 @@-{-# LANGUAGE OverlappingInstances, TemplateHaskell #-}-module Imm.Database (- FeedID,- DatabaseReader(..),- DatabaseWriter(..),- DatabaseState,- FileDatabase,- directory,- getDataFile,-) where---- {{{ Imports-import Imm.Error-import Imm.Util--import Control.Lens-import Control.Monad.Reader-import Control.Monad.Error--import Data.Time hiding(parseTime)-import Data.Time.Clock.POSIX--import Network.URI--import System.Directory-import System.Environment.XDG.BaseDir-import System.FilePath-import System.Locale-import System.IO-import System.Log.Logger--- }}}---- {{{ Types-type FeedID = URI--class DatabaseReader m where- -- | Read the last check time in the state file.- getLastCheck :: FeedID -> m UTCTime--instance (Error e, DatabaseReader m) => DatabaseReader (ErrorT e m) where- getLastCheck = getLastCheck--class DatabaseWriter m where- -- | Write the last update time in the data file.- storeLastCheck :: FeedID -> UTCTime -> m ()- -- | Remove state file as if no update was ever done.- forget :: FeedID -> m ()---type (DatabaseState m) = (DatabaseReader m, DatabaseWriter m)---data FileDatabase = FileDatabase {- _directory :: FilePath,- _getDataFile :: FeedID -> FilePath-}--makeLenses ''FileDatabase---- | A state file stores the last check time for a single feed, identified with its 'URI'.-instance Default (IO FileDatabase) where- def = do- dataDir <- getUserConfigDir "imm" >/> "state"- return FileDatabase {- _directory = dataDir,- _getDataFile = \feedUri -> case uriAuthority feedUri of- Just auth -> toFileName =<< ((++ uriQuery feedUri) . (++ uriPath feedUri) . uriRegName $ auth)- _ -> show feedUri >>= toFileName- }--instance (MonadBase IO m) => DatabaseReader (ReaderT FileDatabase m) where- getLastCheck feedUri = do- dataDirectory <- asks (view directory)- dataFileGetter <- asks (view getDataFile)-- let dataFile = dataDirectory </> dataFileGetter feedUri- io . debugM "imm.database" $ "Reading last check time from: " ++ dataFile-- result <- runErrorT $ do- content <- try $ readFile dataFile- parseTime content- either (const $ io (debugM "imm.database" "Unable to read last update time.") >> return timeZero) return result- where- timeZero = posixSecondsToUTCTime 0--instance (MonadBase IO m, MonadError ImmError m) => DatabaseWriter (ReaderT FileDatabase m) where- storeLastCheck feedUri date = do- dataDirectory <- asks (view directory)- dataFileGetter <- asks (view getDataFile)-- let dataFile = dataFileGetter feedUri-- io . debugM "imm.database" $ "Storing last update time [" ++ show date ++ "] at <" ++ dataDirectory </> dataFile ++ ">"- try . io . createDirectoryIfMissing True $ dataDirectory- (file, stream) <- try $ openTempFile dataDirectory dataFile- io $ hPutStrLn stream (formatTime defaultTimeLocale "%c" date)- io $ hClose stream- try $ renameFile file (dataDirectory </> dataFile)-- forget uri = do- dataDirectory <- asks (view directory)- dataFileGetter <- asks (view getDataFile)-- let dataFile = dataDirectory </> dataFileGetter uri- io . debugM "imm.database" $ "Removing data file <" ++ dataFile ++ ">"- try $ removeFile dataFile--- }}}---- | Remove forbidden characters in a filename.-toFileName :: Char -> String-toFileName '/' = "."-toFileName '?' = "."-toFileName x = [x]
@@ -1,66 +0,0 @@-module Imm.Dyre (- Mode(..),- wrap,- recompile,-) where---- {{{ Imports-import Imm.Util--import Config.Dyre-import Config.Dyre.Compile-import Config.Dyre.Paths--import Control.Monad-import Control.Monad.Trans.Control--import System.IO-import System.Log.Logger--- }}}---- | How dynamic reconfiguration process should behave.--- Default is 'Normal', that is: use custom configuration file and recompile if change detected.-data Mode = Normal | Vanilla | ForceReconfiguration | IgnoreReconfiguration- deriving(Eq, Show)--instance Default Mode where- def = Normal---nullMain :: a -> IO ()-nullMain = const $ return ()---- Print various paths used for dynamic reconfiguration-showPaths :: MonadBase IO m => m String-showPaths = io $ do- (a, b, c, d, e) <- getPaths $ parameters nullMain- return . unlines $- ("Current binary: " ++ a):- ("Custom binary: " ++ b):- ("Config file: " ++ c):- ("Cache directory: " ++ d):- ("Lib directory: " ++ e):[]---- | Dynamic reconfiguration settings-parameters :: (a -> IO ()) -> Params (Either String a)-parameters main = defaultParams {- projectName = "imm",- showError = const Left,- realMain = main',- ghcOpts = ["-threaded"],- statusOut = hPutStrLn stderr,- includeCurrentDirectory = False}- where- main' (Left e) = putStrLn e- main' (Right x) = do- debugM "imm.dyre" =<< showPaths- main x--wrap :: (MonadBaseControl IO m) => Mode -> (a -> m ()) -> a -> m ()-wrap mode main args = liftBaseWith $ \runInIO -> wrapMain ((parameters (void . runInIO . main)) { configCheck = (mode /= Vanilla) }) $ Right args---- | Launch a recompilation of the configuration file-recompile :: IO (Maybe String)-recompile = do- customCompile $ parameters nullMain- getErrorString $ parameters nullMain
@@ -1,97 +0,0 @@-module Imm.Error (--- * Types- ImmError(..),- withError,- localError,--- * Functions redefinition- try,- timeout,- parseURI,- parseTime,-) where---- {{{ Imports-import qualified Control.Exception as E-import Imm.Util--import Control.Monad.Error--import qualified Data.Text as T-import Data.Text.Encoding as T-import Data.Text.Encoding.Error-import Data.Time (UTCTime)-import qualified Data.Time as T--import Network.HTTP.Conduit hiding(HandshakeFailed)-import Network.HTTP.Types.Status-import Network.TLS hiding(DecodeError)-import Network.URI (URI)-import qualified Network.URI as N--import System.IO.Error--import Text.Feed.Query-import Text.Feed.Types--import System.Locale-import System.Log.Logger-import qualified System.Timeout as S--- }}}--data ImmError =- OtherError String- | HTTPError HttpException- | TLSError TLSException- | UnicodeError UnicodeException- | ParseUriError String- | ParseTimeError String- | ParseItemDateError Item- | ParseFeedError String- | IOE IOError- | TimeOut--instance Show ImmError where- show (OtherError e) = e- show (HTTPError (StatusCodeException status _headers _cookieJar)) =- "/!\\ HTTP error: " ++ show (statusCode status) ++ " " ++ (T.unpack . T.decodeUtf8) (statusMessage status)- show (HTTPError e) = "/!\\ HTTP error: " ++ show e- show (TLSError (HandshakeFailed e)) = "/!\\ TLS error: " ++ show e- show (UnicodeError (DecodeError e _)) = e- show (UnicodeError (EncodeError e _)) = e- show (ParseUriError raw) = "/!\\ Cannot parse URI: " ++ raw- show (ParseItemDateError item) = unlines [- "/!\\ Cannot parse date from item: ",- " title: " ++ (show $ getItemTitle item),- " link:" ++ (show $ getItemLink item),- " publish date:" ++ (show (getItemPublishDate item :: Maybe (Maybe UTCTime))),- " date:" ++ (show $ getItemDate item)]- show (ParseTimeError raw) = "/!\\ Cannot parse time: " ++ raw- show (ParseFeedError raw) = "/!\\ Cannot parse feed: " ++ raw- show (IOE e) = "/!\\ IO error [" ++ ioeGetLocation e ++ "]: " ++ fromMaybe "" (ioeGetFileName e) ++ " " ++ ioeGetErrorString e- show TimeOut = "/!\\ Process has timed out"--instance Error ImmError where- strMsg = OtherError---withError :: (Error e, Show e, MonadBase IO m) => String -> ErrorT e m () -> m ()-withError category = runErrorT >=> either (io . errorM category . show) return--localError :: (MonadBase IO m, MonadError ImmError m) => String -> m () -> m ()-localError category f = f `catchError` (io . errorM category . show)---- | Monad-agnostic version of 'Control.Exception.try'-try :: (MonadBase IO m, MonadError ImmError m) => IO a -> m a-try = (io . E.try) >=> either (throwError . IOE) return---- | Monad-agnostic version of 'System.timeout'-timeout :: (MonadBase IO m, MonadError ImmError m) => Int -> IO a -> m a-timeout n f = maybe (throwError TimeOut) (io . return) =<< (io $ S.timeout n (io f))---- | Monad-agnostic version of 'Network.URI.parseURI'-parseURI :: (MonadError ImmError m) => String -> m URI-parseURI uri = maybe (throwError $ ParseUriError uri) return $ N.parseURI uri---- | Monad-agnostic version of 'Data.Time.Format.parseTime'-parseTime :: (MonadError ImmError m) => String -> m UTCTime-parseTime string = maybe (throwError $ ParseTimeError string) return $ T.parseTime defaultTimeLocale "%c" string
@@ -1,6 +0,0 @@---module Executable where--import Imm--main :: IO ()-main = imm []
@@ -1,134 +0,0 @@-{-# LANGUAGE TupleSections #-}-module Imm.Feed where---- {{{ Imports-import Imm.Database-import Imm.Error-import qualified Imm.HTTP as HTTP-import Imm.Util hiding(when)--import Control.Monad.Error---- import qualified Data.ByteString as B--- import qualified Data.ByteString.Lazy as BL-import qualified Data.Text.Lazy as TL-import Data.Time as T hiding(parseTime)-import Data.Time.Clock.POSIX--import Network.URI as N--import qualified Text.Atom.Feed as Atom-import qualified Text.RSS1.Syntax as RSS1-import qualified Text.RSS.Syntax as RSS-import Text.Feed.Import as F-import Text.Feed.Query as F-import Text.Feed.Types as F-import Text.XML.Light.Proc-import Text.XML.Light.Types--import System.Log.Logger--- }}}---- {{{ Types-data Action = Check | ShowStatus | MarkAsRead | MarkAsUnread | Update- deriving(Eq, Show)--type ImmFeed = (FeedID, Feed)--class FeedParser m where- parseDate :: String -> m (Maybe UTCTime)--instance (Monad m, Error e, FeedParser m) => FeedParser (ErrorT e m) where- parseDate = lift . parseDate--- }}}----- | Provide a 'String' representation of the feed type.-showType :: Feed -> String-showType (AtomFeed _) = "Atom"-showType (RSSFeed _) = "RSS 2.x"-showType (RSS1Feed _) = "RSS 1.x"-showType (XMLFeed _) = "XML"--describe :: Feed -> String-describe feed = unlines [- "Type: " ++ showType feed,- "Title: " ++ getFeedTitle feed,- "Author: " ++ fromMaybe "No author" (getFeedAuthor feed),- "Home: " ++ fromMaybe "No home" (getFeedHome feed)]--describeItem :: Item -> String-describeItem item = unlines [- " Item author: " ++ fromMaybe "<empty>" (getItemAuthor item),- " Item title: " ++ fromMaybe "<empty>" (getItemTitle item),- " Item URI: " ++ fromMaybe "<empty>" (getItemLink item),- -- " Item Body: " ++ (Imm.Mail.getItemContent item),- " Item date: " ++ fromMaybe "<empty>" (getItemDate item)]---- | Monad-agnostic version of 'Text.Feed.Import.parseFeedString'-parse :: MonadError ImmError m => String -> m Feed-parse x = maybe (throwError $ ParseFeedError x) return $ parseFeedString x----- | Retrieve, decode and parse the given resource as a feed.-download :: (HTTP.Decoder m, MonadBase IO m, MonadError ImmError m) => URI -> m ImmFeed-download uri = do- io . debugM "imm.feed" $ "Downloading " ++ show uri- fmap (uri,) . parse . TL.unpack =<< HTTP.get uri---- | Count the list of unread items for given feed.-check :: (FeedParser m, DatabaseReader m, MonadBase IO m, MonadError ImmError m) => ImmFeed -> m ()-check (feedID, feed) = do- lastCheck <- getLastCheck feedID- (errors, dates) <- tryGetDates $ feedItems feed- let newItems = filter (> lastCheck) dates-- unless (null errors) . io . errorM "imm.feed" . unlines $ map show errors- io . noticeM "imm.feed" $ show (length newItems) ++ " new item(s) for <" ++ show feedID ++ ">"- where- tryGetDates = fmap partitionEithers . mapM (runErrorT . getDate)---- | Simply set the last check time to now.-markAsRead :: (MonadBase IO m, MonadError ImmError m, DatabaseWriter m) => URI -> m ()-markAsRead uri = do- io getCurrentTime >>= storeLastCheck uri- io . noticeM "imm.feed" $ "Feed <" ++ show uri ++ "> marked as read."---- | Simply remove the state file.-markAsUnread :: (MonadBase IO m, MonadError ImmError m, DatabaseWriter m) => URI -> m ()-markAsUnread uri = do- forget uri- io . noticeM "imm.feed" $ "Feed <" ++ show uri ++ "> marked as unread."---- | Return a 'String' describing the last update for a given feed.-showStatus :: (DatabaseReader m, MonadBase IO m) => URI -> m String-showStatus uri = let nullTime = posixSecondsToUTCTime 0 in do- lastCheck <- getLastCheck uri- return $ ((lastCheck == nullTime) ? "[NEW] " ?? ("[Last update: "++ show lastCheck ++ "]")) ++ " " ++ show uri----- {{{ Item utilities--- | This function is missing from 'Text.Feed.Query', probably because it is difficult to define where the content is located in a generic way for Atom/RSS 1.x/RSS 2.x feeds.-getItemContent :: Item -> String-getItemContent (AtomItem i) = length theContent < length theSummary ? theSummary ?? theContent- where- theContent = fromMaybe "" (extractHtml <$> Atom.entryContent i)- theSummary = fromMaybe "No content" (Atom.txtToString <$> Atom.entrySummary i)-getItemContent (RSSItem i) = length theContent < length theDescription ? theDescription ?? theContent- where- theContent = dropWhile isSpace . concatMap concat . map (map cdData . onlyText . elContent) . RSS.rssItemOther $ i- theDescription = fromMaybe "No description." $ RSS.rssItemDescription i-getItemContent (RSS1Item i) = concat . mapMaybe RSS1.contentValue . RSS1.itemContent $ i-getItemContent item = fromMaybe "No content." . getItemDescription $ item--getDate :: (FeedParser m, Monad m, MonadError ImmError m) => Item -> m UTCTime-getDate item = maybe (throwError $ ParseItemDateError item) return =<< maybe (return Nothing) parseDate =<< return (getItemDate item)--- }}}---extractHtml :: Atom.EntryContent -> String-extractHtml (Atom.HTMLContent c) = c-extractHtml (Atom.XHTMLContent c) = strContent c-extractHtml (Atom.TextContent t) = t-extractHtml (Atom.MixedContent a b) = show a ++ show b-extractHtml (Atom.ExternalContent mediaType uri) = show mediaType ++ show uri
@@ -1,64 +0,0 @@-{-# LANGUAGE ScopedTypeVariables #-}-module Imm.HTTP where---- {{{ Imports-import Imm.Error-import Imm.Util--import Control.Exception as E-import Control.Monad.Error hiding(forM_, mapM_)-import Control.Monad.Trans.Resource--import Data.ByteString as B-import Data.ByteString.Lazy as BL-import Data.ByteString.Char8 as BC-import Data.CaseInsensitive-import Data.Text.ICU.Convert-import qualified Data.Text.Lazy as TL--import Network.HTTP.Conduit as H-import Network.URI--- }}}---- {{{ Types-class (Applicative m, Functor m, Monad m) => Decoder m where- converter :: m Converter- decode :: BL.ByteString -> m TL.Text- decode string = return . TL.fromChunks . (: []) =<< toUnicode <$> converter <*> return strictString- where- strictString = B.concat $ BL.toChunks string--instance (Error e, Decoder m) => Decoder (ErrorT e m) where- converter = lift converter--- }}}---- | Perform an HTTP GET request and return the response body as raw 'ByteString'-getRaw :: (MonadBase IO m, MonadError ImmError m) => URI -> m BL.ByteString-getRaw uri = do- req <- request $ show uri- res <- withManager' (httpLbs req)- return $ responseBody res---- | Same as 'getRaw' with additional decoding-get :: (Decoder m, MonadBase IO m, MonadError ImmError m) => URI -> m TL.Text-get uri = getRaw uri >>= decode---- | Monad-agnostic version of 'withManager'-withManager' :: (MonadError ImmError m, MonadBase IO m) => (Manager -> ResourceT IO b) -> m b-withManager' f = do- res <- timeout 11000000 $ (Right <$> withManager f) `catch` (return . Left . IOE) `catch` (return . Left . HTTPError) `catch` (return . Left . TLSError)- either throwError return res---- | Monad-agnostic version of 'parseUrl'-parseURL :: (MonadBase IO m, MonadError ImmError m) => String -> m Request-parseURL uri = do- result <- io $ (Right <$> parseUrl uri) `catch` (return . Left . HTTPError)- either throwError return result---- | Build an HTTP request for given URI-request :: (MonadBase IO m, MonadError ImmError m) => String -> m Request-request uri = do- req <- parseURL uri- return $ req { requestHeaders = [- (mk $ BC.pack "User-Agent", BC.pack "Mozilla/4.0"),- (mk $ BC.pack "Accept", BC.pack "*/*")]}
@@ -1,74 +0,0 @@-{-# LANGUAGE TemplateHaskell #-}-module Imm.Mail where---- {{{ Imports-import Imm.Feed as F-import Imm.Util--import Control.Lens hiding(from, (??))-import Control.Monad.Error--import Data.Time-import Data.Time.RFC2822---- import Text.Feed.Query as F-import Text.Feed.Types---- import System.Log.Logger--- }}}---- {{{ Types-data Mail = Mail {- _returnPath :: String,- _date :: Maybe ZonedTime,- _from :: String,- _subject :: String,- _mime :: String,- _charset :: String,- _contentDisposition :: String,- _body :: String-}--makeLenses ''Mail--instance Default Mail where- def = Mail {- _charset = "utf-8",- _body = empty,- _contentDisposition = "inline",- _date = Nothing,- _from = "imm",- _mime = "text/html",- _subject = "Untitled",- _returnPath = "<imm@noreply>"}--instance Show Mail where- show mail = unlines [- "Return-Path: " ++ view returnPath mail,- maybe "" (("Date: " ++) . showRFC2822) $ view date mail,- "From: " ++ view from mail,- "Subject: " ++ view subject mail,- "Content-Type: " ++ view mime mail ++ "; charset=" ++ view charset mail,- "Content-Disposition: " ++ view contentDisposition mail,- "",- view body mail]---type Format = (Item, Feed) -> String--class MailFormatter m where- formatFrom :: m Format- formatSubject :: m Format- formatBody :: m Format--- }}}---- | Build mail from a given feed, using builders functions from 'Settings'.-build :: (Applicative m, MailFormatter m, FeedParser m, Monad m) => TimeZone -> (Item, Feed) -> m Mail-build timeZone (item, feed) = do- from' <- formatFrom <*> return (item, feed)- subject' <- formatSubject <*> return (item, feed)- body' <- formatBody <*> return (item, feed)- date' <- runErrorT' (utcToZonedTime timeZone <$> getDate item)- return . set date date' . set from from' . set subject subject' . set body body' $ def- where- runErrorT' = (return . either (const Nothing) Just) <=< runErrorT
@@ -1,53 +0,0 @@-module Imm.Maildir where---- {{{ Imports-import Imm.Error-import Imm.Mail-import Imm.Util--import Control.Monad.Error-import Control.Monad.Reader--import qualified Data.Text.Lazy.IO as T-import qualified Data.Text.Lazy as TL-import Data.Time.Clock.POSIX--import Network.BSD--import System.Directory-import System.FilePath-import System.Log.Logger-import System.Random--- }}}--type Maildir = FilePath--class MaildirWriter m where- -- | Build a maildir with subdirectories cur, new and tmp.- init :: m ()- -- | Add a mail to the maildir- write :: Mail -> m ()--instance (MonadBase IO m, MonadError ImmError m) => MaildirWriter (ReaderT Maildir m) where- init = do- theMaildir <- ask- io . debugM "imm.maildir" $ "Creating maildir [" ++ theMaildir ++ "]"- try . mapM_ (createDirectoryIfMissing True) $- theMaildir:- (theMaildir </> "cur"):- (theMaildir </> "new"):- (theMaildir </> "tmp"):[]- write mail = do- fileName <- io getUniqueName- theMaildir <- ask- io . debugM "imm.maildir" $ "Writing new mail in maildir [" ++ theMaildir ++ "]"- try $ T.writeFile (theMaildir </> "new" </> fileName) (TL.pack $ show mail)---- | Return an allegedly unique filename; useful to add new mail files in a maildir.-getUniqueName :: MonadBase IO m => m String-getUniqueName = io $ do- time <- show <$> getPOSIXTime- hostname <- getHostName- rand <- show <$> (getStdRandom $ randomR (1,100000) :: IO Int)-- return . concat $ time:".":rand:".":hostname:[]
@@ -1,18 +0,0 @@-module Imm.OPML where---- {{{ Imports-import Text.OPML.Reader-import Text.OPML.Syntax-import Text.XML.Light.Types--- }}}---- | Parse an OPML string and return a list of tuples (category title, feed URIs).-read :: String -> Maybe [(String, [String])]-read rawOPML = do- opml <- parseOPMLString rawOPML- let groups = opmlBody opml- groupNames = map opmlText groups- feeds = opmlOutlineChildren- feedURI = concatMap attrVal . filter ((== "xmlUrl") . qName . attrKey) . opmlOutlineAttrs- - return $ zip groupNames (map (map feedURI . feeds) groups)
@@ -1,113 +0,0 @@-{-# LANGUAGE TemplateHaskell #-}-module Imm.Options (- CliOptions,- action,- dyreMode,- feedsList,- dataDirectory,- logLevel,- Action(..),- get,- usage,-) where---- {{{ Imports-import Imm.Dyre as Dyre-import qualified Imm.Feed as Feed-import Imm.Util--import Control.Lens as L hiding(Action, (??))-import Control.Monad.Reader hiding(mapM_, when)--import Data.Foldable--import Network.URI as N--import Prelude hiding(foldl, log, mapM_)--import System.Console.GetOpt-import System.Environment--- import System.Environment.XDG.BaseDir-import System.Log as Log--- }}}---- {{{ Types--- | Mutually exclusive actions.--- Default is 'PrintHelp'.-data Action = Help | ShowVersion | Recompile | Import | Run Feed.Action- deriving(Eq, Show)--instance Default Action where- def = Help---- | Available commandline options-data CliOptions = CliOptions {- _action :: Action,- _dyreMode :: Dyre.Mode,- _dataDirectory :: Maybe FilePath,- _feedsList :: [URI],- _logLevel :: Log.Priority}- deriving(Eq)--makeLenses ''CliOptions--instance Show CliOptions where- show opts = unwords $ catMaybes [- return . ("ACTION=" ++) . show $ view action opts,- return . ("RECONFIGURATION_MODE=" ++) . show $ view dyreMode opts,- null (view feedsList opts) ? Nothing ?? Just ("FEED_URI=[" ++ (unwords . map show $ view feedsList opts) ++ "]"),- return . ("DATA_DIR=" ++) =<< view dataDirectory opts,- return $ "LOG_LEVEL=" ++ show (view logLevel opts)]--instance Default CliOptions where- def = CliOptions {- _action = def,- _dyreMode = def,- _logLevel = Log.INFO,- _dataDirectory = Nothing,- _feedsList = []}--- }}}--description :: [OptDescr (CliOptions -> CliOptions)]-description = [--- Action- Option "c" ["check"] (NoArg (set action $ Run Feed.Check)) "Check availability and validity of all feed sources currently configured, without writing any mail",- Option "l" ["list"] (NoArg (set action $ Run Feed.ShowStatus)) "List all feed sources currently configured, along with their status",- Option "R" ["mark-read"] (NoArg (set action $ Run Feed.MarkAsRead)) "Mark every item of processed feeds as read, ie set last update as now without writing any mail",- Option "U" ["mark-unread"] (NoArg (set action $ Run Feed.MarkAsUnread)) "Mark every item of processed feeds as unread, ie delete corresponding state files",- Option "u" ["update"] (NoArg (set action $ Run Feed.Update)) "Update list of feeds (mostly used option)",- Option "i" ["import"] (NoArg (set action Import)) "Import feeds list from an OPML descriptor (read from stdin)",- Option "h" ["help"] (NoArg (set action Help)) "Print this help",- Option "V" ["version"] (NoArg (set action ShowVersion)) "Print version",- Option "r" ["recompile"] (NoArg (set action Recompile)) "Only recompile configuration",--- Dynamic configuration- Option "1" ["vanilla"] (NoArg (set dyreMode Vanilla)) "Do not read custom configuration file",- Option [] ["force-reconf"] (NoArg (set dyreMode ForceReconfiguration)) "Recompile configuration before starting the program",- Option [] ["deny-reconf"] (NoArg (set dyreMode IgnoreReconfiguration)) "Do not recompile configuration even if it has changed",- Option [] ["dyre-debug"] (NoArg id) "Use './cache/' as the cache directory and ./ as the configuration directory. Useful to debug the program",--- Log level- Option "q" ["quiet"] (NoArg (set logLevel Log.ERROR)) "Do not print any log",- Option "v" ["verbose"] (NoArg (set logLevel Log.DEBUG)) "Print detailed logs",--- Misc- Option "d" ["database"] (ReqArg (set dataDirectory . Just) "PATH") "Where feeds' state (last update time) will be stored"]---- | Usage text (printed when using 'Help' action)-usage :: String-usage = flip usageInfo description . unlines $- "Usage: imm [OPTIONS] [URI]":- "":- "Convert items from RSS/Atom feeds to maildir entries.":- "If one or more URI(s) are given, they will be processed instead of the feeds list from configuration.":[]---- | Get and parse commandline options-get :: (MonadBase IO m) => m CliOptions-get = io $ do- parsedArgs <- getOpt' Permute description <$> getArgs- case parsedArgs of- (opts, input, _, []) -> do- let (errors, valids) = partitionEithers $ map parseURI' input- unless (null errors) $ io . putStrLn $ unlines errors- return $ set feedsList valids (foldl (flip id) def opts)- (_, _, _, _) -> return def- where- parseURI' uri = maybe (Left $ "Invalid URI given in commandline: " ++ uri) Right $ N.parseURI uri
@@ -1,29 +0,0 @@-module Imm.Util (- module X,- (>/>),- io,-) where---- {{{ Imports-import Control.Applicative as X-import Control.Conditional as X hiding(unless)-import Control.Monad.Base as X--import Data.Char as X-import Data.Default as X-import Data.Either as X-import Data.Functor as X-import Data.List as X hiding(foldl, init, sum)-import Data.Maybe as X--import System.FilePath--- }}}----- | Like '</>' with first argument in IO to build platform-dependent paths.-(>/>) :: (MonadBase IO m) => IO FilePath -> FilePath -> m FilePath-(>/>) a b = io $ (</> b) <$> a---- | Shortcut to 'liftBase' with 'IO' as base monad-io :: MonadBase IO m => IO a -> m a-io = liftBase
@@ -1,58 +0,0 @@-=========-About imm-=========--In a nutshell----------------**Technically**, *imm* is a little tool that retrieves a list of RSS/Atom feeds and fills a maildir with new items.--**Functionally**, *imm* makes it possible to use mail readers for feeds, for the sake of *I-want-the-mutt-of-feed-readers* zealots.--*imm* is written and configured in *Haskell*.--Informations about versions, dependencies, source repositories and contacts can be found in hackage_.---Need & purpose-----------------Following numerous RSS/Atom feeds needs organization and aggregation.-Such needs are usually met by feed readers.-Although there are a lot of those, some people still feel unsatisfied with the existing implementations.--The expected features of a feed reader could be defined as follows:--- it retrieves items with the following attributes: an author, a date/time, a (possibly enriched) body;-- items can be sorted, categorized, marked as read/unread, tagged, shared/forwarded;-- items must be available from anywhere on the internet.--Luckily, there's already a widespread solution that provides such features: mail readers.-Considering that, *imm* aims at projecting the RSS/Atom paradigm onto the mail one; this way, all the existing tools that work on mails can be leveraged to work on RSS/Atom feeds as well, no wheel reinventing.---Function-----------*imm* does only one thing and does it well: it downloads an RSS/Atom feeds list, and for each new item it writes a file in a local maildir. How and where to write mail files is setup in *imm*'s configuration file.--No *SMTP* sending, no *IMAP* serving, no aggregating: those should be performed by external tools.---Example usage----------------It is possible to setup a Google Reader-like on a server using the following steps:--- schedule *imm* to check feeds regularly and write new items into a local maildir;-- setup an IMAP server to publish the aforementioned maildir;-- setup a webmail, bound to the IMAP server above, to read feeds from any computer connected to the internet.---Getting started------------------To get started, please fill the configuration file at ``~/.config/imm/imm.hs`` with your feeds list and settings. An example configuration file is provided with the package. Configuring *imm* requires basic knowledge of *Haskell* language.---.. _hackage: http://hackage.haskell.org/package/imm
@@ -0,0 +1,28 @@+# imm++## In a nutshell++*imm* is a tool that does only one thing: it retrieves a list of RSS/Atom feeds, and executes arbitrary actions for each of them (e.g. sending a mail).++Notably, *imm* makes it possible to use mail readers for feeds, for the sake of *I-want-the-mutt-of-feed-readers* zealots.++*imm* is written and configured in *Haskell*. To get started, please consult documentation of `Imm.Boot` module.++Informations about versions, dependencies, source repositories and contacts can be found in [hackage][1].+++## Rationale++Following numerous RSS/Atom feeds needs organization and aggregation, which is usually accomplished through feed readers.+Although there are a lot of those, some people still feel unsatisfied with the existing implementations.++The expected features of a feed reader could be defined as follows:++- it retrieves a bunch of items that have some attributes: an author, a date/time, a (possibly enriched) body;+- items can be sorted, categorized, marked as read/unread, tagged, shared/forwarded;+- items must be available from anywhere on the internet.++Luckily, there's already a widespread solution that provides such features: mail readers.+Considering that, *imm* can project the RSS/Atom paradigm onto the mail one; this way, all the existing tools that work on mails can be leveraged to work on RSS/Atom feeds as well, no wheel reinventing.++[1]: http://hackage.haskell.org/package/imm
@@ -1,97 +1,51 @@-Name: imm-Version: 0.6.0.3-Synopsis: Retrieve RSS/Atom feeds and write one mail per new item in a maildir.-Description: Cf README---Homepage:-Category: Web--License: OtherLicense-License-file: LICENSE--- Copyright:-Author: kamaradclimber, koral-Maintainer: koral att mailoo dott org--Cabal-version: >=1.8-Build-type: Simple-Extra-source-files: README--Source-repository head- Type: mercurial- Location: https://bitbucket.org/k0ral/imm--flag network-uri- description: Get Network.URI from the network-uri package- default: True+name: imm+version: 1.0.0.0+synopsis: Execute arbitrary actions for each unread element of RSS/Atom feeds+--description:+homepage: https://github.com/k0ral/imm+license: OtherLicense+license-file: LICENSE+author: kamaradclimber, koral+maintainer: koral <koral@mailoo.org>+category: Web+build-type: Simple+cabal-version: >=1.8+extra-source-files: README.md -Library- Exposed-modules:- Imm,- Imm.Boot,- Imm.Config,- Imm.Core,- Imm.Database,- Imm.Error,- Imm.Feed,- Imm.HTTP,- Imm.Mail,- Imm.Maildir,- Imm.Util- Other-modules:- Imm.Dyre,- Imm.OPML,- Imm.Options,- Paths_imm- Build-depends:- async,- base == 4.*,- bytestring,- case-insensitive,- cond,- data-default,- directory,- dyre,- feed == 0.3.9.2,- filepath,- hslogger,- http-conduit >= 2.0 && < 2.2,- http-types,- lens,- mime-mail,- monad-control,- mtl,- resourcet,- old-locale,- opml,- random,- text,- text-icu,- transformers-base,- transformers,- time,- timerep >= 1.0.3,- tls >= 1.2 && < 1.3,- utf8-string,- xdg-basedir,- xml- if flag(network-uri)- build-depends: network-uri >= 2.6, network >= 2.6- else- build-depends: network-uri < 2.6, network < 2.6- Extensions:- ConstraintKinds,- KindSignatures,- FlexibleContexts,- FlexibleInstances,- FunctionalDependencies,- GeneralizedNewtypeDeriving,- MultiParamTypeClasses,- RankNTypes+source-repository head+ type: git+ location: git://github.com/k0ral/imm.git - -- Build-tools:- Ghc-options: -Wall+library+ exposed-modules:+ Imm+ Imm.Boot+ Imm.Core+ Imm.Database+ Imm.Database.FeedTable+ Imm.Database.JsonFile+ Imm.Feed+ Imm.Hooks+ Imm.Hooks.SendMail+ Imm.HTTP+ Imm.HTTP.Simple+ Imm.Logger+ Imm.Logger.Simple+ Imm.Prelude+ other-modules:+ Imm.Aeson+ Imm.Dyre+ Imm.Error+ Imm.Options+ Imm.Pretty+ Paths_imm+ build-depends: aeson, atom-conduit, base == 4.*, bytestring, case-insensitive, chunked-data, comonad, compdata, conduit, conduit-combinators, conduit-parse, connection, containers, directory, dyre, directory, exceptions, fast-logger, filepath, free, hashable, HaskellNet, HaskellNet-SSL >= 0.3.3.0, http-client, http-client-tls, http-types, mime-mail, monoid-subclasses, mono-traversable, network, opml-conduit, optparse-applicative, rainbow, rainbox, rss-conduit, text, transformers, time, timerep >= 2.0.0.0, tls, uri-bytestring, xml, xml-conduit, ansi-wl-pprint+ -- Build-tools:+ hs-source-dirs: src/lib+ ghc-options: -Wall -fno-warn-unused-do-bind -Executable imm- Build-depends: imm, base == 4.*- Main-is: Executable.hs- Hs-Source-Dirs: Imm- Ghc-options: -Wall -threaded+executable imm+ build-depends: imm, base == 4.*, free+ main-is: Executable.hs+ hs-source-dirs: src/bin+ ghc-options: -Wall -fno-warn-unused-do-bind -threaded
@@ -0,0 +1,30 @@+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+--module Executable where++-- {{{ Imports+import Imm+import Imm.Database.JsonFile+import Imm.Feed+import Imm.Hooks+import Imm.HTTP.Simple+import Imm.Logger.Simple+import Imm.Prelude++import System.Exit+-- }}}++mkDummyCoHooks :: (MonadIO m, MonadThrow m) => () -> CoHooksF m ()+mkDummyCoHooks _ = CoHooksF coOnNewElement where+ coOnNewElement _ _ = do+ io $ putStrLn "No hook defined."+ throwM $ ExitFailure 1+++main :: IO ()+main = do+ logger <- defaultLogger+ manager <- defaultManager+ database <- defaultDatabase++ imm (mkCoHttpClient, manager) (mkCoDatabase, database) (mkCoLogger, logger) (mkDummyCoHooks, ())
@@ -0,0 +1,12 @@+-- | Meta-module that reexports many Imm sub-modules.+--+-- To get started, please consult "Imm.Boot".+module Imm (module X) where++import Imm.Boot as X+import Imm.Core as X+import Imm.Database as X+import Imm.Feed as X+import Imm.Hooks as X+import Imm.HTTP as X+import Imm.Logger as X
@@ -0,0 +1,19 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+module Imm.Aeson where++-- {{{ Imports+import Imm.Prelude++import Data.Aeson++import URI.ByteString+-- }}}++parseJsonURI :: (MonadPlus m) => Value -> m URI+parseJsonURI (String s) = either (const mzero) return $ parseURI laxURIParserOptions $ encodeUtf8 s+parseJsonURI _ = mzero++toJsonURI :: URI -> Value+toJsonURI = String . decodeUtf8 . serializeURIRef'
@@ -0,0 +1,137 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE TypeOperators #-}+module Imm.Boot (imm) where++-- {{{ Imports+import qualified Imm.Core as Core+import Imm.Database.FeedTable as Database+import Imm.Database as Database+import Imm.Dyre as Dyre+import Imm.Feed+import Imm.HTTP as HTTP+import Imm.Hooks+import Imm.Logger as Logger+import Imm.Options as Options hiding(logLevel)+import Imm.Prelude++import Control.Comonad.Cofree+import Control.Monad.Trans.Free++import System.IO (hFlush)+-- }}}++-- | Main function, meant to be used in your personal configuration file,+-- by default located at @$XDG_CONFIG_HOME\/imm\/imm.hs@.+--+-- For more information about the dynamic reconfiguration system, please consult "Config.Dyre".+--+-- Here is an example:+--+-- > import Imm.Boot+-- > import Imm.Database.JsonFile+-- > import Imm.Feed+-- > import Imm.Hooks.SendMail+-- > import Imm.HTTP.Simple+-- > import Imm.Logger.Simple+-- >+-- > main :: IO ()+-- > main = do+-- > logger <- defaultLogger+-- > manager <- defaultManager+-- > database <- defaultDatabase+-- >+-- > imm (mkCoHttpClient, manager) (mkCoDatabase, database) (mkCoLogger, logger) (mkCoHooks, sendmail)+-- >+-- > sendmail :: SendMailSettings+-- > sendmail = SendMailSettings smtpServer formatMail+-- >+-- > formatMail :: FormatMail+-- > formatMail = FormatMail+-- > (\a b -> (defaultFormatFrom a b) { addressEmail = "user@host" } )+-- > defaultFormatSubject+-- > defaultFormatBody+-- > (\_ _ -> [Address Nothing "user@host"])+-- >+-- > smtpServer :: Feed -> FeedElement -> SMTPServer+-- > smtpServer _ _ = SMTPServer+-- > (Just $ Authentication PLAIN "user" "password")+-- > (StartTls "smtp.host" defaultSettingsSMTPSTARTTLS)+imm :: (a -> CoHttpClientF IO a, a) -- ^ HTTP client interpreter (cf "Imm.HTTP")+ -> (b -> CoDatabaseF' IO b, b) -- ^ Database interpreter (cf "Imm.Database")+ -> (c -> CoLoggerF IO c, c) -- ^ Logger interpreter (cf "Imm.Logger")+ -> (d -> CoHooksF IO d, d) -- ^ Hooks interpreter (cf "Imm.Hooks")+ -> IO ()+imm coHttpClient coDatabase coLogger coHooks = void $ do+ options <- parseOptions+ Dyre.wrap (optionDyreMode options) realMain (optionCommand options, optionLogLevel options, coiter next start)+ where (next, start) = mkCoImm coHttpClient coDatabase coLogger coHooks++realMain :: (MonadIO m, PairingM (CoImmF m) ImmF m, MonadCatch m)+ => (Command, LogLevel, Cofree (CoImmF m) a) -> m ()+realMain (command, logLevel, interpreter) = void $ interpret (\_ b -> return b) interpreter $ do+ setLogLevel logLevel+ logDebug $ "Executing: " <> show (pretty command)++ handleAll (logError . fromString . displayException) $ case command of+ Check t -> Core.check =<< resolveTarget ByPassConfirmation t+ Import -> Core.importOPML+ Read t -> mapM_ Database.markAsRead =<< resolveTarget AskConfirmation t+ Run t -> Core.run =<< resolveTarget ByPassConfirmation t+ Show t -> Core.showFeed =<< resolveTarget ByPassConfirmation t+ ShowVersion -> Core.printVersions+ Subscribe u c -> Core.subscribe u c+ Unread t -> mapM_ Database.markAsUnread =<< resolveTarget AskConfirmation t+ Unsubscribe t -> Database.deleteList FeedTable =<< resolveTarget AskConfirmation t+ _ -> return ()++ Database.commit FeedTable++ return ()++-- * DSL/interpreter model++type CoImmF m = CoHttpClientF m :*: CoDatabaseF' m :*: CoLoggerF m :*: CoHooksF m+type ImmF = HttpClientF :+: DatabaseF' :+: LoggerF :+: HooksF++mkCoImm :: (Functor m)+ => (a -> CoHttpClientF m a, a) -> (b -> CoDatabaseF' m b, b) -> (c -> CoLoggerF m c, c) -> (d -> CoHooksF m d, d)+ -> ((a ::: b ::: c ::: d) -> CoImmF m (a ::: b ::: c ::: d), a ::: b ::: c ::: d)+mkCoImm (coHttpClient, a) (coDatabase, b) (coLogger, c) (coHooks, d) =+ (coHttpClient *:* coDatabase *:* coLogger *:* coHooks, a >: b >: c >: d)+++-- * Util++data SafeGuard = AskConfirmation | ByPassConfirmation+ deriving(Eq, Show)++data InterruptedException = InterruptedException deriving(Eq, Show)+instance Exception InterruptedException where+ displayException _ = "Process interrupted"++promptConfirm :: (MonadIO m, MonadThrow m) => Text -> m ()+promptConfirm s = do+ hPut stdout $ s <> " Confirm [Y/n] "+ io $ hFlush stdout+ x <- getLine+ when (x /= ("" :: Text) && x /= ("Y" :: Text)) $ throwM InterruptedException+++resolveTarget :: (MonadIO m, MonadThrow m, Functor f, MonadFree f m, DatabaseF' :<: f, LoggerF :<: f)+ => SafeGuard -> Maybe Core.FeedRef -> m [FeedID]+resolveTarget s Nothing = do+ result <- keys <$> Database.fetchAll FeedTable+ when (s == AskConfirmation) . promptConfirm $ "This will affect " <> show (length result) <> " feeds."+ return result+resolveTarget _ (Just (FeedRef (Left i))) = do+ result <- fst . (!! i) . mapToList <$> Database.fetchAll FeedTable+ -- logInfo $ "Target(s): " <> show (pretty result)+ return $ singleton result+resolveTarget _ (Just (FeedRef (Right uri))) = return [FeedID uri]
@@ -0,0 +1,186 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE PartialTypeSignatures #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeOperators #-}+module Imm.Core (+-- * Types+ FeedRef,+-- * Actions+ printVersions,+ subscribe,+ showFeed,+ check,+ run,+ importOPML,+) where++-- {{{ Imports+import qualified Imm.Database.FeedTable as Database+import Imm.Database.FeedTable hiding(markAsRead, markAsUnread)+import qualified Imm.Database as Database+import Imm.Feed+import Imm.Hooks as Hooks+import Imm.HTTP (HttpClientF)+import qualified Imm.HTTP as HTTP+import Imm.Logger+import Imm.Prelude+import Imm.Pretty++-- import Control.Concurrent.Async.Lifted (Async, async, mapConcurrently, waitAny)+-- import Control.Concurrent.Async.Pool+import Control.Monad.Free++import qualified Data.ByteString as ByteString+import Data.Conduit+import Data.Conduit.Combinators as Conduit (stdin)+import Data.Conduit.Parser+import qualified Data.Map as Map+import Data.NonNull+import Data.Set (Set)+import Data.Tree+import Data.Version++import qualified Paths_imm as Package++import Rainbow hiding((<>))+import Rainbox++import System.Info++import Text.Atom.Conduit.Parse+import Text.Atom.Types+import Text.OPML.Conduit.Parse+import Text.OPML.Types as OPML+import Text.RSS.Conduit.Parse+import Text.RSS.Types+import Text.XML as XML ()+import Text.XML.Stream.Parse as XML++import URI.ByteString+-- }}}+++printVersions :: (MonadIO m) => m ()+printVersions = io $ do+ putStrLn $ "imm-" ++ showVersion Package.version+ putStrLn $ "compiled by " ++ compilerName ++ "-" ++ showVersion compilerVersion++-- | Print database status for given feed(s)+showFeed :: (MonadIO m, LoggerF :<: f, MonadThrow m, Functor f, MonadFree f m, DatabaseF' :<: f)+ => [FeedID] -> m ()+showFeed feedIDs = do+ feeds <- Database.fetchList FeedTable feedIDs+ if onull feeds then logWarning "No subscription" else putBox $ entryTableToBox feeds++-- | Register the given feed URI in database+subscribe :: (MonadIO m, LoggerF :<: f, Functor f, MonadFree f m, DatabaseF' :<: f, MonadCatch m)+ => URI -> Maybe Text -> m ()+subscribe uri category = Database.register (FeedID uri) $ fromMaybe "default" category++-- | Check for unread elements without processing them+check :: (MonadIO m, MonadCatch m, LoggerF :<: f, Functor f, MonadFree f m, DatabaseF' :<: f, HttpClientF :<: f)+ => [FeedID] -> m ()+check feedIDs = do+ results <- forM (zip ([1..] :: [Int]) feedIDs) $ \(i, f) -> do+ logInfo $ "Checking entry " <> show i <> "/" <> show total <> "..."+ try $ checkOne f++ putBox $ statusTableToBox $ mapFromList $ zip feedIDs results+ where total = length feedIDs++checkOne :: (MonadIO m, MonadCatch m, LoggerF :<: f, Functor f, MonadFree f m, DatabaseF' :<: f, HttpClientF :<: f)+ => FeedID -> m Int+checkOne feedID@(FeedID uri) = do+ body <- HTTP.get uri+ feed <- runConduit $ parseLBS def body =$= runConduitParser ((Left <$> atomFeed) <|> (Right <$> rssDocument))++ case feed of+ Left _ -> logDebug $ "Parsed Atom feed: " <> show (pretty feedID)+ Right _ -> logDebug $ "Parsed RSS feed: " <> show (pretty feedID)++ let dates = either+ (map entryUpdated . feedEntries)+ (mapMaybe itemPubDate . channelItems)+ feed++ logDebug . show . vsep $ either (map prettyEntry . feedEntries) (map prettyItem . channelItems) feed+ status <- Database.getStatus feedID++ return . length $ filter (unread status) dates+ where unread (LastUpdate t1) t2 = t2 > t1+ unread _ _ = True+++run :: (MonadIO m, MonadCatch m, HooksF :<: f, LoggerF :<: f, Functor f, MonadFree f m, DatabaseF' :<: f, HttpClientF :<: f)+ => [FeedID] -> m ()+run feedIDs = forM_ feedIDs $ \feedID@(FeedID uri) -> do+ body <- HTTP.get uri+ feed <- runConduit $ parseLBS def body =$= runConduitParser ((Atom <$> atomFeed) <|> (Rss <$> rssDocument))+ unreadElements <- filterM (fmap not . isRead feedID) $ getElements feed++ logInfo $ show (length unreadElements) <> " unread element(s) for " <> show (pretty feedID)++ forM_ unreadElements $ \element -> do+ onNewElement feed element+ mapM_ (Database.addReadHash feedID) $ getHashes element++ Database.markAsRead feedID+++isRead :: (Functor f, MonadCatch m, DatabaseF' :<: f, MonadFree f m) => FeedID -> FeedElement -> m Bool+isRead feedID element = do+ DatabaseEntry _ _ readHashes lastCheck <- Database.fetch FeedTable feedID+ let matchHash = not $ onull $ (setFromList (getHashes element) :: Set Int) `intersection` readHashes+ matchDate = case (lastCheck, getDate element) of+ (Nothing, _) -> False+ (_, Nothing) -> False+ (Just a, Just b) -> a > b+ return $ matchHash || matchDate++-- | 'subscribe' to all feeds described by the OPML document provided in input (stdin)+importOPML :: (MonadIO m, LoggerF :<: f, Functor f, MonadFree f m, DatabaseF' :<: f, MonadCatch m) => m ()+importOPML = do+ opml <- runConduit $ Conduit.stdin =$= XML.parseBytes def =$= runConduitParser parseOpml+ forM_ (opmlOutlines opml) $ importOPML' mempty++importOPML' :: (MonadIO m, LoggerF :<: f, Functor f, MonadFree f m, DatabaseF' :<: f, MonadCatch m)+ => Maybe Text -> Tree OpmlOutline -> m ()+importOPML' _ (Node (OpmlOutlineGeneric b _) sub) = mapM_ (importOPML' (Just . toNullable $ OPML.text b)) sub+importOPML' c (Node (OpmlOutlineSubscription _ s) _) = subscribe (xmlUri s) c+importOPML' _ _ = return ()+++-- * Boxes++putBox :: (Orientation a, MonadIO m) => Box a -> m ()+putBox = void . io . mapM ByteString.putStr . chunksToByteStrings toByteStringsColors256 . otoList . render++cell :: Text -> Cell+cell a = Cell (singleton $ singleton $ chunk a) top left mempty++type EntryTable = Map FeedID DatabaseEntry++entryTableToBox :: EntryTable -> Box Horizontal+entryTableToBox t = tableByColumns $ Rainbox.intersperse sep $ fromList [col1, col2, col3, col4] where+ result = sortBy (comparing entryURI) $ map snd $ mapToList t+ col1 = fromList $ cell "UID" : map (cell . show) [0..(length result - 1)]+ col2 = fromList $ cell "CATEGORY" : map (cell . entryCategory) result+ col3 = fromList $ cell "LAST CHECK" : map (cell . format . entryLastCheck) result+ col4 = fromList $ cell "FEED URI" : map (cell . show . prettyURI . entryURI) result+ format = maybe "<never>" show+ sep = fromList [separator mempty 1]+++type StatusTable = Map FeedID (Either SomeException Int)++statusTableToBox :: StatusTable -> Box Horizontal+statusTableToBox t = tableByColumns $ Rainbox.intersperse sep $ fromList [col1, col2, col3] where+ result = sortBy (comparing fst) $ Map.toList t+ col1 = fromList $ cell "# UNREAD" : map (cell . either (const "?") show . snd) result+ col2 = fromList $ cell "STATUS" : map (cell . either (fromString . displayException) (const "OK") . snd) result+ col3 = fromList $ cell "FEED" : map (cell . show . pretty . fst) result+ sep = fromList [separator mempty 2]
@@ -0,0 +1,162 @@+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE UndecidableInstances #-}+-- | DSL/interpreter model for a generic key-value database+module Imm.Database where++-- {{{ Imports+import Imm.Error+import Imm.Logger+import Imm.Prelude++import Control.Monad.Trans.Free+-- }}}++-- * DSL/interpreter++-- | Generic database table+class (Ord (Key t), Show (Key t), Show (Entry t), Typeable t, Show t, Pretty t, Pretty (Key t), Pretty (Entry t))+ => Table t where+ type Key t :: *+ type Entry t :: *++-- | Database DSL+data DatabaseF t next+ = FetchList t [Key t] (Either SomeException (Map (Key t) (Entry t)) -> next)+ | FetchAll t (Either SomeException (Map (Key t) (Entry t)) -> next)+ | Update t (Key t) (Entry t -> Entry t) (Either SomeException () -> next)+ | InsertList t [(Key t, Entry t)] (Either SomeException () -> next)+ | DeleteList t [Key t] (Either SomeException () -> next)+ | Purge t (Either SomeException () -> next)+ | Commit t (Either SomeException () -> next)+ deriving(Functor)++-- | Database interpreter+data CoDatabaseF t m a = CoDatabaseF+ { fetchListH :: [Key t] -> m (Either SomeException (Map (Key t) (Entry t)), a)+ , fetchAllH :: m (Either SomeException (Map (Key t) (Entry t)), a)+ , updateH :: Key t -> (Entry t -> Entry t) -> m (Either SomeException (), a)+ , insertListH :: [(Key t, Entry t)] -> m (Either SomeException (), a)+ , deleteListH :: [Key t] -> m (Either SomeException (), a)+ , purgeH :: m (Either SomeException (), a)+ , commitH :: m (Either SomeException (), a)+ } deriving(Functor)++instance Monad m => PairingM (CoDatabaseF t m) (DatabaseF t) m where+ -- pairM :: (a -> b -> m r) -> f a -> g b -> m r+ pairM p (CoDatabaseF fl _ _ _ _ _ _) (FetchList _ key next) = do+ (result, a) <- fl key+ p a $ next result+ pairM p (CoDatabaseF _ fa _ _ _ _ _) (FetchAll _ next) = do+ (result, a) <- fa+ p a $ next result+ pairM p (CoDatabaseF _ _ u _ _ _ _) (Update _ key f next) = do+ (result, a) <- u key f+ p a $ next result+ pairM p (CoDatabaseF _ _ _ i _ _ _) (InsertList _ rows next) = do+ (result, a) <- i rows+ p a $ next result+ pairM p (CoDatabaseF _ _ _ _ d _ _) (DeleteList _ k next) = do+ (result, a) <- d k+ p a $ next result+ pairM p (CoDatabaseF _ _ _ _ _ p' _) (Purge _ next) = do+ (result, a) <- p'+ p a $ next result+ pairM p (CoDatabaseF _ _ _ _ _ _ c) (Commit _ next) = do+ (result, a) <- c+ p a $ next result+++-- * Exception++data DatabaseException t+ = NotCommitted t+ | NotDeleted t [Key t]+ | NotFound t [Key t]+ | NotInserted t [(Key t, Entry t)]+ | NotPurged t+ | NotUpdated t (Key t)+ | UnableFetchAll t++deriving instance (Eq t, Eq (Key t), Eq (Entry t)) => Eq (DatabaseException t)+deriving instance (Show t, Show (Key t), Show (Entry t)) => Show (DatabaseException t)++instance (Table t, Show (Key t), Show (Entry t), Pretty (Key t), Typeable t) => Exception (DatabaseException t) where+ displayException = show . pretty++instance (Pretty t, Pretty (Key t)) => Pretty (DatabaseException t) where+ pretty (NotCommitted _) = text "Unable to commit database changes."+ pretty (NotDeleted _ x) = text "Unable to delete the following entries in database:" <++> indent 2 (vsep $ map pretty x)+ pretty (NotFound _ x) = text "Unable to find the following entries in database:" <++> indent 2 (vsep $ map pretty x)+ pretty (NotInserted _ x) = text "Unable to insert the following entries in database:" <++> indent 2 (vsep $ map (pretty . fst) x)+ pretty (NotPurged t) = text "Unable to purge database" <+> pretty t+ pretty (NotUpdated _ x) = text "Unable to update the following entry in database:" <++> indent 2 (pretty x)+ pretty (UnableFetchAll _) = text "Unable to fetch all entries from database."+++-- * Primitives++fetch :: (Functor f, MonadFree f m, DatabaseF t :<: f, Table t, MonadThrow m)+ => t -> Key t -> m (Entry t)+fetch t k = do+ results <- liftF . inj $ FetchList t [k] id+ result <- lookup k <$> liftE results+ maybe (throwM $ NotFound t [k]) return result++fetchList :: (Functor f, MonadFree f m, DatabaseF t :<: f, Table t, MonadThrow m)+ => t -> [Key t] -> m (Map (Key t) (Entry t))+fetchList t k = do+ result <- liftF . inj $ FetchList t k id+ liftE result++fetchAll :: (MonadThrow m, Functor f, MonadFree f m, DatabaseF t :<: f, Table t) => t -> m (Map (Key t) (Entry t))+fetchAll t = do+ result <- liftF . inj $ FetchAll t id+ liftE result++update :: (Functor f, MonadFree f m, DatabaseF t :<: f, Table t, MonadThrow m)+ => t -> Key t -> (Entry t -> Entry t) -> m ()+update t k f = do+ result <- liftF . inj $ Update t k f id+ liftE result++insert :: (MonadThrow m, Functor f, MonadFree f m, LoggerF :<: f, DatabaseF t :<: f, Table t)+ => t -> Key t -> Entry t -> m ()+insert t k v = insertList t [(k, v)]++insertList :: (MonadThrow m, Functor f, MonadFree f m, LoggerF :<: f, DatabaseF t :<: f, Table t)+ => t -> [(Key t, Entry t)] -> m ()+insertList t i = do+ logInfo $ "Inserting " <> show (length i) <> " entrie(s)..."+ result <- liftF . inj $ InsertList t i id+ liftE result++delete :: (MonadThrow m, Functor f, MonadFree f m, LoggerF :<: f, DatabaseF t :<: f, Table t) => t -> Key t -> m ()+delete t k = deleteList t [k]++deleteList :: (MonadThrow m, Functor f, MonadFree f m, LoggerF :<: f, DatabaseF t :<: f, Table t)+ => t -> [Key t] -> m ()+deleteList t k = do+ logInfo $ "Deleting " <> show (length k) <> " entrie(s)..."+ result <- liftF . inj $ DeleteList t k id+ liftE result++purge :: (MonadThrow m, Functor f, MonadFree f m, DatabaseF t :<: f, LoggerF :<: f, Table t) => t -> m ()+purge t = do+ logInfo "Purging database..."+ result <- liftF . inj $ Purge t id+ liftE result++commit :: (MonadThrow m, Functor f, MonadFree f m, DatabaseF t :<: f, LoggerF :<: f, Table t) => t -> m ()+commit t = do+ logDebug "Committing database transaction..."+ result <- liftF . inj $ Commit t id+ liftE result+ logDebug "Database transaction committed"
@@ -0,0 +1,131 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeOperators #-}+-- | Feed table definitions. This is a specialization of "Imm.Database".+module Imm.Database.FeedTable where++-- {{{ Imports+import Imm.Aeson+import Imm.Database+import Imm.Logger+import Imm.Prelude+import Imm.Pretty++import Control.Monad.Trans.Free++import Data.Aeson+import Data.Set (Set)+import Data.Time as Time++import URI.ByteString+-- }}}++-- * Types++-- | Unique key in feeds table+newtype FeedID = FeedID URI+ deriving(Eq, Ord, Show)++instance FromJSON FeedID where+ parseJSON = fmap FeedID . parseJsonURI++instance ToJSON FeedID where+ toJSON (FeedID uri) = toJsonURI uri++instance Pretty FeedID where+ pretty (FeedID uri) = prettyURI uri+++data DatabaseEntry = DatabaseEntry+ { entryURI :: URI+ , entryCategory :: Text+ , entryReadHashes :: Set Int+ , entryLastCheck :: Maybe UTCTime+ } deriving(Eq, Show)++instance Pretty DatabaseEntry where+ pretty r = text "Entry:" <+> prettyURI (entryURI r) <++> indent 2+ ( text "Category:" <+> text (fromText $ entryCategory r)+ <++> text "Last check:" <+> text (maybe "<never>" (formatTime defaultTimeLocale rfc822DateFormat) $ entryLastCheck r)+ <++> text "Read hashes:" <+> text (show $ length $ entryReadHashes r)+ )++instance FromJSON DatabaseEntry where+ parseJSON (Object v) = DatabaseEntry <$> (parseJsonURI =<< v .: "uri") <*> v .: "category" <*> v.: "readHashes" <*> v .: "lastCheck"+ parseJSON _ = mzero++instance ToJSON DatabaseEntry where+ toJSON entry = object+ [ "uri" .= toJsonURI (entryURI entry)+ , "category" .= entryCategory entry+ , "readHashes" .= entryReadHashes entry+ , "lastCheck" .= entryLastCheck entry+ ]++newDatabaseEntry :: FeedID -> Text -> DatabaseEntry+newDatabaseEntry (FeedID uri) category = DatabaseEntry uri category mempty Nothing++-- | Singleton type to represent feeds table+data FeedTable = FeedTable+ deriving(Show)++instance Pretty FeedTable where+ pretty _ = "Feeds table"++instance Table FeedTable where+ type Key FeedTable = FeedID+ type Entry FeedTable = DatabaseEntry+++data FeedStatus = Unknown | New | LastUpdate UTCTime++instance Pretty FeedStatus where+ pretty Unknown = text "Unknown"+ pretty New = text "New"+ pretty (LastUpdate x) = text "Last update:" <+> text (formatTime defaultTimeLocale rfc822DateFormat x)+++data Database = Database [DatabaseEntry]+ deriving (Eq, Show)++type DatabaseF' = DatabaseF FeedTable+type CoDatabaseF' = CoDatabaseF FeedTable++-- * Primitives++register :: (MonadThrow m, LoggerF :<: f, DatabaseF' :<: f, Functor f, MonadFree f m)+ => FeedID -> Text -> m ()+register feedID category = do+ logInfo $ "Registering feed " <> show (brackets $ pretty feedID) <> "..."+ insert FeedTable feedID $ newDatabaseEntry feedID category++getStatus :: (DatabaseF' :<: f, Functor f, MonadFree f m, MonadCatch m)+ => FeedID -> m FeedStatus+getStatus feedID = handleAll (\_ -> return Unknown) $ do+ result <- fmap Just (fetch FeedTable feedID) `catchAll` (\_ -> return Nothing)+ return $ maybe New LastUpdate $ entryLastCheck =<< result++addReadHash :: (DatabaseF' :<: f, Functor f, MonadFree f m, MonadThrow m, LoggerF :<: f)+ => FeedID -> Int -> m ()+addReadHash feedID hash = do+ logDebug $ "Adding read hash: " <> show hash <> "..."+ update FeedTable feedID f+ where f a = a { entryReadHashes = insertSet hash $ entryReadHashes a }++-- | Set the last check time to now+markAsRead :: (MonadIO m, DatabaseF' :<: f, Functor f, MonadFree f m, MonadThrow m, LoggerF :<: f)+ => FeedID -> m ()+markAsRead feedID = do+ logInfo $ "Marking feed as read: " <> show (pretty feedID) <> "..."+ currentTime <- io Time.getCurrentTime+ update FeedTable feedID (f currentTime)+ where f time a = a { entryLastCheck = Just time }++-- | Unset feed's last update and remove all read hashes+markAsUnread :: (DatabaseF' :<: f, Functor f, MonadFree f m, MonadThrow m, LoggerF :<: f)+ => FeedID -> m ()+markAsUnread feedID = do+ logInfo $ "Marking feed as unread: " <> show (pretty feedID) <> "..."+ update FeedTable feedID $ \a -> a { entryReadHashes = mempty, entryLastCheck = Nothing }
@@ -0,0 +1,127 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedLists #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE UndecidableInstances #-}+-- | Database interpreter based on a JSON file+module Imm.Database.JsonFile (module Imm.Database.JsonFile, module Reexport) where++-- {{{ Imports+import Imm.Database hiding (commit, delete, fetchAll,+ insert, purge, update)+import Imm.Database.FeedTable as Reexport+import Imm.Error+import Imm.Prelude hiding (catch, delete, keys)++import Data.Aeson+import qualified Data.Map as Map+import qualified Data.Set as Set++import System.Directory+import System.FilePath+import System.IO (IOMode (..), openFile)+-- }}}++-- * Types++data CacheStatus = Empty | Clean | Dirty+ deriving(Eq, Show)++data JsonFileDatabase t = JsonFileDatabase FilePath (Map (Key t) (Entry t)) CacheStatus++instance Pretty (JsonFileDatabase t) where+ pretty (JsonFileDatabase file _ _) = "JSON database: " <+> text file++mkJsonFileDatabase :: (Table t) => FilePath -> JsonFileDatabase t+mkJsonFileDatabase file = JsonFileDatabase file mempty Empty++-- | Default database is stored in @$XDG_DATA_HOME\/imm\/feeds.json@+defaultDatabase :: Table t => IO (JsonFileDatabase t)+defaultDatabase = mkJsonFileDatabase <$> getXdgDirectory XdgData "imm/feeds.json"+++data JsonException = UnableDecode+ deriving(Eq, Show)++instance Exception JsonException where+ displayException _ = "Unable to parse JSON"+++-- * Interpreter++-- | Interpreter for 'DatabaseF'+mkCoDatabase :: (Table t, FromJSON (Key t), FromJSON (Entry t), ToJSON (Key t), ToJSON (Entry t), MonadIO m, MonadCatch m)+ => JsonFileDatabase t -> CoDatabaseF t m (JsonFileDatabase t)+mkCoDatabase t = CoDatabaseF coFetch coFetchAll coUpdate coInsert coDelete coPurge coCommit where+ coFetch keys = do+ (cache, t') <- coFetchAll+ let result = fmap (Map.filterWithKey (\uri _ -> member uri $ Set.fromList keys)) cache+ return (result, t')+ coFetchAll = handleAll (\e -> return (Left e, t)) $ do+ t'@(JsonFileDatabase _ cache _) <- loadInCache t+ return (Right cache, t')+ coUpdate key f = exec (\a -> update a key f)+ coInsert rows = exec (`insert` rows)+ coDelete keys = exec (`delete` keys)+ coPurge = exec purge+ coCommit = exec commit+ exec f = handleAll (\e -> return (Left e, t)) $ (Right (),) <$> f t+++-- * Low-level implementation++loadInCache :: (Table t, MonadIO m, MonadCatch m, FromJSON (Key t), FromJSON (Entry t))+ => JsonFileDatabase t -> m (JsonFileDatabase t)+loadInCache t@(JsonFileDatabase file _ status) = case status of+ Empty -> do+ io $ createDirectoryIfMissing True $ takeDirectory file+ fileContent <- hGetContents =<< io (openFile file ReadWriteMode)+ cache <- (`failWith` UnableDecode) $ fmap Map.fromList $ decode $ fromEmpty "[]" fileContent+ return $ JsonFileDatabase file cache Clean+ _ -> return t+ where fromEmpty x "" = x+ fromEmpty _ y = y+++insert :: (Table t, MonadIO m, MonadCatch m, FromJSON (Key t), FromJSON (Entry t))+ => JsonFileDatabase t -> [(Key t, Entry t)] -> m (JsonFileDatabase t)+insert t rows = insertInCache rows <$> loadInCache t++insertInCache :: Table t => [(Key t, Entry t)] -> JsonFileDatabase t -> JsonFileDatabase t+insertInCache rows (JsonFileDatabase file cache _) = JsonFileDatabase file (Map.union cache $ Map.fromList rows) Dirty+++update :: (Table t, MonadIO m, MonadCatch m, FromJSON (Key t), FromJSON (Entry t))+ => JsonFileDatabase t -> Key t -> (Entry t -> Entry t) -> m (JsonFileDatabase t)+update t key f = updateInCache key f <$> loadInCache t++updateInCache :: Table t => Key t -> (Entry t -> Entry t) -> JsonFileDatabase t -> JsonFileDatabase t+updateInCache key f (JsonFileDatabase file cache _) = JsonFileDatabase file newCache Dirty where+ newCache = Map.update (Just . f) key cache++delete :: (Table t, MonadIO m, MonadCatch m, FromJSON (Key t), FromJSON (Entry t))+ => JsonFileDatabase t -> [Key t] -> m (JsonFileDatabase t)+delete t keys = deleteInCache keys <$> loadInCache t++deleteInCache :: Table t => [Key t] -> JsonFileDatabase t -> JsonFileDatabase t+deleteInCache keys (JsonFileDatabase file cache _) = JsonFileDatabase file newCache Dirty where+ newCache = foldr Map.delete cache keys++purge :: (Table t, MonadIO m, MonadCatch m, FromJSON (Key t), FromJSON (Entry t))+ => JsonFileDatabase t -> m (JsonFileDatabase t)+purge t = purgeInCache <$> loadInCache t++purgeInCache :: Table t => JsonFileDatabase t -> JsonFileDatabase t+purgeInCache (JsonFileDatabase file _ _) = JsonFileDatabase file mempty Dirty++commit :: (Table t, MonadIO m, ToJSON (Key t), ToJSON (Entry t))+ => JsonFileDatabase t -> m (JsonFileDatabase t)+commit t@(JsonFileDatabase file cache status) = case status of+ Dirty -> do+ writeFile file $ encode $ Map.toList cache+ return $ JsonFileDatabase file cache Clean+ _ -> return t
@@ -0,0 +1,70 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE TypeFamilies #-}+module Imm.Dyre+ ( Mode(..)+ , defaultMode+ , wrap+ , recompile+ ) where++-- {{{ Imports+import Imm.Prelude++import Config.Dyre+import Config.Dyre.Compile+import Config.Dyre.Paths+-- }}}++-- | How dynamic reconfiguration process should behave.+data Mode = Normal | Vanilla | ForceReconfiguration | IgnoreReconfiguration+ deriving(Eq, Show)++-- | Default mode is 'Normal', that is: use custom configuration file and recompile if change detected.+defaultMode :: Mode+defaultMode = Normal+++-- | Describe the paths used for dynamic reconfiguration+describePaths :: (IsString t, MonadIO m) => m t+describePaths = io $ do+ (a, b, c, d, e) <- getPaths baseParameters+ return $ fromString $ unlines+ [ "Current binary: " <> a+ , "Custom binary: " <> b+ , "Config file: " <> c+ , "Cache directory: " <> d+ , "Lib directory: " <> e+ ]++-- | Dynamic reconfiguration settings+parameters :: Mode -> (a -> IO ()) -> Params (Either Text a)+parameters mode main = baseParameters+ { configCheck = mode /= Vanilla+ , realMain = main'+ }+ where+ main' (Left e) = hPutStrLn stderr e+ main' (Right x) = main x+ -- logDebugN . ("Dynamic reconfiguration paths:\n" ++) =<< describePaths++baseParameters :: Params (Either Text a)+baseParameters = defaultParams+ { projectName = "imm"+ , showError = const (Left . fromString)+ , ghcOpts = ["-threaded"]+ , statusOut = hPutStrLn stderr+ , includeCurrentDirectory = False+ }++wrap :: Mode -> (a -> IO ()) -> a -> IO ()+wrap mode result args = wrapMain (parameters mode result) (Right args)+++-- | Launch a recompilation of the configuration file+recompile :: (MonadIO m) => m (Maybe Text)+recompile = io $ do+ customCompile baseParameters+ fmap fromString <$> getErrorString baseParameters
@@ -0,0 +1,17 @@+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+module Imm.Error (module Imm.Error) where++-- {{{ Imports+import Imm.Prelude+-- }}}++liftE :: (MonadThrow m, Exception e) => Either e a -> m a+liftE (Left e) = throwM e+liftE (Right a) = return a++-- | Wrap a 'Maybe' value in 'MonadError'+failWith, (<!>) :: (MonadThrow m, Exception e) => Maybe a -> e -> m a+failWith x e = maybe (throwM e) return x+(<!>) = failWith
@@ -0,0 +1,55 @@+{-# LANGUAGE NoImplicitPrelude #-}+-- | Helpers to manipulate feeds+module Imm.Feed where++-- {{{ Imports+import Imm.Prelude+import Imm.Pretty++import Data.Hashable+import Data.NonNull+import Data.Time++import Text.Atom.Types+import Text.RSS.Types++import URI.ByteString+-- }}}++-- * Types++-- | Feed reference: either its URI, or its UID from database+newtype FeedRef = FeedRef (Either Int URI)+ deriving(Eq, Show)++instance Pretty FeedRef where+ pretty (FeedRef (Left n)) = text "feed" <+> text (show n)+ pretty (FeedRef (Right u)) = prettyURI u++data Feed = Rss RssDocument | Atom AtomFeed+ deriving(Eq, Show)++data FeedElement = RssElement RssItem | AtomElement AtomEntry+ deriving(Eq, Show)+++-- * Generic getters++getElements :: Feed -> [FeedElement]+getElements (Rss doc) = map RssElement $ channelItems doc+getElements (Atom feed) = map AtomElement $ feedEntries feed++getDate :: FeedElement -> Maybe UTCTime+getDate (RssElement item) = itemPubDate item+getDate (AtomElement entry) = Just $ entryUpdated entry++getTitle :: FeedElement -> Text+getTitle (RssElement item) = itemTitle item+getTitle (AtomElement entry) = show $ prettyAtomText $ entryTitle entry++getHashes :: FeedElement -> [Int]+getHashes (RssElement item) = map (hash . (show :: Doc -> String) . prettyGuid) (maybeToList $ itemGuid item)+ <> map ((hash :: String -> Int) . show . withRssURI prettyURI) (maybeToList $ itemLink item)+ <> [hash $ itemTitle item]+ <> [hash $ itemDescription item]+getHashes (AtomElement entry) = [hash $ toNullable $ entryId entry, (hash :: String -> Int) $ show $ prettyAtomText $ entryTitle entry]
@@ -0,0 +1,48 @@+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeOperators #-}+-- | DSL/interpreter model for the HTTP client+module Imm.HTTP where++-- {{{ Imports+import Imm.Error+import Imm.Logger+import Imm.Prelude+import Imm.Pretty++import Control.Monad.Trans.Free++import URI.ByteString+-- }}}++-- * Types++-- | HTTP client DSL+data HttpClientF next+ = Get URI (Either SomeException LByteString -> next)+ deriving(Functor)++-- | HTTP client interpreter+data CoHttpClientF m a = CoHttpClientF+ { getH :: URI -> m (Either SomeException LByteString, a)+ } deriving(Functor)++instance Monad m => PairingM (CoHttpClientF m) HttpClientF m where+ -- pairM :: (a -> b -> m r) -> f a -> g b -> m r+ pairM p (CoHttpClientF g) (Get uri next) = do+ (result, a) <- g uri+ p a $ next result++-- * Primitives++-- | Perform an HTTP GET request+get :: (MonadFree f m, Functor f, HttpClientF :<: f, LoggerF :<: f, MonadThrow m)+ => URI -> m LByteString+get uri = do+ logDebug $ "Fetching " <> show (prettyURI uri)+ result <- liftF . inj $ Get uri id+ liftE result
@@ -0,0 +1,51 @@+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+-- | Simple HTTP client interpreter.+-- For more information, please consult "Network.HTTP.Client".+module Imm.HTTP.Simple (defaultManager, mkCoHttpClient, module Reexport) where++-- {{{ Imports+import Imm.HTTP+import Imm.Prelude+import Imm.Pretty++import Data.CaseInsensitive++import Network.Connection as Reexport+import Network.HTTP.Client as Reexport+import Network.HTTP.Client.TLS as Reexport++import URI.ByteString+-- }}}++-- | Interpreter for 'HttpClientF'+mkCoHttpClient :: (MonadIO m, MonadCatch m) => Manager -> CoHttpClientF m Manager+mkCoHttpClient manager = CoHttpClientF coGet where+ coGet uri = handleAll (\e -> return (Left e, manager)) $ do+ result <- httpGet manager uri+ return (Right result, manager)++-- | Default manager uses TLS and no proxy+defaultManager :: IO Manager+defaultManager = newManager $ mkManagerSettings (TLSSettingsSimple False False False) Nothing+++-- | Perform an HTTP GET request and return the response body+httpGet :: (MonadIO m, MonadThrow m)+ => Manager -> URI -> m LByteString+httpGet manager uri = do+ request <- makeRequest uri+ responseBody <$> io (httpLbs request manager)+ -- codec' <- reader $ view (config.codec)+ -- return $ response $=+ decode codec'++parseUrl' :: (MonadThrow m) => URI -> m Request+parseUrl' = parseUrl . show . prettyURI++-- | Build an HTTP request for given URI+makeRequest :: (MonadIO m, MonadThrow m) => URI -> m Request+makeRequest uri = do+ req <- parseUrl' uri+ return $ req { requestHeaders = [+ (mk "User-Agent", "Mozilla/4.0"),+ (mk "Accept", "*/*")]}
@@ -0,0 +1,42 @@+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeOperators #-}+-- | DSL/interpreter model for hooks, ie various events that can trigger arbitrary actions+module Imm.Hooks where++-- {{{ Imports+import Imm.Feed+import Imm.Logger+import Imm.Prelude++import Control.Monad.Free.Class+-- }}}++-- * Types++-- | Hooks DSL+data HooksF next+ = OnNewElement Feed FeedElement next+ deriving(Functor)++-- | Hooks interpreter+data CoHooksF m a = CoHooksF+ { onNewElementH :: Feed -> FeedElement -> m a -- ^ Triggered for each unread feed element+ } deriving(Functor)++instance Monad m => PairingM (CoHooksF m) HooksF m where+ -- pairM :: (a -> b -> m r) -> f a -> g b -> m r+ pairM p (CoHooksF f) (OnNewElement feed element next) = do+ a <- f feed element+ p a next++-- * Primitives++onNewElement :: (Functor f, MonadFree f m, LoggerF :<: f, HooksF :<: f) => Feed -> FeedElement -> m ()+onNewElement feed element = do+ logDebug $ "Unread element: " <> getTitle element+ liftF . inj $ OnNewElement feed element ()
@@ -0,0 +1,149 @@+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeOperators #-}+-- | Hooks interpreter that sends a mail via a SMTP server for each unread element+-- You may want to consult "Network.HaskellNet.SMTP", "Network.HaskellNet.SMTP.SSL" and "Network.Mail.Mime" modules for additional information.+--+-- Here is an example configuration:+--+-- > sendmail :: SendMailSettings+-- > sendmail = SendMailSettings smtpServer formatMail+-- >+-- > formatMail :: FormatMail+-- > formatMail = FormatMail+-- > (\a b -> (defaultFormatFrom a b) { addressEmail = "user@host" } )+-- > defaultFormatSubject+-- > defaultFormatBody+-- > (\_ _ -> [Address Nothing "user@host"])+-- >+-- > smtpServer :: Feed -> FeedElement -> SMTPServer+-- > smtpServer _ _ = SMTPServer+-- > (Just $ Authentication PLAIN "user" "password")+-- > (StartTls "smtp.server" defaultSettingsSMTPSTARTTLS)+--+module Imm.Hooks.SendMail (module Imm.Hooks.SendMail, module Reexport) where++-- {{{ Imports+import Imm.Feed+import Imm.Hooks+import Imm.Prelude+import Imm.Pretty++import Data.NonNull+import Data.Time++import Network.HaskellNet.SMTP as Reexport+import Network.HaskellNet.SMTP.SSL as Reexport+import Network.Mail.Mime as Reexport hiding (sendmail)+import Network.Socket++import Text.Atom.Types+import Text.RSS.Types+-- }}}++-- * Settings++type Username = String+type Password = String+type ServerName = String++-- | How to connect to the SMTP server+data ConnectionSettings = Plain ServerName PortNumber | Ssl ServerName Settings | StartTls ServerName Settings+ deriving(Eq, Show)++-- | How to authenticate to the SMTP server+data Authentication = Authentication AuthType Username Password+ deriving(Eq, Show)++data SMTPServer = SMTPServer (Maybe Authentication) ConnectionSettings+ deriving (Eq, Show)++-- | How to format outgoing mails from feed elements+data FormatMail = FormatMail+ { formatFrom :: Feed -> FeedElement -> Address -- ^ How to write the From: header of feed mails+ , formatSubject :: Feed -> FeedElement -> Text -- ^ How to write the Subject: header of feed mails+ , formatBody :: Feed -> FeedElement -> Text -- ^ How to write the body of feed mails (sic!)+ , formatTo :: Feed -> FeedElement -> [Address] -- ^ How to write the To: header of feed mails+ }++data SendMailSettings = SendMailSettings (Feed -> FeedElement -> SMTPServer) FormatMail+++-- * Interpreter++-- | Interpreter for 'HooksF'+mkCoHooks :: (MonadIO m) => SendMailSettings -> CoHooksF m SendMailSettings+mkCoHooks a@(SendMailSettings connectionSettings formatMail) = CoHooksF coOnNewElement where+ coOnNewElement feed element = do+ timezone <- io getCurrentTimeZone+ currentTime <- io getCurrentTime+ let mail = buildMail formatMail currentTime timezone feed element+ io $ withSMTPConnection (connectionSettings feed element) $ sendMimeMail2 mail+ return a++-- | Fill 'addressName' with the feed title and, if available, the authors' names.+--+-- This function leaves 'addressEmail' empty. You are expected to fill it adequately, because many SMTP servers enforce constraints on the From: email.+defaultFormatFrom :: Feed -> FeedElement -> Address+defaultFormatFrom (Rss doc) (RssElement item) = Address (Just $ channelTitle doc <> " (" <> itemAuthor item <> ")") ""+defaultFormatFrom (Atom feed) (AtomElement entry) = Address (Just $ title <> " (" <> authors <> ")") ""+ where title = show . prettyAtomText $ feedTitle feed+ authors = intercalate ", " $ map (toNullable . personName) $ entryAuthors entry <> feedAuthors feed+defaultFormatFrom _ _ = Address (Just "Unknown") ""++-- | Fill mail subject with the element title+defaultFormatSubject :: Feed -> FeedElement -> Text+defaultFormatSubject _ (RssElement item) = itemTitle item+defaultFormatSubject _ (AtomElement entry) = show . prettyAtomText $ entryTitle entry++-- | Fill mail body with:+--+-- - a list of links associated to the element+-- - the element's content or description/summary+defaultFormatBody :: Feed -> FeedElement -> Text+defaultFormatBody _ (RssElement item) = "<p>" <> maybe "<no link>" (withRssURI (show . prettyURI)) (itemLink item) <> "</p><p>" <> itemDescription item <> "</p>"+defaultFormatBody _ (AtomElement entry) = "<p>" <> intercalate "<br/>" links <> "</p><p>" <> fromMaybe "<empty>" (content <|> summary) <> "</p>"+ where links = map (withAtomURI (show . prettyURI) . linkHref) $ entryLinks entry+ content = show . prettyAtomContent <$> entryContent entry+ summary = show . prettyAtomText <$> entrySummary entry+++-- * Low-level helpers++authenticate_ :: SMTPConnection -> Authentication -> IO Bool+authenticate_ connection (Authentication t u p) = do+ result <- authenticate t u p connection+ unless result $ putStrLn "Authentication failed"+ return result++withSMTPConnection :: SMTPServer -> (SMTPConnection -> IO a) -> IO a+withSMTPConnection (SMTPServer authentication (Plain server port)) f =+ doSMTPPort server port $ \connection -> do+ forM_ authentication (authenticate_ connection)+ f connection+withSMTPConnection (SMTPServer authentication (Ssl server settings)) f =+ doSMTPSSLWithSettings server settings $ \connection -> do+ forM_ authentication (authenticate_ connection)+ f connection+withSMTPConnection (SMTPServer authentication (StartTls server settings)) f =+ doSMTPSTARTTLSWithSettings server settings $ \connection -> do+ forM_ authentication (authenticate_ connection)+ f connection++-- | Build mail from a given feed+buildMail :: FormatMail -> UTCTime -> TimeZone -> Feed -> FeedElement -> Mail+buildMail format currentTime timeZone feed element =+ let date = formatTime defaultTimeLocale "%a, %e %b %Y %T %z" $ utcToZonedTime timeZone $ fromMaybe currentTime $ getDate element+ in Mail+ { mailFrom = formatFrom format feed element+ , mailTo = formatTo format feed element+ , mailCc = []+ , mailBcc = []+ , mailHeaders =+ [ ("Return-Path", "<imm@noreply>")+ , ("Date", fromString date)+ , ("Subject", formatSubject format feed element)+ , ("Content-disposition", "inline")+ ]+ , mailParts = [[htmlPart $ fromStrict $ formatBody format feed element]]+ }
@@ -0,0 +1,73 @@+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeOperators #-}+-- | DSL/interpreter model for the logger+module Imm.Logger where++-- {{{ Imports+import Imm.Prelude++import Control.Monad.Trans.Free++-- import Text.PrettyPrint.ANSI.Leijen+-- }}}++-- * Types++data LogLevel = Debug | Info | Warning | Error+ deriving(Eq, Ord, Read, Show)++instance Pretty LogLevel where+ pretty Debug = text "DEBUG"+ pretty Info = text "INFO"+ pretty Warning = text "WARNING"+ pretty Error = text "ERROR"++-- | Logger DSL+data LoggerF next+ = Log LogLevel Text next+ | GetLevel (LogLevel -> next)+ | SetLevel LogLevel next+ deriving(Functor)++-- | Logger interpreter+data CoLoggerF m a = CoLoggerF+ { logH :: LogLevel -> Text -> m a+ , getLevelH :: m (LogLevel, a)+ , setLevelH :: LogLevel -> m a+ } deriving(Functor)++instance Monad m => PairingM (CoLoggerF m) LoggerF m where+ -- pairM :: (a -> b -> m r) -> f a -> g b -> m r+ pairM p (CoLoggerF l _ _) (Log level message next) = do+ a <- l level message+ p a next+ pairM p (CoLoggerF _ gl _) (GetLevel next) = do+ (l, a) <- gl+ p a (next l)+ pairM p (CoLoggerF _ _ sl) (SetLevel level next) = do+ a <- sl level+ p a next++-- * Primitives++log :: (Functor f, MonadFree f m, LoggerF :<: f) => LogLevel -> Text -> m ()+log level message = liftF . inj $ Log level message ()++getLogLevel :: (Functor f, MonadFree f m, LoggerF :<: f) => m LogLevel+getLogLevel = liftF . inj $ GetLevel id++setLogLevel :: (Functor f, MonadFree f m, LoggerF :<: f) => LogLevel -> m ()+setLogLevel level = liftF . inj $ SetLevel level ()++-- * Helpers++logDebug, logInfo, logWarning, logError :: (Functor f, MonadFree f m, LoggerF :<: f) => Text -> m ()+logDebug = log Debug+logInfo = log Info+logWarning = log Warning+logError = log Error
@@ -0,0 +1,41 @@+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+-- | Simple logger interpreter.+-- For further information, please consult "System.Log.FastLogger".+module Imm.Logger.Simple (module Imm.Logger.Simple, module Reexport) where++-- {{{ Imports+import Imm.Logger as Reexport+import Imm.Prelude++import System.Log.FastLogger as Reexport+-- }}}++-- * Settings++data LoggerSettings = LoggerSettings+ { loggerSet :: LoggerSet -- ^ 'LoggerSet' used for 'Debug', 'Info' and 'Warning' logs+ , errorLoggerSet :: LoggerSet -- ^ 'LoggerSet' used for 'Error' logs+ , logLevel :: LogLevel -- ^ Discard logs that are strictly less serious than this level+ }++-- | Default logger forwards error messages to stderr, and other messages to stdout.+defaultLogger :: IO LoggerSettings+defaultLogger = LoggerSettings+ <$> newStdoutLoggerSet defaultBufSize+ <*> newStderrLoggerSet defaultBufSize+ <*> pure Info++-- * Interpreter++-- | Interpreter for 'LoggerF'+mkCoLogger :: (MonadIO m) => LoggerSettings -> CoLoggerF m LoggerSettings+mkCoLogger settings = CoLoggerF coLog coGetLevel coSetLevel where+ coLog Error t = do+ io $ pushLogStrLn (errorLoggerSet settings) $ toLogStr $ "ERROR: " <> t+ return settings+ coLog l t = do+ when (l >= logLevel settings) $ io $ pushLogStrLn (loggerSet settings) $ toLogStr $ show (pretty l) <> ": " <> t+ return settings+ coGetLevel = return (logLevel settings, settings)+ coSetLevel l = return $ settings { logLevel = l }
@@ -0,0 +1,134 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE StandaloneDeriving #-}+module Imm.Options where++-- {{{ Imports+import Imm.Dyre as Dyre (Mode (..))+import qualified Imm.Dyre as Dyre+import Imm.Feed+import Imm.Logger as Logger+import Imm.Prelude hiding ((<>))+import Imm.Pretty++import Options.Applicative.Builder+import Options.Applicative.Extra+import Options.Applicative.Types++import URI.ByteString+-- }}}++-- | Available commands.+data Command = Check (Maybe FeedRef)+ | Import+ | Read (Maybe FeedRef)+ | Rebuild+ | Unread (Maybe FeedRef)+ | Run (Maybe FeedRef)+ | Show (Maybe FeedRef)+ | ShowVersion+ | Subscribe URI (Maybe Text)+ | Unsubscribe (Maybe FeedRef)++deriving instance Eq Command+deriving instance Show Command++instance Pretty Command where+ pretty (Check f) = text "Check feed(s):" <+> pretty f+ pretty Import = text "Import feeds"+ pretty (Read f) = text "Mark feed(s) as read:" <+> pretty f+ pretty Rebuild = text "Rebuild configuration"+ pretty (Unread f) = text "Mark feed(s) as unread:" <+> pretty f+ pretty (Run f) = text "Download new entries from feed(s):" <+> pretty f+ pretty (Show f) = text "Show status for feed(s):" <+> pretty f+ pretty ShowVersion = text "Show program version"+ pretty (Subscribe f _) = text "Subscribe to feed:" <+> prettyURI f+ pretty (Unsubscribe f) = text "Unsubscribe from feed(s):" <+> pretty f++defaultCommand :: Command+defaultCommand = Show Nothing++-- | Available commandline options.+data CliOptions = CliOptions+ { optionCommand :: Command+ , optionDyreMode :: Dyre.Mode+ , optionLogLevel :: LogLevel+ }++-- deriving instance Eq CliOptions+defaultOptions :: CliOptions+defaultOptions = CliOptions defaultCommand Dyre.defaultMode Info++-- instance Pretty CliOptions where+-- pretty opts = text "ACTION" <> equals <> $ opts^.command_+-- , ("RECONFIGURATION_MODE=" ++) . show $ opts^.dyreMode_+-- ]+-- ++ catMaybes [("CONFIG=" ++) <$> opts^.configurationLabel_]++parseOptions :: (MonadIO m) => m CliOptions+parseOptions = io $ customExecParser (prefs noBacktrack) (info parser $ progDesc "Convert items from RSS/Atom feeds to mails.")+ where parser = helper <*> optional dyreMasterBinary *> optional dyreDebug *> cliOptions+++cliOptions :: Parser CliOptions+cliOptions = CliOptions+ <$> commands+ <*> (vanillaFlag <|> forceReconfFlag <|> denyReconfFlag <|> pure Dyre.defaultMode)+ <*> (verboseFlag <|> quietFlag <|> logLevel <|> pure Info)+++commands :: Parser Command+commands = subparser $ mconcat+ [ command "check" . info (Check <$> optional feedRefOption) $ progDesc "Check availability and validity of all feed sources currently configured, without writing any mail."+ , command "import" . info (pure Import) $ progDesc "Import feeds list from an OPML descriptor (read from stdin)."+ , command "read" . info (Read <$> optional feedRefOption) $ progDesc "Mark given feed as read."+ , command "rebuild" . info (pure Rebuild) $ progDesc "Rebuild configuration file."+ , command "run" . info (Run <$> optional feedRefOption) $ progDesc "Update list of feeds."+ , command "show" . info (Show <$> optional feedRefOption) $ progDesc "List all feed sources currently configured, along with their status."+ , command "subscribe" . info subscribeOptions $ progDesc "Subscribe to a feed."+ , command "unread" . info (Unread <$> optional feedRefOption) $ progDesc "Mark given feed as unread."+ , command "unsubscribe" . info unsubscribeOptions $ progDesc "Unsubscribe from a feed."+ , command "version" . info (pure ShowVersion) $ progDesc "Print version."+ ]+++-- {{{ Dynamic reconfiguration options+vanillaFlag, forceReconfFlag, denyReconfFlag :: Parser Dyre.Mode+vanillaFlag = flag' Vanilla $ long "vanilla" <> short '1' <> help "Ignore custom configuration file."+forceReconfFlag = flag' ForceReconfiguration $ long "force-reconf" <> help "Recompile configuration file before starting the application."+denyReconfFlag = flag' IgnoreReconfiguration $ long "deny-reconf" <> help "Do not recompile configuration file even if it has changed."++dyreDebug :: Parser Bool+dyreDebug = switch $ long "dyre-debug" <> help "Use './cache/' as the cache directory and ./ as the configuration directory. Useful to debug the program."++dyreMasterBinary :: Parser String+dyreMasterBinary = strOption $ long "dyre-master-binary" <> metavar "PATH" <> hidden <> internal <> help "Internal flag used for dynamic reconfiguration."+-- }}}++-- {{{ Log level options+verboseFlag, quietFlag, logLevel :: Parser LogLevel+verboseFlag = flag' Logger.Debug $ long "verbose" <> short 'v' <> help "Set log level to DEBUG."+quietFlag = flag' Logger.Error $ long "quiet" <> short 'q' <> help "Set log level to ERROR."+logLevel = option auto $ long "log-level" <> short 'l' <> metavar "LOG-LEVEL" <> value Info <> completeWith ["Debug", "Info", "Warning", "Error"] <> help "Set log level. Available values: Debug, Info, Warning, Error."+-- }}}++-- {{{ Other options+configLabelOption :: Parser Text+configLabelOption = option auto $ long "config" <> short 'C' <> metavar "CONFIG" <> help "Use the given configuration for all operations."++subscribeOptions, unsubscribeOptions :: Parser Command+subscribeOptions = Subscribe <$> uriArgument "URI to subscribe to." <*> optional configLabelOption+unsubscribeOptions = Unsubscribe <$> optional feedRefOption+-- }}}++-- {{{ Util+uriReader :: ReadM URI+uriReader = eitherReader $ first show . parseURI laxURIParserOptions . encodeUtf8 . fromString++feedRefOption :: Parser FeedRef+feedRefOption = fmap FeedRef $ (Left <$> argument auto (metavar "ID")) <|> (Right <$> argument uriReader (metavar "URI"))++uriArgument :: String -> Parser URI+uriArgument helpText = argument uriReader $ metavar "URI" <> help helpText+-- }}}
@@ -0,0 +1,122 @@+{-# LANGUAGE ConstraintKinds #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE UndecidableInstances #-}+module Imm.Prelude (module Imm.Prelude, module X) where++-- {{{ Imports+import Control.Applicative as X+import Control.Comonad+import Control.Comonad.Cofree+import Control.Monad as X hiding (filterM, replicateM)+import Control.Monad.Catch as X+import Control.Monad.IO.Class as X+import Control.Monad.Trans.Free (FreeF (..), FreeT (..))++import Data.Bifunctor as X+import qualified Data.ByteString as B (ByteString ())+import qualified Data.ByteString.Lazy as LB (ByteString ())+import Data.Comp.Ops as X+import Data.Containers as X+import Data.Functor.Identity+import Data.IOData as X+import Data.Map as X (Map)+import Data.Maybe as X hiding (catMaybes)+import Data.Monoid as X+import Data.Monoid.Textual as X (TextualMonoid (), fromText)+import Data.MonoTraversable as X+import Data.Ord as X+-- import Data.Semigroup as X hiding (option)+import Data.Sequences as X+import Data.Sequences.Lazy as X+import Data.String as X (IsString (..))+import qualified Data.Text as T (Text ())+import qualified Data.Text.Lazy as LT (Text ())+import Data.Textual.Encoding as X+import Data.Typeable as X++import qualified GHC.Show as Show++import Prelude as X hiding (break, drop,+ dropWhile, elem, filter,+ getLine, lines, log, lookup,+ notElem, readFile,+ replicate, reverse, show,+ span, splitAt, take,+ takeWhile, unlines, unwords,+ words, writeFile)++import System.IO as X (stderr, stdout)++import Text.PrettyPrint.ANSI.Leijen as X (Doc, Pretty (..), angles,+ brackets, equals, hsep,+ indent, space, text, vsep,+ (<+>))++import Text.PrettyPrint.ANSI.Leijen (line)+-- }}}++-- * Free monad utilities++-- | Right-associative tuple type-constructor+type a ::: b = (a, b)+infixr 0 :::++-- | Right-associative tuple data-constructor+(>:) :: a -> b -> (a,b)+(>:) a b = (a, b)+infixr 0 >:++(*:*) :: (Functor f, Functor g) => (a -> f a) -> (b -> g b) -> (a, b) -> (f :*: g) (a, b)+(*:*) f g (a,b) = ((,b) <$> f a) :*: ((a,) <$> g b)+infixr 0 *:*+++class (Monad m, Functor f, Functor g) => PairingM f g m | f -> g where+ pairM :: (a -> b -> m r) -> f a -> g b -> m r++instance (Monad m) => PairingM Identity Identity m where+ pairM f (Identity a) (Identity b) = f a b++instance (PairingM f f' m, PairingM g g' m) => PairingM (f :+: g) (f' :*: g') m where+ pairM p (Inl x) (a :*: _) = pairM p x a+ pairM p (Inr x) (_ :*: b) = pairM p x b++instance (PairingM f f' m, PairingM g g' m) => PairingM (f :*: g) (f' :+: g') m where+ pairM p (a :*: _) (Inl x) = pairM p a x+ pairM p (_ :*: b) (Inr x) = pairM p b x++interpret :: (PairingM f g m) => (a -> b -> m r) -> Cofree f a -> FreeT g m b -> m r+interpret p eval program = do+ let a = extract eval+ b <- runFreeT program+ case b of+ Pure x -> p a x+ Free gs -> pairM (interpret p) (unwrap eval) gs++-- * Shortcuts++type LByteString = LB.ByteString+type ByteString = B.ByteString+type LText = LT.Text+type Text = T.Text++-- | Generic 'Show.show'+show :: (Show a, IsString b) => a -> b+show = fromString . Show.show++-- | Shortcut to 'liftIO'+io :: MonadIO m => IO a -> m a+io = liftIO++-- | Infix operator for 'line'+(<++>) :: Doc -> Doc -> Doc+x <++> y = x <> line <> y
@@ -0,0 +1,95 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE PartialTypeSignatures #-}+module Imm.Pretty where++-- {{{ Imports+import Imm.Prelude++import Data.NonNull+import Data.Time+import Data.Tree++import Text.Atom.Types as Atom+-- import Text.OPML.Types as OPML hiding (text)+-- import qualified Text.OPML.Types as OPML+import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))+import Text.RSS.Types as RSS++import URI.ByteString+-- }}}++prettyTree :: (Pretty a) => Tree a -> Doc+prettyTree (Node n s) = pretty n <++> indent 2 (vsep $ prettyTree <$> s)++prettyTime :: UTCTime -> Doc+prettyTime = text . formatTime defaultTimeLocale rfc822DateFormat++-- instance Pretty OpmlHead where+-- pretty h = hsep $ catMaybes+-- [ pretty <$> fromNullable (opmlTitle h)+-- , (text "created at:" <+>) . pretty <$> opmlCreated h+-- , (text "modified at:" <+>) . pretty <$> modified h+-- , (text "by" <+>) . pretty <$> fromNullable (ownerName h)+-- , angles . pretty <$> fromNullable (ownerEmail h)+-- ]++-- instance Pretty OutlineBase where+-- pretty b = pretty $ OPML.text b++-- instance Pretty OutlineSubscription where+-- pretty b = angles $ pretty $ xmlUri b++-- instance Pretty OpmlOutline where+-- pretty (OpmlOutlineGeneric base otype) = hsep+-- [ text "type:" <+> pretty otype+-- , pretty base+-- ]+-- pretty (OpmlOutlineSubscription base s) = text "Subscription:" <+> pretty base <+> pretty s+-- pretty (OpmlOutlineLink base uri) = text "Link:" <+> pretty base <+> pretty uri++-- instance Pretty Opml where+-- pretty o = text "OPML" <+> pretty (opmlVersion o) <++> indent 2 (pretty (opmlHead o) <++> (vsep . map pretty $ opmlOutlines o))++prettyPerson :: AtomPerson -> Doc+prettyPerson p = text (fromText $ toNullable $ personName p) <> email where+ email = if onull $ personEmail p+ then mempty+ else space <> angles (text $ fromText $ personEmail p)++prettyLink :: AtomLink -> Doc+prettyLink l = withAtomURI prettyURI $ linkHref l++prettyAtomText :: AtomText -> Doc+prettyAtomText (AtomPlainText _ t) = text $ fromText t+prettyAtomText (AtomXHTMLText t) = text $ fromText t++prettyEntry :: AtomEntry -> Doc+prettyEntry e = text "Entry:" <+> prettyAtomText (entryTitle e) <++> indent 4+ ( text "By" <+> equals <+> list (prettyPerson <$> entryAuthors e)+ <++> text "Updated" <+> equals <+> prettyTime (entryUpdated e)+ <++> text "Links" <+> equals <+> list (prettyLink <$> entryLinks e)+ -- , " Item Body: " ++ (Imm.Mail.getItemContent item),+ )++prettyItem :: RssItem -> Doc+prettyItem i = text "Item:" <+> text (fromText $ itemTitle i) <++> indent 4+ ( text "By" <+> equals <+> text (fromText $ itemAuthor i)+ <++> text "Updated" <+> equals <+> fromMaybe (text "<empty>") (prettyTime <$> itemPubDate i)+ <++> text "Link" <+> equals <+> fromMaybe (text "<empty>") (withRssURI prettyURI <$> itemLink i)+ )++prettyURI :: URIRef a -> Doc+prettyURI uri = text $ fromText $ decodeUtf8 $ serializeURIRef' uri++prettyGuid :: RssGuid -> Doc+prettyGuid (GuidText t) = text $ fromText t+prettyGuid (GuidUri (RssURI u)) = prettyURI u++prettyAtomContent :: AtomContent -> Doc+prettyAtomContent (AtomContentInlineText _ t) = text $ fromText t+prettyAtomContent (AtomContentInlineXHTML t) = text $ fromText t+prettyAtomContent (AtomContentInlineOther _ t) = text $ fromText t+prettyAtomContent (AtomContentOutOfLine _ u) = withAtomURI prettyURI u