5

I want to convert fields in Tags to tags within that tag for example

<book name="Data Structure" price="250" pages="350"/>

to

<book name="Data Structure"> 
<price>250</price>
<pages>350</pages>
</book>

I want to perform this operation in Linux command line using xmlstarlet or sed .

1
  • This sounds like a good use case for using XSLT in conjunction with the xmlstarlet command, but that would mean you're probably better off asking this as an xml-related question on StackOverflow assuming if you need help writing the XSLT file itself...
    – h.j.k.
    Aug 13, 2015 at 9:59

4 Answers 4

4

process.xsl:

<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="xml" indent="yes"/>

  <xsl:template match="//book">
    <xsl:element name="book">
      <xsl:apply-templates select="./@*"/>
    </xsl:element>
  </xsl:template>

  <xsl:template match="book/@*">
      <xsl:if test="name() = 'name'">
    <xsl:attribute name="{name()}">
      <xsl:value-of select="."/>
    </xsl:attribute>
      </xsl:if>
      <xsl:if test="name() != 'name'">
    <xsl:element name="{name()}">
      <xsl:value-of select="."/>
    </xsl:element>
      </xsl:if>
  </xsl:template>
</xsl:stylesheet>

input.xml:

<book name="Data Structure" price="250" pages="350"/>

Command:

xsltproc process.xsl input.xml

Output:

<?xml version="1.0"?>
<book name="Data Structure">
  <price>250</price>
  <pages>350</pages>
</book>
2

I'm aware that the question says "using xmlstarlet or sed", but either of those tools would require an awkward amount of typing (and using sed to modify any structured document format is not advised). There are other XML-capable tools out there that can do the job a bit less verbosely.

Assuming an XML document like

<root>
<book name="Data Structure 1" price="250" pages="350"/>
<book name="Data Structure 2" price="350" pages="250"/>
<book name="Data Structure 3" price="450" pages="150"/>
</root>

