001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.ui.components.fileselect;
029
030import org.opencms.file.CmsObject;
031import org.opencms.file.CmsResource;
032import org.opencms.file.CmsResourceFilter;
033import org.opencms.main.CmsException;
034import org.opencms.main.CmsLog;
035import org.opencms.main.OpenCms;
036import org.opencms.site.CmsSite;
037import org.opencms.ui.A_CmsUI;
038import org.opencms.ui.CmsVaadinUtils;
039import org.opencms.ui.components.CmsErrorDialog;
040import org.opencms.ui.components.CmsResourceTableProperty;
041import org.opencms.util.CmsStringUtil;
042import org.opencms.util.CmsUUID;
043import org.opencms.workplace.CmsWorkplace;
044
045import java.lang.reflect.Field;
046import java.util.ArrayList;
047import java.util.Arrays;
048import java.util.Collections;
049import java.util.Comparator;
050import java.util.HashMap;
051import java.util.HashSet;
052import java.util.List;
053import java.util.Map;
054import java.util.Set;
055import java.util.stream.Collectors;
056
057import org.apache.commons.logging.Log;
058
059import com.google.common.collect.Lists;
060import com.vaadin.event.Action;
061import com.vaadin.event.Action.Handler;
062import com.vaadin.event.ShortcutAction;
063import com.vaadin.ui.CustomComponent;
064import com.vaadin.ui.Window;
065import com.vaadin.v7.data.Container;
066import com.vaadin.v7.data.Container.Filter;
067import com.vaadin.v7.data.Item;
068import com.vaadin.v7.data.Property.ValueChangeEvent;
069import com.vaadin.v7.data.Property.ValueChangeListener;
070import com.vaadin.v7.data.util.IndexedContainer;
071import com.vaadin.v7.shared.ui.combobox.FilteringMode;
072import com.vaadin.v7.ui.ComboBox;
073import com.vaadin.v7.ui.TreeTable;
074
075/**
076 * Dialog with a site selector and file tree which can be used to select resources.<p>
077 */
078public class CmsResourceSelectDialog extends CustomComponent {
079
080    /**
081     * Class for site select options.<p>
082     */
083    public static class Options {
084
085        /**Indexed container.*/
086        private IndexedContainer m_siteSelectionContainer;
087
088        /**
089         * Returns the siteSelectionContainer.<p>
090         *
091         * @return the siteSelectionContainer
092         */
093        public IndexedContainer getSiteSelectionContainer() {
094
095            return m_siteSelectionContainer;
096        }
097
098        /**
099         * Sets the siteSelectionContainer.<p>
100         *
101         * @param siteSelectionContainer the siteSelectionContainer to set
102         */
103        public void setSiteSelectionContainer(IndexedContainer siteSelectionContainer) {
104
105            m_siteSelectionContainer = siteSelectionContainer;
106        }
107
108    }
109
110    /**
111     * Information needed for filtering the resource tree.
112     */
113    static class FilterData {
114
115        /** The set of folders to open. */
116        private Set<CmsResource> m_openFolders = new HashSet<>();
117
118        /** The set of root paths directly matching the filter. */
119        private Set<String> m_matchedPaths = new HashSet<>();
120
121        /**
122         * Gets the set of root paths of resources directly matching the filter.
123         *
124         * @return the set of paths of matching resources
125         */
126        public Set<String> getMatchedPaths() {
127
128            return m_matchedPaths;
129        }
130
131        /**
132         * Gets the set of folders to open +
133         *
134         * @return the set of folders to open
135         */
136        public Set<CmsResource> getOpenFolders() {
137
138            return m_openFolders;
139        }
140    }
141
142    /**
143     * Converts resource selection to path (string) selection - either as root paths or site paths.<p>
144     */
145    class PathSelectionAdapter implements I_CmsSelectionHandler<CmsResource> {
146
147        /** The wrapped string selection handler. */
148        private I_CmsSelectionHandler<String> m_pathHandler;
149
150        /** If true, pass site paths to the wrapped path handler, else root paths. */
151        private boolean m_useSitePaths;
152
153        /**
154         * Creates a new instance.<p>
155         *
156         * @param pathHandler the selection handler to call
157         * @param useSitePaths true if we want changes as site paths
158         */
159        public PathSelectionAdapter(I_CmsSelectionHandler<String> pathHandler, boolean useSitePaths) {
160
161            m_pathHandler = pathHandler;
162            m_useSitePaths = useSitePaths;
163        }
164
165        /**
166         * @see org.opencms.ui.components.fileselect.I_CmsSelectionHandler#onSelection(java.lang.Object)
167         */
168        @SuppressWarnings("synthetic-access")
169        public void onSelection(CmsResource selected) {
170
171            String path = selected.getRootPath();
172            if (m_useSitePaths) {
173                try {
174                    CmsObject cms = OpenCms.initCmsObject(A_CmsUI.getCmsObject());
175                    cms.getRequestContext().setSiteRoot(m_siteRoot);
176                    path = cms.getRequestContext().removeSiteRoot(path);
177                } catch (CmsException e) {
178                    LOG.error(e.getLocalizedMessage(), e);
179
180                }
181            }
182            m_pathHandler.onSelection(path);
183        }
184    }
185
186    /** The property used for the site caption. */
187    public static final String PROPERTY_SITE_CAPTION = "caption";
188
189    /** Logger instance for this class. */
190    private static final Log LOG = CmsLog.getLog(CmsResourceSelectDialog.class);
191
192    /** Serial version id. */
193    private static final long serialVersionUID = 1L;
194
195    /** The CMS context. */
196    protected CmsObject m_currentCms;
197
198    /** The resource filter. */
199    protected CmsResourceFilter m_resourceFilter;
200
201    /** The resource initially displayed at the root of the tree. */
202    protected CmsResource m_root;
203
204    /** The file tree (wrapped in an array, because Vaadin Declarative tries to bind it otherwise) .*/
205    private CmsResourceTreeTable m_fileTree;
206
207    /** Boolean flag indicating whether the tree is currently filtered. */
208    private boolean m_isSitemapView = true;
209
210    /** The site root. */
211    private String m_siteRoot;
212
213    /** Contains the data for the tree. */
214    private CmsResourceTreeContainer m_treeData;
215
216    /** The currently applied filter. */
217    private Filter m_currentFilter;
218
219    /**
220     * Creates a new instance.<p>
221     *
222     * @param filter the resource filter to use
223     *
224     * @throws CmsException if something goes wrong
225     */
226    public CmsResourceSelectDialog(CmsResourceFilter filter)
227    throws CmsException {
228
229        this(filter, A_CmsUI.getCmsObject());
230    }
231
232    /**
233     * public constructor with given CmsObject.<p>
234     *
235     * @param filter filter the resource filter to use
236     * @param cms CmsObejct to use
237     * @throws CmsException if something goes wrong
238     */
239    public CmsResourceSelectDialog(CmsResourceFilter filter, CmsObject cms)
240    throws CmsException {
241
242        this(filter, cms, new Options());
243    }
244
245    /**
246     * public constructor.<p>
247     *
248     * @param filter resource filter
249     * @param cms CmsObject
250     * @param options options
251     * @throws CmsException exception
252     */
253    public CmsResourceSelectDialog(CmsResourceFilter filter, final CmsObject _cms, Options options)
254    throws CmsException {
255
256        m_resourceFilter = filter;
257        setCompositionRoot(new CmsResourceSelectDialogContents());
258        IndexedContainer container = options.getSiteSelectionContainer() != null
259        ? options.getSiteSelectionContainer()
260        : CmsVaadinUtils.getAvailableSitesContainer(_cms, PROPERTY_SITE_CAPTION);
261        getSiteSelector().setContainerDataSource(container);
262
263        final CmsObject cms;
264        if (!_cms.existsResource("/", CmsResourceFilter.IGNORE_EXPIRATION)) {
265            cms = OpenCms.initCmsObject(_cms);
266            cms.getRequestContext().setSiteRoot("/system/");
267        } else {
268            cms = _cms;
269        }
270        m_siteRoot = cms.getRequestContext().getSiteRoot();
271
272        getSiteSelector().setValue(
273            CmsVaadinUtils.getPathItemId(getSiteSelector().getContainerDataSource(), m_siteRoot));
274        getSiteSelector().setNullSelectionAllowed(false);
275        getSiteSelector().setItemCaptionPropertyId(PROPERTY_SITE_CAPTION);
276        getSiteSelector().setFilteringMode(FilteringMode.CONTAINS);
277        getSiteSelector().addValueChangeListener(new ValueChangeListener() {
278
279            /** Serial version id. */
280            private static final long serialVersionUID = 1L;
281
282            public void valueChange(ValueChangeEvent event) {
283
284                String site = (String)(event.getProperty().getValue());
285                onSiteChange(site);
286            }
287        });
288
289        CmsResource root = cms.readResource("/");
290        m_fileTree = createTree(cms, root);
291        m_fileTree.setColumnExpandRatio(CmsResourceTreeTable.CAPTION_FOLDERS, 5);
292        m_fileTree.setColumnExpandRatio(CmsResourceTableProperty.PROPERTY_NAVIGATION_TEXT, 1);
293        m_treeData = m_fileTree.getTreeContainer();
294        updateRoot(cms, root);
295
296        getContents().getTreeContainer().addComponent(m_fileTree);
297        m_fileTree.setSizeFull();
298        getContents().addAttachListener(event -> {
299            Window window = CmsVaadinUtils.getWindow(this);
300            if (window != null) {
301                window.addActionHandler(new Handler() {
302
303                    private final Action enterKeyShortcutAction = new ShortcutAction(
304                        null,
305                        ShortcutAction.KeyCode.ENTER,
306                        null);
307
308                    @Override
309                    public Action[] getActions(Object target, Object sender) {
310
311                        return new Action[] {enterKeyShortcutAction};
312                    }
313
314                    @Override
315                    public void handleAction(Action action, Object sender, Object target) {
316
317                        if (enterKeyShortcutAction.equals(action)) {
318                            if (target == getContents().getFilterBox()) {
319                                updateFilter();
320                            }
321                        }
322
323                    }
324                });
325            }
326        });
327
328        updateView();
329
330        getContents().getFilterButton().addClickListener(event -> {
331            updateFilter();
332        });
333
334    }
335
336    /**
337     * Adds a resource selection handler.<p>
338     *
339     * @param handler the handler
340     */
341    public void addSelectionHandler(I_CmsSelectionHandler<CmsResource> handler) {
342
343        m_fileTree.addResourceSelectionHandler(handler);
344    }
345
346    /**
347     * Disables the option to select resources from other sites.<p>
348     */
349    public void disableSiteSwitch() {
350
351        getSiteSelector().setEnabled(false);
352    }
353
354    /**
355     * Opens the given path.<p>
356     *
357     * @param path the path to open
358     */
359    public void openPath(String path) {
360
361        if (!CmsStringUtil.isPrefixPath(m_root.getRootPath(), path)) {
362            CmsSite site = OpenCms.getSiteManager().getSiteForRootPath(path);
363            if (site != null) {
364                // the given path is a root path switch to the determined site
365                getSiteSelector().setValue(site.getSiteRoot());
366                path = m_currentCms.getRequestContext().removeSiteRoot(path);
367            } else if (OpenCms.getSiteManager().startsWithShared(path)) {
368                getSiteSelector().setValue(OpenCms.getSiteManager().getSharedFolder());
369            } else if (path.startsWith(CmsWorkplace.VFS_PATH_SYSTEM)) {
370                Container container = getSiteSelector().getContainerDataSource();
371                String newSiteRoot = null;
372                for (String possibleSiteRoot : Arrays.asList("", "/", "/system", "/system/")) {
373                    if (container.containsId(possibleSiteRoot)) {
374                        newSiteRoot = possibleSiteRoot;
375                        break;
376                    }
377                }
378                if (newSiteRoot == null) {
379                    LOG.warn(
380                        "Couldn't open path in site selector because neither root site nor system folder are in the site selector. path="
381                            + path);
382                    return;
383                }
384                getSiteSelector().setValue(newSiteRoot);
385            }
386        }
387        if (!"/".equals(path)) {
388            List<CmsUUID> idsToOpen = Lists.newArrayList();
389            try {
390                CmsResource currentFolder = m_currentCms.readResource(CmsResource.getParentFolder(path));
391                if (!m_root.getStructureId().equals(currentFolder.getStructureId())) {
392                    idsToOpen.add(currentFolder.getStructureId());
393                    CmsResource parentFolder = null;
394
395                    do {
396                        try {
397                            parentFolder = m_currentCms.readParentFolder(currentFolder.getStructureId());
398                            idsToOpen.add(parentFolder.getStructureId());
399                            currentFolder = parentFolder;
400                        } catch (CmsException | NullPointerException e) {
401                            LOG.info(e.getLocalizedMessage(), e);
402                            break;
403                        }
404                    } while (!parentFolder.getStructureId().equals(m_root.getStructureId()));
405                    // we need to iterate from "top" to "bottom", so we reverse the list of folders
406                    Collections.reverse(idsToOpen);
407
408                    for (CmsUUID id : idsToOpen) {
409                        m_fileTree.expandItem(id);
410                    }
411                }
412            } catch (CmsException e) {
413                LOG.debug("Can not read parent folder of current path.", e);
414            }
415        }
416    }
417
418    /**
419     * Switches between the folders and sitemap view of the tree.<p>
420     *
421     * @param showSitemapView <code>true</code> to show the sitemap view
422     */
423    public void showSitemapView(boolean showSitemapView) {
424
425        if (m_isSitemapView != showSitemapView) {
426            m_isSitemapView = showSitemapView;
427            updateView();
428        }
429    }
430
431    /**
432     * Displays the start resource by opening all nodes in the tree leading to it.<p>
433     *
434     * @param startResource the resource which should be shown in the tree
435     */
436    public void showStartResource(CmsResource startResource) {
437
438        openPath(startResource.getRootPath());
439    }
440
441    /**
442     * Creates the resource tree for the given root.<p>
443     *
444     * @param cms the CMS context
445     * @param root the root resource
446     * @return the resource tree
447     */
448    protected CmsResourceTreeTable createTree(CmsObject cms, CmsResource root) {
449
450        return new CmsResourceTreeTable(cms, root, m_resourceFilter);
451    }
452
453    /**
454     * Gets the content panel of this dialog.<p>
455     *
456     * @return content panel of this dialog
457     */
458    protected CmsResourceSelectDialogContents getContents() {
459
460        return ((CmsResourceSelectDialogContents)getCompositionRoot());
461    }
462
463    /**
464     * Gets the file tree.<p>
465     *
466     * @return the file tree
467     */
468    protected CmsResourceTreeTable getFileTree() {
469
470        return m_fileTree;
471    }
472
473    /**
474     * Called when the user changes the site.<p>
475     *
476     * @param site the new site root
477     */
478    protected void onSiteChange(String site) {
479
480        try {
481            removeStringFilter();
482            m_treeData.removeAllItems();
483
484            // the tree table caches the open/closed state of items,  even when the content of the data source changes,
485            // and then can get confused during a site change because the container items for a site share IDs
486            // with the corresponding container items in the root site.
487            // The following is a hack to clear the open/closed state cache, which depends on the internals of TreeTable.
488            try {
489                Field cStrategy = TreeTable.class.getDeclaredField("cStrategy");
490                cStrategy.trySetAccessible();
491                cStrategy.set(m_fileTree, null);
492            } catch (Exception e) {
493                LOG.error(e.getLocalizedMessage(), e);
494            }
495            CmsResourceSelectDialogContents contents = (CmsResourceSelectDialogContents)getCompositionRoot();
496            contents.getFilterBox().setValue("");
497
498            CmsObject rootCms = OpenCms.initCmsObject(A_CmsUI.getCmsObject());
499            rootCms.getRequestContext().setSiteRoot("");
500            CmsResource siteRootResource = rootCms.readResource(site);
501            m_treeData.initRoot(rootCms, siteRootResource);
502            m_fileTree.expandItem(siteRootResource.getStructureId());
503            m_siteRoot = site;
504            updateRoot(rootCms, siteRootResource);
505        } catch (CmsException e) {
506            LOG.error(e.getLocalizedMessage(), e);
507        }
508    }
509
510    /**
511     * Updates the current site root resource.<p>
512     *
513     * @param rootCms the CMS context
514     * @param siteRootResource the resource corresponding to a site root
515     */
516    protected void updateRoot(CmsObject rootCms, CmsResource siteRootResource) {
517
518        m_root = siteRootResource;
519        m_currentCms = rootCms;
520        updateView();
521    }
522
523    /**
524     * Updates the filtering state.<p>
525     */
526    protected void updateView() {
527
528        m_fileTree.showSitemapView(m_isSitemapView);
529    }
530
531    private FilterData getFilterData(CmsObject cms, CmsResource root, CmsResourceFilter filter, String filterText)
532    throws CmsException {
533
534        List<CmsResource> allResources = cms.readResources(root, filter, true);
535        List<CmsResource> matching = allResources.stream().filter(
536            res -> res.getName().toLowerCase().contains(filterText.toLowerCase())).collect(Collectors.toList());
537        Map<String, CmsResource> resourcesByPath = allResources.stream().collect(
538            Collectors.toMap(res -> res.getRootPath(), res -> res, (a, b) -> b));
539        FilterData result = new FilterData();
540        for (CmsResource res : matching) {
541            String path = res.getRootPath();
542            result.getMatchedPaths().add(path);
543            do {
544                path = CmsResource.getParentFolder(path);
545                CmsResource parent = resourcesByPath.get(path);
546                if (parent != null) {
547                    result.getOpenFolders().add(parent);
548                }
549            } while ((path != null) && !path.equals(root.getRootPath()));
550        }
551        return result;
552
553    }
554
555    /**
556     * Gets the site selector.<p>
557     *
558     * @return the site selector
559     */
560    private ComboBox getSiteSelector() {
561
562        return getContents().getSiteSelector();
563    }
564
565    /**
566     * Removes the current filter.
567     */
568    private void removeStringFilter() {
569
570        if (m_currentFilter != null) {
571            m_treeData.removeContainerFilter(m_currentFilter);
572            m_currentFilter = null;
573        }
574    }
575
576    /**
577     * Updates the filtering based on the current content of the filter box.
578     */
579    private void updateFilter() {
580
581        String filterText = getContents().getFilterBox().getValue();
582        if (CmsStringUtil.isEmptyOrWhitespaceOnly(filterText)) {
583            removeStringFilter();
584        } else {
585            removeStringFilter();
586            try {
587                FilterData filterData = getFilterData(m_currentCms, m_root, m_resourceFilter, filterText);
588                List<CmsResource> openFolders = new ArrayList<>(filterData.getOpenFolders());
589                // sort open folders by path so parents are opened before children
590                openFolders.sort(Comparator.comparing(res -> res.getRootPath()));
591                for (CmsResource resource : openFolders) {
592                    m_fileTree.expandItem(resource.getStructureId());
593                }
594                m_currentFilter = new Container.Filter() {
595
596                    private Map<CmsUUID, Boolean> m_cache = new HashMap<>();
597
598                    @Override
599                    public boolean appliesToProperty(Object propertyId) {
600
601                        return false;
602                    }
603
604                    @Override
605                    public boolean passesFilter(Object itemId, Item item) throws UnsupportedOperationException {
606
607                        return m_cache.computeIfAbsent((CmsUUID)itemId, id -> {
608
609                            CmsResource resource = (CmsResource)(item.getItemProperty(
610                                CmsResourceTreeContainer.PROPERTY_RESOURCE).getValue());
611                            for (String matchPath : filterData.getMatchedPaths()) {
612                                if (CmsStringUtil.isPrefixPath(matchPath, resource.getRootPath())
613                                    || CmsStringUtil.isPrefixPath(resource.getRootPath(), matchPath)) {
614                                    return true;
615                                }
616                            }
617                            return false;
618                        });
619                    }
620                };
621                m_treeData.addContainerFilter(m_currentFilter);
622            } catch (Exception e) {
623                CmsErrorDialog.showErrorDialog(e);
624            }
625        }
626    }
627
628}