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