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