From 6174e45645781ac9fbd1d3518ef1760056714434 Mon Sep 17 00:00:00 2001 From: Vhati Date: Sun, 1 Sep 2013 14:28:31 -0400 Subject: [PATCH] Added XMLPatcher for fancy mod syntax; not wired to the Patch button yet --- .../net/vhati/modmanager/core/XMLPatcher.java | 554 ++++++++++++++++++ 1 file changed, 554 insertions(+) create mode 100644 src/main/java/net/vhati/modmanager/core/XMLPatcher.java diff --git a/src/main/java/net/vhati/modmanager/core/XMLPatcher.java b/src/main/java/net/vhati/modmanager/core/XMLPatcher.java new file mode 100644 index 0000000..63d7e26 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/core/XMLPatcher.java @@ -0,0 +1,554 @@ +package net.vhati.modmanager.core; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import net.vhati.modmanager.core.SloppyXMLParser; + +import org.jdom2.Attribute; +import org.jdom2.Content; +import org.jdom2.Document; +import org.jdom2.Element; +import org.jdom2.Namespace; +import org.jdom2.filter.AbstractFilter; +import org.jdom2.filter.ElementFilter; +import org.jdom2.filter.Filter; +import org.jdom2.input.JDOMParseException; +import org.jdom2.input.SAXBuilder; + + +/** + * Programmatically edits existing XML with instructions from another XML doc. + * Other tags are simply appended as-is. + */ +public class XMLPatcher { + + protected Namespace modNS; + protected Namespace modAppendNS; + protected Namespace modOverwriteNS; + + + public XMLPatcher() { + modNS = Namespace.getNamespace( "mod", "mod" ); + modAppendNS = Namespace.getNamespace( "mod-append", "mod-append" ); + modOverwriteNS = Namespace.getNamespace( "mod-overwrite", "mod-overwrite" ); + } + + + public Document patch( Document mainDoc, Document appendDoc ) { + Document resultDoc = mainDoc.clone(); + Element resultRoot = resultDoc.getRootElement(); + Element appendRoot = appendDoc.getRootElement(); + + ElementFilter modFilter = new ElementFilter( modNS ); + for ( Content content : appendRoot.getContent() ) { + if ( modFilter.matches( content ) ) { + Element node = (Element)content; + + boolean handled = false; + List matchedNodes = handleModFind( resultRoot, node ); + if ( matchedNodes != null ) { + handled = true; + for ( Element matchedNode : matchedNodes ) { + handleModCommands( matchedNode, node ); + } + } + + if ( !handled ) { + throw new IllegalArgumentException( String.format( "Unrecognized mod tag <%s> (%s).", node.getName(), getPathToRoot(node) ) ); + } + } + else { + resultRoot.addContent( content.clone() ); + } + } + + return resultDoc; + } + + + /** + * Returns find results if node is a find tag, or null if it's not. + * + * An empty list will be returned if there were no matches. + * + * TODO: Throw an exception in callers if results are required. + */ + protected List handleModFind( Element contextNode, Element node ) { + List result = null; + + if ( node.getNamespace().equals( modNS ) ) { + + if ( node.getName().equals( "findName" ) ) { + + String searchName = node.getAttributeValue( "name" ); + boolean searchReverse = getAttributeBooleanValue( node, "reverse", true ); + int searchStart = getAttributeIntValue( node, "start", 0 ); + int searchLimit = getAttributeIntValue( node, "limit", 1 ); + + if ( searchName == null || searchName.length() == 0 ) + throw new IllegalArgumentException( String.format( "<%s> requires a name attribute (%s).", node.getName(), getPathToRoot(node) ) ); + if ( searchStart < 0 ) + throw new IllegalArgumentException( String.format( "<%s> 'start' attribute is not >= 0 (%s).", node.getName(), getPathToRoot(node) ) ); + if ( searchLimit < -1 ) + throw new IllegalArgumentException( String.format( "<%s> 'limit' attribute is not >= -1 (%s).", node.getName(), getPathToRoot(node) ) ); + + Map attrMap = new HashMap(); + attrMap.put( "name", searchName ); + LikeFilter searchFilter = new LikeFilter( null, attrMap, null ); + + List matchedNodes = new ArrayList( contextNode.getContent( searchFilter ) ); + if ( searchReverse ) Collections.reverse( matchedNodes ); + + if ( searchStart < matchedNodes.size() ) { + if ( searchLimit > -1 ) { + matchedNodes = matchedNodes.subList( searchStart, Math.max( matchedNodes.size(), searchStart + searchLimit ) ); + } else if ( searchStart > 0 ) { + matchedNodes = matchedNodes.subList( searchStart, matchedNodes.size() ); + } + } + + result = matchedNodes; + } + else if ( node.getName().equals( "findLike" ) ) { + + String searchType = node.getAttributeValue( "type" ); + boolean searchReverse = getAttributeBooleanValue( node, "reverse", false ); + int searchStart = getAttributeIntValue( node, "start", 0 ); + int searchLimit = getAttributeIntValue( node, "limit", -1 ); + + if ( searchType != null && searchType.length() == 0 ) + throw new IllegalArgumentException( String.format( "<%s> type attribute, when present, can't be empty (%s).", node.getName(), getPathToRoot(node) ) ); + if ( searchStart < 0 ) + throw new IllegalArgumentException( String.format( "<%s> 'start' attribute is not >= 0 (%s).", node.getName(), getPathToRoot(node) ) ); + if ( searchLimit < -1 ) + throw new IllegalArgumentException( String.format( "<%s> 'limit' attribute is not >= -1 (%s).", node.getName(), getPathToRoot(node) ) ); + + Map attrMap = new HashMap(); + String searchValue = null; + + Element selectorNode = node.getChild( "selector", modNS ); + if ( selectorNode != null ) { + for ( Attribute attr : selectorNode.getAttributes() ) { + if ( attr.getNamespace().equals( Namespace.NO_NAMESPACE ) ) { + // Blank element values can't be detected as different from absent values (never null). + // Forbid "" attributes for consistency. :/ + if ( attr.getValue().length() == 0 ) + throw new IllegalArgumentException( String.format( "<%s> attributes, when present, can't be empty (%s).", selectorNode.getName(), getPathToRoot(selectorNode) ) ); + + attrMap.put( attr.getName(), attr.getValue() ); + } + } + searchValue = selectorNode.getTextTrim(); // Never null, but often "". + if ( searchValue.length() > 0 ) searchValue = null; + } + + LikeFilter searchFilter = new LikeFilter( searchType, attrMap, searchValue ); + + List matchedNodes = new ArrayList( contextNode.getContent( searchFilter ) ); + if ( searchReverse ) Collections.reverse( matchedNodes ); + + if ( searchStart < matchedNodes.size() ) { + if ( searchLimit > -1 ) { + matchedNodes = matchedNodes.subList( searchStart, Math.max( matchedNodes.size(), searchStart + searchLimit ) ); + } else if ( searchStart > 0 ) { + matchedNodes = matchedNodes.subList( searchStart, matchedNodes.size() ); + } + } + + result = matchedNodes; + } + else if ( node.getName().equals( "findWithChildLike" ) ) { + + String searchType = node.getAttributeValue( "type" ); + String searchChildType = node.getAttributeValue( "child-type" ); + boolean searchReverse = getAttributeBooleanValue( node, "reverse", false ); + int searchStart = getAttributeIntValue( node, "start", 0 ); + int searchLimit = getAttributeIntValue( node, "limit", -1 ); + + if ( searchType != null && searchType.length() == 0 ) + throw new IllegalArgumentException( String.format( "<%s> type attribute, when present, can't be empty (%s).", node.getName(), getPathToRoot(node) ) ); + if ( searchChildType != null && searchChildType.length() == 0 ) + throw new IllegalArgumentException( String.format( "<%s> child-type attribute, when present, can't be empty (%s).", node.getName(), getPathToRoot(node) ) ); + if ( searchStart < 0 ) + throw new IllegalArgumentException( String.format( "<%s> 'start' attribute is not >= 0 (%s).", node.getName(), getPathToRoot(node) ) ); + if ( searchLimit < -1 ) + throw new IllegalArgumentException( String.format( "<%s> 'limit' attribute is not >= -1 (%s).", node.getName(), getPathToRoot(node) ) ); + + Map attrMap = new HashMap(); + String searchValue = null; + + Element selectorNode = node.getChild( "selector", modNS ); + if ( selectorNode != null ) { + for ( Attribute attr : selectorNode.getAttributes() ) { + if ( attr.getNamespace().equals( Namespace.NO_NAMESPACE ) ) { + // TODO: Forbid "" attributes, because blank value doesn't work? + attrMap.put( attr.getName(), attr.getValue() ); + } + } + searchValue = selectorNode.getTextTrim(); // Never null, but often "". + if ( searchValue.length() > 0 ) searchValue = null; + } + + LikeFilter searchChildFilter = new LikeFilter( searchChildType, attrMap, searchValue ); + WithChildFilter searchFilter = new WithChildFilter( searchType, searchChildFilter ); + + List matchedNodes = new ArrayList( contextNode.getContent( searchFilter ) ); + if ( searchReverse ) Collections.reverse( matchedNodes ); + + if ( searchStart < matchedNodes.size() ) { + if ( searchLimit > -1 ) { + matchedNodes = matchedNodes.subList( searchStart, Math.max( matchedNodes.size(), searchStart + searchLimit ) ); + } else if ( searchStart > 0 ) { + matchedNodes = matchedNodes.subList( searchStart, matchedNodes.size() ); + } + } + + result = matchedNodes; + } + else if ( node.getName().equals( "findComposite" ) ) { + + boolean searchReverse = getAttributeBooleanValue( node, "reverse", false ); + int searchStart = getAttributeIntValue( node, "start", 0 ); + int searchLimit = getAttributeIntValue( node, "limit", -1 ); + + if ( searchStart < 0 ) + throw new IllegalArgumentException( String.format( "<%s> 'start' attribute is not >= 0 (%s).", node.getName(), getPathToRoot(node) ) ); + if ( searchLimit < -1 ) + throw new IllegalArgumentException( String.format( "<%s> 'limit' attribute is not >= -1 (%s).", node.getName(), getPathToRoot(node) ) ); + + Element parNode = node.getChild( "par", modNS ); + if ( parNode == null ) + throw new IllegalArgumentException( String.format( "<%s> requires a tag (%s).", node.getName(), getPathToRoot(node) ) ); + + List matchedNodes = handleModPar( contextNode, parNode ); + if ( searchReverse ) Collections.reverse( matchedNodes ); + + if ( searchStart < matchedNodes.size() ) { + if ( searchLimit > -1 ) { + matchedNodes = matchedNodes.subList( searchStart, Math.max( matchedNodes.size(), searchStart + searchLimit ) ); + } else if ( searchStart > 0 ) { + matchedNodes = matchedNodes.subList( searchStart, matchedNodes.size() ); + } + } + + result = matchedNodes; + } + } + + return result; + } + + + /** + * Returns collated find results (and par results, handled recursively), or null if node wasn't a par. + * + * Unique results from all finds will be combined and sorted in the order they appear under contextNode. + */ + protected List handleModPar( Element contextNode, Element node ) { + List result = null; + + if ( node.getNamespace().equals( modNS ) ) { + + if ( node.getName().equals( "par" ) ) { + + String parOp = node.getAttributeValue( "op" ); + + if ( parOp == null || (!parOp.equals("AND") && !parOp.equals("OR")) ) + throw new IllegalArgumentException( String.format( "Invalid \"op\" attribute (%s). Must be 'AND' or 'OR'.", getPathToRoot(node) ) ); + + boolean isAnd = parOp.equals("AND"); + boolean isOr = parOp.equals("OR"); + + Set candidateSet = new HashSet(); + for ( Element criteriaNode : node.getChildren() ) { + List candidates; + if ( criteriaNode.getName().equals( "par" ) && criteriaNode.getNamespace().equals( modNS ) ) { + candidates = handleModPar( contextNode, criteriaNode ); + } else { + candidates = handleModFind( contextNode, criteriaNode ); + if ( candidates == null ) + throw new IllegalArgumentException( String.format( "Invalid search criteria <%s> (%s). Must be a or .", criteriaNode.getName(), getPathToRoot(criteriaNode) ) ); + } + + if ( isOr || candidateSet.isEmpty() ) { + candidateSet.addAll( candidates ); + } + else if ( isAnd ) { + candidateSet.retainAll( candidates ); + } + } + Map orderedCandidateMap = new TreeMap(); + for ( Element candidate : candidateSet ) { + int index = contextNode.indexOf( candidate ); + orderedCandidateMap.put( new Integer(index), candidate ); + } + + List matchedNodes = new ArrayList( orderedCandidateMap.values() ); + + result = matchedNodes; + } + } + + return result; + } + + + /** + * Performs child mod-commands under node, against contextNode. + * + * TODO: Maybe have handleModCommand() returning null when unrecognized, + * or an object with flags to continue or stop looping commands at + * contextNode (e.g., halting after removeTag). + */ + protected void handleModCommands( Element contextNode, Element node ) { + + for ( Element cmdNode : node.getChildren() ) { + boolean handled = false; + + if ( cmdNode.getNamespace().equals( modNS ) ) { + + // Handle nested finds. + List matchedNodes = handleModFind( contextNode, cmdNode ); + if ( matchedNodes != null ) { + handled = true; + for ( Element matchedNode : matchedNodes ) { + handleModCommands( matchedNode, cmdNode ); + } + } + else if ( cmdNode.getName().equals( "selector" ) ) { + handled = true; + // No-op. + } + else if ( cmdNode.getName().equals( "par" ) ) { + handled = true; + // No-op. + } + else if ( cmdNode.getName().equals( "setAttributes" ) ) { + handled = true; + for ( Attribute attrib : cmdNode.getAttributes() ) { + contextNode.setAttribute( attrib.clone() ); + } + } + else if ( cmdNode.getName().equals( "setValue" ) ) { + handled = true; + contextNode.setText( cmdNode.getTextTrim() ); + } + else if ( cmdNode.getName().equals( "removeTag" ) ) { + handled = true; + contextNode.detach(); + break; + } + + } + + else if ( cmdNode.getNamespace().equals( modAppendNS ) ) { + // Append cmdNode (sans namespace) to the contextNode. + handled = true; + + Element newNode = cmdNode.clone(); + newNode.setNamespace( null ); + contextNode.addContent( newNode ); + } + + else if ( cmdNode.getNamespace().equals( modOverwriteNS ) ) { + // Remove the first child with the same type and insert cmdNode at its position. + // Or just append if nothing was replaced. + handled = true; + + Element newNode = cmdNode.clone(); + newNode.setNamespace( null ); + + Element doomedNode = contextNode.getChild( cmdNode.getName(), null ); + if ( doomedNode != null ) { + int doomedIndex = contextNode.indexOf( doomedNode ); + doomedNode.detach(); + contextNode.addContent( doomedIndex, newNode ); + } + else { + contextNode.addContent( newNode ); + } + } + + if ( !handled ) { + throw new IllegalArgumentException( String.format( "Unrecognized mod tag <%s> (%s).", cmdNode.getName(), getPathToRoot(cmdNode) ) ); + } + } + } + + + /** + * Returns a string describing this element's location. + * + * Example: /root/event(SOME_NAME)/choice/text + */ + protected String getPathToRoot( Element node ) { + StringBuilder buf = new StringBuilder(); + String chunk; + String tmp; + while ( node != null ) { + chunk = "/"+ node.getName(); + + tmp = node.getAttributeValue( "name" ); + if ( tmp != null && tmp.length() > 0 ) + chunk += "("+ tmp +")"; + + buf.insert( 0, chunk ); + node = node.getParentElement(); + } + return buf.toString(); + } + + + /** + * Returns the boolean value of an attribute, or a default when the attribute is null. + * Only 'true' and 'false' are accepted. + */ + protected boolean getAttributeBooleanValue( Element node, String attrName, boolean defaultValue ) { + String tmp = node.getAttributeValue( attrName ); + if ( tmp == null ) return defaultValue; + + if ( tmp.equals( "true" ) ) { + return true; + } else if ( tmp.equals( "false" ) ) { + return false; + } else { + throw new IllegalArgumentException( String.format( "Invalid boolean attribute \"%s\" (%s). Must be 'true' or 'false'.", attrName, getPathToRoot(node) ) ); + } + } + + /** + * Returns the int value of an attribute, or a default when the attribute is null. + */ + protected int getAttributeIntValue( Element node, String attrName, int defaultValue ) { + String tmp = node.getAttributeValue( attrName ); + if ( tmp == null ) return defaultValue; + try { + return Integer.parseInt( tmp ); + } + catch ( NumberFormatException e ) { + throw new IllegalArgumentException( String.format( "Invalid int attribute \"%s\" (%s).", attrName, getPathToRoot(node) ) ); + } + } + + + /** + * Matches elements with equal type/attributes/value. + * Null args are ignored. A blank type or value arg is ignored. + * All given attributes must be present on a candidate to match. + * Attribute values in the map must not be null. + */ + protected static class LikeFilter extends AbstractFilter { + private String type = null;; + private Map attrMap = null; + private String value = null; + + public LikeFilter( String type, Element selectorNode ) { + this.type = type; + + if ( selectorNode.hasAttributes() ) { + this.attrMap = new HashMap(); + for ( Attribute attr : selectorNode.getAttributes() ) { + attrMap.put( attr.getName(), attr.getValue() ); + } + } + + this.value = selectorNode.getTextTrim(); + if ( this.value.length() == 0 ) this.value = null; + } + + public LikeFilter( String type, Map attrMap, String value ) { + if ( type != null && type.length() == 0 ) type = null; + if ( value != null && value.length() == 0 ) value = null; + + this.type = type; + this.attrMap = attrMap; + this.value = value; + } + + @Override + public Element filter( Object content ) { + if ( content instanceof Element == false ) return null; + Element node = (Element)content; + String tmp; + + if ( type != null ) { + if ( type.equals( node.getName() ) == false ) { + return null; + } + } + + if ( attrMap != null ) { + for ( Map.Entry entry : attrMap.entrySet() ) { + String attrName = entry.getKey(); + String attrValue = entry.getValue(); + tmp = node.getAttributeValue( attrName ); + + if ( attrValue.equals( tmp ) == false ) { + return null; + } + } + } + + if ( value != null ) { + if ( value.equals( node.getTextTrim() ) == false ) { + return null; + } + } + return node; + } + } + + + + /** + * Matches elements with child elements that match a filter. + * If the filter is null, matches all elements with children. + */ + protected static class WithChildFilter extends AbstractFilter { + private String type; + private Filter childFilter; + + public WithChildFilter( Filter childFilter ) { + this( null, childFilter ); + } + + public WithChildFilter( String type, Filter childFilter ) { + this.type = type; + this.childFilter = childFilter; + } + + @Override + public Element filter( Object content ) { + if ( content instanceof Element == false ) return null; + Element node = (Element)content; + + if ( type != null ) { + if ( type.equals( node.getName() ) == false ) { + return null; + } + } + + if ( childFilter != null ) { + if ( node.getContent( childFilter ).isEmpty() ) + return null; + } + else if ( node.getChildren().isEmpty() ) { + return null; + } + return node; + } + } +}