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}