{-# LANGUAGE OverloadedStrings #-}
module Network.URI.XDG.AppStream(
Component, loadDatabase, xmlForID, buildMIMEIndex,
App(..), Icon(..), IconCache, scanIconCache, appsForMIME
) where
import qualified Data.Map as M
import qualified Text.XML as XML
import Codec.Compression.GZip (decompress)
import qualified Data.ByteString.Lazy as LBS
import System.Directory
import System.FilePath ((</>), takeBaseName)
import Control.Exception (catch)
import Control.Monad (forM)
import Data.List (isSuffixOf, sortOn, elemIndex)
import Data.Maybe (catMaybes, mapMaybe, fromMaybe)
import System.Process (callProcess)
import Data.Text (Text)
import qualified Data.Text as Txt
import Text.Read (readMaybe)
import Data.Char (isDigit)
----
-- Load in the XML files
----
type Component = M.Map Text [XML.Element]
cachedir = ".cache/nz.geek.adrian.hurl/appstream/"
loadDatabase :: [String] -> IO (M.Map Text Component)
loadDatabase locales = do
-- Handle YAML files for Debian-derivatives
sharePaths' <- yaml2xml "/usr/share/app-info/yaml/" "share" `catch` handleListError
cachePaths' <- yaml2xml "/var/cache/app-info/yaml/" "cache" `catch` handleListError
-- Read in the XML files.
sharePaths <- listDirectory "/usr/share/app-info/xml/" `catch` handleListError
cachePaths <- listDirectory "/var/cache/app-info/xml/" `catch` handleListError
xmls <- forM (sharePaths ++ sharePaths' ++ cachePaths ++ cachePaths') $ \path -> do
text <- LBS.readFile path
let decompressor = if ".gz" `isSuffixOf` path then decompress else id
return $ rightToMaybe $ XML.parseLBS XML.def $ decompressor text
-- Index components by ID and their subelements by name
let components = concat $ map getComponents $ catMaybes xmls
let componentsByID = list2map [(getText "id" comp, comp) | comp <- components]
let mergeComponents' = filterMergeAttrs . localizeComponent locales . mergeComponents
let componentByID = M.filter M.null $ M.map mergeComponents' componentsByID
return componentByID
yaml2xml :: FilePath -> String -> IO [FilePath]
yaml2xml source destSubDir = do
home <- getHomeDirectory
let destDir = home </> cachedir </> destSubDir ++ ".xml.gz"
paths <- listDirectory source
forM paths $ \path -> do
let dest = destDir </> takeBaseName path
destExists <- doesPathExist dest
srcTime <- getModificationTime path
destTime <- if destExists then getModificationTime path else return srcTime
if srcTime >= destTime
then callProcess "appstreamcli" ["convert", "--format=xml", path, dest]
else return ()
listDirectory destDir
getComponents :: XML.Document -> [Component]
getComponents XML.Document {
XML.documentRoot = XML.Element {
XML.elementNodes = nodes
}
} = mapMaybe getComponent nodes
getComponent :: XML.Node -> Maybe Component
getComponent (XML.NodeElement XML.Element {
XML.elementName = XML.Name "component" _ _,
XML.elementAttributes = attrs,
XML.elementNodes = nodes
}) = Just $ list2map (
[(key, txt2el name val) | (name@(XML.Name key _ _), val) <- M.toList attrs] ++
[(key, node) | XML.NodeElement node@(XML.Element (XML.Name key _ _) _ _) <- nodes]
)
where txt2el name txt = XML.Element name M.empty [XML.NodeContent txt]
getComponent _ = Nothing
mergeComponents :: [Component] -> Component
mergeComponents comps = mergeComponents' $ reverse $ sortOn (getInt "priority") comps
mergeComponents' [] = M.empty
mergeComponents' (comp:comps) = let base = mergeComponents' comps in
case getText "merge" comp of
"append" -> M.unionWith (++) comp base
"replace" -> M.union comp base
"remove-component" -> M.empty
"" -> comp
localizeComponent :: [String] -> Component -> Component
localizeComponent locales comp = let locales' = map Txt.pack locales in
let locale = bestXMLLocale locales' $ comp2xml comp in
M.filter null $ M.map (mapMaybe $ filterElByLocale locale) comp
filterMergeAttrs :: Component -> Component
filterMergeAttrs comp = "priority" `M.delete` M.delete "merge" comp
----
-- Lookup by ID
----
xmlForID :: M.Map Text Component -> Text -> Maybe XML.Element
xmlForID comps id = comp2xml <$> M.lookup id comps
elementOrder :: [Text]
elementOrder = [
"id", "pkgname", "source_pkgname", "name",
"project_license", "summary", "description",
"url", "project_group", "icon",
"mimetypes", "categories", "keywords",
"screenshots",
"compulsory_for_desktop", "provides",
"developer_name", "launchable", "releases",
"languages", "bundle", "suggests",
"content_rating", "agreement"
]
comp2xml :: Component -> XML.Element
comp2xml comp = XML.Element "component" M.empty $ map XML.NodeElement $ comp2els comp
comp2els :: Component -> [XML.Element]
comp2els comp = concat (
map (\k -> M.findWithDefault [] k comp) elementOrder ++
(map snd $ M.toList $ M.filterWithKey (\k v -> k `notElem` elementOrder) comp)
)
----
-- Lookup by MIME
----
buildMIMEIndex :: M.Map Text Component -> M.Map Text [Component]
buildMIMEIndex comps = list2map [(mime, comp) | (_, comp) <- M.toList comps, mime <- getMIMEs comp]
getMIMEs :: Component -> [Text]
getMIMEs comp = let nodes = concat $ map (XML.elementNodes) $ getEls "mimetypes" comp
in filter Txt.null $ map node2txt nodes
--
data App = App {
ident :: Text,
name :: Text,
summary :: Text,
icons :: [Icon]
}
data Icon = Icon {
source :: Text,
width :: Maybe Int,
height :: Maybe Int,
url :: Text
}
appsForMIME :: IconCache -> M.Map Text [Component] -> Text -> [App]
appsForMIME iconcache comps mime = mapMaybe (comp2app iconcache) $ M.findWithDefault [] mime comps
comp2app :: IconCache -> Component -> Maybe App
comp2app iconcache comp
| getText "type" comp == "desktop-application" = Just $ App {
ident = getText "id" comp,
name = getText "name" comp,
summary = getText "summary" comp,
icons = sortOn rankIcon $ concat $ map (el2icon iconcache) $ getEls "icon" comp
}
| otherwise = Nothing
where rankIcon icon = source icon `elemIndex` ["stock", "cached", "local", "remote"]
el2icon :: IconCache -> XML.Element -> [Icon]
el2icon iconcache el@(XML.Element _ attrs _)
| Just "cached" <- "type" `M.lookup` attrs =
[Icon "cached" size size $ Txt.append "file://" $ Txt.pack path
| (size, path) <- lookupCachedIcons iconcache $ el2txt el]
el2icon _ el@(XML.Element _ attrs _) = [Icon {
source = M.findWithDefault "" "type" attrs,
width = parseIntAttr "width",
height = parseIntAttr "height",
url = iconURL el
}]
where parseIntAttr attr = M.lookup attr attrs >>= readMaybe . Txt.unpack
iconURL el@(XML.Element _ attrs _) = case "type" `M.lookup` attrs of
Just "stock" -> "icon:" `Txt.append` val -- URI scheme NOT implemented
Just "cached" -> "file:///{usr/share,var/cache}/app-info/icons/*/*/" `Txt.append` val -- FIXME, resolve & provide multiple options.
Just "local" -> "file://" `Txt.append` val
Just "remote" -> val
_ -> "about:blank"
where val = el2txt el
-- AppStream icon cache
type IconCache = [FilePath]
scanIconCache :: IO IconCache
scanIconCache = do
sharePaths <- listDirectory "/usr/share/app-info/icons/" `catch` handleListError
varPaths <- listDirectory "/usr/share/app-info/icons/"
paths <- forM (sharePaths ++ varPaths) (\x -> listDirectory x `catch` handleListError)
return (concat paths ++ sharePaths ++ varPaths)
lookupCachedIcons :: IconCache -> Text -> [(Maybe Int, FilePath)]
lookupCachedIcons iconcache icon = [(size $ takeBaseName dir, dir </> Txt.unpack icon) | dir <- iconcache]
where size dirname = readMaybe $ takeWhile isDigit dirname
----
-- Supporting utilities
----
handleListError :: IOError -> IO [a]
handleListError _ = return []
-- It's not worth importing Data.Either.Combinators for this.
rightToMaybe :: Either l r -> Maybe r
rightToMaybe (Left _) = Nothing
rightToMaybe (Right x) = Just x
list2map :: Ord a => [(a, b)] -> M.Map a [b]
list2map = foldr insertEntry M.empty
where insertEntry (key, value) = M.insertWith (++) key [value]
-- XML Utils
el2txt :: XML.Element -> Text
el2txt el = Txt.concat $ map node2txt $ XML.elementNodes el
node2txt :: XML.Node -> Text
node2txt (XML.NodeElement el) = el2txt el
node2txt (XML.NodeContent txt) = txt
node2txt _ = ""
getEls :: Text -> Component -> [XML.Element]
getEls key comp = M.findWithDefault [emptyEl] key comp
getEl :: Text -> Component -> XML.Element
getEl key comp | ret:_ <- getEls key comp = ret
| otherwise = emptyEl
getText :: Text -> Component -> Text
getText key comp = el2txt $ getEl key comp
getInt :: Text -> Component -> Integer
getInt key comp = fromMaybe 0 $ readMaybe $ Txt.unpack $ getText key comp
emptyEl :: XML.Element
emptyEl = XML.Element "placeholder" M.empty []
bestXMLLocale :: [Text] -> XML.Element -> Text
bestXMLLocale locales (XML.Element _ attrs nodes)
| Just locale <- "xml:lang" `M.lookup` attrs = locale
| locale:_ <- sortOn rankLocale [bestXMLLocale locales el
| XML.NodeElement el <- nodes] = locale
| otherwise = ""
where rankLocale locale = locale `elemIndex` locales
filterElByLocale :: Text -> XML.Element -> Maybe XML.Element
filterElByLocale locale el@(XML.Element _ attrs nodes)
| Just locale' <- "xml:lang" `M.lookup` attrs, locale' /= locale = Nothing
| otherwise = Just $ el {XML.elementNodes = filterNodesByLocale locale nodes}
filterNodesByLocale :: Text -> [XML.Node] -> [XML.Node]
filterNodesByLocale locale (XML.NodeElement el:nodes)
| Just el' <- filterElByLocale locale el = XML.NodeElement el' : filterNodesByLocale locale nodes
| otherwise = filterNodesByLocale locale nodes
filterNodesByLocale locale (node:nodes) = node : filterNodesByLocale locale nodes
filterNodesByLocale _ [] = []