/**
* 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();
}
}
}