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