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