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