# xml.tcl --
#
# This file is part of the XMPP library. It defines procedures which
# wrap XML parser. These procedures are called by functions in XMPP
# library, and they in turn call the TclXML or tDOM library functions.
#
# Copyright (c) 2008-2013 Sergei Golovan <sgolovan@nes.ru>
#
# See the file "license.terms" for information on usage and redistribution
# of this file, and for a DISCLAMER OF ALL WARRANTIES.
package require msgcat
if {[catch {package require tdom 0.8}]} {
package require -exact xml 2.0
}
package provide xmpp::xml 0.1
namespace eval ::xmpp::xml {
namespace export new free parser reset toText create split merge \
isAttr getAttr getCdata getFirstCdata getNextCdata \
streamHeader streamTrailer parseData lang
}
# ::xmpp::xml::new --
#
# Creates new wrapper over an XML parser.
#
# Arguments:
# streamHeaderCmd A command which is to be called when XMPP stream
# header is received.
# streamTrailerCmd A command which is to be called when XMPP stream
# trailer is receoved.
# stanzaCmd A command which is to be called when XMPP stream
# stanza is received.
# Results:
# A new parser token (a state array name).
#
# Side effects:
# A new XML parser is created.
proc ::xmpp::xml::new {streamHeaderCmd streamTrailerCmd stanzaCmd} {
variable id
if {![info exists id]} {
set id 0
} else {
incr id
}
set token [namespace current]::parser#$id
variable $token
upvar 0 $token state
set state(streamHeaderCmd) $streamHeaderCmd
set state(streamTrailerCmd) $streamTrailerCmd
set state(stanzaCmd) $stanzaCmd
set state(parser) \
[::xml::parser parser#$id \
-final 0 \
-elementstartcommand [namespace code [list ElementStart $token]] \
-elementendcommand [namespace code [list ElementEnd $token]] \
-characterdatacommand [namespace code [list ElementCdata $token]]]
if {[llength [info commands ::$state(parser)]] == 0} {
set state(parser) [namespace current]::$state(parser)
}
set state(stack) {}
set state(namespace) {{{} {} xml xml}}
return $token
}
# ::xmpp::xml::free --
#
# Frees a previously created wrapper over an XML parser.
#
# Arguments:
# token A previously created wrapper token.
#
# Results:
# An empty string.
#
# Side effects:
# An existing XML parser is destroyed.
proc ::xmpp::xml::free {token} {
variable $token
upvar 0 $token state
if {![info exists state(parser)]} {
return -code error [::msgcat::mc "Parser \"%s\" doesn't exist" $token]
}
$state(parser) free
unset state
return
}
# ::xmpp::xml::parser --
#
# Calls wrapped XML parser.
#
# Arguments:
# token A wrapper token.
# command An XML parser command (configure, parse, etc.).
# args Arguments for a given command.
#
# Results:
# An empty string.
#
# Side effects:
# An XML parser invokes a series of callbacks.
proc ::xmpp::xml::parser {token command args} {
variable $token
upvar 0 $token state
if {![info exists state(parser)]} {
return -code error [::msgcat::mc "Parser \"%s\" doesn't exist" $token]
}
# TODO: catch and process errors
return [uplevel 1 [list $state(parser) $command] $args]
}
# ::xmpp::xml::reset --
#
# Resets wrapped XML parser and internal stack.
#
# Arguments:
# token A wrapper token.
#
# Results:
# An empty string.
#
# Side effects:
# A wrapped parser is reset.
proc ::xmpp::xml::reset {token} {
variable $token
upvar 0 $token state
if {![info exists state(parser)]} {
return -code error [::msgcat::mc "Parser \"%s\" doesn't exist" $token]
}
$state(parser) reset
catch {$state(parser) configure -namespace 0}
$state(parser) configure \
-final 0 \
-elementstartcommand [namespace code [list ElementStart $token]] \
-elementendcommand [namespace code [list ElementEnd $token]] \
-characterdatacommand [namespace code [list ElementCdata $token]]
set state(stack) {}
set state(namespace) {{{} {} xml xml}}
return
}
# ::xmpp::xml::toText --
#
# Creates textual representation from XML data.
#
# Arguments:
# xmldata A parsed (or created by create) XML element.
# pxmlns Optional. XMLNS of a parent XML element.
# prefixes Optional. List of defined XMLNS prefixes.
# Pairs (XMLNS, prefix)
#
# Results:
# A converted raw XML data.
#
# Side effects:
# None.
proc ::xmpp::xml::toText {xmldata {pxmlns ""}
{prefixes {xml xml http://etherx.jabber.org/streams stream}}} {
set retext ""
set tag [lindex $xmldata 0]
set xmlns [lindex $xmldata 1]
set attrs [lindex $xmldata 2]
set subels [lindex $xmldata 3]
set cdata [lindex $xmldata 4]
array set p $prefixes
set ps {}
foreach ns [array names p] {
lappend ps $p($ns)
}
# Parsimoniously adding new prefixes (only when XMLNS is prepended
# to an attribute).
set newattrs {}
foreach {attr value} $attrs {
set l [::split $attr :]
if {[llength $l] > 1} {
set axmlns [join [lrange $l 0 end-1] :]
set aattr [lindex $l end]
if {[string equal $axmlns $xmlns]} {
lappend newattrs $aattr $value
} elseif {[info exists p($axmlns)]} {
lappend newattrs $p($axmlns):$aattr $value
} else {
set p($axmlns) [FindNewPrefix $ps]
lappend newattrs xmlns:$p($axmlns) $axmlns $p($axmlns):$aattr $value
}
} else {
lappend newattrs $attr $value
}
}
if {![string equal $xmlns ""] && ![string equal $xmlns $pxmlns]} {
if {![info exists p($xmlns)]} {
lappend newattrs xmlns $xmlns
set pxmlns $xmlns
} else {
set tag "$p($xmlns):$tag"
}
}
append retext "<$tag"
foreach {attr value} $newattrs {
append retext " $attr='[Escape $value]'"
}
if {[string equal $cdata ""] && [llength $subels] == 0} {
append retext "/>"
return $retext
} else {
append retext ">"
}
append retext [Escape $cdata]
foreach subdata $subels {
append retext [toText $subdata $pxmlns [array get p]]
append retext [Escape [lindex $subdata 5]]
}
append retext "</$tag>"
return $retext
}
# ::xmpp::xml::toTabbedText --
#
# Creates pretty-printed textual representation from XML data. The XML
# must satisfy the following condition: it must contain either a single
# CDATA element or a list of subelements. Mixing CDATA and subelements
# is not allowed. This procedure may be useful for saving XML into
# files.
#
# Arguments:
# xmldata A parsed (or created by create) XML element.
# pxmlns Optional. XMLNS of a parent XML element.
#
# Results:
# A converted raw XML data.
#
# Side effects:
# None.
proc ::xmpp::xml::toTabbedText {xmldata {pxmlns ""}} {
return [toText [ReplaceCdata $xmldata 0] $pxmlns]
}
# ::xmpp::xml::ReplaceCdata --
#
# Replace character data in XML element to a mix of tabs and linefeeds
# to make its textual representation look pretty. This procedure distorts
# XML element if it has subelements and CDATA simultaneously.
#
# Arguments:
# xmldata A parsed (or created by create) XML element.
# level number of tabulation characters to add before the element.
#
# Result:
# XML element with CDATA sections replaced by tabs (except if CDATA is
# a unique subelement).
#
# Side effects:
# None.
proc ::xmpp::xml::ReplaceCdata {xmldata level} {
set tag [lindex $xmldata 0]
set xmlns [lindex $xmldata 1]
set attrs [lindex $xmldata 2]
set subels [lindex $xmldata 3]
set cdata1 [lindex $xmldata 4]
set cdata2 [lindex $xmldata 5]
set s1 \n[string repeat \t $level]
incr level
set s2 \n[string repeat \t $level]
if {[llength $subels] == 0} {
return [lreplace $xmldata 5 5 $s1]
} else {
set cdata1 $s2
set cdata2 $s1
set newsubels {}
foreach subel [lrange $subels 0 end-1] {
lappend newsubels [ReplaceCdata $subel $level]
}
set newsubel [ReplaceCdata [lindex $subels end] $level]
lappend newsubels [lreplace $newsubel 5 5 $s1]
return [list $tag $xmlns $attrs $newsubels $cdata1 $cdata2]
}
}
# ::xmpp::xml::create --
#
# Creates XML data for an element.
#
# Arguments:
# tag An XML element name.
# -xmlns xmlns An element XMLNS (optional, default is empty which
# means inherited from a parent element).
# -attrs attrlist A list {attr1 value1 attr2 value2 ...} of
# attribute/value pairs (optional, default is no
# attributes). Attribute list must not contain xmlns.
# -cdata cdata CData of an element. It is appended after
# the latest subelement (optional, defaoult is empty
# CData).
# -subelement el A subelement to add after the latest CData or
# already added subelement (optional, default is no
# subelements).
# -subelements ellist A list of subelements to add (optional, default is
# no subelements).
#
# Results:
# A created XML element data.
#
# Side effects:
# None.
proc ::xmpp::xml::create {tag args} {
set xmlns ""
set attrs {}
set cdata ""
set subels {}
foreach {key val} $args {
switch -- $key {
-xmlns {set xmlns $val}
}
}
foreach {key val} $args {
switch -- $key {
-xmlns {}
-attrs {
foreach {attr value} $val {
if {[string equal $attr xmlns]} {
return -code error \
[::msgcat::mc "Illegal attribute \"xmlns\".\
Use -xmlns option"]
}
lappend attrs $attr $value
}
}
-cdata {
if {[llength $subels] == 0} {
append cdata $val
} else {
set tail [lindex $subels end]
set ncdata [lindex $tail 5]$val
set subels \
[lreplace $subels end end [lreplace $tail 5 5 $ncdata]]
}
}
-subelement {
if {[llength $val] > 0} {
if {[string equal [lindex $val 1] ""]} {
lappend subels [lreplace $val 1 1 $xmlns]
} else {
lappend subels $val
}
}
}
-subelements {
foreach subel $val {
if {[llength $subel] > 0} {
if {[string equal [lindex $subel 1] ""]} {
lappend subels [lreplace $subel 1 1 $xmlns]
} else {
lappend subels $subel
}
}
}
}
default {
return -code error [::msgcat::mc "Invalid option \"%s\"" $key]
}
}
}
set retext [list $tag $xmlns $attrs $subels $cdata ""]
return $retext
}
# ::xmpp::xml::split --
#
# Splits the given xmldata into 5 variables.
#
# Arguments:
# xmldata A parsed XML element.
# tagVar A variable for element name.
# xmlnsVar A variable for element XMLNS.
# attrsVar A variable for element attributes.
# cdataVar A variable for element CDATA.
# subelsVar A variable for subelements.
# nextCdataVar (optional) A variable for CDATA just after XML element.
# This variable will always be empty for an outmost
# element.
#
# Results:
# An empty string.
#
# Side effects:
# Five or six variables are assigned.
proc ::xmpp::xml::split {xmldata tagVar xmlnsVar attrsVar cdataVar \
subelsVar {nextCdataVar ""}} {
upvar 1 $tagVar tag $xmlnsVar xmlns $attrsVar attrs $cdataVar cdata \
$subelsVar subels
set tag [lindex $xmldata 0]
set xmlns [lindex $xmldata 1]
set attrs [lindex $xmldata 2]
set subels [lindex $xmldata 3]
set cdata [lindex $xmldata 4]
if {![string equal $nextCdataVar ""]} {
upvar 1 $nextCdataVar nextCdata
set nextCdata [lindex $xmldata 5]
}
return
}
# ::xmpp::xml::merge --
#
# Merges the given data from 5 variables to XML element. The correctness
# of data isn't checked. Since it's very easy to get inconsistent cdata
# this procedure is mainly useful if one wants to change XMLNS or
# attributes.
#
# Arguments:
# tag An element name.
# xmlns An element XMLNS.
# attrs An element attributes.
# cdata An element CDATA.
# subels Subelements.
# nextCdata (optional) A next CDATA.
#
# Results:
# A merged XML element.
#
# Side effects:
# None.
proc ::xmpp::xml::merge {tag xmlns attrs cdata subels {nextCdata ""}} {
return [list $tag $xmlns $attrs $subels $cdata $nextCdata]
}
# ::xmpp::xml::isAttr --
#
# Returns 1, or 0, depending on if the attribute exists in attribute
# list or not.
#
# Arguments:
# attrList A list of attribute-value pairs.
# attrName A name of attribute to check.
#
# Results:
# 1 if the list contains a requested attribute, or 0 otherwise.
#
# Side effects:
# None.
proc ::xmpp::xml::isAttr {attrList attrName} {
foreach {attr val} $attrList {
if {[string equal $attr $attrName]} {
return 1
}
}
return 0
}
# ::xmpp::xml::getAttr --
#
# Returns the value of the last given attribute from attribute list.
#
# Arguments:
# attrList A list of attribute-value pairs.
# attrName A name of attribute to get.
# fallback (optional, defaults to "") A returned value in case
# when attribute is missing
#
# Results:
# An attribute value or a fallback value if the list doesn't
# contain a requested attribute.
#
# Side effects:
# None.
proc ::xmpp::xml::getAttr {attrList attrName {fallback ""}} {
set res $fallback
foreach {attr val} $attrList {
if {[string equal $attr $attrName]} {
set res $val
}
}
return $res
}
# ::xmpp::xml::getCdata --
#
# Returns all element's CDATA chunks concatenated.
#
# Arguments:
# xmldata A parsed XML element.
#
# Results:
# An element CDATA.
#
# Side effects:
# None.
proc ::xmpp::xml::getCdata {xmldata} {
set cdata [lindex $xmldata 4]
foreach subel [lindex $xmldata 3] {
append cdata [lindex $subel 5]
}
return $cdata
}
# ::xmpp::xml::getFirstCdata --
#
# Returns element's CDATA chunk which is located before the first
# subelement.
#
# Arguments:
# xmldata A parsed XML element.
#
# Results:
# A CDATA chunk which goes before the first subelement.
#
# Side effects:
# None.
proc ::xmpp::xml::getFirstCdata {xmldata} {
return [lindex $xmldata 4]
}
# ::xmpp::xml::getNextCdata --
#
# Returns parent's CDATA chunk which is located after the given XML
# element.
#
# Arguments:
# xmldata A parsed XML element.
#
# Results:
# A parent's CDATA chunk which goes after the specified XML element.
#
# Side effects:
# None.
proc ::xmpp::xml::getNextCdata {xmldata} {
return [lindex $xmldata 5]
}
# ::xmpp::xml::streamHeader --
#
# Returns XMPP stream header.
#
# Arguments:
# to A peer's (server's) JID.
# -xmlns:stream uri xmlns:stream attribute
# -xmlns uri xmlns attribute
# -from jid from attribute (optional)
# -xml:lang lang xml:lang attribute (optional)
# -version ver XMPP version attribute (optional)
#
# Results:
# An XMPP stream header.
#
# Side effects:
# None.
proc ::xmpp::xml::streamHeader {to args} {
if {[isAttr $args -xmlns:stream]} {
set xmlns_stream [getAttr $args -xmlns:stream]
} else {
return -code error [::msgcat::mc "Missing option \"%s\"" -xmlns:stream]
}
if {[isAttr $args -xmlns]} {
set xmlns [getAttr $args -xmlns]
} else {
return -code error [::msgcat::mc "Missing option \"%s\"" -xmlns]
}
set retext "<stream:stream xmlns:stream='[Escape $xmlns_stream]'\
xmlns='[Escape $xmlns]' to='[Escape $to]'"
foreach {key val} $args {
switch -- $key {
-from {
append retext " from='[Escape $val]'"
}
-xml:lang {
append retext " xml:lang='[Escape $val]'"
}
-version {
append retext " version='[Escape $val]'"
}
-xmlns:stream -
-xmlns {}
default {
return -code error [::msgcat::mc "Invalid option \"%s\"" $key]
}
}
}
append retext ">"
return $retext
}
# ::xmpp::xml::streamTrailer --
#
# Returns XMPP stream trailer.
#
# Arguments:
# None.
#
# Results:
# An XMPP stream trailer.
#
# Side effects:
# None.
proc ::xmpp::xml::streamTrailer {} {
return "</stream:stream>"
}
# ::xmpp::xml::parseData --
# Parse XML data.
#
# Arguments:
# data XML data to parse.
# stanzaCmd (optional) Callback to invoke on every outmost XML
# stanza. If empty then list of all parsed XML stanzas
# is returned.
#
# Result:
# Empty string or parsed XML.
#
# Side effects:
# Side effects from stanzaCmd.
proc ::xmpp::xml::parseData {data {stanzaCmd ""}} {
set token [new # # $stanzaCmd]
variable $token
upvar 0 $token state
# HACK
if {[string equal $stanzaCmd ""]} {
set state(stanzaCmd) [namespace code [list ParseDataAux $token]]
}
set state(XML) {}
# HACK to move declaration out from file tag
regexp {(^\s*<\?([^?]|\?[^>])*\?>)?(.*)$} $data -> header _ data
parser $token parse "$header\n<tag>$data</tag>"
set xml $state(XML)
free $token
return $xml
}
proc ::xmpp:::xml::ParseDataAux {token xmlElement} {
variable $token
upvar 0 $token state
lappend state(XML) $xmlElement
}
# ::xmpp::xml::lang --
#
# Construct xml:lang attribute from msgcat preferences.
#
# Arguments:
# None.
#
# Result:
# Either language code (en, ru, es etc.) or language code joined with
# country code (en-US, ru-RU, uk-UA etc.) depending on msgcat
# preferences.
#
# Side effects:
# None.
proc ::xmpp::xml::lang {} {
set prefs [::msgcat::mcpreferences]
while {[string equal [lindex $prefs end] ""]} {
set prefs [lreplace $prefs end end]
}
set lang [lindex $prefs end]
switch -- $lang {
"" -
c -
posix {
return en
}
}
set lang2 [lindex $prefs end-1]
if {[regexp {^([A-Za-z]+)_([0-9A-Za-z]+)} $lang2 -> l1 l2]} {
return [string tolower $l1]-[string toupper $l2]
} else {
return $lang
}
}
# ::xmpp::xml::FindNewPrefix --
#
# Find new XMLNS prefix.
#
# Arguments:
# prefixes A list of defined prefixes.
#
# Results:
# A string which isn't contained in the prefixes list.
#
# Side effects:
# None.
proc ::xmpp::xml::FindNewPrefix {prefixes} {
set l0 {a b c d e f g h i j k l m n o p q r s t u v w x y z}
set l1 $l0
while {1} {
foreach p $l1 {
if {[lsearch -exact $prefixes $p] < 0} {
return $p
}
}
set l1 [DescartesProduct $l1 $l0]
}
}
# ::xmpp::xml::DescartesProduct --
#
# Returns a sort of Descartes product of two lists of strings - the list
# of appended strings from the first and the second list.
#
# Arguments:
# prefixes The list of prefixes.
# suffixes The lsit of suffixes.
#
# Results:
# The list of strings, where prefixes from the first list are joined with
# suffixes from the second one.
#
# Side effects:
# None.
proc ::xmpp::xml::DescartesProduct {prefixes suffixes} {
set res {}
foreach p $prefixes {
foreach s $suffixes {
lappend res $p$s
}
}
}
# ::xmpp::xml::Escape --
#
# Escapes predefined XML entities and forbidden space characters.
#
# Arguments:
# text Unescaped text.
#
# Results:
# A string where forbidden space characters are replaced by spaces
# and symbols which correspond to predefined XML entities are
# replaced by them.
#
# Side effects:
# None.
proc ::xmpp::xml::Escape {text} {
return [string map {& & < < > > \" " ' '
\x00 " " \x01 " " \x02 " " \x03 " "
\x04 " " \x05 " " \x06 " " \x07 " "
\x08 " " \x0B " "
\x0C " " \x0E " " \x0F " "
\x10 " " \x11 " " \x12 " " \x13 " "
\x14 " " \x15 " " \x16 " " \x17 " "
\x18 " " \x19 " " \x1A " " \x1B " "
\x1C " " \x1D " " \x1E " " \x1F " "
\x80 " " \x81 " " \x82 " " \x83 " "
\x84 " " \x85 " " \x86 " " \x87 " "
\x88 " " \x89 " " \x8A " " \x8B " "
\x8C " " \x8D " " \x8E " " \x8F " "
\x90 " " \x91 " " \x92 " " \x93 " "
\x94 " " \x95 " " \x96 " " \x97 " "
\x98 " " \x99 " " \x9A " " \x9B " "
\x9C " " \x9D " " \x9E " " \x9F " "} $text]
}
# ::xmpp::xml::ElementStart --
#
# A callback procedure which is called by a SAX parser when it finds
# an XML element start.
#
# Arguments:
# token A wrapper token.
# tag A name of the current element. If tDOM is used then
# it contains XMLNS prepended.
# attrs Attributes list.
# -namespace xmlns An XMLNS if TclXML tclparser is used.
#
# Results:
# An empty string.
#
# Side effects:
# If the current element is a outmost one then stream start command is
# called. The current element is added to an XML elements stack.
proc ::xmpp::xml::ElementStart {token tag attrs args} {
variable $token
upvar 0 $token state
if {![info exists state(parser)]} {
return -code error [::msgcat::mc "Parser \"%s\" doesn't exist" $token]
}
array set namespace [lindex $state(namespace) end]
set newattrs {}
foreach {attr val} $attrs {
set l [::split $attr :]
set prefix [lindex $l 0]
set local [lindex $l 1]
if {[string equal $prefix xmlns]} {
set namespace($local) $val
} else {
lappend newattrs $attr $val
}
}
set l [::split $tag :]
if {[llength $l] > 1} {
set prefix [lindex $l 0]
set tag [lindex $l 1]
if {![info exists namespace($prefix)]} {
set xmlns undefined
} else {
set xmlns $namespace($prefix)
}
} else {
set xmlns $namespace()
}
set attrs {}
foreach {attr val} $newattrs {
set l [::split $attr :]
if {[llength $l] > 1} {
set prefix [lindex $l 0]
set attr [lindex $l 1]
if {![info exists namespace($prefix)]} {
if {![string equal $xmlns undefined]} {
set attr undefined:$attr
}
} elseif {![string equal $xmlns $namespace($prefix)]} {
set attr $namespace($prefix):$attr
}
}
lappend attrs $attr $val
}
lappend state(namespace) [array get namespace]
set state(stack) \
[linsert $state(stack) 0 [list $tag $xmlns $attrs {} "" ""]]
if {[llength $state(stack)] == 1} {
uplevel #0 $state(streamHeaderCmd) [list $attrs]
}
return
}
# ::xmpp::xml::ElementEnd --
#
# A callback procedure which is called by a SAX parser when it finds
# an XML element end.
#
# Arguments:
# token A wrapper token.
# tag A name of the current element.
#
# Results:
# An empty string.
#
# Side effects:
# If the current element is a outmost one then stream end command is
# called. If the current element is level one element then stanza
# command is called. In both cases the element removed from the stack.
# Otherwise the current element is inserted into its parent.
proc ::xmpp::xml::ElementEnd {token tag args} {
variable $token
upvar 0 $token state
if {![info exists state(parser)]} {
return -code error [::msgcat::mc "Parser \"%s\" doesn't exist" $token]
}
set state(namespace) [lreplace $state(namespace) end end]
set newEl [lindex $state(stack) 0]
set tail [lrange $state(stack) 1 end]
set len [llength $tail]
if {$len > 1} {
set head [lindex $tail 0]
set els [linsert [lindex $head 3] end $newEl]
set state(stack) [lreplace $tail 0 0 [lreplace $head 3 3 $els]]
} elseif {$len == 1} {
set state(stack) $tail
uplevel #0 $state(stanzaCmd) [list $newEl]
} else {
set state(stack) $tail
uplevel #0 $state(streamTrailerCmd)
}
return
}
# ::xmpp::xml::ElementCdata --
#
# A callback procedure which is called by a SAX parser when it finds
# an XML element CData.
#
# Arguments:
# token A wrapper token.
# cdata Character data.
#
# Results:
# An empty string.
#
# Side effects:
# A given CData is added to a current XML element.
proc ::xmpp::xml::ElementCdata {token cdata} {
variable $token
upvar 0 $token state
if {![info exists state(parser)]} {
return -code error [::msgcat::mc "Parser \"%s\" doesn't exist" $token]
}
set newEl [lindex $state(stack) 0]
set els [lindex $newEl 3]
if {[llength $els] == 0} {
set newEl [lreplace $newEl 4 4 [lindex $newEl 4]$cdata]
} else {
set els [lindex $newEl 3]
set lastEl [lindex $els end]
set lastEl [lreplace $lastEl 5 5 [lindex $lastEl 5]$cdata]
set els [lreplace $els end end $lastEl]
set newEl [lreplace $newEl 3 3 $els]
}
set state(stack) [lreplace $state(stack) 0 0 $newEl]
return
}
# vim:ts=8:sw=4:sts=4:et