imm 1.5.0.0 → 1.6.0.0
raw patch · 37 files changed
+1473/−1298 lines, 37 filesdep +asyncdep +binarydep +dhalldep −basedep −dyredep −monoid-subclassesdep ~HaskellNetdep ~atom-conduitdep ~base-nopreludenew-component:exe:imm-sendmailnew-component:exe:imm-writefilePVP ok
version bump matches the API change (PVP)
Dependencies added: async, binary, dhall, stm-chans, typed-process
Dependencies removed: base, dyre, monoid-subclasses, mtl, streamly, transformers-base, xml
Dependency ranges changed: HaskellNet, atom-conduit, base-noprelude, directory, network, refined, rss-conduit
API changes (from Hackage documentation)
- Imm: check :: (MonadAsync m, MonadCatch m) => Handle m -> Handle m FeedTable -> Handle m -> Handle m -> [FeedID] -> m ()
- Imm: getHashes :: FeedElement -> [Int]
- Imm: imm :: Handle IO -> Handle IO FeedTable -> Handle IO -> Handle IO -> Handle IO -> IO ()
- Imm: importOPML :: MonadCatch m => Handle m -> Handle m FeedTable -> ConduitT () ByteString m () -> m ()
- Imm: onNewElement :: Monad m => Handle m -> Handle m -> Feed -> FeedElement -> m ()
- Imm: printVersions :: MonadBase IO m => m ()
- Imm: processNewElement :: Handle m -> Feed -> FeedElement -> m ()
- Imm: run :: (MonadTime m, MonadAsync m, MonadCatch m) => Handle m -> Handle m FeedTable -> Handle m -> Handle m -> Handle m -> [FeedID] -> m ()
- Imm: showFeed :: MonadThrow m => Handle m -> Handle m FeedTable -> [FeedID] -> m ()
- Imm: subscribe :: MonadCatch m => Handle m -> Handle m FeedTable -> URI -> Set Text -> m ()
- Imm.Boot: imm :: Handle IO -> Handle IO FeedTable -> Handle IO -> Handle IO -> Handle IO -> IO ()
- Imm.Boot: instance GHC.Classes.Eq Imm.Boot.InterruptedException
- Imm.Boot: instance GHC.Classes.Eq Imm.Boot.SafeGuard
- Imm.Boot: instance GHC.Exception.Type.Exception Imm.Boot.InterruptedException
- Imm.Boot: instance GHC.Read.Read Imm.Boot.InterruptedException
- Imm.Boot: instance GHC.Read.Read Imm.Boot.SafeGuard
- Imm.Boot: instance GHC.Show.Show Imm.Boot.InterruptedException
- Imm.Boot: instance GHC.Show.Show Imm.Boot.SafeGuard
- Imm.Core: check :: (MonadAsync m, MonadCatch m) => Handle m -> Handle m FeedTable -> Handle m -> Handle m -> [FeedID] -> m ()
- Imm.Core: data FeedRef
- Imm.Core: importOPML :: MonadCatch m => Handle m -> Handle m FeedTable -> ConduitT () ByteString m () -> m ()
- Imm.Core: printVersions :: MonadBase IO m => m ()
- Imm.Core: run :: (MonadTime m, MonadAsync m, MonadCatch m) => Handle m -> Handle m FeedTable -> Handle m -> Handle m -> Handle m -> [FeedID] -> m ()
- Imm.Core: showFeed :: MonadThrow m => Handle m -> Handle m FeedTable -> [FeedID] -> m ()
- Imm.Core: subscribe :: MonadCatch m => Handle m -> Handle m FeedTable -> URI -> Set Text -> m ()
- Imm.Database.FeedTable: [entryLastCheck] :: DatabaseEntry -> Maybe UTCTime
- Imm.Database.FeedTable: addReadHash :: MonadThrow m => Handle m -> Handle m FeedTable -> FeedID -> Int -> m ()
- Imm.Database.FeedTable: markAsRead :: (MonadTime m, MonadThrow m) => Handle m -> Handle m FeedTable -> FeedID -> m ()
- Imm.Database.FeedTable: markAsUnread :: MonadThrow m => Handle m -> Handle m FeedTable -> FeedID -> m ()
- Imm.Database.JsonFile: UnableDecode :: JsonException
- Imm.Database.JsonFile: data JsonException
- Imm.Database.JsonFile: data JsonFileDatabase t
- Imm.Database.JsonFile: defaultDatabase :: Table t => IO (TVar (JsonFileDatabase t))
- Imm.Database.JsonFile: instance Data.Text.Prettyprint.Doc.Internal.Pretty (Imm.Database.JsonFile.JsonFileDatabase t)
- Imm.Database.JsonFile: instance GHC.Classes.Eq Imm.Database.JsonFile.CacheStatus
- Imm.Database.JsonFile: instance GHC.Classes.Eq Imm.Database.JsonFile.JsonException
- Imm.Database.JsonFile: instance GHC.Exception.Type.Exception Imm.Database.JsonFile.JsonException
- Imm.Database.JsonFile: instance GHC.Show.Show Imm.Database.JsonFile.CacheStatus
- Imm.Database.JsonFile: instance GHC.Show.Show Imm.Database.JsonFile.JsonException
- Imm.Database.JsonFile: mkHandle :: (Table t, FromJSON (Key t), FromJSON (Entry t), ToJSON (Key t), ToJSON (Entry t), MonadIO m, MonadMask m) => TVar (JsonFileDatabase t) -> Handle m t
- Imm.Database.JsonFile: mkJsonFileDatabase :: Table t => FilePath -> JsonFileDatabase t
- Imm.Feed: getHashes :: FeedElement -> [Int]
- Imm.HTTP.Simple: defaultManager :: IO Manager
- Imm.HTTP.Simple: mkHandle :: MonadBase IO m => Manager -> Handle m
- Imm.Hooks: Handle :: (Feed -> FeedElement -> m ()) -> Handle m
- Imm.Hooks: [processNewElement] :: Handle m -> Feed -> FeedElement -> m ()
- Imm.Hooks: newtype Handle m
- Imm.Hooks: onNewElement :: Monad m => Handle m -> Handle m -> Feed -> FeedElement -> m ()
- Imm.Hooks.Dummy: DummyHooks :: DummyHooks
- Imm.Hooks.Dummy: data DummyHooks
- Imm.Hooks.Dummy: mkHandle :: MonadThrow m => Handle m
- Imm.Hooks.SendMail: AUTH :: AuthType -> UserName -> Password -> Command
- Imm.Hooks.SendMail: Address :: Maybe Text -> Text -> Address
- Imm.Hooks.SendMail: Authentication :: AuthType -> Username -> Password -> Authentication
- Imm.Hooks.SendMail: BadSequence :: Response
- Imm.Hooks.SendMail: Base64 :: Encoding
- Imm.Hooks.SendMail: Boundary :: Text -> Boundary
- Imm.Hooks.SendMail: CRAM_MD5 :: AuthType
- Imm.Hooks.SendMail: CannotVerify :: Response
- Imm.Hooks.SendMail: CommandNotImplemented :: Response
- Imm.Hooks.SendMail: DATA :: ByteString -> Command
- Imm.Hooks.SendMail: EHLO :: String -> Command
- Imm.Hooks.SendMail: EXPN :: String -> Command
- Imm.Hooks.SendMail: ErrorInProcessing :: Response
- Imm.Hooks.SendMail: ExceededStorage :: Response
- Imm.Hooks.SendMail: FormatMail :: (Feed -> FeedElement -> Address) -> (Feed -> FeedElement -> Text) -> (Feed -> FeedElement -> Text) -> (Feed -> FeedElement -> [Address]) -> FormatMail
- Imm.Hooks.SendMail: HELO :: String -> Command
- Imm.Hooks.SendMail: HELP :: String -> Command
- Imm.Hooks.SendMail: HelpMessage :: Response
- Imm.Hooks.SendMail: InsufficientSystemStorage :: Response
- Imm.Hooks.SendMail: LOGIN :: AuthType
- Imm.Hooks.SendMail: MAIL :: String -> Command
- Imm.Hooks.SendMail: Mail :: Address -> [Address] -> [Address] -> [Address] -> Headers -> [Alternatives] -> Mail
- Imm.Hooks.SendMail: MailboxNotAllowed :: Response
- Imm.Hooks.SendMail: MailboxUnavailable :: Response
- Imm.Hooks.SendMail: MailboxUnavailableError :: Response
- Imm.Hooks.SendMail: NOOP :: Command
- Imm.Hooks.SendMail: None :: Encoding
- Imm.Hooks.SendMail: Ok :: Response
- Imm.Hooks.SendMail: PLAIN :: AuthType
- Imm.Hooks.SendMail: ParameterError :: Response
- Imm.Hooks.SendMail: ParameterNotImplemented :: Response
- Imm.Hooks.SendMail: Part :: Text -> Encoding -> Maybe Text -> Headers -> ByteString -> Part
- Imm.Hooks.SendMail: Plain :: ServerName -> PortNumber -> ConnectionSettings
- Imm.Hooks.SendMail: QUIT :: Command
- Imm.Hooks.SendMail: QuotedPrintableBinary :: Encoding
- Imm.Hooks.SendMail: QuotedPrintableText :: Encoding
- Imm.Hooks.SendMail: RCPT :: String -> Command
- Imm.Hooks.SendMail: RSET :: Command
- Imm.Hooks.SendMail: SMTPServer :: Maybe Authentication -> ConnectionSettings -> SMTPServer
- Imm.Hooks.SendMail: SendMailSettings :: (Feed -> FeedElement -> SMTPServer) -> FormatMail -> SendMailSettings
- Imm.Hooks.SendMail: ServiceClosing :: Response
- Imm.Hooks.SendMail: ServiceNotAvailable :: Response
- Imm.Hooks.SendMail: ServiceReady :: Response
- Imm.Hooks.SendMail: Settings :: PortNumber -> Int -> Bool -> Bool -> Settings
- Imm.Hooks.SendMail: Ssl :: ServerName -> Settings -> ConnectionSettings
- Imm.Hooks.SendMail: StartMailInput :: Response
- Imm.Hooks.SendMail: StartTls :: ServerName -> Settings -> ConnectionSettings
- Imm.Hooks.SendMail: SyntaxError :: Response
- Imm.Hooks.SendMail: SystemStatus :: Response
- Imm.Hooks.SendMail: TransactionFailed :: Response
- Imm.Hooks.SendMail: UserNotLocal :: Response
- Imm.Hooks.SendMail: UserNotLocalError :: Response
- Imm.Hooks.SendMail: VRFY :: String -> Command
- Imm.Hooks.SendMail: [addressEmail] :: Address -> Text
- Imm.Hooks.SendMail: [addressName] :: Address -> Maybe Text
- Imm.Hooks.SendMail: [formatBody] :: FormatMail -> Feed -> FeedElement -> Text
- Imm.Hooks.SendMail: [formatFrom] :: FormatMail -> Feed -> FeedElement -> Address
- Imm.Hooks.SendMail: [formatSubject] :: FormatMail -> Feed -> FeedElement -> Text
- Imm.Hooks.SendMail: [formatTo] :: FormatMail -> Feed -> FeedElement -> [Address]
- Imm.Hooks.SendMail: [mailBcc] :: Mail -> [Address]
- Imm.Hooks.SendMail: [mailCc] :: Mail -> [Address]
- Imm.Hooks.SendMail: [mailFrom] :: Mail -> Address
- Imm.Hooks.SendMail: [mailHeaders] :: Mail -> Headers
- Imm.Hooks.SendMail: [mailParts] :: Mail -> [Alternatives]
- Imm.Hooks.SendMail: [mailTo] :: Mail -> [Address]
- Imm.Hooks.SendMail: [partContent] :: Part -> ByteString
- Imm.Hooks.SendMail: [partEncoding] :: Part -> Encoding
- Imm.Hooks.SendMail: [partFilename] :: Part -> Maybe Text
- Imm.Hooks.SendMail: [partHeaders] :: Part -> Headers
- Imm.Hooks.SendMail: [partType] :: Part -> Text
- Imm.Hooks.SendMail: [sslDisableCertificateValidation] :: Settings -> Bool
- Imm.Hooks.SendMail: [sslLogToConsole] :: Settings -> Bool
- Imm.Hooks.SendMail: [sslMaxLineLength] :: Settings -> Int
- Imm.Hooks.SendMail: [sslPort] :: Settings -> PortNumber
- Imm.Hooks.SendMail: [unBoundary] :: Boundary -> Text
- Imm.Hooks.SendMail: addAttachment :: Text -> FilePath -> Mail -> IO Mail
- Imm.Hooks.SendMail: addAttachmentBS :: Text -> Text -> ByteString -> Mail -> Mail
- Imm.Hooks.SendMail: addAttachmentBSCid :: Text -> Text -> ByteString -> Text -> Mail -> Mail
- Imm.Hooks.SendMail: addAttachmentCid :: Text -> FilePath -> Text -> Mail -> IO Mail
- Imm.Hooks.SendMail: addAttachments :: [(Text, FilePath)] -> Mail -> IO Mail
- Imm.Hooks.SendMail: addAttachmentsBS :: [(Text, Text, ByteString)] -> Mail -> Mail
- Imm.Hooks.SendMail: addPart :: Alternatives -> Mail -> Mail
- Imm.Hooks.SendMail: authenticate :: AuthType -> UserName -> Password -> SMTPConnection -> IO Bool
- Imm.Hooks.SendMail: authenticate_ :: SMTPConnection -> Authentication -> IO Bool
- Imm.Hooks.SendMail: buildMail :: FormatMail -> UTCTime -> TimeZone -> Feed -> FeedElement -> Mail
- Imm.Hooks.SendMail: closeSMTP :: SMTPConnection -> IO ()
- Imm.Hooks.SendMail: connectSMTP :: String -> IO SMTPConnection
- Imm.Hooks.SendMail: connectSMTPPort :: String -> PortNumber -> IO SMTPConnection
- Imm.Hooks.SendMail: connectSMTPSSL :: String -> IO SMTPConnection
- Imm.Hooks.SendMail: connectSMTPSSLWithSettings :: String -> Settings -> IO SMTPConnection
- Imm.Hooks.SendMail: connectSMTPSTARTTLS :: String -> IO SMTPConnection
- Imm.Hooks.SendMail: connectSMTPSTARTTLSWithSettings :: String -> Settings -> IO SMTPConnection
- Imm.Hooks.SendMail: connectStream :: BSStream -> IO SMTPConnection
- Imm.Hooks.SendMail: data Address
- Imm.Hooks.SendMail: data AuthType
- Imm.Hooks.SendMail: data Authentication
- Imm.Hooks.SendMail: data Command
- Imm.Hooks.SendMail: data ConnectionSettings
- Imm.Hooks.SendMail: data Encoding
- Imm.Hooks.SendMail: data FormatMail
- Imm.Hooks.SendMail: data Mail
- Imm.Hooks.SendMail: data Part
- Imm.Hooks.SendMail: data Response
- Imm.Hooks.SendMail: data SMTPConnection
- Imm.Hooks.SendMail: data SMTPServer
- Imm.Hooks.SendMail: data SendMailSettings
- Imm.Hooks.SendMail: data Settings
- Imm.Hooks.SendMail: defaultFormatBody :: Feed -> FeedElement -> Text
- Imm.Hooks.SendMail: defaultFormatFrom :: Feed -> FeedElement -> Address
- Imm.Hooks.SendMail: defaultFormatSubject :: Feed -> FeedElement -> Text
- Imm.Hooks.SendMail: defaultSettingsSMTPSSL :: Settings
- Imm.Hooks.SendMail: defaultSettingsSMTPSTARTTLS :: Settings
- Imm.Hooks.SendMail: doSMTP :: () => String -> (SMTPConnection -> IO a) -> IO a
- Imm.Hooks.SendMail: doSMTPPort :: () => String -> PortNumber -> (SMTPConnection -> IO a) -> IO a
- Imm.Hooks.SendMail: doSMTPSSL :: () => String -> (SMTPConnection -> IO a) -> IO a
- Imm.Hooks.SendMail: doSMTPSSLWithSettings :: () => String -> Settings -> (SMTPConnection -> IO a) -> IO a
- Imm.Hooks.SendMail: doSMTPSTARTTLS :: () => String -> (SMTPConnection -> IO a) -> IO a
- Imm.Hooks.SendMail: doSMTPSTARTTLSWithSettings :: () => String -> Settings -> (SMTPConnection -> IO a) -> IO a
- Imm.Hooks.SendMail: doSMTPStream :: () => BSStream -> (SMTPConnection -> IO a) -> IO a
- Imm.Hooks.SendMail: emptyMail :: Address -> Mail
- Imm.Hooks.SendMail: htmlPart :: Text -> Part
- Imm.Hooks.SendMail: instance GHC.Classes.Eq Imm.Hooks.SendMail.Authentication
- Imm.Hooks.SendMail: instance GHC.Classes.Eq Imm.Hooks.SendMail.ConnectionSettings
- Imm.Hooks.SendMail: instance GHC.Classes.Eq Imm.Hooks.SendMail.SMTPServer
- Imm.Hooks.SendMail: instance GHC.Show.Show Imm.Hooks.SendMail.Authentication
- Imm.Hooks.SendMail: instance GHC.Show.Show Imm.Hooks.SendMail.ConnectionSettings
- Imm.Hooks.SendMail: instance GHC.Show.Show Imm.Hooks.SendMail.SMTPServer
- Imm.Hooks.SendMail: mkHandle :: MonadBase IO m => SendMailSettings -> Handle m
- Imm.Hooks.SendMail: newtype Boundary
- Imm.Hooks.SendMail: plainPart :: Text -> Part
- Imm.Hooks.SendMail: quotedPrintable :: Bool -> ByteString -> Builder
- Imm.Hooks.SendMail: randomString :: RandomGen d => Int -> d -> (String, d)
- Imm.Hooks.SendMail: renderAddress :: Address -> Text
- Imm.Hooks.SendMail: renderMail :: RandomGen g => g -> Mail -> (ByteString, g)
- Imm.Hooks.SendMail: renderMail' :: Mail -> IO ByteString
- Imm.Hooks.SendMail: renderSendMail :: Mail -> IO ()
- Imm.Hooks.SendMail: renderSendMailCustom :: FilePath -> [String] -> Mail -> IO ()
- Imm.Hooks.SendMail: sendCommand :: SMTPConnection -> Command -> IO (ReplyCode, ByteString)
- Imm.Hooks.SendMail: sendMail :: String -> [String] -> ByteString -> SMTPConnection -> IO ()
- Imm.Hooks.SendMail: sendMimeMail :: String -> String -> String -> Text -> Text -> [(Text, FilePath)] -> SMTPConnection -> IO ()
- Imm.Hooks.SendMail: sendMimeMail' :: String -> String -> String -> Text -> Text -> [(Text, Text, ByteString)] -> SMTPConnection -> IO ()
- Imm.Hooks.SendMail: sendMimeMail2 :: Mail -> SMTPConnection -> IO ()
- Imm.Hooks.SendMail: sendPlainTextMail :: String -> String -> String -> Text -> SMTPConnection -> IO ()
- Imm.Hooks.SendMail: sendmailCustom :: FilePath -> [String] -> ByteString -> IO ()
- Imm.Hooks.SendMail: sendmailCustomCaptureOutput :: FilePath -> [String] -> ByteString -> IO (ByteString, ByteString)
- Imm.Hooks.SendMail: simpleMail :: Address -> Address -> Text -> Text -> Text -> [(Text, FilePath)] -> IO Mail
- Imm.Hooks.SendMail: simpleMail' :: Address -> Address -> Text -> Text -> Mail
- Imm.Hooks.SendMail: simpleMailInMemory :: Address -> Address -> Text -> Text -> Text -> [(Text, Text, ByteString)] -> Mail
- Imm.Hooks.SendMail: type Alternatives = [Part]
- Imm.Hooks.SendMail: type Headers = [(ByteString, Text)]
- Imm.Hooks.SendMail: type Password = String
- Imm.Hooks.SendMail: type ServerName = String
- Imm.Hooks.SendMail: type Username = String
- Imm.Hooks.SendMail: withSMTPConnection :: SMTPServer -> (SMTPConnection -> IO a) -> IO a
- Imm.Hooks.WriteFile: FileInfo :: FilePath -> Builder -> FileInfo
- Imm.Hooks.WriteFile: WriteFileSettings :: (Feed -> FeedElement -> FileInfo) -> WriteFileSettings
- Imm.Hooks.WriteFile: convertAtomURI :: IsString t => AtomURI -> t
- Imm.Hooks.WriteFile: convertDoc :: IsString t => Doc a -> t
- Imm.Hooks.WriteFile: convertText :: IsString t => Text -> t
- Imm.Hooks.WriteFile: convertURI :: IsString t => URIRef a -> t
- Imm.Hooks.WriteFile: data FileInfo
- Imm.Hooks.WriteFile: defaultArticleAuthor :: Feed -> FeedElement -> Html
- Imm.Hooks.WriteFile: defaultArticleDate :: Feed -> FeedElement -> Html
- Imm.Hooks.WriteFile: defaultArticleTitle :: Feed -> FeedElement -> Html
- Imm.Hooks.WriteFile: defaultBody :: Feed -> FeedElement -> Html
- Imm.Hooks.WriteFile: defaultFileContent :: Feed -> FeedElement -> Builder
- Imm.Hooks.WriteFile: defaultFilePath :: FilePath -> Feed -> FeedElement -> FilePath
- Imm.Hooks.WriteFile: defaultSettings :: FilePath -> WriteFileSettings
- Imm.Hooks.WriteFile: href :: URIRef a -> Attribute
- Imm.Hooks.WriteFile: mkHandle :: MonadBase IO m => MonadIO m => MonadMask m => WriteFileSettings -> Handle m
- Imm.Hooks.WriteFile: newtype WriteFileSettings
- Imm.Logger.Simple: LoggerSettings :: LoggerSet -> LoggerSet -> MVar LogLevel -> MVar Bool -> LoggerSettings
- Imm.Logger.Simple: [_colorizeLogs] :: LoggerSettings -> MVar Bool
- Imm.Logger.Simple: [_errorLoggerSet] :: LoggerSettings -> LoggerSet
- Imm.Logger.Simple: [_logLevel] :: LoggerSettings -> MVar LogLevel
- Imm.Logger.Simple: [_loggerSet] :: LoggerSettings -> LoggerSet
- Imm.Logger.Simple: data LoggerSettings
- Imm.Logger.Simple: defaultLogger :: IO LoggerSettings
- Imm.Logger.Simple: mkHandle :: MonadIO m => LoggerSettings -> Handle m
- Imm.XML.Conduit: XmlParser :: (forall m. Monad m => URI -> ConduitT Event Event m ()) -> XmlParser
- Imm.XML.Conduit: defaultXmlParser :: XmlParser
- Imm.XML.Conduit: mkHandle :: MonadIO m => MonadCatch m => XmlParser -> Handle m
- Imm.XML.Conduit: newtype XmlParser
+ Imm: Callback :: FilePath -> [Text] -> Callback
+ Imm: FeedURI :: URIRef a -> FeedURI
+ Imm: Message :: Feed -> FeedElement -> Message
+ Imm: [_arguments] :: Callback -> [Text]
+ Imm: [_executable] :: Callback -> FilePath
+ Imm: data Callback
+ Imm: data FeedURI
+ Imm: data Message
+ Imm: getId :: FeedElement -> Text
+ Imm: getLink :: FeedElement -> Maybe FeedURI
+ Imm: parseFeed :: MonadCatch m => Text -> m Feed
+ Imm: parseFeedElement :: MonadCatch m => Text -> m FeedElement
+ Imm: parseXml :: Handle m -> URI -> LByteString -> m Feed
+ Imm: readOnly :: Monad m => Pretty (Key t) => Handle m -> Handle m t -> Handle m t
+ Imm: removeElements :: Feed -> Feed
+ Imm: renderFeed :: Feed -> Text
+ Imm: renderFeedElement :: FeedElement -> Text
+ Imm: sameURIType :: URIRef a1 -> URIRef a2 -> Maybe (URIRef a1 :~: URIRef a2)
+ Imm: withFeedURI :: (forall a. URIRef a -> b) -> FeedURI -> b
+ Imm.Callback: Callback :: FilePath -> [Text] -> Callback
+ Imm.Callback: Message :: Feed -> FeedElement -> Message
+ Imm.Callback: [_arguments] :: Callback -> [Text]
+ Imm.Callback: [_executable] :: Callback -> FilePath
+ Imm.Callback: data Callback
+ Imm.Callback: data Message
+ Imm.Callback: instance Data.Aeson.Types.FromJSON.FromJSON Imm.Callback.Message
+ Imm.Callback: instance Data.Aeson.Types.ToJSON.ToJSON Imm.Callback.Message
+ Imm.Callback: instance Data.Text.Prettyprint.Doc.Internal.Pretty Imm.Callback.Callback
+ Imm.Callback: instance Dhall.Interpret Imm.Callback.Callback
+ Imm.Callback: instance GHC.Classes.Eq Imm.Callback.Callback
+ Imm.Callback: instance GHC.Classes.Eq Imm.Callback.Message
+ Imm.Callback: instance GHC.Classes.Ord Imm.Callback.Callback
+ Imm.Callback: instance GHC.Classes.Ord Imm.Callback.Message
+ Imm.Callback: instance GHC.Generics.Generic Imm.Callback.Callback
+ Imm.Callback: instance GHC.Generics.Generic Imm.Callback.Message
+ Imm.Callback: instance GHC.Read.Read Imm.Callback.Callback
+ Imm.Callback: instance GHC.Show.Show Imm.Callback.Callback
+ Imm.Callback: instance GHC.Show.Show Imm.Callback.Message
+ Imm.Database: readOnly :: Monad m => Pretty (Key t) => Handle m -> Handle m t -> Handle m t
+ Imm.Database.FeedTable: JsonElement :: FeedElement -> JsonElement
+ Imm.Database.FeedTable: JsonURI :: URI -> JsonURI
+ Imm.Database.FeedTable: [_unwrapURI] :: JsonURI -> URI
+ Imm.Database.FeedTable: [entryFeed] :: DatabaseEntry -> Maybe Feed
+ Imm.Database.FeedTable: [entryItems] :: DatabaseEntry -> Map FeedElement Bool
+ Imm.Database.FeedTable: [entryLastUpdate] :: DatabaseEntry -> Maybe UTCTime
+ Imm.Database.FeedTable: [unwrapElement] :: JsonElement -> FeedElement
+ Imm.Database.FeedTable: getHashes :: FeedElement -> [Int]
+ Imm.Database.FeedTable: instance Data.Aeson.Types.FromJSON.FromJSON Imm.Database.FeedTable.JsonElement
+ Imm.Database.FeedTable: instance Data.Aeson.Types.FromJSON.FromJSON Imm.Database.FeedTable.JsonURI
+ Imm.Database.FeedTable: instance Data.Aeson.Types.FromJSON.FromJSONKey Imm.Database.FeedTable.JsonElement
+ Imm.Database.FeedTable: instance Data.Aeson.Types.ToJSON.ToJSON Imm.Database.FeedTable.JsonElement
+ Imm.Database.FeedTable: instance Data.Aeson.Types.ToJSON.ToJSON Imm.Database.FeedTable.JsonURI
+ Imm.Database.FeedTable: instance Data.Aeson.Types.ToJSON.ToJSONKey Imm.Database.FeedTable.JsonElement
+ Imm.Database.FeedTable: instance GHC.Classes.Eq Imm.Database.FeedTable.FeedTable
+ Imm.Database.FeedTable: instance GHC.Classes.Eq Imm.Database.FeedTable.JsonElement
+ Imm.Database.FeedTable: instance GHC.Classes.Ord Imm.Database.FeedTable.FeedTable
+ Imm.Database.FeedTable: instance GHC.Classes.Ord Imm.Database.FeedTable.JsonElement
+ Imm.Database.FeedTable: instance GHC.Read.Read Imm.Database.FeedTable.FeedTable
+ Imm.Database.FeedTable: isRead :: MonadCatch m => Handle m FeedTable -> FeedID -> FeedElement -> m Bool
+ Imm.Database.FeedTable: markAsProcessed :: MonadThrow m => MonadTime m => Handle m -> Handle m FeedTable -> FeedID -> FeedElement -> m ()
+ Imm.Database.FeedTable: markAsUnprocessed :: MonadThrow m => Handle m -> Handle m FeedTable -> FeedID -> m ()
+ Imm.Database.FeedTable: newtype JsonElement
+ Imm.Database.FeedTable: newtype JsonURI
+ Imm.Database.FeedTable: prettyShortDatabaseEntry :: DatabaseEntry -> Doc AnsiStyle
+ Imm.Feed: FeedURI :: URIRef a -> FeedURI
+ Imm.Feed: data FeedURI
+ Imm.Feed: getId :: FeedElement -> Text
+ Imm.Feed: getLink :: FeedElement -> Maybe FeedURI
+ Imm.Feed: instance Data.Text.Prettyprint.Doc.Internal.Pretty (Imm.Pretty.PrettyKey Imm.Feed.FeedElement)
+ Imm.Feed: instance GHC.Classes.Eq Imm.Feed.FeedURI
+ Imm.Feed: instance GHC.Classes.Ord Imm.Feed.Feed
+ Imm.Feed: instance GHC.Classes.Ord Imm.Feed.FeedElement
+ Imm.Feed: instance GHC.Classes.Ord Imm.Feed.FeedRef
+ Imm.Feed: instance GHC.Classes.Ord Imm.Feed.FeedURI
+ Imm.Feed: instance GHC.Show.Show Imm.Feed.FeedURI
+ Imm.Feed: parseFeed :: MonadCatch m => Text -> m Feed
+ Imm.Feed: parseFeedElement :: MonadCatch m => Text -> m FeedElement
+ Imm.Feed: removeElements :: Feed -> Feed
+ Imm.Feed: renderFeed :: Feed -> Text
+ Imm.Feed: renderFeedElement :: FeedElement -> Text
+ Imm.Feed: sameURIType :: URIRef a1 -> URIRef a2 -> Maybe (URIRef a1 :~: URIRef a2)
+ Imm.Feed: withFeedURI :: (forall a. URIRef a -> b) -> FeedURI -> b
+ Imm.Pretty: (<++>) :: Doc a -> Doc a -> Doc a
+ Imm.Pretty: (<+>) :: () => Doc ann -> Doc ann -> Doc ann
+ Imm.Pretty: (<>) :: Semigroup a => a -> a -> a
+ Imm.Pretty: AvailablePerLine :: Int -> Double -> PageWidth
+ Imm.Pretty: Deep :: FusionDepth
+ Imm.Pretty: LayoutOptions :: PageWidth -> LayoutOptions
+ Imm.Pretty: PrettyKey :: a -> PrettyKey a
+ Imm.Pretty: SAnnPop :: SimpleDocStream ann -> SimpleDocStream ann
+ Imm.Pretty: SAnnPush :: ann -> SimpleDocStream ann -> SimpleDocStream ann
+ Imm.Pretty: SChar :: Char -> SimpleDocStream ann -> SimpleDocStream ann
+ Imm.Pretty: SEmpty :: SimpleDocStream ann
+ Imm.Pretty: SFail :: SimpleDocStream ann
+ Imm.Pretty: SLine :: !Int -> SimpleDocStream ann -> SimpleDocStream ann
+ Imm.Pretty: SText :: !Int -> Text -> SimpleDocStream ann -> SimpleDocStream ann
+ Imm.Pretty: Shallow :: FusionDepth
+ Imm.Pretty: Unbounded :: PageWidth
+ Imm.Pretty: [layoutPageWidth] :: LayoutOptions -> PageWidth
+ Imm.Pretty: align :: () => Doc ann -> Doc ann
+ Imm.Pretty: alterAnnotations :: () => (ann -> [ann']) -> Doc ann -> Doc ann'
+ Imm.Pretty: alterAnnotationsS :: () => (ann -> Maybe ann') -> SimpleDocStream ann -> SimpleDocStream ann'
+ Imm.Pretty: angles :: () => Doc ann -> Doc ann
+ Imm.Pretty: annotate :: () => ann -> Doc ann -> Doc ann
+ Imm.Pretty: backslash :: () => Doc ann
+ Imm.Pretty: bold :: Doc AnsiStyle -> Doc AnsiStyle
+ Imm.Pretty: braces :: () => Doc ann -> Doc ann
+ Imm.Pretty: brackets :: () => Doc ann -> Doc ann
+ Imm.Pretty: cat :: () => [Doc ann] -> Doc ann
+ Imm.Pretty: class Pretty a
+ Imm.Pretty: colon :: () => Doc ann
+ Imm.Pretty: column :: () => (Int -> Doc ann) -> Doc ann
+ Imm.Pretty: comma :: () => Doc ann
+ Imm.Pretty: concatWith :: Foldable t => (Doc ann -> Doc ann -> Doc ann) -> t (Doc ann) -> Doc ann
+ Imm.Pretty: cyan :: Doc AnsiStyle -> Doc AnsiStyle
+ Imm.Pretty: data AnsiStyle
+ Imm.Pretty: data Doc ann
+ Imm.Pretty: data FusionDepth
+ Imm.Pretty: data PageWidth
+ Imm.Pretty: data SimpleDocStream ann
+ Imm.Pretty: defaultLayoutOptions :: LayoutOptions
+ Imm.Pretty: dot :: () => Doc ann
+ Imm.Pretty: dquote :: () => Doc ann
+ Imm.Pretty: dquotes :: () => Doc ann -> Doc ann
+ Imm.Pretty: emptyDoc :: () => Doc ann
+ Imm.Pretty: enclose :: () => Doc ann -> Doc ann -> Doc ann -> Doc ann
+ Imm.Pretty: encloseSep :: () => Doc ann -> Doc ann -> Doc ann -> [Doc ann] -> Doc ann
+ Imm.Pretty: equals :: () => Doc ann
+ Imm.Pretty: fill :: () => Int -> Doc ann -> Doc ann
+ Imm.Pretty: fillBreak :: () => Int -> Doc ann -> Doc ann
+ Imm.Pretty: fillCat :: () => [Doc ann] -> Doc ann
+ Imm.Pretty: fillSep :: () => [Doc ann] -> Doc ann
+ Imm.Pretty: flatAlt :: () => Doc ann -> Doc ann -> Doc ann
+ Imm.Pretty: fuse :: () => FusionDepth -> Doc ann -> Doc ann
+ Imm.Pretty: green :: Doc AnsiStyle -> Doc AnsiStyle
+ Imm.Pretty: group :: () => Doc ann -> Doc ann
+ Imm.Pretty: hang :: () => Int -> Doc ann -> Doc ann
+ Imm.Pretty: hardline :: () => Doc ann
+ Imm.Pretty: hcat :: () => [Doc ann] -> Doc ann
+ Imm.Pretty: hsep :: () => [Doc ann] -> Doc ann
+ Imm.Pretty: indent :: () => Int -> Doc ann -> Doc ann
+ Imm.Pretty: infixr 6 <>
+ Imm.Pretty: langle :: () => Doc ann
+ Imm.Pretty: layoutCompact :: () => Doc ann -> SimpleDocStream ann
+ Imm.Pretty: layoutPretty :: () => LayoutOptions -> Doc ann -> SimpleDocStream ann
+ Imm.Pretty: layoutSmart :: () => LayoutOptions -> Doc ann -> SimpleDocStream ann
+ Imm.Pretty: lbrace :: () => Doc ann
+ Imm.Pretty: lbracket :: () => Doc ann
+ Imm.Pretty: line :: () => Doc ann
+ Imm.Pretty: line' :: () => Doc ann
+ Imm.Pretty: lparen :: () => Doc ann
+ Imm.Pretty: magenta :: Doc AnsiStyle -> Doc AnsiStyle
+ Imm.Pretty: nest :: () => Int -> Doc ann -> Doc ann
+ Imm.Pretty: nesting :: () => (Int -> Doc ann) -> Doc ann
+ Imm.Pretty: newtype LayoutOptions
+ Imm.Pretty: newtype PrettyKey a
+ Imm.Pretty: pageWidth :: () => (PageWidth -> Doc ann) -> Doc ann
+ Imm.Pretty: parens :: () => Doc ann -> Doc ann
+ Imm.Pretty: pipe :: () => Doc ann
+ Imm.Pretty: plural :: (Num amount, Eq amount) => doc -> doc -> amount -> doc
+ Imm.Pretty: pretty :: Pretty a => a -> Doc ann
+ Imm.Pretty: prettyAtomContent :: AtomContent -> Doc a
+ Imm.Pretty: prettyAtomText :: AtomText -> Doc a
+ Imm.Pretty: prettyContent :: Content -> Doc a
+ Imm.Pretty: prettyElement :: Element -> Doc a
+ Imm.Pretty: prettyEntry :: AtomEntry -> Doc a
+ Imm.Pretty: prettyGuid :: RssGuid -> Doc a
+ Imm.Pretty: prettyItem :: RssItem e -> Doc a
+ Imm.Pretty: prettyLink :: AtomLink -> Doc a
+ Imm.Pretty: prettyList :: Pretty a => [a] -> Doc ann
+ Imm.Pretty: prettyNode :: Node -> Doc a
+ Imm.Pretty: prettyPerson :: AtomPerson -> Doc a
+ Imm.Pretty: prettyTime :: UTCTime -> Doc a
+ Imm.Pretty: prettyTree :: Pretty a => Tree a -> Doc b
+ Imm.Pretty: prettyURI :: URIRef a -> Doc b
+ Imm.Pretty: punctuate :: () => Doc ann -> [Doc ann] -> [Doc ann]
+ Imm.Pretty: rangle :: () => Doc ann
+ Imm.Pretty: rbrace :: () => Doc ann
+ Imm.Pretty: rbracket :: () => Doc ann
+ Imm.Pretty: reAnnotate :: () => (ann -> ann') -> Doc ann -> Doc ann'
+ Imm.Pretty: reAnnotateS :: () => (ann -> ann') -> SimpleDocStream ann -> SimpleDocStream ann'
+ Imm.Pretty: red :: Doc AnsiStyle -> Doc AnsiStyle
+ Imm.Pretty: removeTrailingWhitespace :: () => SimpleDocStream ann -> SimpleDocStream ann
+ Imm.Pretty: rparen :: () => Doc ann
+ Imm.Pretty: semi :: () => Doc ann
+ Imm.Pretty: sep :: () => [Doc ann] -> Doc ann
+ Imm.Pretty: slash :: () => Doc ann
+ Imm.Pretty: softline :: () => Doc ann
+ Imm.Pretty: softline' :: () => Doc ann
+ Imm.Pretty: space :: () => Doc ann
+ Imm.Pretty: squote :: () => Doc ann
+ Imm.Pretty: squotes :: () => Doc ann -> Doc ann
+ Imm.Pretty: surround :: () => Doc ann -> Doc ann -> Doc ann -> Doc ann
+ Imm.Pretty: tupled :: () => [Doc ann] -> Doc ann
+ Imm.Pretty: unAnnotate :: () => Doc ann -> Doc xxx
+ Imm.Pretty: unAnnotateS :: () => SimpleDocStream ann -> SimpleDocStream xxx
+ Imm.Pretty: unsafeViaShow :: Show a => a -> Doc ann
+ Imm.Pretty: vcat :: () => [Doc ann] -> Doc ann
+ Imm.Pretty: viaShow :: Show a => a -> Doc ann
+ Imm.Pretty: vsep :: () => [Doc ann] -> Doc ann
+ Imm.Pretty: yellow :: Doc AnsiStyle -> Doc AnsiStyle
- Imm: _describeDatabase :: Handle m t -> forall a. m (Doc a)
+ Imm: _describeDatabase :: Handle m t -> m (Doc AnsiStyle)
- Imm.Database: Handle :: (forall a. m (Doc a)) -> ([Key t] -> m (Map (Key t) (Entry t))) -> m (Map (Key t) (Entry t)) -> (Key t -> (Entry t -> Entry t) -> m ()) -> ([(Key t, Entry t)] -> m ()) -> ([Key t] -> m ()) -> m () -> m () -> Handle m t
+ Imm.Database: Handle :: m (Doc AnsiStyle) -> ([Key t] -> m (Map (Key t) (Entry t))) -> m (Map (Key t) (Entry t)) -> (Key t -> (Entry t -> Entry t) -> m ()) -> ([(Key t, Entry t)] -> m ()) -> ([Key t] -> m ()) -> m () -> m () -> Handle m t
- Imm.Database: [_describeDatabase] :: Handle m t -> forall a. m (Doc a)
+ Imm.Database: [_describeDatabase] :: Handle m t -> m (Doc AnsiStyle)
- Imm.Database.FeedTable: DatabaseEntry :: URI -> Set Text -> Set Int -> Maybe UTCTime -> DatabaseEntry
+ Imm.Database.FeedTable: DatabaseEntry :: URI -> Set Text -> Set Int -> Maybe Feed -> Map FeedElement Bool -> Maybe UTCTime -> DatabaseEntry
Files
- README.md +67/−19
- data/callbacks.dhall +23/−0
- imm.cabal +41/−54
- src/bin/Executable.hs +0/−27
- src/lib/Imm.hs +2/−5
- src/lib/Imm/Aeson.hs +0/−15
- src/lib/Imm/Boot.hs +0/−138
- src/lib/Imm/Callback.hs +39/−0
- src/lib/Imm/Core.hs +0/−174
- src/lib/Imm/Database.hs +14/−1
- src/lib/Imm/Database/FeedTable.hs +127/−44
- src/lib/Imm/Database/JsonFile.hs +0/−122
- src/lib/Imm/Dyre.hs +0/−71
- src/lib/Imm/Error.hs +0/−15
- src/lib/Imm/Feed.hs +92/−14
- src/lib/Imm/HTTP/Simple.hs +0/−46
- src/lib/Imm/Hooks.hs +0/−27
- src/lib/Imm/Hooks/Dummy.hs +0/−20
- src/lib/Imm/Hooks/SendMail.hs +0/−144
- src/lib/Imm/Hooks/WriteFile.hs +0/−123
- src/lib/Imm/Logger/Simple.hs +0/−46
- src/lib/Imm/Options.hs +0/−150
- src/lib/Imm/Pretty.hs +28/−9
- src/lib/Imm/XML/Conduit.hs +0/−32
- src/lib/Prelude.hs +1/−2
- src/main/Core.hs +70/−0
- src/main/Database.hs +127/−0
- src/main/HTTP.hs +46/−0
- src/main/Logger.hs +52/−0
- src/main/Main.hs +228/−0
- src/main/Options.hs +144/−0
- src/main/Prelude.hs +7/−0
- src/main/XML.hs +32/−0
- src/send-mail/Main.hs +185/−0
- src/send-mail/Prelude.hs +7/−0
- src/write-file/Main.hs +134/−0
- src/write-file/Prelude.hs +7/−0
README.md view
@@ -1,14 +1,29 @@ # imm -*imm* is a tool to execute arbitrary actions for each new element from RSS/Atom feeds (e.g. sending a mail, or writing a file).+*imm* is a program that executes arbitrary callbacks (e.g. sending a mail, or writing a file) for each element of RSS/Atom feeds. -*imm* is written and configured in *Haskell*.+*imm* is written in [Haskell][2], configured in [Dhall][3]. The project includes: -Technical documentation is available at [hackage][1].+- a main executable `imm` that users directly run+- additional executables (`imm-writefile` and `imm-sendmail`) that `imm` can be configured to use as callbacks+- a [*Haskell*][2] library, that exports functions useful to both the main executable and callbacks; the API is documented [in Hackage][1]. -To get started, please consult documentation of `Imm.Boot` module.+## Callbacks +Callbacks are configured through the `$XDG_CONFIG_HOME/imm/callbacks.dhall` file. A commented template file is bundled with the project. +`imm` will call each callback once per feed element, and will fill its standard input (`stdin`) with a JSON object structured as follows:++```json+{+ "feed": "RSS document or Atom feed (XML)",+ "element": "RSS item or Atom entry (XML)"+}+```++`imm` expects callbacks to return `0` in case of success, or any other exit code in case of failure, in which case the standard error output (`stderr`) will be displayed.++ ## Example use cases ### Online feed reader@@ -17,45 +32,78 @@ You can then browse your feeds through your favourite mail reader, and leverage any mail-related tool on your feeds. Bonus points if your mail reader is online as you can now access your feeds from any computer connected to the Internet. -Check out `Imm.Hooks.SendMail` module.+Here is an example configuration:+```dhall+let Callback : Type =+ { _executable : Text+ , _arguments : List Text+ } +let sendMail =+ { _executable = "imm-sendmail"+ , _arguments = ["--login", "-u", "user@domain.com", "-P", "password", "-s", "smtp.domain.com", "-p", "587", "--to", "foo.bar@domain.com"]+ }++let config : List Callback = [ sendMail ]+in config+```+ ### Offline read-it-later *imm* is able to store a local copy of unread elements, to read them later while offline for example. External links won't work offline though. -Check out `Imm.Hooks.WriteFile` module.+```+let Callback : Type =+ { _executable : Text+ , _arguments : List Text+ } +let writeFile =+ { _executable = "imm-writefile"+ , _arguments = [ "-d", "/path/to/folder" ]+ } +let config : List Callback = [ writeFile ]+in config+```+ ## Example usage - Subscribe to a feed:-- ```+ ```bash imm subscribe http://your.feed.org ```-- Import feeds from an OPML file: - ```+- Import feeds from an OPML file:+ ```bash cat feeds.opml | imm import ```-- List subscribed feeds: - ```- imm show+- List subscribed feeds:+ ```bash+ imm list ```-- Unsubscribe from a feed: +- Show details about a feed:+ ```bash+ imm show http://your.feed.org ```++- Unsubscribe from a feed:+ ```bash imm unsubscribe http://your.feed.org ```-- Check for new elements without executing any action: - ```- imm check+- Check for new elements without executing any action:+ ```bash+ imm run --no-callbacks --read-only ```-- Execute configured actions for each new element from subscribed feeds: - ```+- Execute configured callbacks for each new element from subscribed feeds:+ ```bash imm run ```+ [1]: http://hackage.haskell.org/package/imm+[2]: https://www.haskell.org+[3]: https://dhall-lang.org/
+ data/callbacks.dhall view
@@ -0,0 +1,23 @@+-- This file must produce a `List Callback` expression,+-- where `Callback` is defined as follows:+let Callback : Type =+ { _executable : Text+ , _arguments : List Text+ }++-- Below are 2 basic callbacks bundled with imm.+--+-- Check out `imm-writefile --help`+let writeFile =+ { _executable = "imm-writefile"+ , _arguments = [ "-d", "/path/to/folder" ]+ }++-- Check out `imm-sendmail --help`+let sendMail =+ { _executable = "imm-sendmail"+ , _arguments = ["--login", "-u", "user@domain.com", "-P", "password", "-s", "smtp.domain.com", "-p", "587", "--to", "foo.bar@domain.com"]+ }++let config : List Callback = [ writeFile ]+in config
imm.cabal view
@@ -1,102 +1,89 @@+cabal-version: 2.2 name: imm-version: 1.5.0.0-synopsis: Execute arbitrary actions for each unread element of RSS/Atom feeds+version: 1.6.0.0+synopsis: Execute arbitrary callbacks for each element of RSS/Atom feeds description: Cf README file homepage: https://github.com/k0ral/imm-license: PublicDomain+license: CC0-1.0 license-file: LICENSE author: kamaradclimber, koral-maintainer: koral <koral@mailoo.org>+maintainer: chahine.moreau@gmail.com category: Web build-type: Simple-cabal-version: >=1.8+data-files: data/*.dhall extra-source-files: README.md+tested-with: GHC <8.8 && >=8.4 source-repository head type: git location: git://github.com/k0ral/imm.git +common common+ build-depends: base-noprelude >=4.7 && <5, relude+ default-language: Haskell2010+ other-modules:+ Prelude+ library+ import: common exposed-modules: Imm- Imm.Boot- Imm.Core+ Imm.Callback Imm.Database Imm.Database.FeedTable- Imm.Database.JsonFile Imm.Feed- Imm.Hooks- Imm.Hooks.Dummy- Imm.Hooks.SendMail- Imm.Hooks.WriteFile Imm.HTTP- Imm.HTTP.Simple Imm.Logger- Imm.Logger.Simple- Imm.XML- Imm.XML.Conduit- other-modules:- Imm.Aeson- Imm.Dyre- Imm.Error- Imm.Options Imm.Pretty- Paths_imm- Prelude+ Imm.XML build-depends: aeson,- atom-conduit >= 0.6,- base-noprelude == 4.*,- blaze-html,- blaze-markup,- bytestring,- case-insensitive,+ async,+ atom-conduit >= 0.7,+ binary, conduit,- connection, containers,+ dhall, directory >= 1.2.3.0,- dyre,- fast-logger, filepath, hashable,- HaskellNet,- HaskellNet-SSL >= 0.3.3.0,- http-client >= 0.4.30,- http-client-tls, http-types, microlens,- mime-mail, monad-time,- monoid-subclasses,- mtl,- network,- opml-conduit >= 0.7,- optparse-applicative, prettyprinter, prettyprinter-ansi-terminal,- refined,- relude,+ refined >=0.4.1, rss-conduit >= 0.4.1, safe-exceptions,- stm,- streaming-bytestring,- streaming-with,- streamly, text,- transformers-base, time, timerep >= 2.0.0.0, tls, uri-bytestring,- xml, xml-conduit >= 1.5, xml-types- -- 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: src/bin+ import: common+ build-depends: imm, aeson, async, atom-conduit >=0.7, bytestring, case-insensitive, conduit, connection, containers, dhall, directory, fast-logger, filepath, http-client >=0.4.30, http-client-tls, opml-conduit >=0.7, optparse-applicative, prettyprinter-ansi-terminal, refined >=0.4.1, rss-conduit >=0.4.1, safe-exceptions, stm, stm-chans, streaming-bytestring, streaming-with, text, typed-process, uri-bytestring, xml-conduit >=1.5, xml-types+ main-is: Main.hs+ other-modules: Core, Database, HTTP, Logger, Options, Paths_imm, XML+ autogen-modules: Paths_imm+ hs-source-dirs: src/main+ ghc-options: -Wall -fno-warn-unused-do-bind -threaded++executable imm-writefile+ import: common+ build-depends: imm, aeson, atom-conduit, blaze-html, blaze-markup, bytestring, directory, filepath, optparse-applicative, prettyprinter, rss-conduit, streaming-bytestring, streaming-with, text, time, uri-bytestring+ main-is: Main.hs+ hs-source-dirs: src/write-file+ ghc-options: -Wall -fno-warn-unused-do-bind -threaded++executable imm-sendmail+ import: common+ build-depends: imm, aeson, atom-conduit, blaze-html, blaze-markup, bytestring, directory, filepath, HaskellNet >=0.5, HaskellNet-SSL >= 0.3.3.0, mime-mail, network <3, optparse-applicative, prettyprinter, refined, rss-conduit, text, time, uri-bytestring+ main-is: Main.hs+ hs-source-dirs: src/send-mail ghc-options: -Wall -fno-warn-unused-do-bind -threaded
− src/bin/Executable.hs
@@ -1,27 +0,0 @@--- module Executable where---- {{{ Imports-import Imm-import Imm.Database.JsonFile as Database-import Imm.Hooks.Dummy as Hooks-import Imm.HTTP.Simple as HTTP-import Imm.Logger.Simple as Logger-import Imm.XML.Conduit as XML--- }}}---main :: IO ()-main = do- logger <- Logger.mkHandle <$> defaultLogger- database <- Database.mkHandle <$> defaultDatabase- httpClient <- HTTP.mkHandle <$> defaultManager-- imm logger database httpClient hooks xmlParser--xmlParser :: XML.Handle IO-xmlParser = XML.mkHandle defaultXmlParser--hooks :: Hooks.Handle IO-hooks = Hooks.mkHandle--
src/lib/Imm.hs view
@@ -1,12 +1,9 @@ -- | 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.Callback as X import Imm.Database as X hiding(Handle) import Imm.Feed as X-import Imm.Hooks as X hiding(Handle) import Imm.HTTP as X hiding(Handle) import Imm.Logger as X hiding(Handle)+import Imm.XML as X hiding(Handle)
− src/lib/Imm/Aeson.hs
@@ -1,15 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-module Imm.Aeson where---- {{{ Imports-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'
− src/lib/Imm/Boot.hs
@@ -1,138 +0,0 @@-{-# LANGUAGE ExistentialQuantification #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}--- |--- = Getting started------ == Dynamic reconfiguration------ This program is dynamically configured using the <https://hackage.haskell.org/package/dyre dyre> library.------ You may want to check out <https://hackage.haskell.org/package/dyre/docs/Config-Dyre.html this documentation> to know how to get started.------ Your personal configuration is located at @$XDG_CONFIG_HOME\/imm\/imm.hs@.------ == Handle pattern------ The behavior of this program can be customized through the [Handle pattern](https://jaspervdj.be/posts/2018-03-08-handle-pattern.html).-module Imm.Boot (imm) where---- {{{ Imports-import qualified Imm.Core as Core-import Imm.Database as Database-import Imm.Database.FeedTable as Database-import Imm.Dyre as Dyre-import Imm.Feed-import Imm.Hooks as Hooks-import Imm.HTTP as HTTP-import Imm.Logger as Logger-import Imm.Options as Options hiding (logLevel)-import Imm.Pretty-import Imm.XML as XML--import Control.Exception.Safe-import Data.Conduit.Combinators as Conduit (stdin)-import qualified Data.Map as Map-import Data.Text as Text hiding (length)-import Data.Text.IO as Text-import Relude.Unsafe (at)-import System.IO (hFlush)--- }}}---- | Main function, meant to be used in your personal configuration file.------ Here is an example:------ > import Imm.Boot--- > import Imm.Database.JsonFile as Database--- > import Imm.Feed--- > import Imm.Hooks.SendMail as Hooks--- > import Imm.HTTP.Simple as HTTP--- > import Imm.Logger.Simple as Logger--- > import Imm.XML.Conduit as XML--- >--- > main :: IO ()--- > main = do--- > logger <- Logger.mkHandle <$> defaultLogger--- > database <- Database.mkHandle <$> defaultDatabase--- > httpClient <- HTTP.mkHandle <$> defaultManager--- >--- > imm logger database httpClient hooks xmlParser--- >--- > xmlParser :: XML.Handle IO--- > xmlParser = XML.mkHandle defaultXmlParser--- >--- > hooks :: Hooks.Handle IO--- > hooks = Hooks.mkHandle $ 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 :: Logger.Handle IO -> Database.Handle IO FeedTable -> HTTP.Handle IO -> Hooks.Handle IO -> XML.Handle IO -> IO ()-imm logger database httpClient hooks xmlParser = void $ do- options <- parseOptions- Dyre.wrap (optionDyreMode options) realMain (optionCommand options, optionLogLevel options, optionColorizeLogs options, logger, database, httpClient, hooks, xmlParser)--realMain :: (Command, LogLevel, Bool, Logger.Handle IO, Database.Handle IO FeedTable, HTTP.Handle IO, Hooks.Handle IO, XML.Handle IO) -> IO ()-realMain (command, logLevel, enableColors, logger, database, httpClient, hooks, xmlParser) = void $ do- setColorizeLogs logger enableColors- setLogLevel logger logLevel- log logger Debug . ("Dynamic reconfiguration settings:" <++>) . indent 2 =<< Dyre.describePaths- log logger Debug $ "Executing: " <> pretty command- log logger Debug . ("Using database:" <++>) . indent 2 =<< _describeDatabase database-- handleAny (log logger Error . pretty . displayException) $ case command of- Check t -> Core.check logger database httpClient xmlParser =<< resolveTarget database ByPassConfirmation t- Help -> Text.putStrLn helpString- Import -> Core.importOPML logger database Conduit.stdin- Read t -> mapM_ (Database.markAsRead logger database) =<< resolveTarget database AskConfirmation t- Run t -> Core.run logger database httpClient hooks xmlParser =<< resolveTarget database ByPassConfirmation t- Show t -> Core.showFeed logger database =<< resolveTarget database ByPassConfirmation t- ShowVersion -> Core.printVersions- Subscribe u c -> Core.subscribe logger database u c- Unread t -> mapM_ (Database.markAsUnread logger database) =<< resolveTarget database AskConfirmation t- Unsubscribe t -> Database.deleteList logger database =<< resolveTarget database AskConfirmation t- _ -> return ()-- Database.commit logger database- flushLogs logger----- * Util--data SafeGuard = AskConfirmation | ByPassConfirmation- deriving(Eq, Read, Show)--data InterruptedException = InterruptedException deriving(Eq, Read, Show)-instance Exception InterruptedException where- displayException _ = "Process interrupted"--promptConfirm :: Text -> IO ()-promptConfirm s = do- Text.putStr $ s <> " Confirm [Y/n] "- hFlush stdout- x <- Text.getLine- unless (Text.null x || x == "Y") $ throwM InterruptedException---resolveTarget :: MonadIO m => MonadThrow m => Database.Handle m FeedTable -> SafeGuard -> Maybe Core.FeedRef -> m [FeedID]-resolveTarget database s Nothing = do- result <- Map.keys <$> Database.fetchAll database- when (s == AskConfirmation) $ liftIO $ promptConfirm $ "This will affect " <> show (length result) <> " feeds."- return result-resolveTarget database _ (Just (ByUID i)) = do- result <- fst . at (i-1) . Map.toList <$> Database.fetchAll database- -- log logger Info $ "Target(s): " <> show (pretty result)- return [result]-resolveTarget _ _ (Just (ByURI uri)) = return [FeedID uri]
+ src/lib/Imm/Callback.hs view
@@ -0,0 +1,39 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}+module Imm.Callback where++-- {{{ Imports+import Imm.Feed++import Data.Aeson+import Data.Aeson.Types+import Data.Text.Prettyprint.Doc+import Dhall+-- }}}++-- | External program run for each feed element.+--+-- A `Message` is passed to this program through stdin, serialized in JSON.+data Callback = Callback+ { _executable :: FilePath+ , _arguments :: [Text]+ } deriving (Eq, Generic, Ord, Read, Show)++instance Interpret Callback++instance Pretty Callback where+ pretty (Callback executable arguments) = pretty executable <+> sep (pretty <$> arguments)+++-- | All information passed to external programs about a new feed item, are stored in this structure.+data Message = Message Feed FeedElement deriving(Eq, Generic, Ord, Show)++instance ToJSON Message where+ toJSON (Message feed element) = object ["feed" .= renderFeed feed, "element" .= renderFeedElement element]++instance FromJSON Message where+ parseJSON = withObject "Message" $ \v -> Message+ <$> (v .: "feed" >>= (liftEither . parseFeed))+ <*> (v .: "element" >>= (liftEither . parseFeedElement))+ where liftEither :: Either e a -> Parser a+ liftEither = either (const mempty) return
− src/lib/Imm/Core.hs
@@ -1,174 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE PartialTypeSignatures #-}-{-# LANGUAGE TupleSections #-}-{-# LANGUAGE TypeFamilies #-}-module Imm.Core (--- * Types- FeedRef,--- * Actions- printVersions,- subscribe,- showFeed,- check,- run,- importOPML,-) where---- {{{ Imports-import qualified Imm.Database as Database-import Imm.Database.FeedTable-import qualified Imm.Database.FeedTable as Database-import Imm.Feed-import Imm.Hooks as Hooks-import qualified Imm.HTTP as HTTP-import Imm.Logger as Logger-import Imm.Pretty-import Imm.XML as XML--import Control.Exception.Safe-import Control.Monad.Time-import Data.Conduit-import qualified Data.Map as Map-import Data.Set (Set)-import qualified Data.Set as Set-import Data.Tree-import Data.Version-import qualified Paths_imm as Package-import Refined-import Streamly hiding ((<>))-import qualified Streamly.Prelude as Stream-import System.Info-import Text.OPML.Conduit.Parse-import Text.OPML.Types as OPML-import Text.XML as XML ()-import Text.XML.Stream.Parse as XML-import URI.ByteString--- }}}---printVersions :: (MonadBase IO m) => m ()-printVersions = liftBase $ do- putStrLn $ "imm-" <> showVersion Package.version- putStrLn $ "compiled by " <> compilerName <> "-" <> showVersion compilerVersion---- | Print database status for given feed(s)-showFeed :: MonadThrow m => Logger.Handle m -> Database.Handle m FeedTable -> [FeedID] -> m ()-showFeed logger database feedIDs = do- entries <- Database.fetchList database feedIDs- flushLogs logger- when (null entries) $ log logger Warning "No subscription"- forM_ (zip [1..] $ Map.elems entries) $ \(i, entry) ->- log logger Info $ pretty (i :: Int) <+> prettyDatabaseEntry entry---- | Register the given feed URI in database-subscribe :: MonadCatch m => Logger.Handle m -> Database.Handle m FeedTable -> URI -> Set Text -> m ()-subscribe logger database uri = Database.register logger database (FeedID uri)---- | Check for unread elements without processing them-check :: (MonadAsync m, MonadCatch m)- => Logger.Handle m -> Database.Handle m FeedTable -> HTTP.Handle m -> XML.Handle m -> [FeedID] -> m ()-check logger database httpClient xmlParser feedIDs = do- progress <- liftBase $ newTVarIO 0-- results <- Stream.toList $ wAsyncly $ do- feedID <- Stream.fromFoldable feedIDs- result <- lift $ tryAny $ checkOne logger database httpClient xmlParser feedID- let logResult = either (red . pretty . displayException) (\n -> green (pretty n) <+> "new element(s)") result- n <- liftBase $ atomically $ do- modifyTVar' (progress :: TVar Int) (+ 1)- readTVar progress- lift $ log logger Info $ brackets (fill width (bold $ cyan $ pretty n) <+> "/" <+> pretty total) <+> "Checked" <+> magenta (pretty feedID) <+> "=>" <+> logResult- return result-- flushLogs logger-- let (failures, successes) = partitionEithers $ zipWith (\a -> bimap (a,) (a,)) feedIDs results- unless (null failures) $ log logger Error $ bold (pretty $ length failures) <+> "feeds in error"- log logger Info $ bold (pretty $ sum $ map snd successes) <+> "new element(s) overall"-- where width = length (show total :: String)- total = length feedIDs--checkOne :: (MonadBase IO m, MonadCatch m)- => Logger.Handle m -> Database.Handle m FeedTable -> HTTP.Handle m -> XML.Handle m -> FeedID -> m Int-checkOne logger database httpClient xmlParser feedID = do- feed <- getFeed logger httpClient xmlParser feedID- case feed of- Atom _ -> log logger Debug $ "Parsed Atom feed: " <> pretty feedID- Rss _ -> log logger Debug $ "Parsed RSS feed: " <> pretty feedID-- let dates = mapMaybe getDate $ getElements feed-- log logger Debug $ vsep $ map prettyElement $ getElements feed- status <- Database.getStatus database feedID-- return $ length $ filter (unread status) dates- where unread (LastUpdate t1) t2 = t2 > t1- unread _ _ = True---run :: (MonadTime m, MonadAsync m, MonadCatch m)- => Logger.Handle m -> Database.Handle m FeedTable -> HTTP.Handle m -> Hooks.Handle m -> XML.Handle m -> [FeedID] -> m ()-run logger database httpClient hooks xmlParser feedIDs = do- progress <- liftBase $ newTVarIO 0-- results <- Stream.toList $ wAsyncly $ do- feedID <- Stream.fromFoldable feedIDs- result <- lift $ tryAny $ runOne logger database httpClient hooks xmlParser feedID- let logResult = either (red . pretty . displayException) (\n -> green (pretty n) <+> "new element(s)") result- n <- liftBase $ atomically $ do- modifyTVar' progress (+ 1)- readTVar progress :: STM Int- lift $ log logger Info $ brackets (fill width (bold $ cyan $ pretty n) <+> "/" <+> pretty total) <+> "Processed" <+> magenta (pretty feedID) <+> "=>" <+> logResult- return $ bimap (feedID,) (feedID,) result-- flushLogs logger-- let (failures, successes) = partitionEithers results-- unless (null failures) $ log logger Error $ bold (pretty $ length failures) <+> "feeds in error"- log logger Info $ bold (pretty $ sum $ map snd successes) <+> "new element(s) overall"-- where width = length (show total :: String)- total = length feedIDs--runOne :: (MonadTime m, MonadCatch m)- => Logger.Handle m -> Database.Handle m FeedTable -> HTTP.Handle m -> Hooks.Handle m -> XML.Handle m -> FeedID -> m Int-runOne logger database httpClient hooks xmlParser feedID = do- feed <- getFeed logger httpClient xmlParser feedID- unreadElements <- filterM (fmap not . isRead database feedID) $ getElements feed-- forM_ unreadElements $ \element -> do- onNewElement logger hooks feed element- mapM_ (Database.addReadHash logger database feedID) $ getHashes element-- Database.markAsRead logger database feedID- return $ length unreadElements---isRead :: MonadCatch m => Database.Handle m FeedTable -> FeedID -> FeedElement -> m Bool-isRead database feedID element = do- DatabaseEntry _ _ readHashes lastCheck <- Database.fetch database feedID- let matchHash = not $ Set.null $ Set.fromList (getHashes element) `Set.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-importOPML :: MonadCatch m => Logger.Handle m -> Database.Handle m FeedTable -> ConduitT () ByteString m () -> m ()-importOPML logger database input = do- opml <- runConduit $ input .| XML.parseBytes def .| force "Invalid OPML" parseOpml- forM_ (opmlOutlines opml) $ importOPML' logger database mempty--importOPML' :: MonadCatch m => Logger.Handle m -> Database.Handle m FeedTable -> Set Text -> Tree OpmlOutline -> m ()-importOPML' logger database _ (Node (OpmlOutlineGeneric b _) sub) = mapM_ (importOPML' logger database (Set.singleton . unrefine $ OPML.text b)) sub-importOPML' logger database c (Node (OpmlOutlineSubscription _ s) _) = subscribe logger database (xmlUri s) c-importOPML' _ _ _ _ = return ()---getFeed :: MonadCatch m => Logger.Handle m -> HTTP.Handle m -> XML.Handle m -> FeedID -> m Feed-getFeed logger httpClient xmlParser (FeedID uri) = HTTP.get logger httpClient uri >>= parseXml xmlParser uri
src/lib/Imm/Database.hs view
@@ -32,7 +32,7 @@ rep :: t data Handle m t = Handle- { _describeDatabase :: forall a . m (Doc a)+ { _describeDatabase :: m (Doc AnsiStyle) , _fetchList :: [Key t] -> m (Map (Key t) (Entry t)) , _fetchAll :: m (Map (Key t) (Entry t)) , _update :: Key t -> (Entry t -> Entry t) -> m ()@@ -41,6 +41,19 @@ , _purge :: m () , _commit :: m () }++readOnly :: Monad m => Pretty (Key t) => Logger.Handle m -> Handle m t -> Handle m t+readOnly logger handle = handle+ { _describeDatabase = do+ output <- _describeDatabase handle+ return $ output <+> yellow (brackets "read only")+ , _update = \key _ -> log logger Debug $ "Not updating database for key " <> pretty key+ , _insertList = \list -> log logger Debug $ "Not inserting " <> yellow (pretty $ length list) <> " entries"+ , _deleteList = \list -> log logger Debug $ "Not deleting " <> yellow (pretty $ length list) <> " entries"+ , _purge = log logger Debug "Not purging database"+ , _commit = log logger Debug "Not committing database"+ }+ data DatabaseException t
src/lib/Imm/Database/FeedTable.hs view
@@ -1,21 +1,31 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-} -- | Feed table definitions. This is a specialization of "Imm.Database". module Imm.Database.FeedTable where -- {{{ Imports-import Imm.Aeson import Imm.Database as Database+import Imm.Feed import Imm.Logger as Logger import Imm.Pretty import Control.Exception.Safe import Control.Monad.Time import Data.Aeson-import qualified Data.Set as Set (insert)+import Data.Aeson.Types+import Data.Hashable+import Data.Map.Lazy (mapKeys)+import qualified Data.Map.Lazy as Map (insert, filter, keys)+import qualified Data.Set as Set (intersection) import Data.Time import URI.ByteString++-- To be removed soon+import Text.Atom.Types+import Text.RSS.Types -- }}} -- * Types@@ -28,50 +38,112 @@ prettyFeedID (FeedID uri) = prettyURI uri instance FromJSON FeedID where- parseJSON = fmap FeedID . parseJsonURI+ parseJSON value = FeedID . _unwrapURI <$> parseJSON value instance ToJSON FeedID where- toJSON (FeedID uri) = toJsonURI uri+ toJSON (FeedID uri) = toJSON $ JsonURI uri instance Pretty FeedID where pretty (FeedID uri) = prettyURI uri +-- DEPRECATED+getHashes :: FeedElement -> [Int]+getHashes (RssElement item) = map (hash @String . show . prettyGuid) (maybeToList $ itemGuid item)+ <> map (hash @String . show . withRssURI prettyURI) (maybeToList $ itemLink item)+ <> [hash $ itemTitle item]+ <> [hash $ itemDescription item]+getHashes (AtomElement entry) = [hash $ entryId entry, (hash :: String -> Int) $ show $ prettyAtomText $ entryTitle entry]+++-- | Newtype wrapper to provide 'FromJSON' and 'ToJSON' instances for 'URI'+newtype JsonURI = JsonURI { _unwrapURI :: URI }++instance FromJSON JsonURI where+ parseJSON = withText "URI" $ \s ->+ either (const $ fail "Invalid URI") (return . JsonURI) $ parseURI laxURIParserOptions $ encodeUtf8 s++instance ToJSON JsonURI where+ toJSON = String . decodeUtf8 . serializeURIRef' . _unwrapURI+++-- | Newtype wrapper to provide `FromJSON` and `ToJSON` instances for 'FeedElement'+newtype JsonElement = JsonElement { unwrapElement :: FeedElement } deriving(Eq, Ord)++instance FromJSON JsonElement where+ parseJSON = withText "Feed element" $ \t -> parseFeedElement t & liftEither <&> JsonElement+ where liftEither :: Either e a -> Parser a+ liftEither = either (const mempty) return++instance FromJSONKey JsonElement++instance ToJSON JsonElement where+ toJSON (JsonElement element) = String $ renderFeedElement element++instance ToJSONKey JsonElement++ data DatabaseEntry = DatabaseEntry- { entryURI :: URI- , entryTags :: Set Text- , entryReadHashes :: Set Int- , entryLastCheck :: Maybe UTCTime+ { entryURI :: URI+ , entryTags :: Set Text+ , entryReadHashes :: Set Int+ , entryFeed :: Maybe Feed+ , entryItems :: Map FeedElement Bool+ , entryLastUpdate :: Maybe UTCTime } deriving(Eq, Show) -prettyDatabaseEntry :: DatabaseEntry -> Doc AnsiStyle-prettyDatabaseEntry entry = magenta feedID+prettyShortDatabaseEntry :: DatabaseEntry -> Doc AnsiStyle+prettyShortDatabaseEntry DatabaseEntry{..} = magenta feedID <++> indent 3 tags- <++> indent 3 ("Last checked:" <+> lastCheck)+ <++> indent 3 ("Last update:" <+> lastUpdate)+ <++> indent 3 (yellow (pretty totalItems) <+> "items," <+> yellow (pretty totalUnprocessedItems) <+> "unprocessed") - where feedID = prettyURI $ entryURI entry- tags = sep $ map ((<>) "#" . pretty) $ toList $ entryTags entry- lastCheck = format $ entryLastCheck entry- format = maybe "never" (fromString . formatTime defaultTimeLocale "%F %R")+ where feedID = prettyURI entryURI+ tags = sep $ map ((<>) "#" . pretty) $ toList entryTags+ lastUpdate = maybe "never" prettyTime entryLastUpdate+ totalItems = length entryItems+ totalUnprocessedItems = length $ Map.filter not entryItems +prettyDatabaseEntry :: DatabaseEntry -> Doc AnsiStyle+prettyDatabaseEntry DatabaseEntry{..} = magenta feedID+ <++> tags+ <++> ("Last update:" <+> lastUpdate)+ <++> (yellow (pretty $ length $ Map.filter id entryItems) <+> "processed items")+ <++> indent 3 (vsep $ map displayItem $ Map.keys $ Map.filter id entryItems)+ <++> (yellow (pretty $ length $ Map.filter not entryItems) <+> "unprocessed items")+ <++> indent 3 (vsep $ map displayItem $ Map.keys $ Map.filter not entryItems)+ where feedID = prettyURI entryURI+ tags = sep $ map ((<>) "#" . pretty) $ toList entryTags+ lastUpdate = maybe "never" prettyTime entryLastUpdate+ displayItem item = maybe "<unknown>" prettyTime (getDate item) <+> pretty (getTitle item)+ instance FromJSON DatabaseEntry where- parseJSON (Object v) = DatabaseEntry <$> (parseJsonURI =<< v .: "uri") <*> v .: "tags" <*> v.: "readHashes" <*> v .: "lastCheck"+ parseJSON (Object v) = DatabaseEntry+ <$> (_unwrapURI <$> (v .: "uri"))+ <*> v .: "tags"+ <*> (v .: "readHashes" <|> pure mempty)+ <*> ((v .:? "feed") >>= maybe (pure Nothing) (fmap Just . liftEither . parseFeed))+ <*> (v .:? "items" >>= maybe (pure mempty) (return . mapKeys unwrapElement))+ <*> (v .: "lastUpdate" <|> v .: "lastCheck")+ where liftEither :: Either e a -> Parser a+ liftEither = either (const mempty) return+ parseJSON _ = mzero instance ToJSON DatabaseEntry where- toJSON entry = object- [ "uri" .= toJsonURI (entryURI entry)- , "tags" .= entryTags entry- , "readHashes" .= entryReadHashes entry- , "lastCheck" .= entryLastCheck entry- ]+ toJSON DatabaseEntry{..} = object $+ [ "uri" .= JsonURI entryURI+ , "tags" .= entryTags+ , "items" .= mapKeys JsonElement entryItems+ , "lastUpdate" .= entryLastUpdate+ ] <> maybeToList (fmap (("feed" .=) . renderFeed) entryFeed)+ <> if null entryReadHashes then mempty else ["readHashes" .= entryReadHashes] newDatabaseEntry :: FeedID -> Set Text -> DatabaseEntry-newDatabaseEntry (FeedID uri) tags = DatabaseEntry uri tags mempty Nothing+newDatabaseEntry (FeedID uri) tags = DatabaseEntry uri tags mempty mzero mempty Nothing -- | Singleton type to represent feeds table-data FeedTable = FeedTable- deriving(Show)+data FeedTable = FeedTable deriving(Eq, Ord, Read, Show) instance Pretty FeedTable where pretty _ = "Feeds table"@@ -103,25 +175,36 @@ getStatus :: MonadCatch m => Database.Handle m FeedTable -> FeedID -> m FeedStatus getStatus database feedID = handleAny (\_ -> return Unknown) $ do result <- fmap Just (fetch database feedID) `catchAny` (\_ -> return Nothing)- return $ maybe New LastUpdate $ entryLastCheck =<< result--addReadHash :: MonadThrow m => Logger.Handle m -> Database.Handle m FeedTable -> FeedID -> Int -> m ()-addReadHash logger database feedID hash = do- log logger Debug $ "Adding read hash:" <+> pretty hash <> "..."- update database feedID f- where f a = a { entryReadHashes = Set.insert hash $ entryReadHashes a }+ return $ maybe New LastUpdate $ entryLastUpdate =<< result --- | Set the last check time to now-markAsRead :: (MonadTime m, MonadThrow m)- => Logger.Handle m -> Database.Handle m FeedTable -> FeedID -> m ()-markAsRead logger database feedID = do- log logger Debug $ "Marking feed as read:" <+> pretty feedID <> "..."+markAsProcessed :: MonadThrow m => MonadTime m+ => Logger.Handle m -> Database.Handle m FeedTable -> FeedID -> FeedElement -> m ()+markAsProcessed logger database feedID element = do+ log logger Debug $ "Marking as processed:" <+> pretty (PrettyKey element) <> "..." utcTime <- currentTime- update database feedID (f utcTime)- where f time a = a { entryLastCheck = Just time }+ update database feedID $ \entry -> entry+ { entryItems = Map.insert element True $ entryItems entry+ , entryLastUpdate = Just utcTime+ } --- | Unset feed's last update and remove all read hashes-markAsUnread :: MonadThrow m => Logger.Handle m -> Database.Handle m FeedTable -> FeedID -> m ()-markAsUnread logger database feedID = do- log logger Info $ "Marking feed as unread:" <+> prettyFeedID feedID <> "..."- update database feedID $ \a -> a { entryReadHashes = mempty, entryLastCheck = Nothing }++markAsUnprocessed :: MonadThrow m+ => Logger.Handle m -> Database.Handle m FeedTable -> FeedID -> m ()+markAsUnprocessed logger database feedID = do+ log logger Debug $ "Marking as unprocessed:" <+> pretty feedID <> "..."+ update database feedID $ \entry -> entry+ { entryItems = False <$ entryItems entry+ , entryLastUpdate = Nothing+ , entryReadHashes = mempty+ }++isRead :: MonadCatch m => Database.Handle m FeedTable -> FeedID -> FeedElement -> m Bool+isRead database feedID element = do+ DatabaseEntry _ _ readHashes _ items lastUpdate <- Database.fetch database feedID+ let matchHash = not $ null $ fromList (getHashes element) `Set.intersection` readHashes+ matchDate = case (lastUpdate, getDate element) of+ (Nothing, _) -> False+ (_, Nothing) -> False+ (Just a, Just b) -> a > b+ matchKey = lookup element items & fromMaybe False+ return $ matchHash || matchDate || matchKey
− src/lib/Imm/Database/JsonFile.hs
@@ -1,122 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE OverloadedLists #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE UndecidableInstances #-}--- | Implementation of "Imm.Database" based on a JSON file.-module Imm.Database.JsonFile- ( JsonFileDatabase- , mkJsonFileDatabase- , defaultDatabase- , mkHandle- , JsonException(..)- , module Imm.Database.FeedTable- ) where---- {{{ Imports-import Imm.Database hiding (commit, delete, insert,- purge, update)-import Imm.Database.FeedTable-import Imm.Error-import Imm.Pretty--import Control.Concurrent.STM.TVar (swapTVar)-import Data.Aeson-import Data.ByteString.Lazy (hPut)-import Data.ByteString.Streaming (hGetContents, toLazy_)-import Data.Map (Map)-import qualified Data.Map as Map-import qualified Data.Set as Set-import Streaming.With hiding (withFile)-import System.Directory-import System.FilePath--- }}}--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: " <+> pretty file--mkJsonFileDatabase :: (Table t) => FilePath -> JsonFileDatabase t-mkJsonFileDatabase file = JsonFileDatabase file mempty Empty---- | Default database is stored in @$XDG_CONFIG_HOME\/imm\/feeds.json@-defaultDatabase :: Table t => IO (TVar (JsonFileDatabase t))-defaultDatabase = do- databaseFile <- getXdgDirectory XdgConfig "imm/feeds.json"- newTVarIO $ mkJsonFileDatabase databaseFile---data JsonException = UnableDecode- deriving(Eq, Show)--instance Exception JsonException where- displayException _ = "Unable to parse JSON"---mkHandle :: (Table t, FromJSON (Key t), FromJSON (Entry t), ToJSON (Key t), ToJSON (Entry t), MonadIO m, MonadMask m)- => TVar (JsonFileDatabase t) -> Handle m t-mkHandle tvar = Handle- { _describeDatabase = pretty <$> readTVarIO tvar- , _fetchList = \keys -> loadInCache tvar >> (Map.filterWithKey (\uri _ -> Set.member uri $ Set.fromList keys) <$> getCache tvar)- , _fetchAll = loadInCache tvar >> getCache tvar- , _update = \key f -> loadInCache tvar >> atomically (modifyTVar' tvar $ updateInCache key f)- , _insertList = \list -> loadInCache tvar >> atomically (modifyTVar' tvar $ insertInCache list)- , _deleteList = \list -> loadInCache tvar >> atomically (modifyTVar' tvar $ deleteInCache list)- , _purge = loadInCache tvar >> atomically (modifyTVar' tvar purgeInCache)- , _commit = commit tvar- }---- * Low-level implementation--loadInCache :: (Table t, FromJSON (Key t), FromJSON (Entry t), MonadIO m, MonadMask m) => TVar (JsonFileDatabase t) -> m ()-loadInCache tvar = do- JsonFileDatabase file _ status <- readTVarIO tvar- when (status == Empty) $ do- database <- loadFromDisk file- atomically $ do- JsonFileDatabase _ _ status' <- readTVar tvar- when (status' == Empty) $ writeTVar tvar database--loadFromDisk :: (Table t, FromJSON (Key t), FromJSON (Entry t), MonadIO m, MonadMask m) => FilePath -> m (JsonFileDatabase t)-loadFromDisk file = do- liftIO $ createDirectoryIfMissing True $ takeDirectory file- fileContent <- withBinaryFile file ReadWriteMode (toLazy_ . hGetContents)- cache <- (`failWith` UnableDecode) $ fmap Map.fromList $ decode $ fromEmpty "[]" fileContent- return $ JsonFileDatabase file cache Clean- where fromEmpty x "" = x- fromEmpty _ y = y--getCache :: MonadIO m => TVar (JsonFileDatabase t) -> m (Map (Key t) (Entry t))-getCache tvar = do- JsonFileDatabase _ cache _ <- readTVarIO tvar- return cache---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--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--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--purgeInCache :: Table t => JsonFileDatabase t -> JsonFileDatabase t-purgeInCache (JsonFileDatabase file _ _) = JsonFileDatabase file mempty Dirty--commit :: (ToJSON (Key t), ToJSON (Entry t), MonadIO m)- => TVar (JsonFileDatabase t) -> m ()-commit tvar = do- JsonFileDatabase file cache status <- atomically $ do- database@(JsonFileDatabase file cache status) <- readTVar tvar- when (status == Dirty) $ void $ swapTVar tvar $ JsonFileDatabase file cache Clean- return database-- when (status == Dirty) $ liftIO $ withFile file WriteMode $ \h -> hPut h $ encode $ Map.toList cache
− src/lib/Imm/Dyre.hs
@@ -1,71 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE TypeFamilies #-}-module Imm.Dyre- ( Mode(..)- , defaultMode- , describePaths- , wrap- , recompile- ) where---- {{{ Imports-import Imm.Pretty--import Config.Dyre-import Config.Dyre.Compile-import Config.Dyre.Paths--import System.IO--- }}}---- | 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 :: (MonadIO m) => m (Doc AnsiStyle)-describePaths = io $ do- (a, b, c, d, e) <- getPaths baseParameters- return $ vsep- [ "Current binary" <+> equals <+> magenta (fromString a)- , "Custom binary" <+> equals <+> magenta (fromString b)- , "Config file" <+> equals <+> magenta (fromString c)- , "Cache directory" <+> equals <+> magenta (fromString d)- , "Lib directory" <+> equals <+> magenta (fromString e)- ]---- | Dynamic reconfiguration settings-parameters :: Mode -> (a -> IO ()) -> Params (Either String a)-parameters mode main = baseParameters- { configCheck = mode /= Vanilla- , realMain = main'- }- where- main' (Left e) = hPutStrLn stderr e- main' (Right x) = main x--baseParameters :: Params (Either String a)-baseParameters = defaultParams- { projectName = "imm"- , showError = const Left- , 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
− src/lib/Imm/Error.hs
@@ -1,15 +0,0 @@-{-# LANGUAGE ScopedTypeVariables #-}-module Imm.Error (module Imm.Error) where---- {{{ Imports-import Control.Exception.Safe--- }}}--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
src/lib/Imm/Feed.hs view
@@ -1,21 +1,35 @@-{-# LANGUAGE DataKinds #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE TypeOperators #-} -- | Helpers to manipulate feeds module Imm.Feed where -- {{{ Imports import Imm.Pretty -import Data.Hashable+import Conduit+import Control.Exception.Safe+import Data.Binary.Builder import Data.Text as Text (null) import Data.Time+import Data.Type.Equality import Lens.Micro+import Text.Atom.Conduit.Parse+import Text.Atom.Conduit.Render import Text.Atom.Types+import Text.RSS.Conduit.Parse+import Text.RSS.Conduit.Render import Text.RSS.Extensions.Content import Text.RSS.Extensions.DublinCore import Text.RSS.Lens import Text.RSS.Types+import Text.RSS1.Conduit.Parse+import Text.XML.Stream.Parse as XML hiding (content)+import Text.XML.Stream.Render hiding (content) import URI.ByteString -- }}} @@ -23,19 +37,81 @@ -- | Feed reference: either its URI, or its UID from database data FeedRef = ByUID Int | ByURI URI- deriving(Eq, Show)+ deriving(Eq, Ord, Show) instance Pretty FeedRef where pretty (ByUID n) = "feed" <+> pretty n pretty (ByURI u) = prettyURI u data Feed = Rss (RssDocument '[ContentModule, DublinCoreModule]) | Atom AtomFeed- deriving(Eq, Show)+ deriving(Eq, Ord, Show) data FeedElement = RssElement (RssItem '[ContentModule, DublinCoreModule]) | AtomElement AtomEntry- deriving(Eq, Show)+ deriving(Show) +instance Pretty (PrettyKey FeedElement) where+ pretty (PrettyKey element) = "element" <+> pretty (getTitle element) +instance Ord FeedElement where+ compare element1 element2 = compare (getId element1) (getId element2)+ <> compare (getLink element1) (getLink element2)+ <> compare (getTitle element1) (getTitle element2)+ <> compare (getContent element1) (getContent element2)++instance Eq FeedElement where+ element1 == element2 = compare element1 element2 == EQ+++data FeedURI = forall a . FeedURI (URIRef a)++deriving instance Show FeedURI+instance Eq FeedURI where+ (FeedURI a) == (FeedURI b) = case sameURIType a b of+ Just Refl -> a == b+ _ -> False++instance Ord FeedURI where+ compare (FeedURI a) (FeedURI b) = case (a, b) of+ (URI{}, URI{}) -> compare a b+ (RelativeRef{}, RelativeRef{}) -> compare a b+ (URI{}, RelativeRef{}) -> LT+ (RelativeRef{}, URI{}) -> GT++sameURIType :: URIRef a1 -> URIRef a2 -> Maybe (URIRef a1 :~: URIRef a2)+sameURIType a b = case (a, b) of+ (URI{}, URI{}) -> Just Refl+ (RelativeRef{}, RelativeRef{}) -> Just Refl+ _ -> Nothing+++withFeedURI :: (forall a . URIRef a -> b) -> FeedURI -> b+withFeedURI f (FeedURI a) = f a+++-- * Generic parsers/renderers++renderFeed :: Feed -> Text+renderFeed (Rss rss) = decodeUtf8 $ toLazyByteString $ runConduitPure $ renderRssDocument rss .| renderBuilder def .| foldC+renderFeed (Atom atom) = decodeUtf8 $ toLazyByteString $ runConduitPure $ renderAtomFeed atom .| renderBuilder def .| foldC++renderFeedElement :: FeedElement -> Text+renderFeedElement (RssElement item) = decodeUtf8 $ toLazyByteString $ runConduitPure $ renderRssItem item .| renderBuilder def .| foldC+renderFeedElement (AtomElement entry) = decodeUtf8 $ toLazyByteString $ runConduitPure $ renderAtomEntry entry .| renderBuilder def .| foldC++parseFeed :: MonadCatch m => Text -> m Feed+parseFeed text = runConduit $ parseLBS def (encodeUtf8 text) .| XML.force "Invalid feed" ((fmap Atom <$> atomFeed) `orE` (fmap Rss <$> rssDocument) `orE` (fmap Rss <$> rss1Document))++parseFeedElement :: MonadCatch m => Text -> m FeedElement+parseFeedElement text = runConduit $ parseLBS def (encodeUtf8 text) .| XML.force "Invalid feed element" ((fmap AtomElement <$> atomEntry) `orE` (fmap RssElement <$> rssItem) `orE` (fmap RssElement <$> rss1Item))+++-- * Generic mutators++removeElements :: Feed -> Feed+removeElements (Rss rss) = Rss $ rss { channelItems = mempty }+removeElements (Atom atom) = Atom $ atom { feedEntries = mempty }++ -- * Generic getters getFeedTitle :: Feed -> Text@@ -61,14 +137,16 @@ content = show . prettyAtomContent <$> entryContent entry summary = show . prettyAtomText <$> entrySummary entry --getHashes :: FeedElement -> [Int]-getHashes (RssElement item) = map (hash @String . show . prettyGuid) (maybeToList $ itemGuid item)- <> map ((hash :: String -> Int) . show . withRssURI prettyURI) (maybeToList $ itemLink item)- <> [hash $ itemTitle item]- <> [hash $ itemDescription item]-getHashes (AtomElement entry) = [hash $ entryId entry, (hash :: String -> Int) $ show $ prettyAtomText $ entryTitle entry]+getLink :: FeedElement -> Maybe FeedURI+getLink (RssElement item) = itemLink item <&> withRssURI FeedURI+getLink (AtomElement entry) = (alternateLink <|> defaultLink) <&> linkHref <&> withAtomURI FeedURI where+ links = entryLinks entry+ alternateLink = links & filter (\link -> linkRel link == "alternate") & nonEmpty <&> head+ defaultLink = links & filter (Text.null . linkRel) & nonEmpty <&> head +getId :: FeedElement -> Text+getId (RssElement item) = itemGuid item <&> prettyGuid & maybe mempty show+getId (AtomElement entry) = entryId entry -- * Misc
− src/lib/Imm/HTTP/Simple.hs
@@ -1,46 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE OverloadedStrings #-}--- | Implementation of "Imm.HTTP" based on "Network.HTTP.Client".-module Imm.HTTP.Simple (mkHandle, defaultManager, module Reexport) where---- {{{ Imports-import Imm.HTTP-import Imm.Pretty--import Control.Exception.Safe-import Data.CaseInsensitive-import Network.Connection as Reexport-import Network.HTTP.Client as Reexport-import Network.HTTP.Client.TLS as Reexport-import URI.ByteString--- }}}--mkHandle :: MonadBase IO m => Manager -> Handle m-mkHandle manager = Handle- { httpGet = liftBase . httpGet' 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' :: Manager -> URI -> IO LByteString-httpGet' manager uri = do- request <- makeRequest uri- responseBody <$> httpLbs request manager- -- codec' <- reader $ view (config.codec)- -- return $ response $=+ decode codec'--parseRequest' :: MonadThrow m => URI -> m Request-parseRequest' = parseRequest . show . prettyURI---- | Build an HTTP request for given URI-makeRequest :: URI -> IO Request-makeRequest uri = do- req <- parseRequest' uri- return $ req { requestHeaders = [- (mk "User-Agent", "Mozilla/4.0"),- (mk "Accept", "*/*")]}
− src/lib/Imm/Hooks.hs
@@ -1,27 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--- | Hooks module abstracts over the main behavior of the program.------ This module follows the [Handle pattern](https://jaspervdj.be/posts/2018-03-08-handle-pattern.html).------ > import qualified Imm.Hooks as Hooks-module Imm.Hooks where---- {{{ Imports-import Imm.Feed-import qualified Imm.Logger as Logger-import Imm.Logger hiding(Handle)-import Imm.Pretty--- }}}---- * Types--newtype Handle m = Handle- { processNewElement :: Feed -> FeedElement -> m () -- ^ Action triggered for each unread feed element- }---- * Primitives--onNewElement :: Monad m => Logger.Handle m -> Handle m -> Feed -> FeedElement -> m ()-onNewElement logger handle feed element = do- log logger Debug $ "Unread element:" <+> pretty (getTitle element)- processNewElement handle feed element
− src/lib/Imm/Hooks/Dummy.hs
@@ -1,20 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--- | Implementation of "Imm.Hooks" that does nothing,--- except suggesting the user to define proper hooks.------ This is the default implementation of the program.-module Imm.Hooks.Dummy (module Imm.Hooks.Dummy, module Imm.Hooks) where---- {{{ Imports-import Imm.Hooks--import Control.Exception-import Control.Exception.Safe--- }}}--data DummyHooks = DummyHooks--mkHandle :: MonadThrow m => Handle m-mkHandle = Handle- { processNewElement = \_ _ -> throwM $ NoMethodError "Please define a valid Imm.Hooks.processNewElement function"- }
− src/lib/Imm/Hooks/SendMail.hs
@@ -1,144 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE OverloadedStrings #-}--- | Implementation of "Imm.Hooks" that sends a mail via a SMTP server for each new RSS/Atom element.--- You may want to check out "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 Imm.Hooks, module Reexport) where---- {{{ Imports-import Imm.Feed-import Imm.Hooks-import Imm.Pretty--import Data.Text as Text (intercalate)-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 Refined-import Text.Atom.Types-import Text.RSS.Types--- }}}---- * Types--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--mkHandle :: MonadBase IO m => SendMailSettings -> Handle m-mkHandle (SendMailSettings connectionSettings formatMail) = Handle- { processNewElement = \feed element -> do- timezone <- liftBase getCurrentTimeZone- currentTime <- liftBase getCurrentTime- let mail = buildMail formatMail currentTime timezone feed element- liftBase $ withSMTPConnection (connectionSettings feed element) $ sendMimeMail2 mail- }----- * Default behavior---- | 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 = Text.intercalate ", " $ map (unrefine . personName) $ entryAuthors entry <> feedAuthors feed-defaultFormatFrom _ _ = Address (Just "Unknown") ""---- | Fill mail subject with the element title-defaultFormatSubject :: Feed -> FeedElement -> Text-defaultFormatSubject _ = getTitle---- | 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>" <> Text.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]]- }
− src/lib/Imm/Hooks/WriteFile.hs
@@ -1,123 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE OverloadedStrings #-}--- | Implementation of "Imm.Hooks" that writes a file for each new RSS/Atom item.-module Imm.Hooks.WriteFile (module Imm.Hooks.WriteFile, module Imm.Hooks) where---- {{{ Imports-import Imm.Feed-import Imm.Hooks-import Imm.Pretty--import Data.ByteString.Builder-import Data.ByteString.Streaming (toStreamingByteString)-import qualified Data.Text as Text (null, replace)-import Data.Time-import Streaming.With-import System.Directory (createDirectoryIfMissing)-import System.FilePath-import Text.Atom.Types-import Text.Blaze.Html.Renderer.Utf8-import Text.Blaze.Html5 (Html, docTypeHtml,- preEscapedToHtml, (!))-import qualified Text.Blaze.Html5 as H-import qualified Text.Blaze.Html5.Attributes as H (charset, href)-import Text.RSS.Types-import URI.ByteString--- }}}---- * Types---- | Where and what to write in a file-data FileInfo = FileInfo FilePath Builder--newtype WriteFileSettings = WriteFileSettings (Feed -> FeedElement -> FileInfo)--mkHandle :: MonadBase IO m => MonadIO m => MonadMask m => WriteFileSettings -> Handle m-mkHandle (WriteFileSettings f) = Handle- { processNewElement = \feed element -> do- let FileInfo path content = f feed element- liftBase $ createDirectoryIfMissing True $ takeDirectory path- writeBinaryFile path $ toStreamingByteString content- }---- * Default behavior---- | Wrapper around 'defaultFilePath' and 'defaultFileContent'-defaultSettings :: FilePath -- ^ Root directory for 'defaultFilePath'- -> WriteFileSettings-defaultSettings root = WriteFileSettings $ \feed element -> FileInfo- (defaultFilePath root feed element)- (defaultFileContent feed element)---- | Generate a path @<root>/<feed title>/<element date>-<element title>.html@, where @<root>@ is the first argument-defaultFilePath :: FilePath -> Feed -> FeedElement -> FilePath-defaultFilePath root feed element = makeValid $ root </> toString title </> fileName <.> "html" where- date = maybe "" (formatTime defaultTimeLocale "%F-") $ getDate element- fileName = date <> toString (sanitize $ getTitle element)- title = sanitize $ getFeedTitle feed- sanitize = appEndo (mconcat [Endo $ Text.replace (toText [s]) "_" | s <- pathSeparators])- >>> Text.replace "." "_"- >>> Text.replace "?" "_"- >>> Text.replace "!" "_"- >>> Text.replace "#" "_"---- | Generate an HTML page, with a title, a header and an article that contains the feed element-defaultFileContent :: Feed -> FeedElement -> Builder-defaultFileContent feed element = renderHtmlBuilder $ docTypeHtml $ do- H.head $ do- H.meta ! H.charset "utf-8"- H.title $ convertText $ getFeedTitle feed <> " | " <> getTitle element- H.body $ do- H.h1 $ convertText $ getFeedTitle feed- H.article $ do- H.header $ do- defaultArticleTitle feed element- defaultArticleAuthor feed element- defaultArticleDate feed element- defaultBody feed element----- * Low-level helpers--defaultArticleTitle :: Feed -> FeedElement -> Html-defaultArticleTitle _ element@(RssElement item) = H.h2 $ maybe id (\uri -> H.a ! H.href uri) link $ convertText $ getTitle element where- link = withRssURI (convertDoc . prettyURI) <$> itemLink item-defaultArticleTitle _ element@(AtomElement _) = H.h2 $ convertText $ getTitle element--defaultArticleAuthor :: Feed -> FeedElement -> Html-defaultArticleAuthor _ (RssElement item) = unless (Text.null author) $ H.address $ "Published by " >> convertText author where- author = itemAuthor item-defaultArticleAuthor _ (AtomElement entry) = H.address $ do- "Published by "- forM_ (entryAuthors entry) $ \author -> do- convertDoc $ prettyPerson author- ", "--defaultArticleDate :: Feed -> FeedElement -> Html-defaultArticleDate _ element = forM_ (getDate element) $ \date -> H.p $ " on " >> H.time (convertDoc $ prettyTime date)----- | Generate the HTML content for a given feed element-defaultBody :: Feed -> FeedElement -> Html-defaultBody _ element@(RssElement _) = H.p $ preEscapedToHtml $ getContent element-defaultBody _ element@(AtomElement entry) = do- unless (null links) $ H.p $ do- "Related links:"- H.ul $ forM_ links $ \uri -> H.li (H.a ! withAtomURI href uri $ convertAtomURI uri)- H.p $ preEscapedToHtml $ getContent element- where links = map linkHref $ entryLinks entry--href :: URIRef a -> H.Attribute-href = H.href . convertURI--convertAtomURI :: (IsString t) => AtomURI -> t-convertAtomURI = withAtomURI convertURI--convertURI :: (IsString t) => URIRef a -> t-convertURI = convertText . decodeUtf8 . serializeURIRef'--convertText :: (IsString t) => Text -> t-convertText = fromString . toString--convertDoc :: (IsString t) => Doc a -> t-convertDoc = show
− src/lib/Imm/Logger/Simple.hs
@@ -1,46 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}--- | Implementation of "Imm.Logger" based on @fast-logger@.--- 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.Pretty--import Data.Text.Prettyprint.Doc.Render.Terminal-import System.Log.FastLogger as Reexport--- }}}--data LoggerSettings = LoggerSettings- { _loggerSet :: LoggerSet -- ^ 'LoggerSet' used for 'Debug', 'Info' and 'Warning' logs- , _errorLoggerSet :: LoggerSet -- ^ 'LoggerSet' used for 'Error' logs- , _logLevel :: MVar LogLevel -- ^ Discard logs that are strictly less serious than this level- , _colorizeLogs :: MVar Bool -- ^ Enable log colorisation- }----- | Default logger forwards error messages to stderr, and other messages to stdout.-defaultLogger :: IO LoggerSettings-defaultLogger = LoggerSettings- <$> newStdoutLoggerSet defaultBufSize- <*> newStderrLoggerSet defaultBufSize- <*> newMVar Info- <*> newMVar True---mkHandle :: MonadIO m => LoggerSettings -> Handle m-mkHandle settings = Handle- { log = \l t -> do- refLevel <- readMVar $ _logLevel settings- handleColor <- (\c -> if c then id else unAnnotate) <$> readMVar (_colorizeLogs settings)- let loggerSet = (if l == Error then _errorLoggerSet else _loggerSet) settings- when (l >= refLevel) $ liftIO $ pushLogStrLn loggerSet $ toLogStr $ renderLazy $ layoutPretty defaultLayoutOptions $ handleColor t-- , getLogLevel = readMVar $ _logLevel settings- , setLogLevel = void . swapMVar (_logLevel settings)- , setColorizeLogs = void . swapMVar (_colorizeLogs settings)- , flushLogs = liftIO $ do- flushLogStr $ _loggerSet settings- flushLogStr $ _errorLoggerSet settings- }
− src/lib/Imm/Options.hs
@@ -1,150 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE StandaloneDeriving #-}-{-# LANGUAGE TypeApplications #-}-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.Pretty--import Data.Set (Set)-import qualified Data.Set as Set-import qualified Data.Text as Text-import Options.Applicative-import Options.Applicative.Help.Core as Help-import Options.Applicative.Help.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)- | Help- | ShowVersion- | Subscribe URI (Set Text)- | Unsubscribe (Maybe FeedRef)--deriving instance Eq Command-deriving instance Show Command--instance Pretty Command where- pretty (Check f) = "Check feed(s):" <+> pretty f- pretty Import = "Import feeds"- pretty (Read f) = "Mark feed(s) as read:" <+> pretty f- pretty Rebuild = "Rebuild configuration"- pretty (Unread f) = "Mark feed(s) as unread:" <+> pretty f- pretty (Run f) = "Download new entries from feed(s):" <+> pretty f- pretty (Show f) = "Show status for feed(s):" <+> pretty f- pretty Help = "Display help"- pretty ShowVersion = "Show program version"- pretty (Subscribe f _) = "Subscribe to feed:" <+> prettyURI f- pretty (Unsubscribe f) = "Unsubscribe from feed(s):" <+> pretty f--defaultCommand :: Command-defaultCommand = Show Nothing---- | Available commandline options.-data CliOptions = CliOptions- { optionCommand :: Command- , optionDyreMode :: Dyre.Mode- , optionLogLevel :: LogLevel- , optionColorizeLogs :: Bool- }---- deriving instance Eq CliOptions-defaultOptions :: CliOptions-defaultOptions = CliOptions defaultCommand Dyre.defaultMode Info True---- instance Pretty CliOptions where--- pretty opts = text "ACTION" <> equals <> $ opts^.command_--- , ("RECONFIGURATION_MODE=" ++) . show $ opts^.dyreMode_--- ]--- ++ catMaybes [("CONFIG=" ++) <$> opts^.configurationLabel_]--helpString :: Text-helpString = Text.pack $ renderHelp 100 $ Help.parserHelp defaultPrefs optionsParser--parseOptions :: (MonadIO m) => m CliOptions-parseOptions = io $ customExecParser defaultPrefs (info optionsParser $ progDesc "Fetch elements from RSS/Atom feeds and execute arbitrary actions for each of them.")---optionsParser :: Parser CliOptions-optionsParser = optional dyreMasterBinary *> optional dyreDebug *> cliOptions--cliOptions :: Parser CliOptions-cliOptions = CliOptions- <$> commands- <*> (vanillaFlag <|> forceReconfFlag <|> denyReconfFlag <|> pure Dyre.defaultMode)- <*> (verboseFlag <|> quietFlag <|> logLevel <|> pure Info)- <*> (colorizeLogs <|> pure True)---commands :: Parser Command-commands = subparser $ mconcat- [ command "add" $ info subscribeOptions $ progDesc "Alias for subscribe."- , command "check" $ info (Check <$> optional feedRefOption) $ progDesc "Check availability and validity of all feed sources currently configured, without writing any mail."- , command "help" $ info (pure Help) $ progDesc "Display help"- , 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 "remove" $ info unsubscribeOptions $ progDesc "Alias for unsubscribe."- , 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 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."--colorizeLogs :: Parser Bool-colorizeLogs = flag' False $ long "nocolor" <> help "Disable log colorisation."--- }}}---- {{{ Other options-tagOption :: Parser Text-tagOption = option auto $ long "tag" <> short 't' <> metavar "TAG" <> help "Set the given tag."--subscribeOptions, unsubscribeOptions :: Parser Command-subscribeOptions = Subscribe <$> uriArgument "URI to subscribe to." <*> (Set.fromList <$> many tagOption)-unsubscribeOptions = Unsubscribe <$> optional feedRefOption--- }}}---- {{{ Util-uriReader :: ReadM URI-uriReader = eitherReader $ first show . parseURI laxURIParserOptions . encodeUtf8 @Text . fromString--feedRefOption :: Parser FeedRef-feedRefOption = argument ((ByUID <$> auto) <|> (ByURI <$> uriReader)) $ metavar "TARGET"--uriArgument :: String -> Parser URI-uriArgument helpText = argument uriReader $ metavar "URI" <> help helpText--- }}}
src/lib/Imm/Pretty.hs view
@@ -5,10 +5,11 @@ module Imm.Pretty (module Imm.Pretty, module X) where -- {{{ Imports-import Data.Text as Text+import Data.Text (Text)+import qualified Data.Text as Text import Data.Time import Data.Tree-+import Data.XML.Types as XML import Text.Atom.Types as Atom -- import Text.OPML.Types as OPML hiding (text) -- import qualified Text.OPML.Types as OPML@@ -23,6 +24,10 @@ import URI.ByteString -- }}} ++-- | Newtype wrapper to prettyprint a key uniquely identifying an object+newtype PrettyKey a = PrettyKey a+ -- | Infix operator for 'line' (<++>) :: Doc a -> Doc a -> Doc a x <++> y = x <> line <> y@@ -31,7 +36,7 @@ prettyTree (Node n s) = pretty n <++> indent 2 (vsep $ prettyTree <$> s) prettyTime :: UTCTime -> Doc a-prettyTime = pretty . formatTime defaultTimeLocale rfc822DateFormat+prettyTime = pretty . formatTime defaultTimeLocale "%F %T" -- instance Pretty OpmlHead where -- pretty h = hsep $ catMaybes@@ -69,9 +74,23 @@ prettyLink l = withAtomURI prettyURI $ linkHref l prettyAtomText :: AtomText -> Doc a-prettyAtomText (AtomPlainText _ t) = pretty t-prettyAtomText (AtomXHTMLText t) = pretty t+prettyAtomText (AtomPlainText _ t) = pretty t+prettyAtomText (AtomXHTMLText element) = prettyElement element +prettyElement :: Element -> Doc a+prettyElement (Element _ _ nodes) = mconcat $ map prettyNode nodes++prettyNode :: Node -> Doc a+prettyNode (NodeElement element) = prettyElement element+prettyNode (NodeContent content) = prettyContent content+prettyNode _ = mempty++prettyContent :: Content -> Doc a+prettyContent (ContentText t) = pretty t+prettyContent (ContentEntity "lt") = "<"+prettyContent (ContentEntity "gt") = ">"+prettyContent _ = "?"+ prettyEntry :: AtomEntry -> Doc a prettyEntry e = "Entry:" <+> prettyAtomText (entryTitle e) <++> indent 4 ( "By" <+> equals <+> list (prettyPerson <$> entryAuthors e)@@ -95,10 +114,10 @@ prettyGuid (GuidUri (RssURI u)) = prettyURI u prettyAtomContent :: AtomContent -> Doc a-prettyAtomContent (AtomContentInlineText _ t) = pretty t-prettyAtomContent (AtomContentInlineXHTML t) = pretty t-prettyAtomContent (AtomContentInlineOther _ t) = pretty t-prettyAtomContent (AtomContentOutOfLine _ u) = withAtomURI prettyURI u+prettyAtomContent (AtomContentInlineText _ t) = pretty t+prettyAtomContent (AtomContentInlineXHTML element) = prettyElement element+prettyAtomContent (AtomContentInlineOther _ t) = pretty t+prettyAtomContent (AtomContentOutOfLine _ u) = withAtomURI prettyURI u magenta :: Doc AnsiStyle -> Doc AnsiStyle magenta = annotate $ color Magenta
− src/lib/Imm/XML/Conduit.hs
@@ -1,32 +0,0 @@-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE RankNTypes #-}--- | Implementation of "Imm.XML" based on 'Conduit'.-module Imm.XML.Conduit (module Imm.XML.Conduit, module Imm.XML) where---- {{{ Imports-import Imm.Feed-import Imm.XML--import Control.Exception.Safe-import Data.Conduit-import Data.XML.Types-import Text.Atom.Conduit.Parse-import Text.RSS.Conduit.Parse-import Text.RSS1.Conduit.Parse-import Text.XML.Stream.Parse as XML-import URI.ByteString--- }}}---- | A pre-process 'Conduit' can be set to alter the raw XML before feeding it to the parser,--- depending on the feed 'URI'-newtype XmlParser = XmlParser (forall m . Monad m => URI -> ConduitT Event Event m ())---- | 'Conduit' based implementation-mkHandle :: MonadIO m => MonadCatch m => XmlParser -> Handle m-mkHandle (XmlParser preProcess) = Handle- { parseXml = \uri bytestring -> liftIO $ runConduit $ parseLBS def bytestring .| preProcess uri .| XML.force "Invalid feed" ((fmap Atom <$> atomFeed) `orE` (fmap Rss <$> rssDocument) `orE` (fmap Rss <$> rss1Document))- }---- | Forward all 'Event's without any pre-process-defaultXmlParser :: XmlParser-defaultXmlParser = XmlParser $ const $ fix $ \loop -> await >>= maybe (return ()) (yield >=> const loop)
src/lib/Prelude.hs view
@@ -1,8 +1,7 @@-module Prelude (io, module Relude, module Relude.Extra.Map, module Control.Monad.Base) where+module Prelude (io, module Relude, module Relude.Extra.Map) where import Relude hiding(Handle, force) import Relude.Extra.Map (lookup)-import Control.Monad.Base io :: MonadIO m => IO a -> m a io = liftIO
+ src/main/Core.hs view
@@ -0,0 +1,70 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE PartialTypeSignatures #-}+{-# LANGUAGE TypeFamilies #-}+module Core (+-- * Types+ FeedRef,+-- * Actions+ subscribe,+ listFeeds,+ showFeed,+ importOPML,+) where++-- {{{ Imports+import qualified Imm.Database as Database+import Imm.Database.FeedTable+import qualified Imm.Database.FeedTable as Database+import Imm.Feed+import Imm.Logger as Logger+import Imm.Pretty++import Control.Exception.Safe+import Data.Conduit+import qualified Data.Map as Map+import Data.Set (Set)+import qualified Data.Set as Set+import Data.Tree+import Refined+import Text.OPML.Conduit.Parse+import Text.OPML.Types as OPML+import Text.XML as XML ()+import Text.XML.Stream.Parse as XML+import URI.ByteString+-- }}}++++-- | Print database status for given feed(s)+showFeed :: MonadThrow m => Logger.Handle m -> Database.Handle m FeedTable -> FeedID -> m ()+showFeed logger database feedID = do+ entry <- Database.fetch database feedID+ flushLogs logger+ log logger Info $ prettyDatabaseEntry entry++-- | Register the given feed URI in database+subscribe :: MonadCatch m => Logger.Handle m -> Database.Handle m FeedTable -> URI -> Set Text -> m ()+subscribe logger database uri = Database.register logger database (FeedID uri)++-- | List all subscribed feeds and their status+listFeeds :: MonadCatch m => Logger.Handle m -> Database.Handle m FeedTable -> m ()+listFeeds logger database = do+ entries <- Database.fetchAll database+ flushLogs logger+ when (null entries) $ log logger Warning "No subscription"+ forM_ (zip [1..] $ Map.elems entries) $ \(i, entry) ->+ log logger Info $ pretty (i :: Int) <+> prettyShortDatabaseEntry entry+++-- | 'subscribe' to all feeds described by the OPML document provided in input+importOPML :: MonadCatch m => Logger.Handle m -> Database.Handle m FeedTable -> ConduitT () ByteString m () -> m ()+importOPML logger database input = do+ opml <- runConduit $ input .| XML.parseBytes def .| force "Invalid OPML" parseOpml+ forM_ (opmlOutlines opml) $ importOPML' logger database mempty++importOPML' :: MonadCatch m => Logger.Handle m -> Database.Handle m FeedTable -> Set Text -> Tree OpmlOutline -> m ()+importOPML' logger database _ (Node (OpmlOutlineGeneric b _) sub) = mapM_ (importOPML' logger database (Set.singleton . unrefine $ OPML.text b)) sub+importOPML' logger database c (Node (OpmlOutlineSubscription _ s) _) = subscribe logger database (xmlUri s) c+importOPML' _ _ _ _ = return ()
+ src/main/Database.hs view
@@ -0,0 +1,127 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedLists #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE UndecidableInstances #-}+-- | Implementation of "Imm.Database" based on a JSON file.+module Database+ ( JsonFileDatabase+ , mkJsonFileDatabase+ , defaultDatabase+ , mkHandle+ , JsonException(..)+ , module Imm.Database.FeedTable+ ) where++-- {{{ Imports+import Imm.Database hiding (commit, delete, insert,+ purge, update)+import Imm.Database.FeedTable+import Imm.Pretty++import Control.Concurrent.STM.TVar (swapTVar)+import Control.Exception.Safe+import Data.Aeson+import Data.ByteString.Lazy (hPut)+import Data.ByteString.Streaming (hGetContents, toLazy_)+import Data.Map (Map)+import qualified Data.Map as Map+import qualified Data.Set as Set+import Streaming.With hiding (withFile)+import System.Directory+import System.FilePath+-- }}}++data CacheStatus = Empty | Clean | Dirty+ deriving(Eq, Ord, Read, Show)++data JsonFileDatabase t = JsonFileDatabase FilePath (Map (Key t) (Entry t)) CacheStatus++instance Pretty (JsonFileDatabase t) where+ pretty (JsonFileDatabase file _ _) = "JSON database: " <+> pretty file++mkJsonFileDatabase :: (Table t) => FilePath -> JsonFileDatabase t+mkJsonFileDatabase file = JsonFileDatabase file mempty Empty++-- | Default database is stored in @$XDG_CONFIG_HOME\/imm\/feeds.json@+defaultDatabase :: Table t => IO (TVar (JsonFileDatabase t))+defaultDatabase = do+ databaseFile <- getXdgDirectory XdgConfig "imm/feeds.json"+ newTVarIO $ mkJsonFileDatabase databaseFile+++data JsonException = UnableDecode+ deriving(Eq, Show)++instance Exception JsonException where+ displayException _ = "Unable to parse JSON"+++mkHandle :: (Table t, FromJSON (Key t), FromJSON (Entry t), ToJSON (Key t), ToJSON (Entry t), MonadIO m, MonadMask m)+ => TVar (JsonFileDatabase t) -> Handle m t+mkHandle tvar = Handle+ { _describeDatabase = pretty <$> readTVarIO tvar+ , _fetchList = \keys -> loadInCache tvar >> (Map.filterWithKey (\uri _ -> Set.member uri $ Set.fromList keys) <$> getCache tvar)+ , _fetchAll = loadInCache tvar >> getCache tvar+ , _update = \key f -> loadInCache tvar >> atomically (modifyTVar' tvar $ updateInCache key f)+ , _insertList = \list -> loadInCache tvar >> atomically (modifyTVar' tvar $ insertInCache list)+ , _deleteList = \list -> loadInCache tvar >> atomically (modifyTVar' tvar $ deleteInCache list)+ , _purge = loadInCache tvar >> atomically (modifyTVar' tvar purgeInCache)+ , _commit = commit tvar+ }++-- * Low-level implementation++loadInCache :: (Table t, FromJSON (Key t), FromJSON (Entry t), MonadIO m, MonadMask m) => TVar (JsonFileDatabase t) -> m ()+loadInCache tvar = do+ JsonFileDatabase file _ status <- readTVarIO tvar+ when (status == Empty) $ do+ database <- loadFromDisk file+ atomically $ do+ JsonFileDatabase _ _ status' <- readTVar tvar+ when (status' == Empty) $ writeTVar tvar database++loadFromDisk :: (Table t, FromJSON (Key t), FromJSON (Entry t), MonadIO m, MonadMask m) => FilePath -> m (JsonFileDatabase t)+loadFromDisk file = do+ liftIO $ createDirectoryIfMissing True $ takeDirectory file+ fileContent <- withBinaryFile file ReadWriteMode (toLazy_ . hGetContents)+ cache <- (`failWith` UnableDecode) $ fmap Map.fromList $ decode $ fromEmpty "[]" fileContent+ return $ JsonFileDatabase file cache Clean+ where fromEmpty x "" = x+ fromEmpty _ y = y++getCache :: MonadIO m => TVar (JsonFileDatabase t) -> m (Map (Key t) (Entry t))+getCache tvar = do+ JsonFileDatabase _ cache _ <- readTVarIO tvar+ return cache+++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++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++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++purgeInCache :: Table t => JsonFileDatabase t -> JsonFileDatabase t+purgeInCache (JsonFileDatabase file _ _) = JsonFileDatabase file mempty Dirty++commit :: (ToJSON (Key t), ToJSON (Entry t), MonadIO m)+ => TVar (JsonFileDatabase t) -> m ()+commit tvar = do+ JsonFileDatabase file cache status <- atomically $ do+ database@(JsonFileDatabase file cache status) <- readTVar tvar+ when (status == Dirty) $ void $ swapTVar tvar $ JsonFileDatabase file cache Clean+ return database++ when (status == Dirty) $ liftIO $ withFile file WriteMode $ \h -> hPut h $ encode $ Map.toList cache+++-- | Wrap a 'Maybe' value in 'MonadThrow'+failWith :: (MonadThrow m, Exception e) => Maybe a -> e -> m a+failWith x e = maybe (throwM e) return x
+ src/main/HTTP.hs view
@@ -0,0 +1,46 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+-- | Implementation of "Imm.HTTP" based on "Network.HTTP.Client".+module HTTP (mkHandle, defaultManager, module Reexport) where++-- {{{ Imports+import Imm.HTTP+import Imm.Pretty++import Control.Exception.Safe+import Data.CaseInsensitive+import Network.Connection as Reexport+import Network.HTTP.Client as Reexport+import Network.HTTP.Client.TLS as Reexport+import URI.ByteString+-- }}}++mkHandle :: MonadIO m => Manager -> Handle m+mkHandle manager = Handle+ { httpGet = io . httpGet' 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' :: Manager -> URI -> IO LByteString+httpGet' manager uri = do+ request <- makeRequest uri+ responseBody <$> httpLbs request manager+ -- codec' <- reader $ view (config.codec)+ -- return $ response $=+ decode codec'++parseRequest' :: MonadThrow m => URI -> m Request+parseRequest' = parseRequest . show . prettyURI++-- | Build an HTTP request for given URI+makeRequest :: URI -> IO Request+makeRequest uri = do+ req <- parseRequest' uri+ return $ req { requestHeaders = [+ (mk "User-Agent", "Mozilla/4.0"),+ (mk "Accept", "*/*")]}
+ src/main/Logger.hs view
@@ -0,0 +1,52 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+-- | Implementation of "Imm.Logger" based on @fast-logger@.+-- For further information, please consult "System.Log.FastLogger".+module Logger (module Logger, module Reexport) where++-- {{{ Imports+import Imm.Logger as Reexport+import Imm.Pretty++import Data.Text.Prettyprint.Doc.Render.Terminal+import System.Log.FastLogger as Reexport+-- }}}++data LoggerSettings = LoggerSettings+ { _loggerSet :: LoggerSet -- ^ 'LoggerSet' used for 'Debug', 'Info' and 'Warning' logs+ , _errorLoggerSet :: LoggerSet -- ^ 'LoggerSet' used for 'Error' logs+ , _logLevel :: MVar LogLevel -- ^ Discard logs that are strictly less serious than this level+ , _colorizeLogs :: MVar Bool -- ^ Enable log colorisation+ }+++-- | Default logger forwards error messages to stderr, and other messages to stdout.+defaultLogger :: IO LoggerSettings+defaultLogger = LoggerSettings+ <$> newStdoutLoggerSet defaultBufSize+ <*> newStderrLoggerSet defaultBufSize+ <*> newMVar Info+ <*> newMVar True+++mkHandle :: MonadIO m => LoggerSettings -> Handle m+mkHandle settings = Handle+ { log = \l t -> do+ refLevel <- readMVar $ _logLevel settings+ handleColor <- do+ keepColor <- readMVar (_colorizeLogs settings)+ case (l, keepColor) of+ (_, False) -> return unAnnotate+ (Error, _) -> return red+ (Warning, _) -> return yellow+ _ -> return id+ let loggerSet = (if l == Error then _errorLoggerSet else _loggerSet) settings+ when (l >= refLevel) $ liftIO $ pushLogStrLn loggerSet $ toLogStr $ renderLazy $ layoutPretty defaultLayoutOptions $ handleColor t++ , getLogLevel = readMVar $ _logLevel settings+ , setLogLevel = void . swapMVar (_logLevel settings)+ , setColorizeLogs = void . swapMVar (_colorizeLogs settings)+ , flushLogs = liftIO $ do+ flushLogStr $ _loggerSet settings+ flushLogStr $ _errorLoggerSet settings+ }
+ src/main/Main.hs view
@@ -0,0 +1,228 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE PartialTypeSignatures #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TypeApplications #-}+-- {{{ Imports+import Imm+import Imm.Database as Database+import qualified Imm.HTTP as HTTP+import Imm.Pretty++import qualified Core+import Database+import HTTP+import Logger+import Options+import XML++import Control.Concurrent.Async+import Control.Concurrent.STM.TMChan+import Control.Exception.Safe+import Data.Aeson hiding (Error)+import Data.Conduit.Combinators as Conduit (stdin)+import qualified Data.Map as Map+import qualified Data.Text as Text+import qualified Data.Text.IO as Text+import Dhall+import Relude.Unsafe (at)+import System.Exit+import System.IO (hFlush)+import System.Process.Typed+-- }}}+++main :: IO ()+main = do+ AllOptions{..} <- parseOptions+ let GlobalOptions{..} = optionGlobal++ -- Setup logger+ logger <- Logger.mkHandle <$> defaultLogger+ setColorizeLogs logger optionColorizeLogs+ setLogLevel logger optionLogLevel+ log logger Debug $ "Options: " <> pretty optionCommand++ handleAny (log logger Error . pretty . displayException) $ do+ -- Setup database+ database <- Database.mkHandle <$> defaultDatabase+ let database' = if optionReadOnlyDatabase then readOnly logger database else database+ log logger Debug . ("Using database:" <++>) . indent 2 =<< _describeDatabase database'++ case optionCommand of+ Import -> Core.importOPML logger database' Conduit.stdin+ Subscribe u c -> Core.subscribe logger database' u c+ List -> Core.listFeeds logger database'+ ShowFeed feedRef -> Core.showFeed logger database =<< toFeedID database' feedRef+ OnFeedRef t c -> main2 logger database' optionCallbacksFile t c++ Database.commit logger database'++ flushLogs logger+++main2 :: Logger.Handle IO -> Database.Handle IO FeedTable -> FilePath -> Maybe FeedRef -> CommandOnFeedRef -> IO ()+main2 logger database callbacksFile feedRef command = do+ feedIDs <- resolveTarget database ByPassConfirmation feedRef++ case command of+ Unsubscribe -> Database.deleteList logger database feedIDs+ Reset -> mapM_ (Database.markAsUnprocessed logger database) feedIDs+ Run callbackMode -> do+ callbacks <- case callbackMode of+ EnableCallbacks -> input auto $ fromString callbacksFile+ _ -> return []+ main3 logger database callbacks feedIDs+++main3 :: Logger.Handle IO -> Database.Handle IO FeedTable -> [Callback] -> [FeedID] -> IO ()+main3 logger database callbacks feedIDs = do+ httpClient <- HTTP.mkHandle <$> defaultManager++ targetFeedChan <- newTMChanIO+ newItemsChan <- newTMChanIO+ processedChan <- newTMChanIO+ fetchErrorsChan <- newTMChanIO+ callbackErrorsChan <- newTMChanIO++ newItemsCount <- newTVarIO (0 :: Int)+ errorsCount <- newTVarIO (0 :: Int)++ -- => Feed IDs events+ producer <- async $ forM_ feedIDs $ atomically . writeTMChan targetFeedChan++ -- Feed IDs events => new item events+ fetchers <- replicateM 5 $ async $ fix $ \recurse -> do+ target <- atomically $ readTMChan targetFeedChan+ forM_ target $ \feedID@(FeedID uri) -> do+ catchAny+ (do+ feed <- HTTP.get logger httpClient uri >>= parseXml xmlParser uri++ unreadElements <- filterM (fmap not . isRead database feedID) $ getElements feed+ forM_ unreadElements $ \element -> do+ log logger Info $ "New item:" <+> magenta (pretty feedID) <+> "/" <+> yellow (pretty $ getTitle element)+ atomically $ do+ writeTMChan newItemsChan (feedID, removeElements feed, element)+ count <- readTVar newItemsCount+ writeTVar newItemsCount (count + 1) )++ (\e -> atomically $ do+ writeTMChan fetchErrorsChan (feedID, e)+ count <- readTVar errorsCount+ writeTVar errorsCount (count + 1) )++ recurse++ -- New items events => execute callback => processed/error events+ runners <- replicateM 5 $ async $ fix $ \recurse -> do+ newItem <- atomically $ readTMChan newItemsChan+ forM_ newItem $ \(feedID, feed, element) -> do+ results <- forM callbacks $ \callback@(Callback executable arguments) -> do+ let processInput = byteStringInput $ encode $ Message feed element+ processConfig = proc executable (map toString arguments) & setStdin processInput++ log logger Debug $ "Running" <+> cyan (pretty executable) <+> "on" <+> magenta (pretty feedID) <+> "/" <+> yellow (pretty $ getTitle element)++ (exitCode, output, errors) <- readProcess processConfig+ case exitCode of+ ExitSuccess -> return $ Right callback+ ExitFailure i -> return $ Left (callback, i, output, errors)++ case lefts results of+ [] -> atomically $ writeTMChan processedChan (feedID, element)+ e -> atomically $ do+ writeTMChan callbackErrorsChan (feedID, element, e)+ count <- readTVar errorsCount+ writeTVar errorsCount (count + 1)++ recurse++ -- Processed events => update database+ storer <- async $ fix $ \recurse -> do+ item <- atomically $ readTMChan processedChan+ forM_ item $ \(feedID, element) -> do+ log logger Debug $ "Updating database for" <+> pretty feedID <+> "/" <+> pretty (getTitle element)+ Database.markAsProcessed logger database feedID element+ recurse++ -- Wait for all async processes to complete+ wait producer+ atomically $ closeTMChan targetFeedChan++ mapM_ wait fetchers+ atomically $ closeTMChan newItemsChan++ mapM_ wait runners+ atomically $ do+ closeTMChan processedChan+ closeTMChan fetchErrorsChan+ closeTMChan callbackErrorsChan++ wait storer++ -- Error events => log+ printErrors logger fetchErrorsChan callbackErrorsChan+ flushLogs logger++ readTVarIO newItemsCount <&> pretty <&> bold <&> (<+> "new items") >>= log logger Info+ readTVarIO errorsCount <&> pretty <&> bold <&> (<+> "errors") >>= log logger Info++printErrors :: (MonadIO m, Exception e, Pretty a1, Pretty a2, Pretty a3, Pretty a4, ConvertUtf8 Text b1, ConvertUtf8 Text b2)+ => Logger.Handle m -> TMChan (a1, e) -> TMChan (a2, FeedElement, [(a3, a4, b1, b2)]) -> m ()+printErrors logger fetchErrorsChan callbackErrorsChan = do+ fix $ \recurse -> do+ items <- atomically $ readTMChan fetchErrorsChan+ forM_ items $ \(feedID, e) -> do+ log logger Error $ bold ("Fetch error for" <+> pretty feedID) <++> indent 2 (pretty $ displayException e)+ recurse++ fix $ \recurse -> do+ items <- atomically $ readTMChan callbackErrorsChan+ forM_ items $ \(feedID, element, errors) -> do+ let prettyErrors = vsep $ do+ (callback, i, stdout', stderr') <- errors+ return $ "When running:" <+> pretty callback+ <++> "Exit code:" <+> pretty i+ <++> "Stdout:" <++> indent 2 (pretty $ decodeUtf8 @Text stdout')+ <++> "Stderr:" <++> indent 2 (pretty $ decodeUtf8 @Text stderr')+ log logger Error $ bold ("Callback error for" <+> pretty feedID <+> "/" <+> pretty (getTitle element)) <++> indent 2 prettyErrors+ recurse+++xmlParser :: XML.Handle IO+xmlParser = XML.mkHandle defaultXmlParser+++-- * Util++data SafeGuard = AskConfirmation | ByPassConfirmation+ deriving(Eq, Read, Show)++data InterruptedException = InterruptedException deriving(Eq, Read, Show)+instance Exception InterruptedException where+ displayException _ = "Process interrupted"++promptConfirm :: Text -> IO ()+promptConfirm s = do+ Text.putStr $ s <> " Confirm [Y/n] "+ hFlush stdout+ x <- Text.getLine+ unless (Text.null x || x == "Y") $ throwM InterruptedException+++resolveTarget :: MonadIO m => MonadThrow m => Database.Handle m FeedTable -> SafeGuard -> Maybe FeedRef -> m [FeedID]+resolveTarget database s Nothing = do+ result <- Map.keys <$> Database.fetchAll database+ when (s == AskConfirmation) $ liftIO $ promptConfirm $ "This will affect " <> show (length result) <> " feeds."+ return result+resolveTarget database _ (Just feedRef) = do+ result <- toFeedID database feedRef+ return [result]++toFeedID :: Monad m => Database.Handle m FeedTable -> FeedRef -> m FeedID+toFeedID database (ByUID i) = fst . at (i-1) . Map.toList <$> Database.fetchAll database+toFeedID _ (ByURI uri) = return $ FeedID uri
+ src/main/Options.hs view
@@ -0,0 +1,144 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE TypeApplications #-}+module Options where++-- {{{ Imports+import Imm.Feed+import Imm.Logger as Logger+import Imm.Pretty+import qualified Paths_imm as Package++import Data.List+import Data.Set (Set)+import qualified Data.Set as Set+import Data.Version+import Options.Applicative+import System.Directory+import System.FilePath+import System.Info+import URI.ByteString+-- }}}++-- * Types++data AllOptions = AllOptions+ { optionCommand :: Command+ , optionGlobal :: GlobalOptions+ } deriving(Eq, Show)+++data GlobalOptions = GlobalOptions+ { optionLogLevel :: LogLevel+ , optionColorizeLogs :: Bool+ , optionReadOnlyDatabase :: Bool+ , optionCallbacksFile :: FilePath+ } deriving(Eq, Ord, Read, Show)+++data Command = Import | Subscribe URI (Set Text) | List | ShowFeed FeedRef | OnFeedRef (Maybe FeedRef) CommandOnFeedRef++deriving instance Eq Command+deriving instance Ord Command+deriving instance Show Command++instance Pretty Command where+ pretty Import = "Import feeds"+ pretty (Subscribe f _) = "Subscribe to feed:" <+> prettyURI f+ pretty (OnFeedRef t c) = pretty c <> ":" <+> pretty t+ pretty List = "List all feeds"+ pretty (ShowFeed f) = "Show details of feed" <+> pretty f+++data CallbackMode = DisableCallbacks | EnableCallbacks+ deriving(Eq, Ord, Read, Show)+++data CommandOnFeedRef = Reset | Run CallbackMode | Unsubscribe+ deriving(Eq, Ord, Read, Show)++instance Pretty CommandOnFeedRef where+ pretty Reset = "Mark feed as unprocessed"+ pretty (Run c) = "Download new entries from feeds" <> (if c == DisableCallbacks then space <> brackets "callbacks disabled" else mempty)+ pretty Unsubscribe = "Unsubscribe from feed"++-- * Option parsers++parseOptions :: (MonadIO m) => m AllOptions+parseOptions = io $ do+ defaultCallbacksFile <- getXdgDirectory XdgConfig $ "imm" </> "callbacks.dhall"+ customExecParser defaultPrefs $ info (allOptions defaultCallbacksFile <**> helper <**> versionPrinter) $ progDesc description++description :: String+description = "Execute arbitrary callbacks for each element of RSS/Atom feeds."++allOptions :: FilePath -> Parser AllOptions+allOptions defaultCallbacksFile = AllOptions+ <$> commandParser+ <*> globalOptions defaultCallbacksFile++globalOptions :: FilePath -> Parser GlobalOptions+globalOptions defaultCallbacksFile = GlobalOptions+ <$> (verboseFlag <|> quietFlag <|> logLevel <|> pure Info)+ <*> colorizeLogs+ <*> readOnlyDatabase+ <*> (callbacksFileOption <|> pure defaultCallbacksFile)++commandParser :: Parser Command+commandParser = hsubparser $ mconcat+ [ command "add" $ info subscribeOptions $ progDesc "Alias for subscribe."+ , command "import" $ info (pure Import) $ progDesc "Import feeds list from an OPML descriptor (read from stdin)."+ , command "subscribe" $ info subscribeOptions $ progDesc "Subscribe to a feed."++ , command "remove" $ info unsubscribeOptions $ progDesc "Alias for unsubscribe."+ , command "run" $ info (OnFeedRef <$> optional feedRefOption <*> runOptions) $ progDesc "Update list of feeds."+ , command "show" $ info (ShowFeed <$> feedRefOption) $ progDesc "Show details about given feed."+ , command "list" $ info (pure List) $ progDesc "List all feed sources currently configured, along with their status."+ , command "reset" $ info (OnFeedRef <$> optional feedRefOption <*> pure Reset) $ progDesc "Mark given feed as unprocessed."+ , command "unsubscribe" $ info unsubscribeOptions $ progDesc "Unsubscribe from a feed."+ ]++-- {{{ Log 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."++colorizeLogs :: Parser Bool+colorizeLogs = flag True False $ long "nocolor" <> help "Disable log colorisation."+-- }}}++-- {{{ Other options+readOnlyDatabase :: Parser Bool+readOnlyDatabase = switch $ long "read-only" <> help "Disable database writes."++runOptions :: Parser CommandOnFeedRef+runOptions = Run <$> flag EnableCallbacks DisableCallbacks (long "no-callbacks" <> help "Disable callbacks.")++tagOption :: Parser Text+tagOption = option auto $ long "tag" <> short 't' <> metavar "TAG" <> help "Set the given tag."++subscribeOptions, unsubscribeOptions :: Parser Command+subscribeOptions = Subscribe <$> uriArgument "URI to subscribe to." <*> (Set.fromList <$> many tagOption)+unsubscribeOptions = OnFeedRef <$> optional feedRefOption <*> pure Unsubscribe++callbacksFileOption :: Parser FilePath+callbacksFileOption = strOption $ long "callbacks" <> short 'c' <> metavar "FILE" <> help "Dhall configuration file for callbacks"+-- }}}++-- {{{ Util+uriReader :: ReadM URI+uriReader = eitherReader $ first show . parseURI laxURIParserOptions . encodeUtf8 @Text . fromString++feedRefOption :: Parser FeedRef+feedRefOption = argument ((ByUID <$> auto) <|> (ByURI <$> uriReader)) $ metavar "TARGET"++uriArgument :: String -> Parser URI+uriArgument helpText = argument uriReader $ metavar "URI" <> help helpText++versionPrinter :: Parser (a -> a)+versionPrinter = infoOption+ (Data.List.unlines ["imm-" <> showVersion Package.version, "built with " <> compilerName <> "-" <> showVersion compilerVersion])+ (long "version" <> short 'V' <> help "Print versions.")+-- }}}
+ src/main/Prelude.hs view
@@ -0,0 +1,7 @@+module Prelude (io, module Relude, module Relude.Extra.Map) where++import Relude hiding(Handle, force)+import Relude.Extra.Map (lookup)++io :: MonadIO m => IO a -> m a+io = liftIO
+ src/main/XML.hs view
@@ -0,0 +1,32 @@+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE RankNTypes #-}+-- | Implementation of "Imm.XML" based on 'Conduit'.+module XML (module XML, module Imm.XML) where++-- {{{ Imports+import Imm.Feed+import Imm.XML++import Control.Exception.Safe+import Data.Conduit+import Data.XML.Types+import Text.Atom.Conduit.Parse+import Text.RSS.Conduit.Parse+import Text.RSS1.Conduit.Parse+import Text.XML.Stream.Parse as XML+import URI.ByteString+-- }}}++-- | A pre-process 'Conduit' can be set to alter the raw XML before feeding it to the parser,+-- depending on the feed 'URI'+newtype XmlParser = XmlParser (forall m . Monad m => URI -> ConduitT Event Event m ())++-- | 'Conduit' based implementation+mkHandle :: MonadIO m => MonadCatch m => XmlParser -> Handle m+mkHandle (XmlParser preProcess) = Handle+ { parseXml = \uri bytestring -> liftIO $ runConduit $ parseLBS def bytestring .| preProcess uri .| XML.force "Invalid feed" ((fmap Atom <$> atomFeed) `orE` (fmap Rss <$> rssDocument) `orE` (fmap Rss <$> rss1Document))+ }++-- | Forward all 'Event's without any pre-process+defaultXmlParser :: XmlParser+defaultXmlParser = XmlParser $ const $ fix $ \loop -> await >>= maybe (return ()) (yield >=> const loop)
+ src/send-mail/Main.hs view
@@ -0,0 +1,185 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+-- | 'Callback' for @imm@ that sends a mail via a SMTP server the input RSS/Atom item.+-- {{{ Imports+import Imm.Callback+import Imm.Feed+import Imm.Pretty++import Data.Aeson+import Data.ByteString (getContents)+import Data.Text as Text (intercalate)+import Data.Time+import Network.HaskellNet.SMTP+import Network.HaskellNet.SMTP.SSL+import Network.Mail.Mime hiding (sendmail)+import Network.Socket+import Options.Applicative+import Refined+import Text.Atom.Types+import Text.RSS.Types+-- }}}++-- * Types++type Username = String+type Password = String+type ServerName = String++-- | How to connect to the SMTP server+data ConnectionSettings = Plain ServerName PortNumber | Encrypted ServerName Settings Bool+ deriving(Eq, Generic, Ord, Show)++-- | How to authenticate to the SMTP server+data Authentication = Authentication AuthType Username Password+ deriving(Eq, Generic, Show)++data SMTPServer = SMTPServer (Maybe Authentication) ConnectionSettings+ deriving (Eq, Generic, 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 CliOptions = CliOptions+ { _smtpServer :: SMTPServer+ , _recipients :: [Address]+ , _dryRun :: Bool+ } deriving (Eq, Generic, Show)+++parseOptions :: MonadIO m => m CliOptions+parseOptions = io $ customExecParser (prefs $ showHelpOnError <> showHelpOnEmpty) (info (cliOptions <**> helper) $ progDesc "Send a mail for each new RSS/Atom item.")++cliOptions :: Parser CliOptions+cliOptions = CliOptions+ <$> smtpServerParser+ <*> many recipientParser+ <*> switch (long "dry-run" <> help "Disable all I/Os, except for logs.")++smtpServerParser :: Parser SMTPServer+smtpServerParser = SMTPServer+ <$> ((Just <$> authenticationParser) <|> pure Nothing)+ <*> (plainConnection <|> encryptedConnection)++authenticationParser :: Parser Authentication+authenticationParser = Authentication+ <$> authenticationType+ <*> strOption (long "user" <> short 'u' <> help "SMTP username")+ <*> strOption (long "password" <> short 'P' <> help "SMTP password")++authenticationType :: Parser AuthType+authenticationType = flag' PLAIN (long "plain" <> help "Use plain authentication.")+ <|> flag' LOGIN (long "login" <> help "Use login authentication")+ <|> flag' CRAM_MD5 (long "cram-md5" <> help "Use CRAM MD5 authentication")++plainConnection :: Parser ConnectionSettings+plainConnection = Plain+ <$> strOption (long "server" <> short 's' <> help "SMTP server address.")+ <*> option auto (long "port" <> short 'p' <> help "SMTP server port.")++encryptedConnection :: Parser ConnectionSettings+encryptedConnection = Encrypted+ <$> strOption (long "server" <> short 's' <> help "SMTP server address.")+ <*> encryptionSettings+ <*> switch (long "starttls" <> help "Use STARTTLS.")++encryptionSettings :: Parser Settings+encryptionSettings = Settings+ <$> option auto (long "ssl-port" <> short 's' <> help "SSL port.")+ <*> option auto (long "max-line-length" <> short 'l' <> help "Maximum line length.")+ <*> switch (long "log" <> help "Log to console.")+ <*> switch (long "disable-certificate-validation" <> help "Disable certificate validation.")++recipientParser :: Parser Address+recipientParser = strOption (long "to" <> help "Mail recipients.")++main :: IO ()+main = do+ CliOptions smtpServer recipients dryRun <- parseOptions++ message <- getContents <&> fromStrict <&> eitherDecode+ case message of+ Left e -> putStrLn e+ Right (Message feed element) -> do+ timezone <- io getCurrentTimeZone+ currentTime <- io getCurrentTime+ let formatMail = FormatMail defaultFormatFrom defaultFormatSubject defaultFormatBody (const $ const recipients)+ mail = buildMail formatMail currentTime timezone feed element+ unless dryRun $ io $ withSMTPConnection smtpServer $ sendMimeMail2 mail++ return ()+++-- * Default behavior++-- | 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 = Text.intercalate ", " $ map (unrefine . personName) $ entryAuthors entry <> feedAuthors feed+defaultFormatFrom _ _ = Address (Just "Unknown") ""++-- | Fill mail subject with the element title+defaultFormatSubject :: Feed -> FeedElement -> Text+defaultFormatSubject _ = getTitle++-- | 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>" <> Text.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 (Encrypted server settings False)) f =+ doSMTPSSLWithSettings server settings $ \connection -> do+ forM_ authentication (authenticate_ connection)+ f connection+withSMTPConnection (SMTPServer authentication (Encrypted server settings True)) 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]]+ }
+ src/send-mail/Prelude.hs view
@@ -0,0 +1,7 @@+module Prelude (io, module Relude, module Relude.Extra.Map) where++import Relude hiding(Handle, force)+import Relude.Extra.Map (lookup)++io :: MonadIO m => IO a -> m a+io = liftIO
+ src/write-file/Main.hs view
@@ -0,0 +1,134 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+-- | Write a file from the input RSS/Atom item.+--+-- Meant to be use as a callback for imm.+-- {{{ Imports+import Imm.Callback+import Imm.Feed+import Imm.Pretty++import Data.Aeson+import Data.ByteString (getContents)+import Data.ByteString.Builder+import Data.ByteString.Streaming (toStreamingByteString)+import qualified Data.Text as Text (null, replace)+import Data.Time+import Options.Applicative+import Streaming.With+import System.Directory (createDirectoryIfMissing)+import System.FilePath+import Text.Atom.Types+import Text.Blaze.Html.Renderer.Utf8+import Text.Blaze.Html5 (Html, docTypeHtml,+ preEscapedToHtml, (!))+import qualified Text.Blaze.Html5 as H+import qualified Text.Blaze.Html5.Attributes as H (charset, href)+import Text.RSS.Types+import URI.ByteString+-- }}}++data CliOptions = CliOptions+ { _directory :: FilePath+ , _dryRun :: Bool+ } deriving (Eq, Ord, Read, Show)++parseOptions :: MonadIO m => m CliOptions+parseOptions = io $ customExecParser defaultPrefs (info cliOptions $ progDesc "Write a file for each new RSS/Atom item. An intermediate folder will be created for each feed.")++cliOptions :: Parser CliOptions+cliOptions = CliOptions+ <$> strOption (long "directory" <> short 'd' <> metavar "PATH" <> help "Root directory where files will be created.")+ <*> switch (long "dry-run" <> help "Disable all I/Os, except for logs.")++++main :: IO ()+main = do+ CliOptions directory dryRun <- parseOptions+ message <- getContents <&> fromStrict <&> eitherDecode+ case message of+ Left e -> putStrLn e+ Right (Message feed element) -> do+ let content = defaultFileContent feed element+ filePath = defaultFilePath directory feed element+ putStrLn filePath+ unless dryRun $ do+ createDirectoryIfMissing True $ takeDirectory filePath+ writeBinaryFile filePath $ toStreamingByteString content+ return ()++-- * Default behavior++-- | Generate a path @<root>/<feed title>/<element date>-<element title>.html@, where @<root>@ is the first argument+defaultFilePath :: FilePath -> Feed -> FeedElement -> FilePath+defaultFilePath root feed element = makeValid $ root </> toString title </> fileName <.> "html" where+ date = maybe "" (formatTime defaultTimeLocale "%F-") $ getDate element+ fileName = date <> toString (sanitize $ getTitle element)+ title = sanitize $ getFeedTitle feed+ sanitize = appEndo (mconcat [Endo $ Text.replace (toText [s]) "_" | s <- pathSeparators])+ >>> Text.replace "." "_"+ >>> Text.replace "?" "_"+ >>> Text.replace "!" "_"+ >>> Text.replace "#" "_"++-- | Generate an HTML page, with a title, a header and an article that contains the feed element+defaultFileContent :: Feed -> FeedElement -> Builder+defaultFileContent feed element = renderHtmlBuilder $ docTypeHtml $ do+ H.head $ do+ H.meta ! H.charset "utf-8"+ H.title $ convertText $ getFeedTitle feed <> " | " <> getTitle element+ H.body $ do+ H.h1 $ convertText $ getFeedTitle feed+ H.article $ do+ H.header $ do+ defaultArticleTitle feed element+ defaultArticleAuthor feed element+ defaultArticleDate feed element+ defaultBody feed element+++-- * Low-level helpers++defaultArticleTitle :: Feed -> FeedElement -> Html+defaultArticleTitle _ element@(RssElement item) = H.h2 $ maybe id (\uri -> H.a ! H.href uri) link $ convertText $ getTitle element where+ link = withRssURI (convertDoc . prettyURI) <$> itemLink item+defaultArticleTitle _ element@(AtomElement _) = H.h2 $ convertText $ getTitle element++defaultArticleAuthor :: Feed -> FeedElement -> Html+defaultArticleAuthor _ (RssElement item) = unless (Text.null author) $ H.address $ "Published by " >> convertText author where+ author = itemAuthor item+defaultArticleAuthor _ (AtomElement entry) = H.address $ do+ "Published by "+ forM_ (entryAuthors entry) $ \author -> do+ convertDoc $ prettyPerson author+ ", "++defaultArticleDate :: Feed -> FeedElement -> Html+defaultArticleDate _ element = forM_ (getDate element) $ \date -> H.p $ " on " >> H.time (convertDoc $ prettyTime date)+++-- | Generate the HTML content for a given feed element+defaultBody :: Feed -> FeedElement -> Html+defaultBody _ element@(RssElement _) = H.p $ preEscapedToHtml $ getContent element+defaultBody _ element@(AtomElement entry) = do+ unless (null links) $ H.p $ do+ "Related links:"+ H.ul $ forM_ links $ \uri -> H.li (H.a ! withAtomURI href uri $ convertAtomURI uri)+ H.p $ preEscapedToHtml $ getContent element+ where links = map linkHref $ entryLinks entry++href :: URIRef a -> H.Attribute+href = H.href . convertURI++convertAtomURI :: (IsString t) => AtomURI -> t+convertAtomURI = withAtomURI convertURI++convertURI :: (IsString t) => URIRef a -> t+convertURI = convertText . decodeUtf8 . serializeURIRef'++convertText :: (IsString t) => Text -> t+convertText = fromString . toString++convertDoc :: (IsString t) => Doc a -> t+convertDoc = show
+ src/write-file/Prelude.hs view
@@ -0,0 +1,7 @@+module Prelude (io, module Relude, module Relude.Extra.Map) where++import Relude hiding(Handle, force)+import Relude.Extra.Map (lookup)++io :: MonadIO m => IO a -> m a+io = liftIO