/* * (C) Copyright 2013-2016 Nuxeo SA (http://nuxeo.com/) and others. * * Licensed 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 * * http://www.apache.org/licenses/LICENSE-2.0 * * 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. * * Contributors: * <a href="mailto:tdelprat@nuxeo.com">Tiry</a> * <a href="mailto:grenard@nuxeo.com">Guillaume</a> */ package org.nuxeo.ecm.automation.core.operations.services.directory; import java.io.Serializable; import java.text.Collator; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TreeSet; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.common.utils.i18n.I18NUtils; import org.nuxeo.ecm.automation.OperationContext; import org.nuxeo.ecm.automation.core.Constants; import org.nuxeo.ecm.automation.core.annotations.Context; import org.nuxeo.ecm.automation.core.annotations.Operation; import org.nuxeo.ecm.automation.core.annotations.OperationMethod; import org.nuxeo.ecm.automation.core.annotations.Param; import org.nuxeo.ecm.automation.features.SuggestConstants; import org.nuxeo.ecm.core.api.Blob; import org.nuxeo.ecm.core.api.Blobs; import org.nuxeo.ecm.core.api.DocumentModel; import org.nuxeo.ecm.core.api.DocumentModelList; import org.nuxeo.ecm.core.api.PropertyException; import org.nuxeo.ecm.core.schema.SchemaManager; import org.nuxeo.ecm.core.schema.types.Field; import org.nuxeo.ecm.core.schema.types.QName; import org.nuxeo.ecm.core.schema.types.Schema; import org.nuxeo.ecm.directory.Directory; import org.nuxeo.ecm.directory.DirectoryException; import org.nuxeo.ecm.directory.Session; import org.nuxeo.ecm.directory.api.DirectoryService; import net.sf.json.JSONArray; import net.sf.json.JSONObject; /** * SuggestDirectoryEntries Operation * * @since 5.7.3 */ @Operation(id = SuggestDirectoryEntries.ID, category = Constants.CAT_SERVICES, label = "Get suggested directory entries", description = "Get the entries suggestions of a directory. This is returning a blob containing a serialized JSON array. Prefix parameter is used to filter the entries.", addToStudio = false) public class SuggestDirectoryEntries { /** * @since 5.9.3 */ Collator collator; /** * Convenient class to build JSON serialization of results. * * @since 5.7.2 */ private class JSONAdapter implements Comparable<JSONAdapter> { private final Map<String, JSONAdapter> children; private final Session session; private final Schema schema; private boolean isRoot = false; private Boolean isLeaf = null; private JSONObject obj; public JSONAdapter(Session session, Schema schema) { this.session = session; this.schema = schema; this.children = new HashMap<>(); // We are the root node this.isRoot = true; } public JSONAdapter(Session session, Schema schema, DocumentModel entry) throws PropertyException { this(session, schema); // Carry entry, not root isRoot = false; // build JSON object for this entry obj = new JSONObject(); for (Field field : schema.getFields()) { QName fieldName = field.getName(); String key = fieldName.getLocalName(); Serializable value = entry.getPropertyValue(fieldName.getPrefixedName()); if (label.equals(key)) { if (localize && !dbl10n) { // translations are in messages*.properties files value = translate(value.toString()); } obj.element(SuggestConstants.LABEL, value); } obj.element(key, value); } if (displayObsoleteEntries) { if (obj.containsKey(SuggestConstants.OBSOLETE_FIELD_ID) && obj.getInt(SuggestConstants.OBSOLETE_FIELD_ID) > 0) { obj.element(SuggestConstants.WARN_MESSAGE_LABEL, getObsoleteWarningMessage()); } } } @Override public int compareTo(JSONAdapter other) { if (other != null) { int i = this.getOrder() - other.getOrder(); if (i != 0) { return i; } else { return getCollator().compare(this.getLabel(), other.getLabel()); } } else { return -1; } } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } JSONAdapter other = (JSONAdapter) obj; if (!getOuterType().equals(other.getOuterType())) { return false; } if (this.obj == null) { if (other.obj != null) { return false; } } else if (!this.obj.equals(other.obj)) { return false; } return true; } public JSONArray getChildrenJSONArray() { JSONArray result = new JSONArray(); for (JSONAdapter ja : getSortedChildren()) { // When serializing in JSON, we are now able to COMPUTED_ID // which is the chained path of the entry (i.e absolute path // considering its ancestor) ja.getObj().element(SuggestConstants.COMPUTED_ID, (!isRoot ? (getComputedId() + keySeparator) : "") + ja.getId()); ja.getObj().element(SuggestConstants.ABSOLUTE_LABEL, (!isRoot ? (getAbsoluteLabel() + absoluteLabelSeparator) : "") + ja.getLabel()); result.add(ja.toJSONObject()); } return result; } public String getComputedId() { return isRoot ? null : obj.optString(SuggestConstants.COMPUTED_ID); } public String getId() { return isRoot ? null : obj.optString(SuggestConstants.ID); } public String getLabel() { return isRoot ? null : obj.optString(SuggestConstants.LABEL); } public String getAbsoluteLabel() { return isRoot ? null : obj.optString(SuggestConstants.ABSOLUTE_LABEL); } public JSONObject getObj() { return obj; } public int getOrder() { return isRoot ? -1 : obj.optInt(SuggestConstants.DIRECTORY_ORDER_FIELD_NAME); } private SuggestDirectoryEntries getOuterType() { return SuggestDirectoryEntries.this; } public String getParentId() { return isRoot ? null : obj.optString(SuggestConstants.PARENT_FIELD_ID); } public List<JSONAdapter> getSortedChildren() { if (children == null) { return null; } List<JSONAdapter> result = new ArrayList<>(children.values()); Collections.sort(result); return result; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + getOuterType().hashCode(); result = prime * result + ((obj == null) ? 0 : obj.hashCode()); return result; } /** * Does the associated vocabulary / directory entry have child entries. * * @return true if it has children * @since 5.7.2 */ public boolean isLeaf() { if (isLeaf == null) { if (isChained) { String id = getId(); if (id != null) { Map<String, Serializable> filter = Collections.singletonMap(SuggestConstants.PARENT_FIELD_ID, getId()); try { isLeaf = session.query(filter, Collections.emptySet(), Collections.emptyMap(), false, 1, -1) .isEmpty(); } catch (DirectoryException ce) { log.error("Could not retrieve children of entry", ce); isLeaf = true; } } else { isLeaf = true; } } else { isLeaf = true; } } return isLeaf; } public boolean isObsolete() { return isRoot ? false : obj.optInt(SuggestConstants.OBSOLETE_FIELD_ID) > 0; } private void mergeJsonAdapter(JSONAdapter branch) { JSONAdapter found = children.get(branch.getLabel()); if (found != null) { // I already have the given the adapter as child, let's merge // all its children. for (JSONAdapter branchChild : branch.children.values()) { found.mergeJsonAdapter(branchChild); } } else { // First time I see this adapter, I adopt it. // We use label as key, this way display will be alphabetically // sorted children.put(branch.getLabel(), branch); } } public JSONAdapter push(final JSONAdapter newEntry) throws PropertyException { String parentIdOfNewEntry = newEntry.getParentId(); if (parentIdOfNewEntry != null && !parentIdOfNewEntry.isEmpty()) { // The given adapter has a parent which could already be in my // descendants if (parentIdOfNewEntry.equals(this.getId())) { // this is the parent. We must insert the given adapter // here. We merge all its // descendants mergeJsonAdapter(newEntry); return this; } else { // I am not the parent, let's check if I could be the // parent // of one the ancestor. final String parentId = newEntry.getParentId(); DocumentModel parent = session.getEntry(parentId); if (parent == null) { if (log.isInfoEnabled()) { log.info(String.format("parent %s not found for entry %s", parentId, newEntry.getId())); } mergeJsonAdapter(newEntry); return this; } else { return push(new JSONAdapter(session, schema, parent).push(newEntry)); } } } else { // The given adapter has no parent, I can merge it in my // descendants. mergeJsonAdapter(newEntry); return this; } } private JSONObject toJSONObject() { if (isLeaf()) { return getObj(); } else { // This entry has sub entries in the directory. // Ruled by Select2: an optionGroup is selectable or not // whether // we provide an Id or not in the JSON object. if (canSelectParent) { // Make it selectable, keep as it is return getObj().element("children", getChildrenJSONArray()); } else { // We don't want it to be selectable, we just serialize the // label return new JSONObject().element(SuggestConstants.LABEL, getLabel()).element("children", getChildrenJSONArray()); } } } public String toString() { return obj != null ? obj.toString() : null; } } private static final Log log = LogFactory.getLog(SuggestDirectoryEntries.class); public static final String ID = "Directory.SuggestEntries"; @Context protected OperationContext ctx; @Context protected DirectoryService directoryService; @Context protected SchemaManager schemaManager; @Param(name = "directoryName", required = true) protected String directoryName; @Param(name = "localize", required = false) protected boolean localize; @Param(name = "lang", required = false) protected String lang; @Param(name = "searchTerm", alias = "prefix", required = false) protected String prefix; @Param(name = "labelFieldName", required = false) protected String labelFieldName = SuggestConstants.DIRECTORY_DEFAULT_LABEL_COL_NAME; @Param(name = "dbl10n", required = false) protected boolean dbl10n = false; @Param(name = "canSelectParent", required = false) protected boolean canSelectParent = false; @Param(name = "filterParent", required = false) protected boolean filterParent = false; @Param(name = "keySeparator", required = false) protected String keySeparator = SuggestConstants.DEFAULT_KEY_SEPARATOR; @Param(name = "displayObsoleteEntries", required = false) protected boolean displayObsoleteEntries = false; /** * @since 8.2 */ @Param(name = "limit", required = false) protected int limit = -1; /** * Fetch mode. If not contains, then starts with. * * @since 5.9.2 */ @Param(name = "contains", required = false) protected boolean contains = false; /** * Choose if sort is case sensitive * * @since 5.9.3 */ @Param(name = "caseSensitive", required = false) protected boolean caseSensitive = false; /** * Separator to display absolute label * * @since 5.9.2 */ @Param(name = "absoluteLabelSeparator", required = false) protected String absoluteLabelSeparator = "/"; private String label = null; private boolean isChained = false; private String obsoleteWarningMessage = null; protected String getLang() { if (lang == null) { lang = (String) ctx.get("lang"); if (lang == null) { lang = SuggestConstants.DEFAULT_LANG; } } return lang; } protected Locale getLocale() { return new Locale(getLang()); } /** * @since 5.9.3 */ protected Collator getCollator() { if (collator == null) { collator = Collator.getInstance(getLocale()); if (caseSensitive) { collator.setStrength(Collator.TERTIARY); } else { collator.setStrength(Collator.SECONDARY); } } return collator; } protected String getObsoleteWarningMessage() { if (obsoleteWarningMessage == null) { obsoleteWarningMessage = I18NUtils.getMessageString("messages", "obsolete", new Object[0], getLocale()); } return obsoleteWarningMessage; } @OperationMethod public Blob run() { Directory directory = directoryService.getDirectory(directoryName); if (directory == null) { log.error("Could not find directory with name " + directoryName); return null; } try (Session session = directory.getSession()) { String schemaName = directory.getSchema(); Schema schema = schemaManager.getSchema(schemaName); Field parentField = schema.getField(SuggestConstants.PARENT_FIELD_ID); isChained = parentField != null; String parentDirectory = directory.getParentDirectory(); if (parentDirectory == null || parentDirectory.isEmpty() || parentDirectory.equals(directoryName)) { parentDirectory = null; } boolean postFilter = true; label = SuggestConstants.getLabelFieldName(schema, dbl10n, labelFieldName, getLang()); Map<String, Serializable> filter = new HashMap<>(); if (!displayObsoleteEntries) { // Exclude obsolete filter.put(SuggestConstants.OBSOLETE_FIELD_ID, Long.valueOf(0)); } Set<String> fullText = new TreeSet<>(); if (dbl10n || !localize) { postFilter = false; // do the filtering at directory level if (prefix != null && !prefix.isEmpty()) { // filter.put(directory.getIdField(), prefix); String computedPrefix = prefix; if (contains) { computedPrefix = '%' + computedPrefix; } filter.put(label, computedPrefix); fullText.add(label); } } // when post filtering we need to get all entries DocumentModelList entries = session.query(filter, fullText, Collections.emptyMap(), false, postFilter ? -1 : limit, -1); JSONAdapter jsonAdapter = new JSONAdapter(session, schema); for (DocumentModel entry : entries) { JSONAdapter adapter = new JSONAdapter(session, schema, entry); if (!filterParent && isChained && parentDirectory == null) { if (!adapter.isLeaf()) { continue; } } if (prefix != null && !prefix.isEmpty() && postFilter) { if (contains) { if (!adapter.getLabel().toLowerCase().contains(prefix.toLowerCase())) { continue; } } else { if (!adapter.getLabel().toLowerCase().startsWith(prefix.toLowerCase())) { continue; } } } jsonAdapter.push(adapter); } return Blobs.createBlob(jsonAdapter.getChildrenJSONArray().toString(), "application/json"); } } protected String translate(final String key) { if (key == null) { return ""; } return I18NUtils.getMessageString("messages", key, new Object[0], getLocale()); } }