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