packages feed

doc-review-0.7: src/State/Disk.hs

{- WARNING: this store implementation has race-conditions for
   multi-threaded access! Its operations are also not atomic. Beware!
 -}

module State.Disk
    ( new
    )
where

import Control.Applicative   ( (<$>) )
import Control.Arrow         ( second )
import Control.Monad         ( when )
import Data.Binary           ( encode, decode )
import Data.Bits             ( (.&.), shiftR )
import Data.Char             ( chr )
import Data.Function         ( on )
import Data.List             ( nub, sortBy )
import Data.Maybe            ( fromMaybe, mapMaybe )
import Data.Time.Clock.POSIX ( POSIXTime )
import Data.Word             ( Word8 )
import Numeric               ( readHex )
import System.Directory      ( getDirectoryContents, createDirectoryIfMissing )
import System.FilePath       ( (</>), (<.>) )
import System.IO             ( withBinaryFile, IOMode(ReadMode, WriteMode) )
import Network.URI           ( parseRelativeReference, URI )
import qualified Data.ByteString      as BS
import qualified Data.ByteString.Lazy as B
import qualified Data.Text            as T
import qualified Data.Text.Encoding   as E
import qualified Data.Text.IO         as T

import State.Types         ( State(..), CommentId, commentId , ChapterId
                           , chapterId, mkCommentId, Comment(..)
                           , SessionId(..), SessionInfo(..) )

safe :: T.Text -> FilePath
safe = safeBS . E.encodeUtf8

-- Encode arbitrary text as a filename that is safe to use on a POSIX
-- filesystem (no special characters)
safeBS :: BS.ByteString -> FilePath
safeBS = concatMap makeSafe . BS.unpack
    where
      -- Conservative set of characters that are allowed unescaped in
      -- a filename
      unescaped w = if or [ '0' |-| '9', 'a' |-| 'z', 'A' |-| 'Z' ]
                    then Just c
                    else Nothing
          where
            c = chr $ fromIntegral w
            lo |-| hi = c >= lo && c <= hi

      -- Keep characters that are allowed unescaped, and use '-' as an
      -- escape sequence, followed by two hex digits for octets that
      -- are out of range.
      makeSafe :: Word8 -> [Char]
      makeSafe c = maybe ('-':hex c) return $ unescaped c

      -- Convert the Word8 to a pair of hex digits
      hex c = map hDig [c `shiftR` 4, c .&. 0x0f]

      -- Convert the four-bit value into a hex digit
      hDig = ((['0'..'9'] ++ ['a'..'f']) !!) . fromIntegral

decodeFileName :: FilePath -> T.Text
decodeFileName = T.pack . go
    where
      go ('-':x:y:cs) = case readHex [x, y] of
                          [(n, [])] -> chr n:go cs
                          _ -> '-':go (x:y:cs)
      go (c:cs) = c:go cs
      go [] = []

new :: FilePath -> IO State
new storeDir =
    do createDirectoryIfMissing True commentsDir
       createDirectoryIfMissing True chaptersDir
       createDirectoryIfMissing True sessionsDir
       return $  State { findComments = findComments'

                       , getCounts =
                         \mChId -> do
                           cIds <- case mChId of
                                     Nothing -> getAllCommentIds
                                     Just chId -> maybe [] id <$> readChapterFile chId
                           cs <- readComments cIds
                           return $ filter ((> 0) . snd) $ map (second length) cs

                       , addComment =
                         \cId chId c -> do
                           case chId of
                             Nothing -> return ()
                             Just chap -> addChapter' chap [cId] (Nothing :: Maybe URI)
                           cs <- findComments' cId
                           writeCommentsFile cId (cs ++ [c])
                           writeSession (cSession c) (cName c)
                                            (cEmail c) (cDate c)

                       , addChapter = addChapter'


                       , getLastInfo =
                         \sId -> fmap (\(n, e, _) -> SessionInfo n e)
                                 `fmap` readSession sId

                       , getChapterComments =
                         \chId -> do
                           mCIds <- readChapterFile chId
                           let fixUp (cId, cs) = map ((,) cId) cs
                               sortDateDesc = sortBy $ flip $
                                              (compare `on` (cDate . snd))
                           case mCIds of
                             Nothing -> return []
                             Just cIds -> sortDateDesc . concatMap fixUp <$>
                                          readComments cIds

                       , getChapterURI = getChapterURI'
                       }
    where
      commentsDir = storeDir </> "comments"

      commentPath cId = commentsDir </> safe (commentId cId)

      readComments :: [CommentId] -> IO [(CommentId, [Comment])]
      readComments cIds = zip cIds `fmap` mapM findComments' cIds

      writeCommentsFile cId = B.writeFile (commentPath cId) . encode

      findComments' cId = tryRead `catch` \_ -> return []
          where
            tryRead = withBinaryFile (commentPath cId) ReadMode $ \h ->
                      do cs <- decode <$> B.hGetContents h
                         length cs `seq` return cs

      getAllCommentIds =
          mapMaybe (mkCommentId . decodeFileName) . filter okId
                       <$> getDirectoryContents commentsDir
              where
                okId = not . (`elem` [".", ".."])

      chaptersDir = storeDir </> "chapters"

      chapterPath chId = chaptersDir </> safe (chapterId chId)
      chapterURIPath chId = chapterPath chId <.> "uri"

      addChapter' chId cIds mURI = do
        cIds' <- fromMaybe [] <$> readChapterFile chId
        writeChapterFile chId (nub $ cIds ++ cIds')
        case mURI of
          Nothing -> return ()
          Just u  -> T.writeFile (chapterURIPath chId) $ T.pack $ show u

      getChapterURI' chId = tryRead `catch` \_ -> return Nothing
          where
            tryRead =
                parseRelativeReference . T.unpack <$>
                T.readFile (chapterURIPath chId)

      readChapterFile chId = (Just `fmap` tryRead) `catch` \_ -> return Nothing
          where
            tryRead = do
              content <- T.readFile $ chapterPath chId
              return $ mapMaybe mkCommentId $ T.lines content

      writeChapterFile chId =
          T.writeFile (chapterPath chId) . T.unlines . map commentId

      sessionsDir = storeDir </> "sessions"

      sessionPath sid = sessionsDir </> safeBS (sidBS sid)

      writeSession sid n e d = do
        exists <- readSession sid
        let overwrite = case exists of
                          Nothing -> True
                          Just (_, _, d') -> d > d'
        when overwrite $
             withBinaryFile (sessionPath sid) WriteMode $ \h ->
                 B.hPut h $ encode ( E.encodeUtf8 n
                                   , fmap E.encodeUtf8 e
                                   , realToFrac d :: Double
                                   )

      readSession :: SessionId -> IO (Maybe (T.Text, Maybe T.Text, POSIXTime))
      readSession sid = (Just `fmap` readOK) `catch` \_ -> return Nothing
          where
            readOK =
                withBinaryFile (sessionPath sid) ReadMode $ \h -> do
                  (nBS, eBS, dD) <- decode <$> B.hGetContents h
                  return ( E.decodeUtf8 nBS
                         , fmap E.decodeUtf8 eBS
                         , realToFrac (dD :: Double)
                         )