source: exist/trunk/python/ndgUtils/models/Atom.py @ 4236

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/exist/trunk/python/ndgUtils/models/Atom.py@4236
Revision 4236, 26.0 KB checked in by cbyrom, 12 years ago (diff)

Improve the creation of new atom entries in eXist - generating unique
IDs, and checking these, where necessary + fix update date updates +
add methods to the vocab data class to retrieve lists of data categories
+ subtypes - for use in the UI dropdowns.

Line 
1'''
2 Class representing data in  atom format - allowing moles data to be stored and accessed in a web feed compatible way
3 
4 @author: C Byrom, Tessella Jun 2008
5'''
6try: #python 2.5
7    from xml.etree import cElementTree as ET
8except ImportError:
9    try:
10        # if you've installed it yourself it comes this way
11        import cElementTree as ET
12    except ImportError:
13        # if you've egged it this is the way it comes
14        from ndgUtils.elementtree import cElementTree as ET
15import sys, logging, re, datetime
16from ndgUtils.eXistConnector import eXistConnector
17from ndgUtils.ETxmlView import et2text
18from utilities import getTripleData, escapeSpecialCharacters, \
19    tidyUpParameters, getISO8601Date
20from ndgUtils.vocabtermdata import VocabTermData as VTD
21from ndgUtils.models import MolesEntity as ME
22
23
24class AtomError(Exception):
25    """
26    Exception handling for Atom class.
27    """
28    def __init__(self, msg):
29        logging.error(msg)
30        Exception.__init__(self, msg)
31
32
33class Person():
34    '''
35    Class representing atom author type data - with name, uri and role attributes
36    '''
37    def __init__(self, tagName = "author", namespace = None):
38        self.tagName = tagName
39        self.ns = namespace
40        self.name = ""
41        self.uri = ""
42        self.role = ""
43
44    def fromString(self, personString):
45        (self.name, self.uri, self.role) = getTripleData(personString)
46       
47    def fromETElement(self, personTag):
48        self.name = personTag.findtext('name') or ""
49        self.role = personTag.findtext('role') or ""
50        self.uri = personTag.findtext('uri') or ""
51        logging.debug("Added name: '%s', role: '%s', uri: '%s'" \
52                      %(self.name, self.role, self.uri))
53
54    def toXML(self):
55        prefix = ""
56        if self.ns:
57            prefix = self.ns + ':'
58
59        author = ET.Element(prefix + self.tagName)
60
61        if self.name:
62            name = ET.SubElement(author, prefix + "name")
63            name.text = self.name
64       
65        if self.uri:
66            uri = ET.SubElement(author, prefix + "uri")
67            uri.text = self.uri
68       
69        if self.role:
70            role = ET.SubElement(author, prefix + "role")
71            role.text = self.role
72
73        return author
74   
75
76class Link():
77    '''
78    Class representing an atom link - with href, title and rel attributes
79    '''
80    def __init__(self):
81        self.href = ""
82        self.title = ""
83        self.rel = ""
84
85    def fromString(self, linkString):
86        (self.href, self.title, self.ref) = getTripleData(linkString)
87       
88    def fromETElement(self, linkTag):
89        self.href = linkTag.attrib.get('href') or ""
90        self.rel = linkTag.attrib.get('rel') or ""
91        self.title = linkTag.attrib.get('title') or ""
92
93    def toXML(self):
94        link = ET.Element("link")
95        link.attrib["href"] = self.href
96        link.attrib["title"] = self.title
97        link.attrib["rel"] = self.rel
98        return link
99
100
101class Category():
102    '''
103    Class representing an atom category - with term, scheme and label attributes
104    '''
105    def __init__(self):
106        self.term = ""
107        self.scheme = ""
108        self.label = ""
109
110    def fromString(self, linkString):
111        (self.label, self.scheme, self.term) = getTripleData(linkString)
112       
113    def fromETElement(self, linkTag):
114        self.term = linkTag.attrib.get('term') or ""
115        self.label = linkTag.attrib.get('label') or ""
116        self.scheme = linkTag.attrib.get('scheme') or ""
117
118    def toXML(self):
119        link = ET.Element("category")
120        link.attrib["term"] = self.term
121        link.attrib["scheme"] = self.scheme
122        link.attrib["label"] = self.label
123        return link
124
125
126class Atom(object):
127
128    ATOM_TYPE = "ATOM_TYPE"
129    ATOM_SUBTYPE = "ATOM_SUBTYPE"
130
131    def __init__(self, atomType = None, vocabTermData = None, ndgObject = None, \
132                 xmlString = None, state = eXistConnector.WORKING_COLLECTION_PATH, **inputs):
133        '''
134        Constructor - initialise the atom variables
135        '''
136        logging.info("Initialising atom")
137        if atomType:
138            logging.info(" - of type '%s'" %atomType)
139        self.atomTypeID = atomType
140
141        # some data have further subtypes specified
142        self.subtype = None
143       
144        self.ndgObject = ndgObject
145
146        self.atomName = None
147        self.files = []
148        self.author = None
149        self.contributors = []
150        self.atomAuthors = []
151        self.parameters = []
152        self.spatialData = []
153        self.temporalData = []
154        self.relatedLinks = []
155        self.summary = []
156        self.csmlFile = None
157        self.cdmlFile = None
158        # general variable to use for setting the atom content - NB, if a csmlFile is specified
159        # (either directly or via a cdmlFile specification), this will be the content by default
160        # for this purpose
161        self.contentFile = None     
162        self.logos = []
163        self.title = None
164        self.datasetID = None        # NB, the dataset id ends up in the atomName - <path><datasetID>.atom
165        self.atomID = None
166   
167        # boundary box info - to replace spatial/temporalData?
168        self.minX = None
169        self.minY = None
170        self.maxX = None
171        self.maxY = None
172        self.t1 = None
173        self.t2 = None
174
175        self.ME = ME.MolesEntity()
176       
177        # date when the atom was first ingested
178        self.publishedDate = None
179
180        # last update date
181        self.updatedDate = None
182
183        # assume atom in working state by default - this is used to define what collection
184        # in eXist the atom is stored in
185        self.state = state
186       
187        # additional, non standard atom data can be included in the molesExtra element
188        if vocabTermData:
189            self.VTD = vocabTermData
190        else:
191            self.VTD = VTD()
192       
193        if xmlString:
194            self.fromString(xmlString)
195
196        # if inputs passed in as dict, add these now
197        if inputs:
198            self.__dict__.update(inputs)
199           
200            if inputs.get('providerID'):
201                self.ME.providerID = inputs.get('providerID')
202
203
204        if self.atomTypeID:
205            self.atomTypeName = self.VTD.TERM_DATA[self.atomTypeID].title
206
207        logging.info("Atom initialised")
208
209
210    def getDefaultCollectionPath(self):
211        '''
212        Determine the correct collection to use for the atom in eXist
213        '''
214        collectionPath = eXistConnector.BASE_COLLECTION_PATH + self.state
215       
216        if self.atomTypeID == VTD.DE_TERM:
217            collectionPath += eXistConnector.DE_COLLECTION_PATH
218        elif self.atomTypeID == VTD.GRANULE_TERM:
219            collectionPath += eXistConnector.GRANULE_COLLECTION_PATH
220        else:
221            collectionPath += eXistConnector.DEPLOYMENT_COLLECTION_PATH
222       
223        if not self.ME.providerID:
224            raise AtomError("Error: cannot determine atom collection path because " + \
225                            "the provider ID is not defined")
226           
227        collectionPath += self.ME.providerID + "/"
228        return collectionPath
229           
230
231    def __addAtomTypeDataXML(self, root):
232        '''
233        Add the atom type, and subtype data, if available, to atom categories
234        - and lookup and add the appropriate vocab term data
235        '''
236        if self.atomTypeID:
237            logging.info("Adding atom type info to XML output")
238            category = Category()
239            category.label = self.atomTypeID
240            # look up the appropriate vocab term data
241            category.scheme = self.VTD.getTermCurrentVocabURL(self.atomTypeID)
242            category.term = self.ATOM_TYPE
243            root.append(category.toXML())
244
245        if self.subtype:
246            logging.info("Adding atom subtype info to XML output")
247            # NB subtypes not all defined, so leave this out for the moment
248            category.label = self.subtype
249            # look up the appropriate vocab term data
250            category.scheme = self.VTD.getTermCurrentVocabURL(self.subtype)
251            category.term = self.ATOM_SUBTYPE
252            root.append(category.toXML())
253
254
255    def addMolesEntityData(self, abbreviation, provider_id, object_creation_time):
256        '''
257        Add data to include in the moles entity element
258        '''
259        logging.debug('Adding moles entity information')
260        self.ME.abbreviation = abbreviation
261        self.ME.providerID = provider_id
262        self.ME.createdDate = getISO8601Date(object_creation_time)
263        logging.debug('Moles entity information added')
264
265
266    def _isNewParameter(self, param):
267        '''
268        Check if a parameter is already specified in the atom, return False if
269        so, otherwise return True
270        '''
271        for p in self.parameters:
272            if p.term == param.term and \
273                p.scheme == param.scheme and \
274                p.label == param.label:
275                return False
276        return True
277
278
279    def addRelatedLinks(self, linkVals):
280        '''
281        Add related links in string format - converting to Link objects
282        @param linkVals: string of format, 'uri | title | vocabServerURL'
283        '''
284        self.relatedLinks.append(self.objectify(linkVals, 'relatedLinks'))
285
286
287    def addLogos(self, logoVals):
288        '''
289        Add related logos in string format - converting to Link objects
290        @param linkVals: string of format, 'uri | title | vocabServerURL'
291        '''
292        self.relatedLinks.append(self.objectify(logoVals, 'logo'))
293
294
295    def addParameters(self, params):
296        '''
297        Add a parameter to list - ensuring it is unique and has been formatted and tidied appropriately
298        @params param: parameter, as string array, to add to atom parameters collection
299        '''
300        # avoid strings being parsed character by character
301        if type(params) is str:
302            params = [params]
303           
304        for param in params:
305            # firstly tidy parameter
306            param = tidyUpParameters(param)
307            category = Category()
308            category.fromString(param)
309
310            # now check for uniqueness
311            if self._isNewParameter(category):
312                logging.debug("Adding new parameter: %s" %param)
313                self.parameters.append(category)
314   
315   
316    def _linksToXML(self, root):
317        '''
318        Add required links to the input element
319        @param root: element to add links to - NB, should be the root element of the atom
320        '''
321        selfLink = ET.SubElement(root, "link")
322        selfLink.attrib["href"] = self.atomBrowseURL
323        selfLink.attrib["rel"] = "self"
324        molesLink = ET.SubElement(root, "link")
325        molesDoc = re.sub('ATOM','NDG-B1', self.atomBrowseURL)
326        molesLink.attrib["href"] = molesDoc
327        molesLink.attrib["rel"] = "related"
328       
329        for relatedLink in self.relatedLinks:
330            root.append(relatedLink.toXML())
331       
332        for logo in self.logos:
333            root.append(logo.toXML())
334   
335    def toXML(self):
336        '''
337        Convert the atom into XML representation and return this
338        @return: xml version of atom
339        '''
340        logging.info("Creating formatted XML version of Atom")
341        root = ET.Element("entry")
342        root.attrib["xmlns"] = "http://www.w3.org/2005/Atom"
343        root.attrib["xmlns:moles"] = "http://ndg.nerc.ac.uk/schema/moles2alpha"
344        root.attrib["xmlns:georss"] = "http://www.georss.org/georss"
345        root.attrib["xmlns:gml"] = "http://www.opengis.net/gml"
346        id = ET.SubElement(root, "id")
347        id.text = self.atomID
348        title = ET.SubElement(root, "title")
349        title.text = self.title
350        self._linksToXML(root)
351
352        # NB, the author tag is mandatory for atoms - so if an explicit
353        # author has not been set, just take the author to be the provider
354        if not self.author:
355            author = Person()
356            author.name = self.ME.providerID
357            author.uri = self.ME.providerID
358            self.author = author
359
360        root.append(self.author.toXML())
361           
362        # NB, only the first author in the list is the author; the rest are contrinbutors
363        for contributor in self.contributors:
364            root.append(contributor.toXML())
365
366        # add the moles entity section, if it is required
367        if self.ME:
368            # add any authors info
369            for author in self.atomAuthors:
370                if author not in self.ME.responsibleParties:
371                    self.ME.responsibleParties.append(author)
372            root.append(self.ME.toXML())
373
374        # add parameters data
375        for param in self.parameters:
376            root.append(param.toXML())
377
378        # add the type and subtype data
379        self.__addAtomTypeDataXML(root)
380                   
381        summary = ET.SubElement(root, "summary")
382        summary.text = self.Summary
383
384        # add link to content, if required - NB, can only have one content element in atom
385        # - and this is mandatory
386        content = ET.SubElement(root, "content")
387        if self.contentFile:
388            content.attrib["type"] = "application/xml"
389            content.attrib["src"] = self.contentFile
390        else:
391            content.text = "Metadata document"
392       
393        # if there's a published date already defined, assume we're doing an update now
394        # NB, update element is mandatory
395        currentDate = datetime.datetime.today().strftime("%Y-%m-%dT%H:%M:%SZ")
396        if not self.publishedDate:
397            self.publishedDate = currentDate
398
399        updated = ET.SubElement(root, "updated")
400        if not self.updatedDate:
401            self.updatedDate = currentDate
402        updated.text = self.updatedDate
403
404        published = ET.SubElement(root, "published")
405        published.text = self.publishedDate
406
407        # add temporal range data, if available
408        temporalRange = ET.SubElement(root, "moles:temporalRange")
409        if self.t1:
410            temporalRange.text = self.t1
411            if self.t2:
412                temporalRange.text += "/" + self.t2
413
414        # add spatial range data, if available
415        self._addSpatialData(root)
416
417        tree = ET.ElementTree(root)
418        logging.info("XML version of Atom created")
419        return tree
420
421
422    def __getSummary(self):
423        logging.debug("Getting summary data")
424        summaryString = ""
425        for summary_line in self.summary:
426            summaryString += summary_line + "\n"
427
428        return summaryString
429
430    def __setSummary(self, summary):
431        logging.debug("Adding summary data")
432        self.summary = []
433        for summary_line in summary.split('\n'):
434            self.summary.append(summary_line)
435           
436    Summary = property(fset=__setSummary, fget=__getSummary, doc="Atom summary")
437
438           
439    def fromString(self, xmlString):
440        '''
441        Initialise Atom object using an xmlString
442        @param xmlString: representation of atom as an XML string
443        '''
444        logging.info("Ingesting data from XML string")
445       
446        # firstly, remove any namespaces used - to avoid problems with elementtree
447        logging.debug("Stripping moles namespace from string to allow easy handling with elementtree")
448        xmlString = xmlString.replace('moles:', '')
449        xmlString = xmlString.replace('georss:', '')
450        xmlString = xmlString.replace('gml:', '')
451        xmlString = xmlString.replace('xmlns="http://www.w3.org/2005/Atom"', '')
452
453        # now create elementtree with the XML string
454        logging.debug("Create elementtree instance with XML string")
455        tree = ET.fromstring(xmlString)
456       
457        title = tree.findtext('title')
458        if title:
459            logging.debug("Adding title data")
460            self.title = title
461
462        summary = tree.findtext('summary')
463        if summary:
464            self.Summary = summary
465
466        authorElement = tree.find('author')
467        logging.debug("Adding author data")
468        author = Person()
469        author.fromETElement(authorElement)
470        self.author = author
471
472        contributorElements = tree.findall('contributor')
473        for contributorElement in contributorElements:
474            logging.debug("Adding contributor data")
475            contributor = Person(tagName = 'contributor')
476            contributor.fromETElement(contributorElement)
477            self.contributors.append(contributor)
478
479        molesElement = tree.find('entity')
480        if molesElement:
481            self.ME.fromET(molesElement)
482            for author in self.ME.responsibleParties:
483                self.atomAuthors.append(author)
484               
485        self.atomID = tree.findtext('id')
486
487        self._parseCategoryData(tree.findall('category'))
488
489        self._parseLinksData(tree.findall('link'))
490           
491        contentTag = tree.find('content')
492        if contentTag != None:
493            logging.debug("Found content tag - checking for CSML/CDML file data")
494            file = contentTag.attrib.get('src')
495            if file:
496                # NB, the path will reveal more reliably whether we're dealing with CSML and CDML files
497                if file.upper().find('CSML') > -1:
498                    logging.debug("Adding CSML file data")
499                    self.csmlFile = file
500                elif file.upper().find('CDML') > -1:
501                    logging.debug("Adding CDML file data")
502                    self.cdmlFile = file
503                self.contentFile = file
504       
505        range = tree.findtext('temporalRange')
506        if range:
507            logging.debug("Adding temporal range data")
508            timeData = range.split('/')
509            self.t1 = timeData[0]
510            if len(timeData) > 1:
511                self.t2 = timeData[1]
512       
513        # NB, this parser won't mind if we're dealing with Envelope or EnvelopeWithTimePeriod
514        minBBox = tree.findall('.//lowerCorner')
515        if minBBox:
516            logging.debug("Adding min spatial range data")
517            minBBox = minBBox[0]
518            spatialData = minBBox.text.split()
519            self.minX = spatialData[0]
520            if len(spatialData) > 1:
521                self.minY = spatialData[1]
522       
523        maxBBox = tree.findall('.//upperCorner')
524        if maxBBox:
525            maxBBox = maxBBox[0]
526            logging.debug("Adding max spatial range data")
527            spatialData = maxBBox.text.split()
528            self.maxX = spatialData[0]
529            if len(spatialData) > 1:
530                self.maxY = spatialData[1]
531               
532        publishedDate = tree.findtext('published')
533        if publishedDate:
534            logging.debug("Adding published date")
535            self.publishedDate = publishedDate
536               
537        updatedDate = tree.findtext('updated')
538        if updatedDate:
539            logging.debug("Adding updated date")
540            self.updatedDate = updatedDate
541           
542        logging.info("Completed data ingest")
543   
544   
545    def _parseCategoryData(self, categories):
546        logging.debug("Adding category/parameters data")
547        for category in categories:
548            cat = Category()
549            cat.fromETElement(category)
550           
551            if cat.term == self.ATOM_TYPE:
552                logging.debug("Found atom type data")
553                self.atomTypeID = cat.label
554                self.atomTypeName = self.VTD.TERM_DATA[cat.label].title
555                continue
556            elif cat.term == self.ATOM_SUBTYPE:
557                logging.debug("Found atom subtype data")
558                self.subtype = cat.label
559                continue
560
561            self.parameters.append(cat)
562   
563
564    def setDatasetID(self, datasetID):
565        '''
566        Set the dataset ID for the atom - and generate an appropriate atom name using this
567        @param datasetID: ID to set for the atom
568        '''
569        self.datasetID = datasetID
570        self._generateAtomName(datasetID) 
571        self.atomID = self.createAtomID(datasetID)
572
573
574    def createAtomID(self, datasetID):
575        '''
576        Create a unique ID, conforming to atom standards, for atom
577        NB, see http://diveintomark.org/archives/2004/05/28/howto-atom-id
578        @param datasetID: ID of atom's dataset
579        @return: unique ID
580        '''
581        logging.info("Creating unique ID for atom")
582        if not self.atomBrowseURL:
583            self._generateAtomName(datasetID)
584        urlBit = self.atomBrowseURL.split('://')[1]
585        urlBit = urlBit.replace('#', '')
586        urlBits = urlBit.split('/')
587        dateBit = datetime.datetime.today().strftime("%Y-%m-%d")
588       
589        id = "tag:" + urlBits[0] + "," + dateBit + ":/" + "/".join(urlBits[1:])
590        logging.info("- unique ID created for atom")
591        logging.debug(" - '%s'" %id)
592        return id
593       
594       
595    def _generateAtomName(self, datasetID):
596        '''
597        Generate a consistent name for the atom - with full eXist doc path
598        @param datasetID: ID of atom's dataset
599        '''
600        self.atomName = datasetID + ".atom"
601        self.ndgURI = self.ME.providerID + "__ATOM__" + datasetID
602        self.atomBrowseURL = VTD.BROWSE_ROOT_URL + self.ndgURI
603
604
605    def _parseLinksData(self, links):
606        '''
607        Extract links and atom data from array of link elements in the XML representation of the atom
608        @param links: an array of <link> elements
609        '''
610        # firstly, get all data to start with, so we can properly process it afterwards
611        linkData = {}
612        logging.debug("Getting link data")
613        for linkTag in links:
614            link = Link()
615            link.fromETElement(linkTag)
616
617            if not linkData.has_key(link.rel):
618                linkData[link.rel] = []
619            if link.title == VTD.TERM_DATA[VTD.LOGO_TERM].title:
620                self.logos.append(link)
621            else:
622                linkData[link.rel].append(link)
623
624        # there should be one self referencing link - which will provide info on the atom itself
625        if not linkData.has_key('self'):
626            errorMessage = "Atom does not have self referencing link - " + \
627                "cannot ascertain datasetID without this - please fix"
628            logging.error(errorMessage)
629            raise ValueError(errorMessage)
630       
631        # this is the link describing the atom itself
632        self.atomBrowseURL = linkData['self'][0].href
633       
634        self.datasetID = self.atomBrowseURL.split("__ATOM__")[-1]
635        self.atomName = self.datasetID + ".atom"
636        self.ndgURI = self.atomBrowseURL.split(VTD.BROWSE_ROOT_URL)[1]
637       
638        # now remove this value and the associated moles doc link
639        del linkData['self']
640        molesDoc = self.atomBrowseURL.replace('ATOM', 'NDG-B1')
641        if linkData.has_key('related'):
642            relatedLinks = []
643            for link in linkData['related']:
644                if link.href != molesDoc:
645                    relatedLinks.append(link)
646           
647            linkData['related'] = relatedLinks
648               
649        # now add the remaining links to the atom
650        for key in linkData:
651            for link in linkData[key]:
652                logging.debug("Adding link data")
653                self.relatedLinks.append(link)
654       
655
656    def _addSpatialData(self, element):
657        '''
658        Add spatial coverage element to an input element
659        @param element: element to add coverage data to
660        '''
661        logging.info("Adding spatial data to Atom")
662        bbox = ET.SubElement(element, "georss:where")
663        if not self.minX:
664            logging.info("No spatial data specified")
665            return
666       
667        envelope = ET.SubElement(bbox, "gml:Envelope")
668        lc = ET.SubElement(envelope, "gml:lowerCorner")
669        lc.text = self.minX + " " + self.minY
670        uc = ET.SubElement(envelope, "gml:upperCorner")
671        uc.text = self.maxX + " " + self.maxY
672
673       
674    def setAttribute(self, attributeName, attributeValue):
675        '''
676        Set the value of an atom attribute - and do some basic tidying up of the string content
677        - to escape any XML unfriendly characters
678        @param attributeName: name of the attribute whose value to set
679        @param attributeValue: value to set the attribute to 
680        '''
681        logging.debug("Setting attribute, %s, to %s" %(attributeName, attributeValue))
682        origValue = attributeValue
683       
684        # escape any special characters if a value has been specified
685        # NB, need to cope with both single values and arrays
686        if attributeValue:
687            if type(attributeValue) is list:
688                newVals = []
689                for val in attributeValue:
690                    newVals.append(objectify(escapeSpecialCharacters(val)), attributeName)
691                attributeValue = newVals
692                   
693            else:
694                attributeValue = objectify(escapeSpecialCharacters(attributeValue), attributeName)
695
696        # handle the special case of authors; only one author is allowed per atom
697        # - the others should be treated as contributors
698        if attributeName == "authors":
699            setattr(self, "author", attributeValue[0])
700            if len(attributeValue) > 1:
701                setattr(self, "contributors", attributeValue[1:])
702        else:
703            setattr(self, attributeName, attributeValue)
704
705
706    def objectify(self, objectVals, attributeName):
707        '''
708        Some inputs are specified as strings but need to be converted into
709        objects - do this here
710        @param objectVals: a '|' delimited string of values
711        @param attributeName: name of attribute the values belong to
712        '''
713        obj = None
714        if type(objectVals) != str:
715            return objectVals
716       
717        if attributeName == "relatedLinks" or attributeName == "logo":
718            obj = Link()
719        elif attributeName == "atomAuthors" or attributeName == "authors":
720            obj = Person()
721
722        if obj:
723            obj.fromString(objectVals)
724            return obj
725       
726        return objectVals
727
728
729    def toPrettyXML(self):
730        '''
731        Returns nicely formatted XML as string
732        '''
733        atomXML = self.toXML()
734
735        # create the string
736        logging.debug("Converting the elementtree object into a string")
737        prettyXML = et2text(atomXML.getroot())
738
739        # add XML version tag
740        prettyXML = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" + prettyXML
741        logging.info("Created formatted version of XML object")
742        return prettyXML
Note: See TracBrowser for help on using the repository browser.