dictionaries-0.2.0: src/NLP/Dictionary/StarDict/Common.hs
{-|
Module: NLP.Dictionary.StarDict.Common
Copyright: (c) 2016 Al Zohali
License: BSD3
Maintainer: Al Zohali <zohl@fmap.me>
Stability: experimental
= Description
Common functions and types for StarDict dictionaries.
-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE DeriveGeneric #-}
module NLP.Dictionary.StarDict.Common (
StarDictException (..)
, checkFiles
, checkGZFiles
, IfoFile(..)
, IfoFilePath
, ifoDateFormat
, readIfoFile
, renderIfoFile
, IndexEntry
, readIndexFile
, renderIndexFile
, getIndexNumber
, putIndexNumber
, checkDataFile
, DataEntry (..)
, Renderer
, mkDataParser
, StarDict (..)
) where
import Prelude hiding (takeWhile)
import Control.Applicative (liftA2, many)
import Control.Arrow ((***))
import Control.Monad (unless)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Catch (Exception, MonadThrow, throwM)
import Data.Attoparsec.ByteString.Lazy (Result(..), Parser, parse, string, takeWhile, inClass)
import Data.Attoparsec.ByteString.Char8 (isEndOfLine, endOfLine, skipSpace, char)
import Data.Binary.Get (Get, runGetOrFail, isEmpty)
import Data.Binary.Get (getRemainingLazyByteString, getLazyByteStringNul, getLazyByteString)
import Data.Binary.Get (getWord32be, getWord64be)
import Data.Binary.Builder (Builder, fromLazyByteString, empty, toLazyByteString)
import Data.Binary.Builder (putWord64be, putWord32be, singleton)
import Data.ByteString.Lazy (ByteString)
import Data.List (intercalate)
import Data.Maybe (catMaybes)
import Data.Monoid ((<>))
import Data.Tagged (Tagged)
import Data.Typeable (Typeable)
import Data.Time (parseTimeM, defaultTimeLocale, formatTime)
import Data.Time.Clock (UTCTime)
import Data.Text.Lazy (Text)
import Data.Text.Lazy.Encoding (decodeUtf8, encodeUtf8, decodeLatin1)
import NLP.Dictionary (Dictionary)
import System.Directory (doesFileExist, getTemporaryDirectory)
import System.FilePath.Posix (joinPath, takeBaseName, (-<.>), (<.>))
import qualified Codec.Compression.GZip as GZip
import qualified Data.ByteString.Lazy as BS
import qualified Data.ByteString.Lazy.Char8 as BSC8
import qualified Data.Map.Strict as Map
import qualified Data.Text.Lazy as T
import Control.DeepSeq (NFData(..))
import GHC.Generics (Generic)
-- | Exceptions that are thrown when something with this module went wrong.
data StarDictException
= WrongIfoFormat FilePath String
-- ^ Thrown when information file (.ifo) has unsupported format.
| IndexNotFound FilePath
-- ^ Thrown when index file (.idx, .idx.gz) is not found.
| WrongIndexFormat FilePath String
-- ^ Thrown when index file has unsupported format.
| DictionaryNotFound FilePath
-- ^ Thrown when dictionary file (.dict, .dict.dz) has unsupported format.
deriving (Eq, Show, Typeable)
instance Exception StarDictException
-- | Type synonym to distinguish usage of paths.
type IfoFilePath = FilePath
-- | Representation of .ifo file.
data IfoFile = IfoFile {
ifoMagicData :: ByteString -- ^ Corresponds to the first string in the file.
, ifoVersion :: String -- ^ Corresponds to version field.
, ifoBookName :: Text -- ^ Corresponds to bookname field.
, ifoWordCount :: Int -- ^ Corresponds to wordcount field.
, ifoIdxFileSize :: Int -- ^ Corresponds to idxfilesize field.
, ifoIdxOffsetBits :: Maybe Int -- ^ Corresponds to idxoffsetbits field.
, ifoSynWordCount :: Maybe Int -- ^ Corresponds to synwordcount field.
, ifoAuthor :: Maybe Text -- ^ Corresponds to author field.
, ifoEmail :: Maybe Text -- ^ Corresponds to email field.
, ifoWebsite :: Maybe Text -- ^ Corresponds to website field.
, ifoDescription :: Maybe Text -- ^ Corresponds to description field.
, ifoDate :: Maybe UTCTime -- ^ Corresponds to date field.
, ifoSameTypeSequence :: Maybe String -- ^ Corresponds to sametypesequence field.
, ifoDictType :: Maybe String
} deriving (Eq, Show, Generic)
instance NFData IfoFile
-- | Date format of 'ifoDate' in IfoFile.
ifoDateFormat :: String
ifoDateFormat = "%0Y.%m.%d"
-- | Read .ifo file at the given path.
readIfoFile :: (MonadThrow m, MonadIO m) => FilePath -> m IfoFile
readIfoFile ifoPath = (liftIO . BS.readFile $ ifoPath) >>= parseContents where
parseContents contents = case (parse ifoFile contents) of
(Fail _ _ msg) -> throwM $ WrongIfoFormat ifoPath msg
(Done _ r) -> return r
expect :: (Eq a, Show a) => String -> a -> [a] -> Parser ()
expect name x xs = unless (x `elem` xs) . fail . concat $ [
name, " must be ", fmts xs, " (", show x, " provided)"
] where
fmt y = '\'':(show y) ++ "'"
fmts = \case
[] -> ""
(y:[]) -> fmt y
ys -> (intercalate ", " . map fmt . init $ ys) ++ " or " ++ (fmt . last $ ys)
justExpect :: (Eq a, Show a) => String -> Maybe a -> [a] -> Parser ()
justExpect name mx xs = maybe (return ()) (\x -> expect name x xs) mx
ifoFile :: Parser IfoFile
ifoFile = do
ifoMagicData <- magicData
expect "magic data" ifoMagicData ["StarDict's dict ifo file"]
(_, ifoVersion) <- (id *** BSC8.unpack) <$> (endOfLine *> pair (Just "version"))
expect "version" ifoVersion ["2.4.2", "3.0.0"]
ifoData <- Map.fromList <$> (endOfLine *> (many (pair Nothing) <* endOfLine))
let get = flip Map.lookup ifoData
let require field = ( $ (get field)) $ maybe
(fail $ "required field " ++ BSC8.unpack field ++ " not found") (return)
ifoBookName <- decodeUtf8 <$> require "bookname"
ifoWordCount <- read . BSC8.unpack <$> require "wordcount"
ifoIdxFileSize <- read . BSC8.unpack <$> require "idxfilesize"
let ifoIdxOffsetBits = read . BSC8.unpack <$> get "idxoffsetbits"
justExpect "idxoffsetbits" ifoIdxOffsetBits [32, 64]
let ifoSynWordCount = read . BSC8.unpack <$> get "synwordcount"
let ifoAuthor = decodeUtf8 <$> get "author"
let ifoEmail = decodeUtf8 <$> get "email"
let ifoWebsite = decodeUtf8 <$> get "website"
let ifoDescription = decodeUtf8 <$> get "description"
let ifoDate = get "date" >>= parseTimeM False defaultTimeLocale ifoDateFormat . BSC8.unpack
let ifoSameTypeSequence = BSC8.unpack <$> get "sametypesequence"
let ifoDictType = BSC8.unpack <$> get "dicttype"
justExpect "dicttype" ifoDictType ["wordnet"]
return IfoFile {..}
magicData :: Parser ByteString
magicData = BS.fromStrict <$> takeWhile (not . isEndOfLine)
pair :: Maybe ByteString -> Parser (ByteString, ByteString)
pair = pair' . maybe
(takeWhile $ inClass "A-Za-z0-9-_")
(string . BS.toStrict) where
pair' key = do
k <- BS.fromStrict <$> (skipSpace *> key)
_ <- skipSpace *> char '='
v <- BS.fromStrict <$> (skipSpace *> takeWhile (not . isEndOfLine))
return (k, v)
-- | Generates .ifo file contents based on 'IfoFile'
renderIfoFile :: IfoFile -> Text
renderIfoFile IfoFile {..} = T.intercalate "\n" $ [
decodeUtf8 ifoMagicData
, "version=" <> (T.pack ifoVersion)
, "bookname=" <> ifoBookName
, "wordcount=" <> (T.pack . show $ ifoWordCount)
, "idxfilesize=" <> (T.pack . show $ ifoIdxFileSize)
] ++ catMaybes [
(("idxoffsetbits=" <>) . T.pack . show) <$> ifoIdxOffsetBits
, (("synwordcount=" <>) . T.pack . show) <$> ifoSynWordCount
, ("author=" <>) <$> ifoAuthor
, ("email=" <>) <$> ifoEmail
, ("website=" <>) <$> ifoWebsite
, ("description=" <>) <$> ifoDescription
, (("date=" <>) . T.pack
. formatTime defaultTimeLocale ifoDateFormat) <$> ifoDate
, (("sametypesequence=" <>) . T.pack) <$> ifoSameTypeSequence
, (("dicttype=" <>) . T.pack) <$> ifoDictType
]
-- | Get 32-bit or 64-bit integer depending on description in the .ifo file.
getIndexNumber :: Maybe Int -> Get Int
getIndexNumber = \case
(Just 64) -> fromIntegral <$> getWord64be
_ -> fromIntegral <$> getWord32be
-- | Put 32-bit or 64-bit integer depending on description in the .ifo file.
putIndexNumber :: Maybe Int -> (Int -> Builder)
putIndexNumber = \case
(Just 64) -> putWord64be . fromIntegral
_ -> putWord32be . fromIntegral
-- | Representation of an .idx file entry.
type IndexEntry = (Text, (Int, Int))
-- | Given .ifo file name and list of extensions, returns first existing file with the same basename.
checkFiles :: IfoFilePath -> [FilePath] -> IO (Maybe FilePath)
checkFiles _ [] = return Nothing
checkFiles ifoPath (ext:exts) = let fn = ifoPath -<.> ext
in (doesFileExist fn) >>= \case
True -> return . Just $ fn
False -> checkFiles ifoPath exts
-- | Given .ifo file name and two lists of extensions, returns first
-- existing file with with the same basename and extension from the first
-- list or (if such file doesn't exists) from the second list.
checkGZFiles
:: IfoFilePath
-> [FilePath]
-> [FilePath]
-> IO (Maybe (Either FilePath FilePath))
checkGZFiles ifoPath exts exts' = (checkFiles ifoPath exts) >>= maybe
(fmap (Right <$>) (checkFiles ifoPath exts'))
(return . Just . Left)
-- | Read .idx (.idx.gz) file.
readIndexFile :: (MonadThrow m, MonadIO m) => IfoFilePath -> Get Int -> m [IndexEntry]
readIndexFile fn num = checkIndexFile fn >>= getIndexContents >>= mkIndex where
checkIndexFile :: (MonadThrow m, MonadIO m) => IfoFilePath -> m (Either FilePath FilePath)
checkIndexFile ifoPath = (liftIO $ checkGZFiles ifoPath ["idx"] ["idx.gz"]) >>= \case
Nothing -> throwM $ IndexNotFound ifoPath
Just path -> return path
getIndexContents :: (MonadIO m)
=> Either FilePath FilePath -> m (FilePath, ByteString)
getIndexContents path = liftIO . fmap (fn',) . postprocess . BS.readFile $ fn' where
postprocess = either (const id) (const $ fmap GZip.decompress) path
fn' = either id id path
mkIndex :: (MonadThrow m) => (FilePath, ByteString) -> m [IndexEntry]
mkIndex (fn', contents) = either
(\(_, _, err) -> throwM $ WrongIndexFormat fn' err)
(\(_, _, res) -> return $ res)
(runGetOrFail getIndexEntries contents)
getIndexEntries :: Get [IndexEntry]
getIndexEntries = isEmpty >>= \case
True -> return []
False -> liftA2 (:) getIndexEntry getIndexEntries
getIndexEntry :: Get IndexEntry
getIndexEntry = (,) <$> (decodeUtf8 <$> getLazyByteStringNul)
<*> ((,) <$> num <*> num)
-- | Generates .idx file contents based on 'Index'.
renderIndexFile :: [IndexEntry] -> (Int -> Builder) -> ByteString
renderIndexFile entries putNum = toLazyByteString $ buildIndex entries where
buildIndex [] = empty
buildIndex (e:es) = putEntry e <> buildIndex es
putEntry (entry, (offset, size)) = foldr1 (<>) [
(fromLazyByteString . encodeUtf8 $ entry)
, singleton 0
, putNum offset
, putNum size
]
-- | Returns path of decompressed dictionary.
checkDataFile :: (MonadThrow m, MonadIO m) => IfoFilePath -> m FilePath
checkDataFile ifoPath = (liftIO $ checkGZFiles ifoPath ["dict"] ["dict.dz"]) >>= \case
Nothing -> throwM $ DictionaryNotFound ifoPath
Just (Left fn) -> return fn
Just (Right fn) -> liftIO $ do
fn' <- (joinPath . (:[(takeBaseName ifoPath) <.> "dict"])) <$> getTemporaryDirectory
GZip.decompress <$> (BS.readFile fn) >>= BS.writeFile fn'
return fn'
-- | Possible dictionary entry formats.
data DataEntry
= UTF8Text Text
| LocaleText Text
| Pango Text
| Phonetics Text
| XDXF Text
| CJK Text
| PowerWord Text
| MediaWiki Text
| HTML Text
| Resource [FilePath]
| WAVEAudio ByteString
| Picture ByteString
| Reserved ByteString
deriving (Eq, Show)
-- | Parser for a list of elements.
getMany :: Get a -> Get [a]
getMany p = isEmpty >>= \case
True -> return []
False -> liftA2 (:) p (getMany p)
-- | Returns parser based on description in .ifo file.
mkDataParser :: Maybe String -> Get [DataEntry]
mkDataParser = maybe (getMany getGenericEntry) getSpecificEntries where
getGenericEntry :: Get DataEntry
getGenericEntry = BSC8.head <$> getLazyByteString 1
>>= getSpecificEntry getLazyByteStringNul
getSpecificEntries :: [Char] -> Get [DataEntry]
getSpecificEntries cs = sequence $ zipWith getSpecificEntry ps cs where
ps :: [Get ByteString]
ps = reverse . take (length cs) $ getRemainingLazyByteString:(repeat getLazyByteStringNul)
getSpecificEntry :: Get ByteString -> Char -> Get DataEntry
getSpecificEntry getData = \case
'm' -> UTF8Text . decodeUtf8 <$> getData
'l' -> LocaleText . decodeLatin1 <$> getData
'g' -> Pango . decodeUtf8 <$> getData
't' -> Phonetics . decodeUtf8 <$> getData
'x' -> XDXF . decodeUtf8 <$> getData
'y' -> CJK . decodeUtf8 <$> getData
'k' -> PowerWord . decodeUtf8 <$> getData
'w' -> MediaWiki . decodeUtf8 <$> getData
'h' -> HTML . decodeUtf8 <$> getData
'n' -> Resource . lines . T.unpack . decodeUtf8 <$> getData
'r' -> Resource . lines . T.unpack . decodeUtf8 <$> getData
'W' -> WAVEAudio <$> getData
'P' -> Picture <$> getData
'X' -> Reserved <$> getData
_ -> error "type not supported"
-- | Type of function to transform dictionary entries to a text.
type Renderer = DataEntry -> Text
-- | Classtype for stardict dictionaries.
class (Dictionary d) => StarDict d where
getIfoFile :: d -> IfoFile
mkDictionary :: (MonadThrow m, MonadIO m) => Tagged d IfoFilePath -> Renderer -> m d