/* * See the NOTICE file distributed with this work for additional * information regarding copyright ownership. * * This is free software; you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as * published by the Free Software Foundation; either version 2.1 of * the License, or (at your option) any later version. * * This software is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this software; if not, write to the Free * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA * 02110-1301 USA, or see the FSF site: http://www.fsf.org. */ package com.xpn.xwiki.objects.classes; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.apache.ecs.xhtml.input; import org.apache.ecs.xhtml.option; import org.apache.ecs.xhtml.select; import org.dom4j.Element; import org.hibernate.collection.PersistentCollection; import org.xwiki.model.reference.EntityReference; import org.xwiki.xar.internal.property.ListXarObjectPropertySerializer; import org.xwiki.xml.XMLUtils; import com.xpn.xwiki.XWikiContext; import com.xpn.xwiki.doc.merge.MergeConfiguration; import com.xpn.xwiki.doc.merge.MergeResult; import com.xpn.xwiki.internal.xml.XMLAttributeValueFilter; import com.xpn.xwiki.objects.BaseCollection; import com.xpn.xwiki.objects.BaseProperty; import com.xpn.xwiki.objects.DBStringListProperty; import com.xpn.xwiki.objects.ListProperty; import com.xpn.xwiki.objects.StringListProperty; import com.xpn.xwiki.objects.StringProperty; import com.xpn.xwiki.objects.meta.PropertyMetaClass; public abstract class ListClass extends PropertyClass { /** * @since 6.2RC1 */ public static final String DISPLAYTYPE_INPUT = "input"; /** * @since 6.2RC1 */ public static final String DISPLAYTYPE_RADIO = "radio"; /** * @since 6.2RC1 */ public static final String DISPLAYTYPE_CHECKBOX = "checkbox"; /** * @since 6.2RC1 */ public static final String DISPLAYTYPE_SELECT = "select"; /** * Default separator/delimiter to use to split or join a list stored as a string. Not to be confused with * {@link #getSeparator()} and {@link #getSeparator()} which are used only for UI view and edit operations. * * @since 7.0M2 */ public static final String DEFAULT_SEPARATOR = "|"; /** * Used to escape a separator character inside a string serialized list item. * * @since 7.0M2 */ public static final char SEPARATOR_ESCAPE = '\\'; private static final String XCLASSNAME = "list"; /** * Regex used to split lists stored in a string. Supports escaped separators inside values. The individually * regex-escaped separators string needs to be passed as parameter. */ private static final String LIST_ITEM_SEPARATOR_REGEX_FORMAT = "(?<!\\\\)[%s]"; /** * Regex used to unescape separators inside the actual values of the list. The individually regex-escaped separators * string needs to be passed as parameter. */ private static final String ESCAPED_SEPARATORS_REGEX_FORMAT = "\\%s([%s])"; /** * Regex used to find unescaped separators in a list item's value. Regex-escaped separators string needs to be * passed as parameter. */ private static final String UNESCAPED_SEPARATORS_REGEX_FORMAT = "([%s])"; /** * Replacement string used to escaped a separator found by the String.replace regex. */ private static final String UNESCAPED_SEPARATOR_REPLACEMENT = String.format("\\%s$1", SEPARATOR_ESCAPE); public ListClass(String name, String prettyname, PropertyMetaClass wclass) { super(name, prettyname, wclass); setRelationalStorage(false); setDisplayType(DISPLAYTYPE_SELECT); setMultiSelect(false); setSize(1); setSeparator(" "); setCache(false); } public ListClass(PropertyMetaClass wclass) { this(XCLASSNAME, "List", wclass); } public ListClass() { this(null); } /** * @return a string of separator characters used to split/deserialize an input string coming from the UI (filled by * the user) that represents a serialized list * @see #displayEdit(StringBuffer, String, String, BaseCollection, XWikiContext) * @see #fromString(String) */ public String getSeparators() { String separators = getStringValue("separators"); if (separators == null || separators.equals("")) { separators = "|,"; } return separators; } /** * @param separators a string of characters used to split/deserialize an input string coming from the UI (filled by * the user) that represents a serialized list */ public void setSeparators(String separators) { setStringValue("separators", separators); } public String getDisplayType() { return getStringValue("displayType"); } public void setDisplayType(String type) { setStringValue("displayType", type); } public String getSort() { return getStringValue("sort"); } public void setSort(String sort) { setStringValue("sort", sort); } public int getSize() { return getIntValue("size"); } public void setSize(int size) { setIntValue("size", size); } public boolean isCache() { return (getIntValue("cache") == 1); } public void setCache(boolean cache) { setIntValue("cache", cache ? 1 : 0); } public boolean isMultiSelect() { return (getIntValue("multiSelect") == 1); } public void setMultiSelect(boolean multiSelect) { setIntValue("multiSelect", multiSelect ? 1 : 0); } public boolean isRelationalStorage() { return (getIntValue("relationalStorage") == 1); } public void setRelationalStorage(boolean storage) { setIntValue("relationalStorage", storage ? 1 : 0); } public boolean isPicker() { return (getIntValue("picker") == 1); } public void setPicker(boolean picker) { setIntValue("picker", picker ? 1 : 0); } /** * @return a string (usually just 1 character long) used to join this list's items when displaying it in the UI in * view mode. * @see #displayView(StringBuffer, String, String, BaseCollection, XWikiContext) */ public String getSeparator() { return getStringValue("separator"); } /** * @param separator a string (usually just 1 character long) used to join this list's items when displaying it in * the UI in view mode. */ public void setSeparator(String separator) { setStringValue("separator", separator); } /** * Convenience method, using {@value #DEFAULT_SEPARATOR} as separator and parsing key=value items. * * @param value the string holding a serialized list * @return the list that was stored in the input string * @see #getListFromString(String, String, boolean) */ public static List<String> getListFromString(String value) { return getListFromString(value, null, true); } /** * @param value the string holding a serialized list * @param separators the separator characters (given as a string) used to delimit the list's items inside the input * string. These separators can also be present, in escaped ({@value #SEPARATOR_ESCAPE}) form, inside * list items * @param withMap set to true if the list's values contain map entries (key=value pairs) that should also be parsed. * Only the keys are extracted from such list items * @return the list that was stored in the input string */ public static List<String> getListFromString(String value, String separators, boolean withMap) { List<String> list = new ArrayList<>(); if (value == null) { return list; } if (separators == null) { separators = DEFAULT_SEPARATOR; } // Escape the list of separators individually to be safely used in regexes. String regexEscapedSeparatorsRegexPart = SEPARATOR_ESCAPE + StringUtils.join(separators.toCharArray(), SEPARATOR_ESCAPE); String escapedSeparatorsRegex = String.format(ESCAPED_SEPARATORS_REGEX_FORMAT, SEPARATOR_ESCAPE, regexEscapedSeparatorsRegexPart); // Split the values and process each list item. String listItemSeparatorRegex = String.format(LIST_ITEM_SEPARATOR_REGEX_FORMAT, regexEscapedSeparatorsRegexPart); String[] elements = value.split(listItemSeparatorRegex); for (String element : elements) { // Adjacent separators are treated as one separator. if (StringUtils.isBlank(element)) { continue; } // Unescape any escaped separator in the individual list item. String unescapedElement = element.replaceAll(escapedSeparatorsRegex, "$1"); String item = unescapedElement; // Check if it is a map entry, i.e. "key=value" if (withMap && (unescapedElement.indexOf('=') != -1)) { // Get just the key, ignore the value/label. item = StringUtils.split(unescapedElement, '=')[0]; } // Ignore empty items. if (StringUtils.isNotBlank(item.trim())) { list.add(item); } } return list; } /** * Convenience method, using {@value #DEFAULT_SEPARATOR} as separator. * * @param list the list to serialize * @return a string representing a serialized list, delimited by the first separator character (from the ones inside * the separators string). Separators inside list items are safely escaped ({@value #SEPARATOR_ESCAPE}). * @see #getStringFromList(List, String) */ public static String getStringFromList(List<String> list) { return getStringFromList(list, null); } /** * @param list the list to serialize * @param separators the separator characters (given as a string) used when the list was populated with values. The * list's items can contain these separators in plain/unescaped form. The first separator character will * be used to join the list in the output. * @return a string representing a serialized list, delimited by the first separator character (from the ones inside * the separators string). Separators inside list items are safely escaped ({@value #SEPARATOR_ESCAPE}). */ public static String getStringFromList(List<String> list, String separators) { if ((list instanceof PersistentCollection) && (!((PersistentCollection) list).wasInitialized())) { return ""; } if (separators == null) { separators = DEFAULT_SEPARATOR; } // Escape the list of separators individually to be safely used in regexes. String regexEscapedSeparatorsRegexPart = SEPARATOR_ESCAPE + StringUtils.join(separators.toCharArray(), SEPARATOR_ESCAPE); String unescapedSeparatorsRegex = String.format(UNESCAPED_SEPARATORS_REGEX_FORMAT, regexEscapedSeparatorsRegexPart); List<String> escapedValues = new ArrayList<>(); for (String value : list) { String escapedValue = value.replaceAll(unescapedSeparatorsRegex, UNESCAPED_SEPARATOR_REPLACEMENT); escapedValues.add(escapedValue); } // Use the first separator to join the list. return StringUtils.join(escapedValues, separators.charAt(0)); } public static Map<String, ListItem> getMapFromString(String value) { Map<String, ListItem> map = new LinkedHashMap<>(); if (value == null) { return map; } String val = StringUtils.replace(value, SEPARATOR_ESCAPE + DEFAULT_SEPARATOR, "%PIPE%"); String[] result = StringUtils.split(val, "|"); for (String element2 : result) { String element = StringUtils.replace(element2, "%PIPE%", DEFAULT_SEPARATOR); if (element.indexOf('=') != -1) { String[] data = StringUtils.split(element, "=", 2); map.put(data[0], new ListItem(data[0], data[1])); } else { map.put(element, new ListItem(element, element)); } } return map; } /** * Used in {@link #displayEdit(StringBuffer, String, String, BaseCollection, XWikiContext)}. * * @param property a property to be used in an form input * @return the text value to be used in an form input. If a {@link ListProperty} is passed, the list's separators * defined by {@link #getSeparators()} are escaped for each list item and the items are joined by the first * separator * @see #getStringFromList(List, String) */ public String toFormString(BaseProperty property) { String result; if (property instanceof ListProperty) { ListProperty listProperty = (ListProperty) property; result = ListClass.getStringFromList(listProperty.getList(), getSeparators()); } else { result = property.toText(); } return result; } @Override public BaseProperty newProperty() { BaseProperty lprop; if (isRelationalStorage() && isMultiSelect()) { lprop = new DBStringListProperty(); } else if (isMultiSelect()) { lprop = new StringListProperty(); } else { lprop = new StringProperty(); } return lprop; } @Override public BaseProperty fromString(String value) { BaseProperty prop = newProperty(); if (isMultiSelect()) { ((ListProperty) prop).setList(getListFromString(value, getSeparators(), false)); } else { prop.setValue(value); } return prop; } @Override public BaseProperty fromStringArray(String[] strings) { if (!isMultiSelect()) { return fromString(strings[0]); } BaseProperty prop = newProperty(); if (prop instanceof StringProperty) { return fromString(strings[0]); } List<String> list = new ArrayList<>(); if (strings.length == 0) { return prop; } if ((strings.length == 1) && (getDisplayType().equals(DISPLAYTYPE_INPUT) || isMultiSelect())) { ((ListProperty) prop).setList(getListFromString(strings[0], getSeparators(), false)); return prop; } // If Multiselect and multiple results for (String item : strings) { if (!item.trim().equals("")) { list.add(item); } } // setList will copy the list, so this call must be made last. ((ListProperty) prop).setList(list); return prop; } @Override public BaseProperty newPropertyfromXML(Element ppcel) { if (!isMultiSelect()) { return super.newPropertyfromXML(ppcel); } @SuppressWarnings("unchecked") List<Element> elist = ppcel.elements(ListXarObjectPropertySerializer.ELEMENT_VALUE); BaseProperty lprop = newProperty(); if (lprop instanceof ListProperty) { List<String> llist = ((ListProperty) lprop).getList(); for (int i = 0; i < elist.size(); i++) { Element el = elist.get(i); llist.add(el.getText()); } } else { for (int i = 0; i < elist.size(); i++) { Element el = elist.get(i); ((StringProperty) lprop).setValue(el.getText()); } } return lprop; } /** * Search for an internationalizable display text for the current value. The search process is: * <ol> * <li>let V = the internal value of the option, used as the "value" attribute of the {@code <option>} element, and * D = the displayed value</li> * <li>if a message with the key {@code <fieldFullName>_<V>} exists, return it as D</li> * <li>else, if a message with the key {@code option_<fieldName>_<V>} exists, return it as D</li> * <li>else, if a message with the key {@code option_<V>} exists, return it as D</li> * <li>else, D can be specified in the values parameter of the property by using V=D</li> * <li>else return V</li> * </ol> * * @param value The internal value. * @param name The name of the ListProperty. * @param map The value=name mapping specified in the "values" parameter of the property. * @param context The request context. * @return The text that should be displayed, representing a human-understandable name for the internal value. */ protected String getDisplayValue(String value, String name, Map<String, ListItem> map, XWikiContext context) { ListItem item = map.get(value); String displayValue; if (item == null) { displayValue = value; } else { displayValue = item.getValue(); } if ((context == null) || (context.getWiki() == null)) { return displayValue; } String msgname = getFieldFullName() + "_" + value; String newresult = localizePlain(msgname); if (newresult == null) { msgname = "option_" + name + "_" + value; newresult = localizePlain(msgname); if (newresult == null) { msgname = "option_" + value; newresult = localizePlain(msgname); if (newresult == null) { newresult = displayValue; } } } return newresult; } /** * Search for an internationalizable display text for the current value. The value can be either a simple string, or * a value=name pair selected from the database. * * @see #getDisplayValue(String, String, Map, XWikiContext) * @param rawvalue The internal value, or a value=name pair. * @param name The name of the ListProperty. * @param map The value=name mapping specified in the "values" parameter of the property. * @param context The request context. * @return The text that should be displayed, representing a human-understandable name for the internal value. */ protected String getDisplayValue(Object rawvalue, String name, Map<String, ListItem> map, XWikiContext context) { if (rawvalue == null) { return ""; } if (rawvalue instanceof Object[]) { return ((Object[]) rawvalue)[1].toString(); } return getDisplayValue(rawvalue.toString(), name, map, context); } /** * If the list is populated with value=name pairs selected from the database, then return only the value. Otherwise, * it is a simple value. * * @param rawvalue * @return The list value */ protected String getElementValue(Object rawvalue) { if (rawvalue == null) { return ""; } if (rawvalue instanceof Object[]) { return ((Object[]) rawvalue)[0].toString(); } return rawvalue.toString(); } @Override public void displayHidden(StringBuffer buffer, String name, String prefix, BaseCollection object, XWikiContext context) { input input = new input(); input.setAttributeFilter(new XMLAttributeValueFilter()); BaseProperty prop = (BaseProperty) object.safeget(name); if (prop != null) { input.setValue(prop.toText()); } input.setType("hidden"); input.setName(prefix + name); input.setID(prefix + name); buffer.append(input.toString()); } @Override public void displayView(StringBuffer buffer, String name, String prefix, BaseCollection object, XWikiContext context) { List<String> selectlist; String separator = getSeparator(); BaseProperty prop = (BaseProperty) object.safeget(name); Map<String, ListItem> map = getMap(context); // Skip unset values. if (prop == null) { return; } if (prop instanceof ListProperty) { selectlist = ((ListProperty) prop).getList(); List<String> newlist = new ArrayList<>(); for (String value : selectlist) { newlist.add(getDisplayValue(value, name, map, context)); } buffer.append(StringUtils.join(newlist, separator)); } else { buffer.append(getDisplayValue(prop.getValue(), name, map, context)); } } @Override public void displayEdit(StringBuffer buffer, String name, String prefix, BaseCollection object, XWikiContext context) { if (getDisplayType().equals(DISPLAYTYPE_INPUT)) { input input = new input(); input.setAttributeFilter(new XMLAttributeValueFilter()); BaseProperty prop = (BaseProperty) object.safeget(name); if (prop != null) { input.setValue(this.toFormString(prop)); } input.setType("text"); input.setSize(getSize()); input.setName(prefix + name); input.setID(prefix + name); input.setDisabled(isDisabled()); buffer.append(input.toString()); } else if (getDisplayType().equals(DISPLAYTYPE_RADIO) || getDisplayType().equals(DISPLAYTYPE_CHECKBOX)) { displayRadioEdit(buffer, name, prefix, object, context); } else { displaySelectEdit(buffer, name, prefix, object, context); } if (!getDisplayType().equals(DISPLAYTYPE_INPUT)) { org.apache.ecs.xhtml.input hidden = new input(input.hidden, prefix + name, ""); hidden.setAttributeFilter(new XMLAttributeValueFilter()); buffer.append(hidden); } } protected void displayRadioEdit(StringBuffer buffer, String name, String prefix, BaseCollection object, XWikiContext context) { List<String> list = getList(context); Map<String, ListItem> map = getMap(context); BaseProperty prop = (BaseProperty) object.safeget(name); List<String> selectlist = toList(prop); // Add the selected values that are not in the predefined list. for (String item : selectlist) { // The empty value means no selection when it's not in the predefined list. Both the radio and the checkbox // input support empty selection (unlike the select input which automatically selects the first value when // single selection is on) so we don't have to generate a radio/checkbox for the empty value. if (!StringUtils.isEmpty(item) && !list.contains(item)) { list.add(item); } } // Add options from Set int count = 0; for (Object rawvalue : list) { String value = getElementValue(rawvalue); String display = XMLUtils.escape(getDisplayValue(rawvalue, name, map, context)); input radio = new input((getDisplayType().equals(DISPLAYTYPE_RADIO) && !isMultiSelect()) ? input.radio : input.checkbox, prefix + name, value); radio.setAttributeFilter(new XMLAttributeValueFilter()); radio.setID("xwiki-form-" + name + "-" + object.getNumber() + "-" + count); radio.setDisabled(isDisabled()); if (selectlist.contains(value)) { radio.setChecked(true); } radio.addElement(display); buffer.append("<label class=\"xwiki-form-listclass\" for=\"xwiki-form-" + XMLUtils.escape(name) + "-" + object.getNumber() + "-" + count++ + "\">"); buffer.append(radio.toString()); buffer.append("</label>"); } // We need a hidden input with an empty value to be able to clear the selected values when no value is selected // from the above radio/checkbox buttons. org.apache.ecs.xhtml.input hidden = new input(input.hidden, prefix + name, ""); hidden.setAttributeFilter(new XMLAttributeValueFilter()); hidden.setDisabled(isDisabled()); buffer.append(hidden); } protected class MapComparator implements Comparator<String> { protected Map<String, ListItem> map; public MapComparator(Map<String, ListItem> map) { this.map = map; } @Override public int compare(String o1, String o2) { ListItem s1 = this.map.get(o1); ListItem s2 = this.map.get(o2); if ((s1 == null) && (s2 == null)) { return 0; } if (s1 == null) { return -1; } if (s2 == null) { return 1; } return s1.getValue().compareTo(s2.getValue()); } } protected void displaySelectEdit(StringBuffer buffer, String name, String prefix, BaseCollection object, XWikiContext context) { select select = new select(prefix + name, 1); select.setAttributeFilter(new XMLAttributeValueFilter()); select.setMultiple(isMultiSelect()); select.setSize(getSize()); select.setName(prefix + name); select.setID(prefix + name); select.setDisabled(isDisabled()); List<String> list = getList(context); Map<String, ListItem> map = getMap(context); String sort = getSort(); if (!"none".equals(sort)) { if ("id".equals(sort)) { Collections.sort(list); } if ("value".equals(sort)) { Collections.sort(list, new MapComparator(map)); } } List<String> selectlist = toList((BaseProperty) object.safeget(name)); // Add the selected values that are not in the predefined list. for (String item : selectlist) { if (!list.contains(item)) { list.add(item); } } // Add options from Set for (String rawvalue : list) { String value = getElementValue(rawvalue); String display = getDisplayValue(rawvalue, name, map, context); option option = new option(display, value); option.setAttributeFilter(new XMLAttributeValueFilter()); option.addElement(XMLUtils.escape(display)); if (selectlist.contains(value)) { option.setSelected(true); } select.addElement(option); } buffer.append(select.toString()); } public abstract List<String> getList(XWikiContext context); public abstract Map<String, ListItem> getMap(XWikiContext context); /** * {@link ListClass} does not produce only {@link ListProperty}s and this method allows to access the value as * {@link List} whatever property is actually storing it. * <p> * There is no guarantees the returned {@link List} will be modifiable. * * @param property the property created by this class * @return the {@link List} representation of this property * @since 6.2M1 */ public List<String> toList(BaseProperty<?> property) { List<String> list; if (property == null) { list = Collections.emptyList(); } else if (property instanceof ListProperty) { list = ((ListProperty) property).getList(); } else { list = Arrays.asList(String.valueOf(property.getValue())); } return list; } /** * Set the passed {@link List} into the passed property. * * @param property the property to modify * @param list the list to set * @since 6.2M1 */ public void fromList(BaseProperty<?> property, List<String> list) { if (property instanceof ListProperty) { ((ListProperty) property).setList(list); } else { property.setValue(list == null || list.isEmpty() ? null : list.get(0)); } } @Override public <T extends EntityReference> void mergeProperty(BaseProperty<T> currentProperty, BaseProperty<T> previousProperty, BaseProperty<T> newProperty, MergeConfiguration configuration, XWikiContext context, MergeResult mergeResult) { // If it's not a multiselect then we don't have any special merge to do. We keep default StringProperty behavior if (isMultiSelect()) { // If not a free input assume it's not an ordered list if (!DISPLAYTYPE_INPUT.equals(getDisplayType()) && currentProperty instanceof ListProperty) { mergeNotOrderedListProperty(currentProperty, previousProperty, newProperty, configuration, context, mergeResult); return; } } // Fallback on default ListProperty merging super.mergeProperty(currentProperty, previousProperty, newProperty, configuration, context, mergeResult); } protected <T extends EntityReference> void mergeNotOrderedListProperty(BaseProperty<T> currentProperty, BaseProperty<T> previousProperty, BaseProperty<T> newProperty, MergeConfiguration configuration, XWikiContext context, MergeResult mergeResult) { List<String> currentList = new LinkedList<>(toList(currentProperty)); List<String> previousList = toList(previousProperty); List<String> newList = toList(newProperty); // Remove elements to remove if (previousList != null) { for (String element : previousList) { if (newList == null || !newList.contains(element)) { currentList.remove(element); mergeResult.setModified(true); } } } // Add missing elements if (newList != null) { for (String element : newList) { if ((previousList == null || !previousList.contains(element))) { if (!currentList.contains(element)) { currentList.add(element); mergeResult.setModified(true); } } } } fromList(currentProperty, currentList); return; } }