/** * https://github.com/tadija/AEXML * Copyright (c) Marko Tadić 2014-2019 * Licensed under the MIT license. See LICENSE file. */ import Foundation #if canImport(FoundationXML) import FoundationXML #endif /** This is base class for holding XML structure. You can access its structure by using subscript like this: `element["foo"]["bar"]` which would return `` element from `` XML as an `Element` object. */ @dynamicMemberLookup open class Element { // MARK: - Properties /// Every `Element` should have its parent element instead of `XMLDocument` which parent is `nil`. open internal(set) weak var parent: Element? /// Child XML elements. open internal(set) var children = [Element]() /// XML Element name. open var name: String /// XML Element value. open var value: String? /// XML Element attributes. open var attributes: [String : String] /// Error value (`nil` if there is no error). open var error: XMLError? // MARK: - Init /** Designated initializer - all parameters are optional. - parameter name: XML element name. - parameter value: XML element value (defaults to `nil`). - parameter attributes: XML element attributes (defaults to empty dictionary). - returns: An initialized `Element` object. */ public init(name: String, value: String? = nil, attributes: [String : String] = [:]) { self.name = name self.value = value self.attributes = attributes } // MARK: - XML Read /// The first element with given name **(Empty element with error if not exists)**. open subscript(key: String) -> Element { if let first = children.first(where: { $0.name == key }) { return first } let element = Element(name: key) element.error = XMLError.elementNotFound return element } public subscript(dynamicMember member: String) -> Element { self[member] } open func get() throws -> T where T: LosslessStringConvertible { guard let string = value, let value = T(string) else { throw XMLError.valueConversionFailed } return value } open func date(formatter: DateFormatter) -> Date? { guard let value = value else { return nil } return formatter.date(from: value) } open func date(formatter: ISO8601DateFormatter = ISO8601DateFormatter()) -> Date? { guard let value = value else { return nil } return formatter.date(from: value) } /// Returns all of the elements with equal name as `self` **(nil if not exists)**. open var all: [Element]? { return parent?.children.filter { $0.name == self.name } } /// Returns the first element with equal name as `self` **(nil if not exists)**. open var first: Element? { return all?.first } /// Returns the last element with equal name as `self` **(nil if not exists)**. open var last: Element? { return all?.last } /// Returns number of all elements with equal name as `self`. open var count: Int { return all?.count ?? 0 } /** Returns all elements with given value. - parameter value: XML element value. - returns: Optional Array of found XML elements. */ open func all(withValue value: String) -> [Element]? { return all?.compactMap { $0.value == value ? $0 : nil } } /** Returns all elements containing given attributes. - parameter attributes: Array of attribute names. - returns: Optional Array of found XML elements. */ open func all(containingAttributeKeys keys: [String]) -> [Element]? { return all?.compactMap { element in keys.reduce(true) { (result, key) in result && Array(element.attributes.keys).contains(key) } ? element : nil } } /** Returns all elements with given attributes. - parameter attributes: Dictionary of Keys and Values of attributes. - returns: Optional Array of found XML elements. */ open func all(withAttributes attributes: [String : String]) -> [Element]? { let keys = Array(attributes.keys) return all(containingAttributeKeys: keys)?.compactMap { element in attributes.reduce(true) { (result, attribute) in result && element.attributes[attribute.key] == attribute.value } ? element : nil } } /** Returns all descendant elements which satisfy the given predicate. Searching is done vertically; children are tested before siblings. Elements appear in the list in the order in which they are found. - parameter predicate: Function which returns `true` for a desired element and `false` otherwise. - returns: Array of found XML elements. */ open func allDescendants(where predicate: (Element) -> Bool) -> [Element] { var result: [Element] = [] for child in children { if predicate(child) { result.append(child) } result.append(contentsOf: child.allDescendants(where: predicate)) } return result } /** Returns the first descendant element which satisfies the given predicate, or nil if no such element is found. Searching is done vertically; children are tested before siblings. - parameter predicate: Function which returns `true` for the desired element and `false` otherwise. - returns: Optional Element. */ open func firstDescendant(where predicate: (Element) -> Bool) -> Element? { for child in children { if predicate(child) { return child } else if let descendant = child.firstDescendant(where: predicate) { return descendant } } return nil } /** Indicates whether the element has a descendant satisfying the given predicate. - parameter predicate: Function which returns `true` for the desired element and `false` otherwise. - returns: Bool. */ open func hasDescendant(where predicate: (Element) -> Bool) -> Bool { return firstDescendant(where: predicate) != nil } // MARK: - XML Write /** Adds child XML element to `self`. - parameter child: Child XML element to add. - returns: Child XML element with `self` as `parent`. */ @discardableResult open func addChild(_ child: Element) -> Element { child.parent = self children.append(child) return child } /** Adds child XML element to `self`. - parameter name: Child XML element name. - parameter value: Child XML element value (defaults to `nil`). - parameter attributes: Child XML element attributes (defaults to empty dictionary). - returns: Child XML element with `self` as `parent`. */ @discardableResult open func addChild(name: String, value: String? = nil, attributes: [String : String] = [:]) -> Element { let child = Element(name: name, value: value, attributes: attributes) return addChild(child) } /** Adds an array of XML elements to `self`. - parameter children: Child XML element array to add. - returns: Child XML elements with `self` as `parent`. */ @discardableResult open func addChildren(_ children: [Element]) -> [Element] { children.forEach{ addChild($0) } return children } /// Removes `self` from `parent` XML element. open func removeFromParent() { if let index = parent?.children.firstIndex(where: { $0 === self }) { parent?.children.remove(at: index) } } /// Complete hierarchy of `self` and `children` in **XML** escaped and formatted String open var xml: String { var xml = String() // open element xml += indent(withDepth: parentsCount - 1) xml += "<\(name)" if attributes.count > 0 { // insert attributes for (key, value) in attributes.sorted(by: { $0.key < $1.key }) { xml += " \(key)=\"\(value.xmlEscaped)\"" } } if value == nil && children.count == 0 { // close element xml += " />" } else { if children.count > 0 { // add children xml += ">\n" for child in children { xml += "\(child.xml)\n" } // add indentation xml += indent(withDepth: parentsCount - 1) xml += "" } else if let string = value { // insert string value and close element xml += ">\(string.xmlEscaped)" } else { // insert empty value and close element xml += ">" } } return xml } /// Same as `xmlString` but without `\n` and `\t` characters open var xmlCompact: String { let chars = CharacterSet(charactersIn: "\n\t") return xml.components(separatedBy: chars).joined(separator: "") } /// Same as `xmlString` but with 4 spaces instead '\t' characters open var xmlSpaces: String { let chars = CharacterSet(charactersIn: "\t") return xml.components(separatedBy: chars).joined(separator: " ") } // MARK: - Helpers private var parentsCount: Int { var count = 0 var element = self while let parent = element.parent { count += 1 element = parent } return count } private func indent(withDepth depth: Int) -> String { var count = depth var indent = String() while count > 0 { indent += "\t" count -= 1 } return indent } } public extension String { /// String representation of self with XML special characters escaped. var xmlEscaped: String { // we need to make sure "&" is escaped first. Not doing this may break escaping the other characters var escaped = replacingOccurrences(of: "&", with: "&", options: .literal) // replace the other four special characters let escapeChars = ["<" : "<", ">" : ">", "'" : "'", "\"" : """] for (char, echar) in escapeChars { escaped = escaped.replacingOccurrences(of: char, with: echar, options: .literal) } return escaped } }