xml-to-json-0.1.1.0: src/xml-to-json.hs
module Main(main) where
import Control.Category (id)
import Control.Applicative ((<*), (*>))
import Control.Arrow (first, (>>>), (&&&), (***))
import Control.Monad (when, forM_)
import Data.Maybe (catMaybes)
import Data.Tree.NTree.TypeDefs
import Prelude hiding (id)
import System.Console.GetOpt (OptDescr(..), ArgDescr(NoArg, ReqArg), ArgOrder(Permute), usageInfo, getOpt)
import System.Environment (getArgs)
import System.Exit (ExitCode(ExitFailure), exitWith)
import System.IO (hPutStrLn, stderr)
import Text.XML.HXT.Core (readDocument, getChildren, getText, isElem, XmlTree, XNode(..), deep, getName, localPart, hasName, ArrowXml, runLA, getAttrl, runX, withValidate, no)
import Text.XML.HXT.Curl -- use libcurl for HTTP access, only necessary when reading http://...
import Text.XML.HXT.Expat (withExpat)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy.Char8 as BS
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Map as M
import qualified Data.Text as T
import qualified Data.Vector as Vector
data Flag = Input String | StartFrom String | Multiline | SkipRoots | NoIgnoreNulls | WrapArray | NoCollapseText | ShowHelp
deriving (Show, Eq)
options :: [OptDescr Flag]
options =
[ Option "h" ["help"] (NoArg ShowHelp) "Show this help"
, Option "t" ["tag-name"] (ReqArg StartFrom "TAG") "Start conversion with nodes named TAG (ignoring all parent nodes)"
, Option "s" ["skip-roots"] (NoArg SkipRoots) ("Ignore the selected nodes, and start converting from their children\n"
++ "(can be combined with the 'start-tag' option to process only children of the matching nodes)")
, Option "a" ["as-array"] (NoArg WrapArray) "Output the resulting objects in a top-level JSON array"
, Option "m" ["multiline"] (NoArg Multiline) ("When using 'as-array' output, print each of top-level json object on a seperate line.\n"
++ "(If not using 'as-array', this option will be on regardless, and output is always line-seperated.)")
, Option "" ["no-collapse-text"] (NoArg NoCollapseText) ("Don't collapse elements that only contain text into a simple string property.\n"
++ "Instead, always emit '.value' properties for text nodes, even if an element contains only text.\n"
++ "(Output 'schema' will be more stable.)")
, Option "" ["no-ignore-nulls"] (NoArg NoIgnoreNulls) "Don't ignore nulls (and do output them) in the top level of output objects"
]
parseOptions :: [String] -> IO ([Flag], [String])
parseOptions argv =
case getOpt Permute options argv of
(o,n,[] ) -> return (o,n)
(_,_,errs) -> ioError (userError (concat errs ++ usageInfo usageHeader options))
usageHeader :: String
usageHeader = "Usage: <program> [OPTION...] files..."
getStartNodes :: ArrowXml cat => [Flag] -> cat (NTree XNode) XmlTree
getStartNodes flags =
case [x | StartFrom x <- flags] of
[] -> getChildren >>> isElem
[x] -> deep (isElem >>> hasName x)
_ -> error "Expecting at most one --tag-name (-t) option"
main :: IO ()
main = do
args <- getArgs
(flags, inputFiles) <- parseOptions args
when (elem ShowHelp flags || (null flags && null inputFiles)) .
die $ usageInfo usageHeader options
let
skipRoots = SkipRoots `elem` flags
wrapArray = WrapArray `elem` flags
collapseText = NoCollapseText `notElem` flags
wrapAction act
| wrapArray = putStr "[" *> act <* putStr "]"
| otherwise = act
multiline = case (wrapArray, Multiline `elem` flags) of
(False, _) -> BS.intercalate (BS.pack "\n")
(True, False) -> BS.intercalate (BS.pack ",")
(True, True) -> BS.intercalate (BS.pack ",\n")
ignoreNulls
| NoIgnoreNulls `notElem` flags =
filter (/= Aeson.Null)
| otherwise = id
nodesFilter
| skipRoots = getChildren
| otherwise = id
forM_ inputFiles $ \src -> do
rootElems <-
runX $
readDocument
[ withValidate no
, withExpat True
, withCurl []
]
src
>>> getStartNodes flags
>>> nodesFilter
-- TODO: de-uglify and optimize the following
wrapAction
. BS.putStr . multiline
. map Aeson.encode
. ignoreNulls
. map (wrapRoot . xmlTreeToJSON collapseText)
$ rootElems
data JSValueName = Text | Tag String | Attr String
deriving (Eq, Ord, Show)
concatMapValues :: (Ord k) => [M.Map k v] -> M.Map k [v]
concatMapValues = M.unionsWith (++) . (fmap . fmap) (: [])
getAttrVals :: XmlTree -> [(String, String)]
getAttrVals = runLA (getAttrl >>> getName &&& (getChildren >>> getText))
arrayValuesToJSONArrays :: (Ord k) => M.Map k [Aeson.Value] -> M.Map k Aeson.Value
arrayValuesToJSONArrays = M.mapMaybe f
where
f [] = Nothing -- will be discarded
f [x] = Just x -- don't store as array, just a single value
f xss = Just $ Aeson.Array . Vector.fromList $ xss -- arrays with more than one element are kept
packJSValueName :: JSValueName -> T.Text
packJSValueName Text = T.pack "value"
packJSValueName (Attr x) = T.pack x
packJSValueName (Tag x) = T.pack x
wrapRoot :: Maybe (JSValueName, Aeson.Value) -> Aeson.Value
wrapRoot Nothing = Aeson.Null
wrapRoot (Just (a, b)) = Aeson.object [(packJSValueName a, b)]
-- converts a map to a json value, usually resulting in a json object unless the map contains ONLY a single Text entry,
-- in which case the value produced is a json string
tagMapToJSValue :: Bool -> M.Map JSValueName Aeson.Value -> Aeson.Value
tagMapToJSValue collapseText m = case (collapseText, M.toList m) of
(True, [(Text, val)]) -> val
_ ->
Aeson.Object . HashMap.fromList . (map . first) packJSValueName $ M.toList m
xmlTreeToJSON :: Bool -> XmlTree -> Maybe (JSValueName, Aeson.Value)
xmlTreeToJSON collapseText node@(NTree (XTag qName _) children)
= Just (Tag (localPart qName),
tagMapToJSValue collapseText objMap)
where
objMap =
arrayValuesToJSONArrays -- unify into a single map,
. concatMapValues -- grouping into arrays by pair name
. map (uncurry M.singleton) -- convert pairs to maps
. (++) attrVals
. catMaybes -- filter out the empty values (unconvertable nodes)
$ map (xmlTreeToJSON collapseText) children -- convert xml nodes to Maybe (QName, Aeson.Value) pairs
attrVals =
map (Attr *** Aeson.String . T.pack) $ getAttrVals node
xmlTreeToJSON _ (NTree (XText str) _)
| T.null text = Nothing
| otherwise = Just (Text, Aeson.String text)
where
text = T.strip $ T.pack str
xmlTreeToJSON _ _ = Nothing
die :: String -> IO a
die msg = do
hPutStrLn stderr msg
exitWith (ExitFailure 1)