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