/** * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file * distributed with this work for additional information regarding copyright ownership. Apereo * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use * this file except in compliance with the License. You may obtain a copy of the License at the * following location: * * <p>http://www.apache.org/licenses/LICENSE-2.0 * * <p>Unless required by applicable law or agreed to in writing, software distributed under the * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either * express or implied. See the License for the specific language governing permissions and * limitations under the License. */ package org.apereo.portal.layout.dlm; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import javax.xml.xpath.XPath; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpression; import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathFactory; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apereo.portal.PortalException; import org.apereo.portal.layout.IUserLayoutStore; import org.apereo.portal.security.IPerson; import org.apereo.portal.spring.locator.UserLayoutStoreLocator; import org.apereo.portal.xml.XmlUtilitiesImpl; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; /** * Applies and updates position specifiers for child nodes in the composite layout. * * @since 2.5 */ public class PositionManager { private static Log LOG = LogFactory.getLog(PositionManager.class); private static IUserLayoutStore dls = null; /** * Hands back the single instance of RDBMDistributedLayoutStore. There is already a method for * acquiring a single instance of the configured layout store so we delegate over there so that * all references refer to the same instance. This method is solely for convenience so that we * don't have to keep calling UserLayoutStoreFactory and casting the resulting class. */ private static IUserLayoutStore getDLS() { if (dls == null) { dls = UserLayoutStoreLocator.getUserLayoutStore(); } return dls; } /** * This method and ones that it delegates to have the responsibility of organizing the child * nodes of the passed in composite view parent node according to the order specified in the * passed in position set and return via the passed in result set whether the personal layout * fragment (one portion of which is the position set) or the incoporated layouts fragment (one * portion of which is the compViewParent) were changed. * * <p>This may also include pulling nodes in from other parents under certain circumstances. For * example, if allowed a user can move nodes that are not part of their personal layout fragment * or PLF; the UI elements that they own. These node do not exist in their layout in the * database but instead are merged in with their owned elements at log in and other times. So to * move them during subsequent merges a position set can contain a position directive indicating * the id of the node to be moved into a specific position in the sibling list and that well may * refer to a node not in the sibling list to begin with. If the node no longer exists in the * composite view then that position directive can safely be discarded. * * <p>Positioning is meant to preserve as much as possible the user's specified ordering of user * interface elements but always respecting movement restrictions placed on those elements that * are incorporated by their owners. So the following rules apply from most important to least. * * <p>1) nodes with moveAllowed="false" prevent nodes of lower precedence from being to their * left or higher with left or higher defined as having a lower index in the sibling list. * (applyLowerPrecRestriction) * * <p>2) nodes with moveAllowed="false" prevent nodes of equal precedence from moving from one * side of this node to the other from their position as found in the compViewParent initially * and prevents nodes with the same precedence from moving from other parents into this parent * prior to the restricted node. Prior to implies a lower sibling index. * (applyHoppingRestriction) * * <p>3) nodes with moveAllowed="false" prevent nodes of equal precedence lower in the sibling * list from being reparented. (ie: moving to another parent) However, they can be deleted. * (applyReparentingCheck) * * <p>4) nodes should be ordered as much as possible in the order specified by the user but in * view of the above conditions. So if a user has moved nodes thus specifying some order and the * owner of some node in that set then locks one of those nodes some of those nodes will have to * move back to their orinial positions to conform with the rules above but for the remaining * nodes they should be found in the same relative order specified by the user. (getOrder) * * <p>5) nodes not included in the order specified by the user (ie: nodes added since the user * last ordered them) should maintain their relative order as much as possible and be appended * to the end of the sibling list after all others rules have been applied. (getOrder) * * <p>Each of these rules is applied by a call to a method 5 being first and 1 being last so * that 1 has the highest precedence and last say. Once the final ordering is specified then it * is applied to the children of the compViewParent and returned. */ static void applyPositions( Element compViewParent, Element positionSet, IntegrationResult result, NodeInfoTracker tracker) throws PortalException { if (positionSet == null || positionSet.getFirstChild() == null) return; List<NodeInfo> order = new ArrayList<NodeInfo>(); applyOrdering(order, compViewParent, positionSet, tracker); applyNoReparenting(order, compViewParent, positionSet); applyNoHopping(order); applyLowerPrecedence(order); evaluateAndApply(order, compViewParent, positionSet, result); } /** * This method determines if applying all of the positioning rules and restrictions ended up * making changes to the compViewParent or the original position set. If changes are applicable * to the CVP then they are applied. If the position set changed then the original stored in the * PLF is updated. */ static void evaluateAndApply( List<NodeInfo> order, Element compViewParent, Element positionSet, IntegrationResult result) throws PortalException { adjustPositionSet(order, positionSet, result); if (hasAffectOnCVP(order, compViewParent)) { applyToNodes(order, compViewParent); result.setChangedILF(true); ; } } /** * This method trims down the position set to the position directives on the node info elements * still having a position directive. Any directives that violated restrictions were removed * from the node info objects so the position set should be made to match the order of those * still having one. */ static void adjustPositionSet( List<NodeInfo> order, Element positionSet, IntegrationResult result) { Node nodeToMatch = positionSet.getFirstChild(); Element nodeToInsertBefore = positionSet.getOwnerDocument().createElement("INSERT_POINT"); positionSet.insertBefore(nodeToInsertBefore, nodeToMatch); for (Iterator<NodeInfo> iter = order.iterator(); iter.hasNext(); ) { NodeInfo ni = iter.next(); if (ni.getPositionDirective() != null) { // found one check it against the current one in the position // set to see if it is different. If so then indicate that // something (the position set) has changed in the plf if (ni.getPositionDirective() != nodeToMatch) result.setChangedPLF(true); ; // now bump the insertion point forward prior to // moving on to the next position node to be evaluated if (nodeToMatch != null) nodeToMatch = nodeToMatch.getNextSibling(); // now insert it prior to insertion point positionSet.insertBefore(ni.getPositionDirective(), nodeToInsertBefore); } } // now for any left over after the insert point remove them. while (nodeToInsertBefore.getNextSibling() != null) positionSet.removeChild(nodeToInsertBefore.getNextSibling()); // now remove the insertion point positionSet.removeChild(nodeToInsertBefore); } /** * This method compares the children by id in the order list with the order in the * compViewParent's ui visible children and returns true if the ordering differs indicating that * the positioning if needed. */ static boolean hasAffectOnCVP(List<NodeInfo> order, Element compViewParent) { if (order.size() == 0) return false; int idx = 0; Element child = (Element) compViewParent.getFirstChild(); NodeInfo ni = order.get(idx); if (child == null && ni != null) // most likely nodes to be pulled in return true; while (child != null) { if (child.getAttribute("hidden").equals("false") && (!child.getAttribute("chanID").equals("") || child.getAttribute("type").equals("regular"))) { if (ni.getId().equals(child.getAttribute(Constants.ATT_ID))) { if (idx >= order.size() - 1) // at end of order list return false; ni = order.get(++idx); } else // if not equal then return true return true; } child = (Element) child.getNextSibling(); } if (idx < order.size()) return true; // represents nodes to be pulled in return false; } /** * This method applies the ordering specified in the passed in order list to the child nodes of * the compViewParent. Nodes specified in the list but located elsewhere are pulled in. */ static void applyToNodes(List<NodeInfo> order, Element compViewParent) { // first set up a bogus node to assist with inserting Node insertPoint = compViewParent.getOwnerDocument().createElement("bogus"); Node first = compViewParent.getFirstChild(); if (first != null) compViewParent.insertBefore(insertPoint, first); else compViewParent.appendChild(insertPoint); // now pass through the order list inserting the nodes as you go for (int i = 0; i < order.size(); i++) compViewParent.insertBefore(order.get(i).getNode(), insertPoint); compViewParent.removeChild(insertPoint); } /** * This method is responsible for preventing nodes with lower precedence from being located to * the left (lower sibling order) of nodes having a higher precedence and moveAllowed="false". */ static void applyLowerPrecedence(List<NodeInfo> order) { for (int i = 0; i < order.size(); i++) { NodeInfo ni = order.get(i); if (ni.getNode().getAttribute(Constants.ATT_MOVE_ALLOWED).equals("false")) { for (int j = 0; j < i; j++) { NodeInfo lefty = order.get(j); if (lefty.getPrecedence() == null || lefty.getPrecedence().isLessThan(ni.getPrecedence())) { order.remove(j); order.add(i, lefty); } } } } } /** * This method is responsible for preventing nodes with identical precedence in the same parent * from hopping over each other so that a layout fragment can lock two tabs that are next to * each other and they can only be separated by tabs with higher precedence. * * <p>If this situation is detected then the positioning of all nodes currently in the * compViewParent is left as they are found in the CVP with any nodes brought in from other * parents appended at the end with their relative order preserved. */ static void applyNoHopping(List<NodeInfo> order) { if (isIllegalHoppingSpecified(order) == true) { ArrayList<NodeInfo> cvpNodeInfos = new ArrayList<>(); // pull those out of the position list from the CVP for (int i = order.size() - 1; i >= 0; i--) if (order.get(i).getIndexInCVP() != -1) cvpNodeInfos.add(order.remove(i)); // what is left is coming from other parents. Now push them back in // in the order specified in the CVP NodeInfo[] nodeInfos = cvpNodeInfos.toArray(new NodeInfo[cvpNodeInfos.size()]); Arrays.sort(nodeInfos, new NodeInfoComparator()); List<NodeInfo> list = Arrays.asList(nodeInfos); order.addAll(0, list); } } /** * This method determines if any illegal hopping is being specified. To determine if the * positioning is specifying an ordering that will result in hopping I need to determine for * each node n in the list if any of the nodes to be positioned to its right currently lie to * its left in the CVP and have moveAllowed="false" and have the same precedence or if any of * the nodes to be positioned to its left currently lie to its right in the CVP and have * moveAllowed="false" and have the same precedence. */ static boolean isIllegalHoppingSpecified(List<NodeInfo> order) { for (int i = 0; i < order.size(); i++) { NodeInfo ni = (NodeInfo) order.get(i); // look for move restricted nodes if (!ni.getNode().getAttribute(Constants.ATT_MOVE_ALLOWED).equals("false")) continue; // now check nodes in lower position to see if they "hopped" here // or if they have similar precedence and came from another parent. for (int j = 0; j < i; j++) { NodeInfo niSib = (NodeInfo) order.get(j); // skip lower precedence nodes from this parent. These will get // bumped during the lower precedence check if (niSib.getPrecedence() == Precedence.getUserPrecedence()) continue; if (niSib.getPrecedence().isEqualTo(ni.getPrecedence()) && (niSib.getIndexInCVP() == -1 || // from another parent ni.getIndexInCVP() < niSib.getIndexInCVP())) // niSib hopping left return true; } // now check upper positioned nodes to see if they "hopped" for (int j = i + 1; j < order.size(); j++) { NodeInfo niSib = (NodeInfo) order.get(j); // ignore nodes from other parents and user precedence nodes if (niSib.getIndexInCVP() == -1 || niSib.getPrecedence() == Precedence.getUserPrecedence()) continue; if (ni.getIndexInCVP() > niSib.getIndexInCVP() && // niSib hopped right niSib.getPrecedence().isEqualTo(ni.getPrecedence())) return true; } } return false; } /** * This method scans through the nodes in the ordered list and identifies those that are not in * the passed in compViewParent. For those it then looks in its current parent and checks to see * if there are any down- stream (higher sibling index) siblings that have moveAllowed="false". * If any such sibling is found then the node is not allowed to be reparented and is removed * from the list. */ static void applyNoReparenting( List<NodeInfo> order, Element compViewParent, Element positionSet) { int i = 0; while (i < order.size()) { NodeInfo ni = order.get(i); if (!ni.getNode().getParentNode().equals(compViewParent)) { if (isNotReparentable(ni, compViewParent, positionSet)) { LOG.info( "Resetting the following NodeInfo because it is not reparentable: " + ni); // this node should not be reparented. If it was placed // here by way of a position directive then delete that // directive out of the ni and posSet will be updated later ni.setPositionDirective(null); // now we need to remove it from the ordering list but // skip incrementing i, deleted ni now filled by next ni order.remove(i); continue; } } i++; } } /** * Return true if the passed in node or any downstream (higher index) siblings <strong>relative * to its destination location</strong> have moveAllowed="false". */ private static boolean isNotReparentable( NodeInfo ni, Element compViewParent, Element positionSet) { // This one is easy -- can't re-parent a node with dlm:moveAllowed=false if (ni.getNode().getAttribute(Constants.ATT_MOVE_ALLOWED).equals("false")) { return true; } try { /* * Annoying to do in Java, but we need to find our own placeholder * element in the positionSet */ final XPathFactory xpathFactory = XPathFactory.newInstance(); final XPath xpath = xpathFactory.newXPath(); final String findPlaceholderXpath = ".//*[local-name()='position' and @name='" + ni.getId() + "']"; final XPathExpression findPlaceholder = xpath.compile(findPlaceholderXpath); final NodeList findPlaceholderList = (NodeList) findPlaceholder.evaluate(positionSet, XPathConstants.NODESET); switch (findPlaceholderList.getLength()) { case 0: LOG.warn( "Node not found for XPathExpression=\"" + findPlaceholderXpath + "\" in positionSet=" + XmlUtilitiesImpl.toString(positionSet)); return true; case 1: // This is healthy break; default: LOG.warn( "More than one node found for XPathExpression=\"" + findPlaceholderXpath + "\" in positionSet=" + XmlUtilitiesImpl.toString(positionSet)); return true; } final Element placeholder = (Element) findPlaceholderList.item(0); // At last for (Element nextPlaceholder = (Element) placeholder .getNextSibling(); // Start with the next dlm:position element after placeholder nextPlaceholder != null; // As long as we have a placeholder to look at nextPlaceholder = (Element) nextPlaceholder .getNextSibling()) { // Advance to the next placeholder if (LOG.isDebugEnabled()) { LOG.debug( "Considering whether node ''" + ni.getId() + "' is Reparentable; subsequent sibling is: " + nextPlaceholder.getAttribute("name")); } /* * Next task: we have to find the non-placeholder representation of * the nextSiblingPlaceholder within the compViewParent */ final String unmaskPlaceholderXpath = ".//*[@ID='" + nextPlaceholder.getAttribute("name") + "']"; final XPathExpression unmaskPlaceholder = xpath.compile(unmaskPlaceholderXpath); final NodeList unmaskPlaceholderList = (NodeList) unmaskPlaceholder.evaluate(compViewParent, XPathConstants.NODESET); switch (unmaskPlaceholderList.getLength()) { case 0: // Not a problem; the nextSiblingPlaceholder also refers // to a node that has been moved to this context (afaik) continue; case 1: final Element nextSibling = (Element) unmaskPlaceholderList.item(0); if (LOG.isDebugEnabled()) { LOG.debug( "Considering whether node ''" + ni.getId() + "' is Reparentable; subsequent sibling '" + nextSibling.getAttribute("ID") + "' has dlm:moveAllowed=" + !nextSibling .getAttribute(Constants.ATT_MOVE_ALLOWED) .equals("false")); } // Need to perform some checks... if (nextSibling.getAttribute(Constants.ATT_MOVE_ALLOWED).equals("false")) { /* * The following check is a bit strange; it seems to verify * that the current NodeInfo and the nextSibling come from the * same fragment. If they don't, the re-parenting is allowable. * I believe this check could only be unsatisfied in the case * of tabs. */ Precedence p = Precedence.newInstance( nextSibling.getAttribute(Constants.ATT_FRAGMENT)); if (ni.getPrecedence().isEqualTo(p)) { return true; } } break; default: LOG.warn( "More than one node found for XPathExpression=\"" + unmaskPlaceholderXpath + "\" in compViewParent"); return true; } } } catch (XPathExpressionException xpe) { throw new RuntimeException("Failed to evaluate XPATH", xpe); } return false; // Re-parenting is "not disallowed" (double-negative less readable) } /** * This method assembles in the passed in order object a list of NodeInfo objects ordered first * by those specified in the position set and whose nodes still exist in the composite view and * then by any remaining children in the compViewParent. */ static void applyOrdering( List<NodeInfo> order, Element compViewParent, Element positionSet, NodeInfoTracker tracker) { // first pull out all visible channel or visible folder children and // put their id's in a list of available children and record their // relative order in the CVP. final Map<String, NodeInfo> available = new LinkedHashMap<String, NodeInfo>(); Element child = (Element) compViewParent.getFirstChild(); Element next = null; int indexInCVP = 0; while (child != null) { next = (Element) child.getNextSibling(); if (child.getAttribute("hidden").equals("false") && (!child.getAttribute("chanID").equals("") || child.getAttribute("type").equals("regular"))) { final NodeInfo nodeInfo = new NodeInfo(child, indexInCVP++); tracker.track(order, compViewParent, positionSet); final NodeInfo prevNode = available.put(nodeInfo.getId(), nodeInfo); if (prevNode != null) { throw new IllegalStateException( "Infinite loop detected in layout. Triggered by " + nodeInfo.getId() + " with already visited node ids: " + available.keySet()); } } child = next; } // now fill the order list using id's from the position set if nodes // having those ids exist in the composite view. Otherwise discard // that position directive. As they are added to the list remove them // from the available nodes in the parent. Document CV = compViewParent.getOwnerDocument(); Element directive = (Element) positionSet.getFirstChild(); while (directive != null) { next = (Element) directive.getNextSibling(); // id of child to move is in the name attrib on the position nodes String id = directive.getAttribute("name"); child = CV.getElementById(id); if (child != null) { // look for the NodeInfo for this node in the available // nodes and if found use that one. Otherwise use a new that // does not include an index in the CVP parent. In either case // indicate the position directive responsible for placing this // NodeInfo object in the list. final String childId = child.getAttribute(Constants.ATT_ID); NodeInfo ni = available.remove(childId); if (ni == null) { ni = new NodeInfo(child); tracker.track(order, compViewParent, positionSet); } ni.setPositionDirective(directive); order.add(ni); } directive = next; } // now append any remaining ids from the available list maintaining // the order that they have there. order.addAll(available.values()); } /** * This method updates the positions recorded in a position set to reflect the ids of the nodes * in the composite view of the layout. Any position nodes already in existence are reused to * reduce database interaction needed to generate a new ID attribute. If any are left over after * updating those position elements are removed. If no position set existed a new one is created * for the parent. If no ILF nodes are found in the parent node then the position set as a whole * is reclaimed. */ public static void updatePositionSet(Element compViewParent, Element plfParent, IPerson person) throws PortalException { if (LOG.isDebugEnabled()) LOG.debug("Updating Position Set"); if (compViewParent.getChildNodes().getLength() == 0) { // no nodes to position. if set exists reclaim the space. if (LOG.isDebugEnabled()) LOG.debug("No Nodes to position"); Element positions = getPositionSet(plfParent, person, false); if (positions != null) plfParent.removeChild(positions); return; } Element posSet = (Element) getPositionSet(plfParent, person, true); Element position = (Element) posSet.getFirstChild(); Element viewNode = (Element) compViewParent.getFirstChild(); boolean ilfNodesFound = false; while (viewNode != null) { String ID = viewNode.getAttribute(Constants.ATT_ID); String channelId = viewNode.getAttribute(Constants.ATT_CHANNEL_ID); String type = viewNode.getAttribute(Constants.ATT_TYPE); String hidden = viewNode.getAttribute(Constants.ATT_HIDDEN); if (ID.startsWith(Constants.FRAGMENT_ID_USER_PREFIX)) ilfNodesFound = true; if (!channelId.equals("") || // its a channel node or (type.equals("regular") && // a regular, visible folder hidden.equals("false"))) { if (position != null) position.setAttribute(Constants.ATT_NAME, ID); else position = createAndAppendPosition(ID, posSet, person); position = (Element) position.getNextSibling(); } viewNode = (Element) viewNode.getNextSibling(); } if (ilfNodesFound == false) // only plf nodes, no pos set needed plfParent.removeChild(posSet); else { // reclaim any leftover positions while (position != null) { Element nextPos = (Element) position.getNextSibling(); posSet.removeChild(position); position = nextPos; } } } /** * This method locates the position set element in the child list of the passed in plfParent or * if not found it will create one automatically and return it if the passed in create flag is * true. */ private static Element getPositionSet(Element plfParent, IPerson person, boolean create) throws PortalException { Node child = plfParent.getFirstChild(); while (child != null) { if (child.getNodeName().equals(Constants.ELM_POSITION_SET)) return (Element) child; child = child.getNextSibling(); } if (create == false) return null; String ID = null; try { ID = getDLS().getNextStructDirectiveId(person); } catch (Exception e) { throw new PortalException( "Exception encountered while " + "generating new position set node " + "Id for userId=" + person.getID(), e); } Document plf = plfParent.getOwnerDocument(); Element positions = plf.createElement(Constants.ELM_POSITION_SET); positions.setAttribute(Constants.ATT_TYPE, Constants.ELM_POSITION_SET); positions.setAttribute(Constants.ATT_ID, ID); plfParent.appendChild(positions); return positions; } /** * Create, append to the passed in position set, and return a position element that references * the passed in elementID. */ private static Element createAndAppendPosition( String elementID, Element positions, IPerson person) throws PortalException { if (LOG.isDebugEnabled()) LOG.debug("Adding Position Set entry " + elementID + "."); String ID = null; try { ID = getDLS().getNextStructDirectiveId(person); } catch (Exception e) { throw new PortalException( "Exception encountered while " + "generating new position node " + "Id for userId=" + person.getID(), e); } Document plf = positions.getOwnerDocument(); Element position = plf.createElement(Constants.ELM_POSITION); position.setAttribute(Constants.ATT_TYPE, Constants.ELM_POSITION); position.setAttribute(Constants.ATT_ID, ID); position.setAttributeNS(Constants.NS_URI, Constants.ATT_NAME, elementID); positions.appendChild(position); return position; } private static class NodeInfoComparator implements Comparator<NodeInfo> { @Override public int compare(NodeInfo o1, NodeInfo o2) { return o1.getIndexInCVP() - o2.getIndexInCVP(); } } }