packages feed

imm-2.1.2.0: src/lib/Imm/Feed.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PartialTypeSignatures #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE UnicodeSyntax #-}

-- | Feed data structures.
module Imm.Feed (
  -- * Types
  FeedLocation (..),
  UID,
  FeedQuery (..),
  FeedDefinition (..),
  FeedItem (..),
  Author (..),

  -- * Parsers
  parseFeed,
  feedC,
  parseFeedItem,

  -- * Utilities
  getMainLink,
  areSameItem,
)
where

-- {{{ Imports
import Conduit
import Control.Exception.Safe
import Data.Aeson.Extended
import Data.Text as Text (null)
import Data.Time
import Data.XML.Types
import Imm.Link
import Imm.Pretty
import Refined
import Safe
import Text.Atom.Conduit.Parse
import Text.Atom.Types
import Text.RSS.Conduit.Parse
import Text.RSS.Extensions.Content
import Text.RSS.Extensions.DublinCore
import Text.RSS.Types
import Text.RSS1.Conduit.Parse
import Text.XML.Stream.Parse as XML hiding (content)
import URI.ByteString.Extended

-- }}}

-- | Feed location identifies a feed. It is either:
-- - the feed URI
-- - a webpage URI that refers to the feed through an alternate link, in which case an optional feed title can be provided to disambiguate multiple such links
data FeedLocation = FeedLocation URI Text
  deriving (Eq, Generic, Ord, Show, Typeable)

instance Pretty FeedLocation where
  pretty (FeedLocation uri title) =
    prettyURI uri
      <> if Text.null title then mempty else space <> brackets (pretty title)

instance ToJSON FeedLocation where
  toJSON (FeedLocation uri title) =
    object
      [ "uri" .= toJSON (JsonURI uri)
      , "title" .= title
      ]

instance FromJSON FeedLocation where
  parseJSON = withObject "FeedLocation" $ \v → do
    FeedLocation <$> (_unwrapURI <$> (v .: "uri")) <*> (v .: "title")

-- | Database identifier for a feed
type UID = Int

-- | A query describes a set of feeds through some criteria.
data FeedQuery = QueryByUID UID | QueryAll
  deriving (Eq, Ord, Read, Show, Typeable)

instance Pretty FeedQuery where
  pretty QueryAll = "All subscribed feeds"
  pretty (QueryByUID k) = "Feed" <+> pretty k

newtype FeedDefinition = FeedDefinition
  { _feedTitle ∷ Text
  }
  deriving (Eq, Generic, Ord, Read, Show, Typeable)

feedDefinitionOptions ∷ Options
feedDefinitionOptions =
  defaultOptions
    { fieldLabelModifier = camelTo2 '_' . drop (length @[] "_feed")
    , omitNothingFields = True
    }

instance ToJSON FeedDefinition where
  toJSON = genericToJSON feedDefinitionOptions
  toEncoding = genericToEncoding feedDefinitionOptions

instance FromJSON FeedDefinition where
  parseJSON = genericParseJSON feedDefinitionOptions

instance Pretty FeedDefinition where
  pretty definition = pretty $ _feedTitle definition

instance Pretty (PrettyName FeedDefinition) where
  pretty (PrettyName definition) = pretty $ _feedTitle definition

data Author = Author
  { _authorName ∷ Text
  , _authorEmail ∷ Text
  , _authorURI ∷ Maybe AnyURI
  }
  deriving (Eq, Generic, Ord, Show, Typeable)

authorOptions ∷ Options
authorOptions =
  defaultOptions
    { fieldLabelModifier = camelTo2 '_' . drop (length @[] "_author")
    , omitNothingFields = True
    }

instance ToJSON Author where
  toJSON = genericToJSON authorOptions
  toEncoding = genericToEncoding authorOptions

instance FromJSON Author where
  parseJSON = genericParseJSON authorOptions

instance Pretty Author where
  pretty Author{..} = pretty _authorName <+> brackets (pretty _authorEmail)

data FeedItem = FeedItem
  { _itemDate ∷ Maybe UTCTime
  , _itemTitle ∷ Text
  , _itemContent ∷ Text
  , _itemLinks ∷ [Link]
  , _itemIdentifier ∷ Text
  , _itemAuthors ∷ [Author]
  }
  deriving (Eq, Generic, Ord, Show, Typeable)

feedItemOptions ∷ Options
feedItemOptions =
  defaultOptions
    { fieldLabelModifier = camelTo2 '_' . drop (length @[] "_item")
    , omitNothingFields = True
    }

instance ToJSON FeedItem where
  toJSON = genericToJSON feedItemOptions
  toEncoding = genericToEncoding feedItemOptions

