packages feed

imm-1.9.0.0: src/main/Database.hs

{-# LANGUAGE FlexibleContexts      #-}
{-# LANGUAGE FlexibleInstances     #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedLists       #-}
{-# LANGUAGE OverloadedStrings     #-}
{-# LANGUAGE TupleSections         #-}
{-# LANGUAGE UndecidableInstances  #-}
-- | Implementation of "Imm.Database.Feed" based on a JSON file.
module Database
  ( JsonFileDatabase
  , mkJsonFileDatabase
  , defaultDatabase
  , mkHandle
  , JsonException(..)
  , module Imm.Database.Feed
  ) where

-- {{{ Imports
import           Imm.Database.Feed           hiding (commit, delete1)
import           Imm.Feed
import           Imm.Pretty

import           Control.Concurrent.STM.TVar (swapTVar)
import           Control.Exception.Safe
import           Data.Aeson
import qualified Data.ByteString             as ByteString
import           Data.ByteString.Lazy        (hPut)
import qualified Data.Map                    as Map
import           System.Directory
import           System.FilePath
import           System.IO                   hiding (Handle)
-- }}}

data CacheStatus = Empty | Clean | Dirty
  deriving(Eq, Ord, Read, Show)

data JsonFileDatabase = JsonFileDatabase FilePath (Map FeedLocation Entry) CacheStatus

instance Pretty JsonFileDatabase where
  pretty (JsonFileDatabase file _ _) = "JSON database: " <+> pretty file

mkJsonFileDatabase :: FilePath -> JsonFileDatabase
mkJsonFileDatabase file = JsonFileDatabase file mempty Empty

-- | Default database is stored in @$XDG_CONFIG_HOME\/imm\/feeds.json@
defaultDatabase :: IO (TVar JsonFileDatabase)
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 :: (MonadIO m, MonadMask m)
         => TVar JsonFileDatabase -> Handle m
mkHandle tvar = Handle
  { _describeDatabase = pretty <$> readTVarIO tvar
  , _fetch = \keys -> do
      loadInCache tvar
      database <- readTVarIO tvar
      return $ Map.fromList $ do
        key <- keys
        entry <- maybeToList $ fetchFromCache key database
        return (key, entry)
  , _fetchAll = loadInCache tvar
      >> getCache tvar
      <&> Map.toList
      <&> map snd
      <&> zip [0..]
      <&> Map.fromList
  , _update = \key f -> loadInCache tvar >> atomically (modifyTVar' tvar $ updateInCache key f)
  , _insert = \entries -> do
      loadInCache tvar
      atomically $ do
        modifyTVar' tvar $ insertInCache entries
        database <- readTVar tvar
        return $ Map.fromList $ do
          location <- entries <&> entryLocation
          i <- maybeToList $ getIndex location database
          return (location, i)
  , _delete = \list -> loadInCache tvar >> atomically (modifyTVar' tvar $ deleteInCache list)
  , _purge = loadInCache tvar >> atomically (modifyTVar' tvar purgeInCache)
  , _commit = commit tvar
  }

-- * Low-level implementation

getIndex :: FeedLocation -> JsonFileDatabase -> Maybe Int
getIndex location (JsonFileDatabase _ cache _) = Map.lookupIndex location cache

loadInCache :: (MonadIO m, MonadMask m) => TVar JsonFileDatabase -> 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 :: (MonadIO m, MonadMask m) => FilePath -> m JsonFileDatabase
loadFromDisk file = do
  liftIO $ createDirectoryIfMissing True $ takeDirectory file
  fileContent <- io $ withBinaryFile file ReadWriteMode ByteString.hGetContents
  cache <- fileContent
    & fromEmpty "[]"
    & decodeStrict
    & fmap Map.fromList
    & (`failWith` UnableDecode)
  return $ JsonFileDatabase file cache Clean
  where fromEmpty x "" = x
        fromEmpty _ y  = y

getCache :: MonadIO m => TVar JsonFileDatabase -> m (Map FeedLocation Entry)
getCache tvar = do
  JsonFileDatabase _ cache _ <- readTVarIO tvar
  return cache

fetchFromCache :: EntryKey -> JsonFileDatabase -> Maybe (Int, Entry)
fetchFromCache (ByLocation location) (JsonFileDatabase _ cache _) = (,)
  <$> Map.lookupIndex location cache
  <*> Map.lookup location cache
fetchFromCache (ById i) (JsonFileDatabase _ cache _) = if i >= 0 && i < Map.size cache
  then Just $ (i,) $ snd $ Map.elemAt i cache
  else Nothing


insertInCache :: [Entry] -> JsonFileDatabase -> JsonFileDatabase
insertInCache entries (JsonFileDatabase file cache _) = JsonFileDatabase file newCache Dirty where
  newCache = Map.union cache $ Map.fromList $ map (\entry -> (entryLocation entry, entry)) entries

updateInCache :: [EntryKey] -> (Entry -> Entry) -> JsonFileDatabase -> JsonFileDatabase
updateInCache keys f (JsonFileDatabase file cache _) = JsonFileDatabase file newCache Dirty where
  newCache = foldr update1 cache keys
  update1 (ByLocation location) = Map.update (Just . f) location
  update1 (ById i)              = Map.updateAt (const $ Just . f) i

deleteInCache :: [EntryKey] -> JsonFileDatabase -> JsonFileDatabase
deleteInCache keys (JsonFileDatabase file oldCache _) = JsonFileDatabase file newCache Dirty where
  newCache = foldr delete1 oldCache keys
  delete1 (ByLocation location) = Map.delete location
  delete1 (ById i)              = Map.deleteAt i

purgeInCache :: JsonFileDatabase -> JsonFileDatabase
purgeInCache (JsonFileDatabase file _ _) = JsonFileDatabase file mempty Dirty

commit :: (MonadIO m)
       => TVar JsonFileDatabase -> 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