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}