instance FromJSON FeedItem where
  parseJSON = genericParseJSON feedItemOptions

instance Pretty (PrettyName FeedItem) where
  pretty (PrettyName item) = pretty (_itemTitle item)

instance Pretty FeedItem where
  pretty FeedItem{..} = maybe "<unknown>" prettyTime _itemDate <+> pretty _itemTitle

parseFeed ∷ MonadCatch m ⇒ Text → m (FeedDefinition, [FeedItem])
parseFeed text = runConduit $ parseLBS def (encodeUtf8 text) .| XML.force "Invalid feed" feedC

parseAtomFeed ∷ AtomFeed → (FeedDefinition, [FeedItem])
parseAtomFeed feed = (definition, items)
 where
  definition = FeedDefinition (show $ prettyAtomText $ feedTitle feed)
  items = parseAtomItem <$> feedEntries feed

parseRssFeed ∷ RssDocument (ContentModule (DublinCoreModule NoExtensions)) → (FeedDefinition, [FeedItem])
parseRssFeed doc = (definition, items)
 where
  definition = FeedDefinition (channelTitle doc)
  items = parseRssItem <$> channelItems doc

-- | Conduit version of 'parseFeed'
feedC ∷ MonadCatch m ⇒ ConduitT Event o m (Maybe (FeedDefinition, [FeedItem]))
feedC = choose [atom, rss, rss1]
 where
  atom = fmap parseAtomFeed <$> atomFeed
  rss = fmap parseRssFeed <$> rssDocument
  rss1 = fmap parseRssFeed <$> rss1Document

parseFeedItem ∷ MonadCatch m ⇒ Text → m FeedItem
parseFeedItem text = runConduit $ parseLBS def (encodeUtf8 text) .| XML.force "Invalid feed element" (choose [fmap parseAtomItem <$> atomEntry, fmap parseRssItem <$> rssItem, fmap parseRssItem <$> rss1Item])

parseAtomItem ∷ AtomEntry → FeedItem
parseAtomItem entry = FeedItem date title content links identifier authors
 where
  date = Just $ entryUpdated entry
  title = show $ prettyAtomText $ entryTitle entry
  content = fromMaybe "<empty>" $ rawContent <|> summary
  rawContent = show . prettyAtomContent <$> entryContent entry
  summary = show . prettyAtomText <$> entrySummary entry
  links = parseLink <$> entryLinks entry
  parseLink link = Link (pure $ fromMaybe Alternate $ parseRelation $ linkRel link) (linkTitle link) (parseMediaType $ linkType link) (withAtomURI AnyURI $ linkHref link)
  identifier = entryId entry
  authors = [Author (unrefine name) email (withAtomURI AnyURI <$> uri) | AtomPerson name email uri ← entryAuthors entry]

parseRssItem ∷ RssItem (ContentModule (DublinCoreModule NoExtensions)) → FeedItem
parseRssItem item = FeedItem date title content links identifier authors
 where
  date = itemPubDate item <|> (item & itemExtensions & itemContentOther & itemDcMetaData & elementDate)
  title = itemTitle item
  content = if not (Text.null rawContent) then rawContent else itemDescription item
  rawContent = item & itemExtensions & itemContent
  links = [Link (Just Alternate) mempty mzero (withRssURI AnyURI uri) | uri ← maybeToList $ itemLink item]
  identifier = itemGuid item <&> prettyGuid & maybe mempty show
  authors = [Author (itemAuthor item) mempty mzero]

-- TODO: replace headMay with singleMay
getMainLink ∷ FeedItem → Maybe Link
getMainLink item = _itemLinks item & filter (\l → _linkRelation l == Just Alternate) & headMay

haveSameIdentifier ∷ FeedItem → FeedItem → Maybe Bool
haveSameIdentifier item1 item2 = case (_itemIdentifier item1, _itemIdentifier item2) of
  ("", "") → Nothing
  (a, b) → Just (a == b)

haveSameLink ∷ FeedItem → FeedItem → Maybe Bool
haveSameLink item1 item2 = case (getMainLink item1, getMainLink item2) of
  (Just a, Just b) → Just (a == b)
  (Nothing, Nothing) → Nothing
  _ → Just False

haveSameTitle ∷ FeedItem → FeedItem → Maybe Bool
haveSameTitle item1 item2 = case (_itemTitle item1, _itemTitle item2) of
  ("" ∷ Text, "" ∷ Text) → Nothing
  (a, b) → Just (a == b)

areSameItem ∷ FeedItem → FeedItem → Bool
areSameItem a b = fromMaybe (comparing _itemContent a b == EQ) $ haveSameIdentifier a b <|> haveSameLink a b <|> haveSameTitle a b