Functional XML editing using zippers in Clojure
Since the Clojure documentation for clojure.zip
is so bad I thought
I'd walk through an example of editing some XML using a zipper.
First off you'll need to add a few dependencies to your project.clj
:
(defproject zipper-demo "0.1.0"
:description "An example app to show how to edit XML in Clojure using Zippers"
:url "https://github.com/r4vi/zipper-demo/"
:license {:name "WTFPL – Do What the Fuck You Want to Public License"
:url "http://www.wtfpl.net"}
:dependencies [[org.clojure/clojure "1.5.1"]
[org.clojure/data.xml "0.0.7"]
[org.clojure/data.zip "0.1.1"]])
then open up src/core.clj
and require them.
(ns zipper-demo.core
(:require
[clojure.zip :as zip]
[clojure.data.zip.xml :as zx]
[clojure.data.xml :as xml]))
So what do they do?
- clojure.zip
Allows you to create
zippers
for most of Clojure's collections, not just XML.
zipper-demo.core>
(def nested-vecs
[:foo
[:bar :baz [:bork]]])
;; now let's make a zipper
zipper-demo.core> (def z
(zip/vector-zip nested-vecs))
zipper-demo.core> (-> z
zip/down
zip/node)
;;=> :foo
;; move one step down from the head
;; left side <----------- | -------> right side
;; [:foo *<-- we are here* [:bar :baz [:bork]]]
zipper-demo.core> (-> z
zip/down
zip/right
zip/node)
;; [:bar :baz [:bork]]
;; [:foo [:bar :baz [:bork]] *<-- we are here*]
;; lets move to the :bork leaf
zipper-demo.core> (-> z
zip/down
zip/right
zip/down
zip/right
zip/right
zip/down
zip/node)
;; :bork
;; we can edit too
zipper-demo.core> (-> z
zip/down
zip/right
zip/down
zip/right
zip/right
zip/down
(zip/edit #(clojure.string/upper-case %))
zip/node)
;; :BORK
;; to get the entire (edited) structure back we can do this
zipper-demo.core> (-> z
zip/down
zip/right
zip/down
zip/right
zip/right
zip/down
(zip/edit #(clojure.string/upper-case %))
zip/root)
;; [:foo [:bar :baz [":BORK"]]]
I would recommend using clojure.data.xml
to parse xml rather than
clojure.xml
which seems like a toy library, because it doesn't
output to a stream but actually prints the xml to the console when
parsing so you need to wrap it with-out-str
, when you output xml it
doesn't escape xml entities so you end up with invalid xml and it's
not lazy so huge xml files will make it choke.
If you wanted to load a file in your resources directory called
pokemon.xml
, do the following:
zipper-demo.core> (def pokemon
(-> "pokemon.xml"
clojure.java.io/resource
clojure.java.io/input-stream
xml/parse))
;; #'zipper-demo.core/pokemon
;; this returns an element tree, each element has a :tag, :attrs and
;; :content
zipper-demo.core> (keys pokemon)
;; (:tag :attrs :content)
;; we can explore the data like this
zipper-demo.core> (first (:content pokemon))
;;{:tag :pokemon,
;; :attrs {:id "1"},
;; :content
;; ({:tag :name, :attrs {}, :content ("BULBASAUR")}
;; {:tag :type, :attrs {}, :content ("Grass")}
;; {:tag :type, :attrs {}, :content ("Poison")}
;; {:tag :ability, :attrs {}, :content ("Overgrow")}
;; {:tag :exp, :attrs {}, :content ("1059860")}
;; {:tag :stats,
;; :attrs {},
;; :content
;; ({:tag :HP, :attrs {}, :content ("45")}
;; {:tag :ATK, :attrs {}, :content ("49")}
;; {:tag :DEF, :attrs {}, :content ("49")}
;; {:tag :SPD, :attrs {}, :content ("45")}
;; {:tag :SAT, :attrs {}, :content ("65")}
;; {:tag :SDF, :attrs {}, :content ("65")})}
;; {:tag :evolutions,
;; :attrs {},
;; :content
;; ({:tag :evolution,
;; :attrs {:id "1"},
;; :content ({:tag :name, :attrs {}, :content ("Bulbasaur")})}
;; {:tag :evolution,
;; :attrs {:id "2"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Ivysaur")}
;; {:tag :lvl, :attrs {}, :content ("16")})}
;; {:tag :evolution,
;; :attrs {:id "3"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Venusaur")}
;; {:tag :lvl, :attrs {}, :content ("32")})})}
;; {:tag :ratio,
;; :attrs {},
;; :content
;; ({:tag :male, :attrs {}, :content ("87.5")}
;; {:tag :female, :attrs {}, :content ("12.5")})}
;; {:tag :egg-group, :attrs {}, :content ("Monster")}
;; {:tag :egg-group, :attrs {}, :content ("Grass")}
;; {:tag :species, :attrs {}, :content ("Seed Pokemon")}
;; {:tag :height, :attrs {}, :content ("0.7")}
;; {:tag :weight, :attrs {}, :content ("6.9")}
;; {:tag :description,
;; :attrs {},
;; :content
;; ("For some time after its birth, it grows by gaining nourishment from the seed on its back.")}
;; {:tag :moves,
;; :attrs {},
;; :content
;; ({:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Tackle")}
;; {:tag :lvl, :attrs {}, :content ("1")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Growl")}
;; {:tag :lvl, :attrs {}, :content ("3")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Leech Seed")}
;; {:tag :lvl, :attrs {}, :content ("7")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Vine Whip")}
;; {:tag :lvl, :attrs {}, :content ("9")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Poisonpowder")}
;; {:tag :lvl, :attrs {}, :content ("13")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Sleep Powder")}
;; {:tag :lvl, :attrs {}, :content ("13")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Take Down")}
;; {:tag :lvl, :attrs {}, :content ("15")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Razor Leaf")}
;; {:tag :lvl, :attrs {}, :content ("19")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Sweet Scent")}
;; {:tag :lvl, :attrs {}, :content ("21")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Growth")}
;; {:tag :lvl, :attrs {}, :content ("25")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Double-edge")}
;; {:tag :lvl, :attrs {}, :content ("27")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Worry Seed")}
;; {:tag :lvl, :attrs {}, :content ("31")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Synthesis")}
;; {:tag :lvl, :attrs {}, :content ("33")})}
;; {:tag :move,
;; :attrs {:type "level-up"},
;; :content
;; ({:tag :name, :attrs {}, :content ("Seed Bomb")}
;; {:tag :lvl, :attrs {}, :content ("37")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Toxic")}
;; {:tag :lvl, :attrs {}, :content ("06")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Bullet Seed")}
;; {:tag :lvl, :attrs {}, :content ("09")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Hidden Power")}
;; {:tag :lvl, :attrs {}, :content ("10")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Sunny Day")}
;; {:tag :lvl, :attrs {}, :content ("11")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Protect")}
;; {:tag :lvl, :attrs {}, :content ("17")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Giga Drain")}
;; {:tag :lvl, :attrs {}, :content ("19")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Frustration")}
;; {:tag :lvl, :attrs {}, :content ("21")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Solarbeam")}
;; {:tag :lvl, :attrs {}, :content ("22")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Return")}
;; {:tag :lvl, :attrs {}, :content ("27")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Double Team")}
;; {:tag :lvl, :attrs {}, :content ("32")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Sludge Bomb")}
;; {:tag :lvl, :attrs {}, :content ("36")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Facade")}
;; {:tag :lvl, :attrs {}, :content ("42")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Secret Power")}
;; {:tag :lvl, :attrs {}, :content ("43")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Rest")}
;; {:tag :lvl, :attrs {}, :content ("44")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Attract")}
;; {:tag :lvl, :attrs {}, :content ("45")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Energy Ball")}
;; {:tag :lvl, :attrs {}, :content ("53")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Endure")}
;; {:tag :lvl, :attrs {}, :content ("58")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Flash")}
;; {:tag :lvl, :attrs {}, :content ("70")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Swords Dance")}
;; {:tag :lvl, :attrs {}, :content ("75")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Captivate")}
;; {:tag :lvl, :attrs {}, :content ("78")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Sleep Talk")}
;; {:tag :lvl, :attrs {}, :content ("82")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Natural Gift")}
;; {:tag :lvl, :attrs {}, :content ("83")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Grass Knot")}
;; {:tag :lvl, :attrs {}, :content ("86")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Swagger")}
;; {:tag :lvl, :attrs {}, :content ("87")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("TM")}
;; {:tag :name, :attrs {}, :content ("Substitute")}
;; {:tag :lvl, :attrs {}, :content ("90")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("HM")}
;; {:tag :name, :attrs {}, :content ("Cut")}
;; {:tag :lvl, :attrs {}, :content ("1")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("HM")}
;; {:tag :name, :attrs {}, :content ("Strength")}
;; {:tag :lvl, :attrs {}, :content ("4")})}
;; {:tag :move,
;; :attrs {:type "TM/HM"},
;; :content
;; ({:tag :machine, :attrs {}, :content ("HM")}
;; {:tag :name, :attrs {}, :content ("Rock Smash")}
;; {:tag :lvl, :attrs {}, :content ("6")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Amnesia")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Charm")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Curse")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Grasswhistle")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Ingrain")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Leaf Storm")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Light Screen")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Magical Leaf")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Nature Power")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Petal Dance")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Safeguard")})}
;; {:tag :move,
;; :attrs {:type "egg"},
;; :content ({:tag :name, :attrs {}, :content ("Skull Bash")})})})}
This can get annoying as you get deeper in the structure, we can use a zipper instead.
zipper-demo.core> (def pokemon-zip (zip/xml-zip pokemon))
;; #'zipper-demo.core/pokemon-zip
zipper-demo.core> (-> pokemon-zip zip/down zip/right zip/node)
;;{:tag :pokemon,
;; :attrs {:id "1"},
;; :content
;; ({:tag :name, :attrs {}, :content ("BULBASAUR")}
;; {:tag :type, :attrs {}, :content ("Grass")}
;; {:tag :type, :attrs {}, :content ("Poison")}
;; {:tag :ability, :attrs {}, :content ("Overgrow")}
;; {:tag :exp, :attrs {}, :content ("1059860")}
;; {:tag :stats,
;; :attrs {},
;; :content
;; ({:tag :HP, :attrs {}, :content ("45")}
;; {:tag :ATK, :attrs {}, :content ("49")}
;; {:tag :DEF, :attrs {}, :content ("49")}
;; {:tag :SPD, :attrs {}, :content ("45")}
;; ...
If you do any programming on the web you still find this convoluted
compared to document.querySelector
or document.querySelectorAll
when working with the DOM. That's where clojure.data.xml.zip
comes
in.
xml->
is equivalent to document.querySelectorAll
and xml1->
is
equivalent to document.querySelector
in that xml->
will return all
elements that match a given predicate, xml1->
will only return the
first one. The predicates can be chained and clojure.data.xml.zip
provides a few helpers for common things like tag names or attribute
values.
Say you wanted to get the name of the fist pokemon:
(zip/node (zip/down
(zx/xml1-> pokemon-zip :pokemon :name)))
;; "BULBASAUR"
This matches all <name> tags inside <pokemon> tags and returns the first element. We use zip/down to move into the text-content location instead of returning the actual element location, then we use zip/node to turn the location we're at into a real value from the pokemon structure.
If you want the name of every pokemon you could do this:
zipper-demo.core> (map zip/node
(map zip/down
(zx/xml-> pokemon-zip :pokemon :name)))
;;("BULBASAUR" "IVYSAUR" "VENUSAUR" "CHARMANDER" "CHARMELEON"
;; "CHARIZARD" "SQUIRTLE" "WARTORTLE" "BLASTOISE" "CATERPIE" "METAPOD"
;; "BUTTERFREE" "WEEDLE" "KAKUNA" "BEEDRILL" "PIDGEY" "PIDGEOTTO"
;; "PIDGEOT" "RATTATA" "RATICATE" "SPEAROW" "FEAROW" "EKANS" "ARBOK"
;; "PIKACHU" ";;RAICHU" "SANDSHREW" "SANDSLASH" "NIDORAN" "NIDORINA"
;; "NIDOQUEEN" "NIDORAN" "NIDORINO" "NIDOKING" "CLEFAIRY" "CLEFABLE"
;; "NOCTOWL" "LEDYBA" "LEDIAN" "SPINARAK" "ARIADOS" "CROBAT" "CHINCHOU"
;; "LANTURN" "PICHU" "CLEFFA" "IGGLYBUFF" "TOGEPI" "TOGETIC" "NATU"
;; "XATU" "MAREEP" "FLAAFFY" "AMPHAROS" "BELLOSSOM" "MARILL"
;; "AZUMARILL" "SUDOWOODO" "POLITOED" "HOPPIP" "SKIPLOOM" "JUMPLUFF"
;; "AIPOM" "HO" "SUNKERN" "SUNFLORA" "YANMA" "WOOPER" "QUAGSIRE"
;; "ESPEON" "UMBREON" "MURKROW" "SLOWKING" "MISDREAVUS" "UNOWN"
;; "WOBBUFFET" "GIRAFARIG" "PINECO" "FORRETRESS" "DUNSPARCE" "GLIGAR"
;; "STEELIX" "SNUBBULL" "GRANBULL" "QWILFISH" "SCIZOR" "SHUCKLE"
;; "HERACROSS" "SNEASEL" "TEDDIURSA" "URSARING" "SLUGMA" "MAGCARGO"
;; ...
;; "CRESSELIA" "PHIONE" "MANAPHY" "DARKRAI" "SHAYMIN" "ARCEUS")
If you want to get the name of a pokemon by it's ID then you could do this:
(zip/node (zx/xml1-> pokemon-zip :pokemon (zx/attr= :id "2") :name))
;; #clojure.data.xml.Element{:tag :name, :attrs {}, :content ("IVYSAUR")}
Round-tripping XML
Now to get to the point of this whole post, round-tripping some XML. Suppose you have some XML containing an inventory of books that looks like this:
(def books
"
<?xml version=\"1.0\"?>
<catalog>
<book id=\"bk101\">
<author>Gambardella, Matthew</author>
<title>XML Developer's Guide</title>
<genre>Computer</genre>
<publish_date>2000-10-01</publish_date>
<description>An in-depth look at creating applications
with XML.</description>
</book>
<book id=\"bk102\">
<author>Ralls, Kim</author>
<title>Midnight Rain</title>
<genre>Fantasy</genre>
<publish_date>2000-12-16</publish_date>
<description>A former architect battles corporate zombies,
an evil sorceress, and her own childhood to become queen
of the world.</description>
</book>
<book id=\"bk103\">
<author>Corets, Eva</author>
<title>Maeve Ascendant</title>
<genre>Fantasy</genre>
<publish_date>2000-11-17</publish_date>
<description>After the collapse of a nanotechnology
society in England, the young survivors lay the
foundation for a new society.</description>
</book>
<book id=\"bk104\">
<author>Corets, Eva</author>
<title>Oberon's Legacy</title>
<genre>Fantasy</genre>
<publish_date>2001-03-10</publish_date>
<description>In post-apocalypse England, the mysterious
agent known only as Oberon helps to create a new life
for the inhabitants of London. Sequel to Maeve
Ascendant.</description>
</book>
<book id=\"bk105\">
<author>Corets, Eva</author>
<title>The Sundered Grail</title>
<genre>Fantasy</genre>
<publish_date>2001-09-10</publish_date>
<description>The two daughters of Maeve, half-sisters,
battle one another for control of England. Sequel to
Oberon's Legacy.</description>
</book>
</catalog>
")
and you have a map of ids and prices:
(def prices
{:bk101 "44.95"
:bk102 "12.50"
:bk103 "24.20"
:bk104 "4.20"
:bk105 "45.00"})
You want to add a price tag to each book, with the price looked up
from prices
.
Let's start by turning the XML into a zipper.
(def books-as-elements (xml/parse-str books))
(def books-zipper (zip/xml-zip books-as-elements))
then we can use this tree edit function, it takes a zipper to edit, a predicate to tell it which nodes need to be edited and a edit function which takes a node and returns an edited node.
(defn tree-edit
"Take a zipper, a function that matches a pattern in the tree,
and a function that edits the current location in the tree. Examine the tree
nodes in depth-first order, determine whether the matcher matches, and if so
apply the editor."
[zipper matcher editor]
(loop [loc zipper]
(if (zip/end? loc)
(zip/root loc)
(if-let [matcher-result (matcher loc)]
(let [new-loc (zip/edit loc editor)]
(if (not (= (zip/node new-loc) (zip/node loc)))
(recur (zip/next new-loc))))
(recur (zip/next loc))))))
;; match predicate, all book tags
(defn match-book? [loc]
(let [tag (:tag (zip/node loc))]
;; true if tag is of type <path>
(= :book tag)))
;; edit function
(defn editor [node]
(let [id (-> node :attrs :id keyword)
new-content (conj
(:content node)
(xml/element :price {} (get prices id "0.0")))]
(assoc-in node [:content] new-content)))
(def edited (tree-edit books-zipper match-book? editor))
(xml/indent-str
edited)
;; <catalog>
;;<book id="bk101">
;;<price>44.95</price>
;;<author>Gambardella, Matthew</author>
;;<title>XML Developer's Guide</title>
;;<genre>Computer</genre>
;;<publish_date>2000-10-01</publish_date>
;;<description>
;;An in-depth look at creating applications with XML.
;;</description>
;;</book>
;; save it
(with-open [f (clojure.java.io/writer "/tmp/books_with_prices.xml")]
(xml/emit edited f))
If you want to play with this stuff yourself then the demo project is on github