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