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