001/* 002 * (C) Copyright 2013-2016 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * <a href="mailto:tdelprat@nuxeo.com">Tiry</a> 018 * <a href="mailto:grenard@nuxeo.com">Guillaume</a> 019 */ 020package org.nuxeo.ecm.automation.core.operations.services.directory; 021 022import java.io.Serializable; 023import java.text.Collator; 024import java.util.ArrayList; 025import java.util.Collections; 026import java.util.HashMap; 027import java.util.List; 028import java.util.Locale; 029import java.util.Map; 030import java.util.Set; 031import java.util.TreeSet; 032 033import org.apache.commons.logging.Log; 034import org.apache.commons.logging.LogFactory; 035import org.nuxeo.common.utils.i18n.I18NUtils; 036import org.nuxeo.ecm.automation.OperationContext; 037import org.nuxeo.ecm.automation.core.Constants; 038import org.nuxeo.ecm.automation.core.annotations.Context; 039import org.nuxeo.ecm.automation.core.annotations.Operation; 040import org.nuxeo.ecm.automation.core.annotations.OperationMethod; 041import org.nuxeo.ecm.automation.core.annotations.Param; 042import org.nuxeo.ecm.automation.features.SuggestConstants; 043import org.nuxeo.ecm.core.api.Blob; 044import org.nuxeo.ecm.core.api.Blobs; 045import org.nuxeo.ecm.core.api.DocumentModel; 046import org.nuxeo.ecm.core.api.DocumentModelList; 047import org.nuxeo.ecm.core.api.PropertyException; 048import org.nuxeo.ecm.core.schema.SchemaManager; 049import org.nuxeo.ecm.core.schema.types.Field; 050import org.nuxeo.ecm.core.schema.types.QName; 051import org.nuxeo.ecm.core.schema.types.Schema; 052import org.nuxeo.ecm.directory.Directory; 053import org.nuxeo.ecm.directory.DirectoryException; 054import org.nuxeo.ecm.directory.Session; 055import org.nuxeo.ecm.directory.api.DirectoryService; 056 057import net.sf.json.JSONArray; 058import net.sf.json.JSONObject; 059 060/** 061 * SuggestDirectoryEntries Operation 062 * 063 * @since 5.7.3 064 */ 065@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) 066public class SuggestDirectoryEntries { 067 068 /** 069 * @since 5.9.3 070 */ 071 Collator collator; 072 073 /** 074 * Convenient class to build JSON serialization of results. 075 * 076 * @since 5.7.2 077 */ 078 private class JSONAdapter implements Comparable<JSONAdapter> { 079 080 private final Map<String, JSONAdapter> children; 081 082 private final Session session; 083 084 private final Schema schema; 085 086 private boolean isRoot = false; 087 088 private Boolean isLeaf = null; 089 090 private JSONObject obj; 091 092 public JSONAdapter(Session session, Schema schema) { 093 this.session = session; 094 this.schema = schema; 095 this.children = new HashMap<>(); 096 // We are the root node 097 this.isRoot = true; 098 } 099 100 public JSONAdapter(Session session, Schema schema, DocumentModel entry) throws PropertyException { 101 this(session, schema); 102 // Carry entry, not root 103 isRoot = false; 104 // build JSON object for this entry 105 obj = new JSONObject(); 106 for (Field field : schema.getFields()) { 107 QName fieldName = field.getName(); 108 String key = fieldName.getLocalName(); 109 Serializable value = entry.getPropertyValue(fieldName.getPrefixedName()); 110 if (label.equals(key)) { 111 if (localize && !dbl10n) { 112 // translations are in messages*.properties files 113 value = translate(value.toString()); 114 } 115 obj.element(SuggestConstants.LABEL, value); 116 } 117 obj.element(key, value); 118 119 } 120 if (displayObsoleteEntries) { 121 if (obj.containsKey(SuggestConstants.OBSOLETE_FIELD_ID) 122 && obj.getInt(SuggestConstants.OBSOLETE_FIELD_ID) > 0) { 123 obj.element(SuggestConstants.WARN_MESSAGE_LABEL, getObsoleteWarningMessage()); 124 } 125 } 126 } 127 128 @Override 129 public int compareTo(JSONAdapter other) { 130 if (other != null) { 131 int i = this.getOrder() - other.getOrder(); 132 if (i != 0) { 133 return i; 134 } else { 135 return getCollator().compare(this.getLabel(), other.getLabel()); 136 } 137 } else { 138 return -1; 139 } 140 } 141 142 @Override 143 public boolean equals(Object obj) { 144 if (this == obj) { 145 return true; 146 } 147 if (obj == null) { 148 return false; 149 } 150 if (getClass() != obj.getClass()) { 151 return false; 152 } 153 JSONAdapter other = (JSONAdapter) obj; 154 if (!getOuterType().equals(other.getOuterType())) { 155 return false; 156 } 157 if (this.obj == null) { 158 if (other.obj != null) { 159 return false; 160 } 161 } else if (!this.obj.equals(other.obj)) { 162 return false; 163 } 164 return true; 165 } 166 167 public JSONArray getChildrenJSONArray() { 168 JSONArray result = new JSONArray(); 169 for (JSONAdapter ja : getSortedChildren()) { 170 // When serializing in JSON, we are now able to COMPUTED_ID 171 // which is the chained path of the entry (i.e absolute path 172 // considering its ancestor) 173 ja.getObj().element(SuggestConstants.COMPUTED_ID, 174 (!isRoot ? (getComputedId() + keySeparator) : "") + ja.getId()); 175 ja.getObj().element(SuggestConstants.ABSOLUTE_LABEL, 176 (!isRoot ? (getAbsoluteLabel() + absoluteLabelSeparator) : "") + ja.getLabel()); 177 result.add(ja.toJSONObject()); 178 } 179 return result; 180 } 181 182 public String getComputedId() { 183 return isRoot ? null : obj.optString(SuggestConstants.COMPUTED_ID); 184 } 185 186 public String getId() { 187 return isRoot ? null : obj.optString(SuggestConstants.ID); 188 } 189 190 public String getLabel() { 191 return isRoot ? null : obj.optString(SuggestConstants.LABEL); 192 } 193 194 public String getAbsoluteLabel() { 195 return isRoot ? null : obj.optString(SuggestConstants.ABSOLUTE_LABEL); 196 } 197 198 public JSONObject getObj() { 199 return obj; 200 } 201 202 public int getOrder() { 203 return isRoot ? -1 : obj.optInt(SuggestConstants.DIRECTORY_ORDER_FIELD_NAME); 204 } 205 206 private SuggestDirectoryEntries getOuterType() { 207 return SuggestDirectoryEntries.this; 208 } 209 210 public String getParentId() { 211 return isRoot ? null : obj.optString(SuggestConstants.PARENT_FIELD_ID); 212 } 213 214 public List<JSONAdapter> getSortedChildren() { 215 if (children == null) { 216 return null; 217 } 218 List<JSONAdapter> result = new ArrayList<>(children.values()); 219 Collections.sort(result); 220 return result; 221 } 222 223 @Override 224 public int hashCode() { 225 final int prime = 31; 226 int result = 1; 227 result = prime * result + getOuterType().hashCode(); 228 result = prime * result + ((obj == null) ? 0 : obj.hashCode()); 229 return result; 230 } 231 232 /** 233 * Does the associated vocabulary / directory entry have child entries. 234 * 235 * @return true if it has children 236 * @since 5.7.2 237 */ 238 public boolean isLeaf() { 239 if (isLeaf == null) { 240 if (isChained) { 241 String id = getId(); 242 if (id != null) { 243 Map<String, Serializable> filter = Collections.singletonMap(SuggestConstants.PARENT_FIELD_ID, 244 getId()); 245 try { 246 isLeaf = session.query(filter, Collections.emptySet(), Collections.emptyMap(), false, 1, -1) 247 .isEmpty(); 248 } catch (DirectoryException ce) { 249 log.error("Could not retrieve children of entry", ce); 250 isLeaf = true; 251 } 252 } else { 253 isLeaf = true; 254 } 255 } else { 256 isLeaf = true; 257 } 258 } 259 return isLeaf; 260 } 261 262 public boolean isObsolete() { 263 return isRoot ? false : obj.optInt(SuggestConstants.OBSOLETE_FIELD_ID) > 0; 264 } 265 266 private void mergeJsonAdapter(JSONAdapter branch) { 267 JSONAdapter found = children.get(branch.getLabel()); 268 if (found != null) { 269 // I already have the given the adapter as child, let's merge 270 // all its children. 271 for (JSONAdapter branchChild : branch.children.values()) { 272 found.mergeJsonAdapter(branchChild); 273 } 274 } else { 275 // First time I see this adapter, I adopt it. 276 // We use label as key, this way display will be alphabetically 277 // sorted 278 children.put(branch.getLabel(), branch); 279 } 280 } 281 282 public JSONAdapter push(final JSONAdapter newEntry) throws PropertyException { 283 String parentIdOfNewEntry = newEntry.getParentId(); 284 if (parentIdOfNewEntry != null && !parentIdOfNewEntry.isEmpty()) { 285 // The given adapter has a parent which could already be in my 286 // descendants 287 if (parentIdOfNewEntry.equals(this.getId())) { 288 // this is the parent. We must insert the given adapter 289 // here. We merge all its 290 // descendants 291 mergeJsonAdapter(newEntry); 292 return this; 293 } else { 294 // I am not the parent, let's check if I could be the 295 // parent 296 // of one the ancestor. 297 final String parentId = newEntry.getParentId(); 298 DocumentModel parent = session.getEntry(parentId); 299 if (parent == null) { 300 if (log.isInfoEnabled()) { 301 log.info(String.format("parent %s not found for entry %s", parentId, newEntry.getId())); 302 } 303 mergeJsonAdapter(newEntry); 304 return this; 305 } else { 306 return push(new JSONAdapter(session, schema, parent).push(newEntry)); 307 } 308 } 309 } else { 310 // The given adapter has no parent, I can merge it in my 311 // descendants. 312 mergeJsonAdapter(newEntry); 313 return this; 314 } 315 } 316 317 private JSONObject toJSONObject() { 318 if (isLeaf()) { 319 return getObj(); 320 } else { 321 // This entry has sub entries in the directory. 322 // Ruled by Select2: an optionGroup is selectable or not 323 // whether 324 // we provide an Id or not in the JSON object. 325 if (canSelectParent) { 326 // Make it selectable, keep as it is 327 return getObj().element("children", getChildrenJSONArray()); 328 } else { 329 // We don't want it to be selectable, we just serialize the 330 // label 331 return new JSONObject().element(SuggestConstants.LABEL, getLabel()).element("children", 332 getChildrenJSONArray()); 333 } 334 } 335 } 336 337 public String toString() { 338 return obj != null ? obj.toString() : null; 339 } 340 341 } 342 343 private static final Log log = LogFactory.getLog(SuggestDirectoryEntries.class); 344 345 public static final String ID = "Directory.SuggestEntries"; 346 347 @Context 348 protected OperationContext ctx; 349 350 @Context 351 protected DirectoryService directoryService; 352 353 @Context 354 protected SchemaManager schemaManager; 355 356 @Param(name = "directoryName", required = true) 357 protected String directoryName; 358 359 @Param(name = "localize", required = false) 360 protected boolean localize; 361 362 @Param(name = "lang", required = false) 363 protected String lang; 364 365 @Param(name = "searchTerm", alias = "prefix", required = false) 366 protected String prefix; 367 368 @Param(name = "labelFieldName", required = false) 369 protected String labelFieldName = SuggestConstants.DIRECTORY_DEFAULT_LABEL_COL_NAME; 370 371 @Param(name = "dbl10n", required = false) 372 protected boolean dbl10n = false; 373 374 @Param(name = "canSelectParent", required = false) 375 protected boolean canSelectParent = false; 376 377 @Param(name = "filterParent", required = false) 378 protected boolean filterParent = false; 379 380 @Param(name = "keySeparator", required = false) 381 protected String keySeparator = SuggestConstants.DEFAULT_KEY_SEPARATOR; 382 383 @Param(name = "displayObsoleteEntries", required = false) 384 protected boolean displayObsoleteEntries = false; 385 386 /** 387 * @since 8.2 388 */ 389 @Param(name = "limit", required = false) 390 protected int limit = -1; 391 392 /** 393 * Fetch mode. If not contains, then starts with. 394 * 395 * @since 5.9.2 396 */ 397 @Param(name = "contains", required = false) 398 protected boolean contains = false; 399 400 /** 401 * Choose if sort is case sensitive 402 * 403 * @since 5.9.3 404 */ 405 @Param(name = "caseSensitive", required = false) 406 protected boolean caseSensitive = false; 407 408 /** 409 * Separator to display absolute label 410 * 411 * @since 5.9.2 412 */ 413 @Param(name = "absoluteLabelSeparator", required = false) 414 protected String absoluteLabelSeparator = "/"; 415 416 private String label = null; 417 418 private boolean isChained = false; 419 420 private String obsoleteWarningMessage = null; 421 422 protected String getLang() { 423 if (lang == null) { 424 lang = (String) ctx.get("lang"); 425 if (lang == null) { 426 lang = SuggestConstants.DEFAULT_LANG; 427 } 428 } 429 return lang; 430 } 431 432 protected Locale getLocale() { 433 return new Locale(getLang()); 434 } 435 436 /** 437 * @since 5.9.3 438 */ 439 protected Collator getCollator() { 440 if (collator == null) { 441 collator = Collator.getInstance(getLocale()); 442 if (caseSensitive) { 443 collator.setStrength(Collator.TERTIARY); 444 } else { 445 collator.setStrength(Collator.SECONDARY); 446 } 447 } 448 return collator; 449 } 450 451 protected String getObsoleteWarningMessage() { 452 if (obsoleteWarningMessage == null) { 453 obsoleteWarningMessage = I18NUtils.getMessageString("messages", "obsolete", new Object[0], getLocale()); 454 } 455 return obsoleteWarningMessage; 456 } 457 458 @OperationMethod 459 public Blob run() { 460 Directory directory = directoryService.getDirectory(directoryName); 461 if (directory == null) { 462 log.error("Could not find directory with name " + directoryName); 463 return null; 464 } 465 try (Session session = directory.getSession()) { 466 String schemaName = directory.getSchema(); 467 Schema schema = schemaManager.getSchema(schemaName); 468 469 Field parentField = schema.getField(SuggestConstants.PARENT_FIELD_ID); 470 isChained = parentField != null; 471 472 String parentDirectory = directory.getParentDirectory(); 473 if (parentDirectory == null || parentDirectory.isEmpty() || parentDirectory.equals(directoryName)) { 474 parentDirectory = null; 475 } 476 477 boolean postFilter = true; 478 479 label = SuggestConstants.getLabelFieldName(schema, dbl10n, labelFieldName, getLang()); 480 481 Map<String, Serializable> filter = new HashMap<>(); 482 if (!displayObsoleteEntries) { 483 // Exclude obsolete 484 filter.put(SuggestConstants.OBSOLETE_FIELD_ID, Long.valueOf(0)); 485 } 486 Set<String> fullText = new TreeSet<>(); 487 if (dbl10n || !localize) { 488 postFilter = false; 489 // do the filtering at directory level 490 if (prefix != null && !prefix.isEmpty()) { 491 // filter.put(directory.getIdField(), prefix); 492 String computedPrefix = prefix; 493 if (contains) { 494 computedPrefix = '%' + computedPrefix; 495 } 496 filter.put(label, computedPrefix); 497 fullText.add(label); 498 } 499 } 500 // when post filtering we need to get all entries 501 DocumentModelList entries = session.query(filter, fullText, Collections.emptyMap(), false, 502 postFilter ? -1 : limit, -1); 503 504 JSONAdapter jsonAdapter = new JSONAdapter(session, schema); 505 506 for (DocumentModel entry : entries) { 507 JSONAdapter adapter = new JSONAdapter(session, schema, entry); 508 if (!filterParent && isChained && parentDirectory == null) { 509 if (!adapter.isLeaf()) { 510 continue; 511 } 512 } 513 514 if (prefix != null && !prefix.isEmpty() && postFilter) { 515 if (contains) { 516 if (!adapter.getLabel().toLowerCase().contains(prefix.toLowerCase())) { 517 continue; 518 } 519 } else { 520 if (!adapter.getLabel().toLowerCase().startsWith(prefix.toLowerCase())) { 521 continue; 522 } 523 } 524 } 525 526 jsonAdapter.push(adapter); 527 528 } 529 return Blobs.createBlob(jsonAdapter.getChildrenJSONArray().toString(), "application/json"); 530 } 531 } 532 533 protected String translate(final String key) { 534 if (key == null) { 535 return ""; 536 } 537 return I18NUtils.getMessageString("messages", key, new Object[0], getLocale()); 538 } 539 540}