Then you may use xq (part of yq, a YAML parser wrapper around jq from https://kislyuk.github.io/yq/) to do what it is you want to do, using a jq expression.

The xq tool parses the example XML document into an equivalent JSON document:

{
  "root": {
    "book": [
      {
        "@name": "Data Structure 1",
        "@price": "250",
        "@pages": "350"
      },
      {
        "@name": "Data Structure 2",
        "@price": "350",
        "@pages": "250"
      },
      {
        "@name": "Data Structure 3",
        "@price": "450",
        "@pages": "150"
      }
    ]
  }
}

Applying the following expression iterates over the .root.book[] array, modifying the keys of each JSON element by removing the initial @ from the keys that ares not @name. A key with an initial @ in its name corresponds to an attribute in the XML, so removing the @ would turn the key into an XML node rather than an attribute of a node.

xq -x '.root.book[] |= (with_entries(select(.key != "@name").key |= ltrimstr("@")))' file.xml

With my own example file above, this produces

<root>
  <book name="Data Structure 1">
    <price>250</price>
    <pages>350</pages>
  </book>
  <book name="Data Structure 2">
    <price>350</price>
    <pages>250</pages>
  </book>
  <book name="Data Structure 3">
    <price>450</price>
    <pages>150</pages>
  </book>
</root>

If your XML document is literally the single node

<book name="Data Structure" price="250" pages="350"/>

then use

xq -x '.book |= (with_entries(select(.key != "@name").key|=ltrimstr("@")))' file.xml

This is the same expression as above, but applied to just the top-level .book section rather than to the elements of a .root.book[] array.

1

Please - don't use sed - it's not a suitable tool for the job.

I'd use perl myself:

#!/usr/bin/env perl
use strict;
use warnings;

use XML::Twig;

my $twig = XML::Twig->new( 'pretty_print' => 'indented_a' );
$twig->parsefile ( 'your_file.xml' );

foreach my $thing ( $twig -> root -> children ) {

    my $newthing = $twig -> root -> insert_new_elt($thing->tag);
    foreach my $key ( keys %{$thing -> atts()} ) {
        $newthing -> insert_new_elt($key, $thing -> att($key));
    }
    $thing -> delete;
}

$twig->print;

Outputs:

<root>
  <book>
    <pages>350</pages>
    <name>Data Structure</name>
    <price>250</price>
  </book>
</root>

That's pretty simple, because we're working with an (anonymous) hash att(). To pick out one attribute we have to do a bit more - we need to define that we want to keep name and insert that as an attribute of our parent element.

This uses map which can be a bit of a headache:

#!/usr/bin/env perl
use strict;
use warnings;
use Data::Dumper;

use XML::Twig;

my %keep_att = ( name => 1 );
my $twig = XML::Twig->new( 'pretty_print' => 'indented_a' );
$twig->parse( \*DATA );

foreach my $thing ( $twig->root->children ) {
    my $newthing = $twig->root->insert_new_elt( $thing->tag,
        { map { $_ => $thing->att($_) } keys %keep_att } );

    foreach my $key ( keys %{ $thing->atts() } ) {
        next if $keep_att{$key};
        $newthing->insert_new_elt( $key, $thing->att($key) );
    }
    $thing->delete;
}

$twig->print;

__DATA__
<root>
<book name="Data Structure" price="250" pages="350"/>
</root>

This produces:

<root>
  <book name="Data Structure">
    <price>250</price>
    <pages>350</pages>
  </book>
</root>

Now, what's going on with that map is we're basically splitting out the attributes we want to keep - and re-inserting them into our new element - and the elements we don't want to keep, and turning them into children.

A bit like this:

foreach my $thing ( $twig->root->children ) {

    my %attributes = %{$thing->atts()};
    my %new_children; 
    foreach my $attr ( keys %attributes ) {
       if ( $keep_att{$attr} ) { 
           #leave it in %attributes; 
       }
       else {
           $new_children{$attr} = $attributes{$attr}; 
           delete $attributes{$attr}
       }
    }
    print Dumper \%attributes;
    print Dumper \%new_children;

    my $newthing = $twig->root->insert_new_elt( $thing->tag,
        { %attributes } );

    foreach my $key ( keys %new_children ) {
        $newthing->insert_new_elt( $key, $new_children{$key} );
    }
    $thing->delete;
}
1

I want to perform this operation in Linux command line using xmlstarlet or sed

Using xmlstarlet 1.6.1 and your input XML file the following command will produce the output you're after:

xmlstarlet edit --omit-decl --var T 'book' \
 -s '$T' -t elem -n 'price'  -u '$prev' -x 'string(../@price)'  -d '$T/@price' \
 -s '$T' -t elem -n 'pages'  -u '$prev' -x 'string(../@pages)'  -d '$T/@pages' \
file.xml

where

  • the T variable contains the nodeset of book elements to convert; with a single element in the input file book (or *) will do, if input has books enclosed in a root element, instead */book; //book selects all, (//book)[1] the first
  • -s / --subnode creates an element (-t elem) subnode named (-n) as an attribute for each element in $T
  • -u / --update inserts values in each newly created element ($prev) using a relative XPath expression (-x)
  • -d / --delete deletes the attribute in each element in $T after conversion

xmlstarlet edit code can use the convenience $prev (aka $xstar:prev) node to refer to the node created by the most recent -i / --insert, -a / --append, or -s / --subnode option. Examples of $prev are given in doc/xmlstarlet.txt and the source code's examples/ed-backref*.


To list attribute names (other than name) for all books eliminating duplicates, e.g. for a script generator, you could say,

xmlstarlet select -t \
  -m '//book/@*[name() != "name"]' -v 'name()' -n \
file.xml | 
awk '!seen[$1]++'

or, using no tools but an xmlstarlet supporting the EXSLT dyn:map function:

xmlstarlet select -t \
  --var T='//book/@*[name() != "name"]' \
  -m 'set:distinct(dyn:map($T,"name()"))' -v . -n \
file.xml

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .