001/*
002 *  Copyright 2021 Anyware Services
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 */
016package org.ametys.plugins.forms.dao;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.stream.Collectors;
023
024import javax.jcr.Node;
025import javax.jcr.RepositoryException;
026
027import org.apache.avalon.framework.component.Component;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.jackrabbit.util.Text;
032
033import org.ametys.core.observation.Event;
034import org.ametys.core.observation.ObservationManager;
035import org.ametys.core.ui.Callable;
036import org.ametys.core.user.CurrentUserProvider;
037import org.ametys.core.user.UserIdentity;
038import org.ametys.core.util.I18nUtils;
039import org.ametys.plugins.forms.FormEvents;
040import org.ametys.plugins.forms.question.types.ChoicesListQuestionType;
041import org.ametys.plugins.forms.repository.Form;
042import org.ametys.plugins.forms.repository.FormPage;
043import org.ametys.plugins.forms.repository.FormPageFactory;
044import org.ametys.plugins.forms.repository.FormPageRule;
045import org.ametys.plugins.forms.repository.FormPageRule.PageRuleType;
046import org.ametys.plugins.forms.repository.FormQuestion;
047import org.ametys.plugins.repository.AmetysObject;
048import org.ametys.plugins.repository.AmetysObjectResolver;
049import org.ametys.plugins.repository.AmetysRepositoryException;
050import org.ametys.plugins.repository.UnknownAmetysObjectException;
051import org.ametys.plugins.repository.jcr.DefaultTraversableAmetysObject;
052import org.ametys.plugins.repository.jcr.JCRAmetysObject;
053import org.ametys.plugins.repository.jcr.NameHelper;
054import org.ametys.runtime.i18n.I18nizableText;
055import org.ametys.runtime.plugin.component.AbstractLogEnabled;
056
057/**
058 * DAO for manipulating form pages
059 */
060public class FormPageDAO extends AbstractLogEnabled implements Serviceable, Component
061{
062    /** The Avalon role */
063    public static final String ROLE = FormPageDAO.class.getName();
064    /** Observer manager. */
065    protected ObservationManager _observationManager;
066    /** The Ametys object resolver */
067    protected AmetysObjectResolver _resolver;
068    /** The current user provider. */
069    protected CurrentUserProvider _currentUserProvider;
070    /** The form question DAO */
071    protected FormQuestionDAO _formQuestionDAO;
072    /** I18n Utils */
073    protected I18nUtils _i18nUtils;
074    /**The form DAO */
075    protected FormDAO _formDAO;
076    
077    public void service(ServiceManager manager) throws ServiceException
078    {
079        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
080        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
081        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
082        _formQuestionDAO = (FormQuestionDAO) manager.lookup(FormQuestionDAO.ROLE);
083        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
084        _formDAO = (FormDAO) manager.lookup(FormDAO.ROLE);
085    }
086
087    /**
088     * Creates a {@link FormPage}.
089     * @param parentId The id of the parent.
090     * @param name The desired name for the new {@link FormPage}
091     * @return The id of the created form page
092     * @throws Exception if an error occurs during the form creation process
093     */
094    @Callable
095    public Map<String, String> createPage (String parentId, String name) throws Exception
096    {
097        Map<String, String> result = new HashMap<>();
098        
099        Form rootNode = _resolver.resolveById(parentId);
100        
101        // Find unique name
102        String originalName = NameHelper.filterName(name);
103        String uniqueName = originalName;
104        int index = 1;
105        while (rootNode.hasChild(uniqueName))
106        {
107            uniqueName = originalName + "-" + index;
108            index++;
109        }
110        
111        FormPage page = rootNode.createChild(uniqueName, FormPageFactory.FORM_PAGE_NODETYPE);
112        
113        page.setTitle(name + " " + index);
114        
115        rootNode.saveChanges();
116
117        Map<String, Object> eventParams = new HashMap<>();
118        eventParams.put("form", rootNode);
119        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
120        
121        
122        result.put("id", page.getId());
123        result.put("name", page.getTitle());
124        
125        return result;
126    }
127    
128    /**
129     * Rename a {@link FormPage}
130     * @param id The id of the page 
131     * @param newName The new name of the page
132     * @return A result map
133     */
134    @Callable
135    public Map<String, String> renamePage (String id, String newName)
136    {
137        Map<String, String> results = new HashMap<>();
138        
139        FormPage page = _resolver.resolveById(id);
140        
141        String legalName = Text.escapeIllegalJcrChars(newName);
142        Node node = page.getNode();
143        try
144        {
145            page.setTitle(newName);
146            
147            node.getSession().move(node.getPath(), node.getParent().getPath() + '/' + legalName);
148            node.getSession().save();
149            
150            Map<String, Object> eventParams = new HashMap<>();
151            eventParams.put("form", page.getForm());
152            _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
153            
154            results.put("id", id);
155            results.put("newName", legalName);
156            results.put("formId", page.getForm().getId());
157        }
158        catch (RepositoryException e)
159        {
160            getLogger().error("Form renaming failed.", e);
161            results.put("message", "cannot-rename");
162        }
163        
164        return results;
165    }
166    
167    /**
168     * Deletes a {@link FormPage}.
169     * @param id The id of the page to delete
170     * @return The id of the page
171     */
172    @Callable
173    public Map<String, String> deletePage (String id)
174    {
175        Map<String, String> result = new HashMap<>();
176        
177        FormPage page = _resolver.resolveById(id);
178        Form parent = page.getParent();
179        
180        //remove question rules references
181        _removeReferencesFromQuestionsRules(page, parent);
182        
183        page.remove();
184        
185        // Remove page rules references
186        _removeReferencesFromPages (id, parent);
187        _removeReferencesFromQuestions(id, parent);
188        parent.saveChanges();
189        
190        Map<String, Object> eventParams = new HashMap<>();
191        eventParams.put("form", parent);
192        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
193        
194        result.put("id", id);
195        result.put("formId", parent.getId());
196        
197        return result;
198    }
199    
200    /**
201     * Copies and pastes a form page.
202     * @param formId The id of the form, target of the copy
203     * @param pageId The id of the page to copy
204     * @return The id of the created page
205     */
206    @Callable
207    public Map<String, String> copyPage(String formId, String pageId)
208    {
209        Map<String, String> result = new HashMap<>();
210        
211        FormPage originalPage = _resolver.resolveById(pageId);
212        Form parentForm = _resolver.resolveById(formId);
213        
214        // Find unique name
215        String originalName = originalPage.getName();
216        String name = originalName;
217        int index = 2;
218        while (parentForm.hasChild(name))
219        {
220            name = originalName + "-" + (index++);
221        }
222        FormPage cPage = originalPage.copyTo(parentForm, name);
223        String copyTitle = _i18nUtils.translate(new I18nizableText("plugin.forms", "PLUGIN_FORMS_TREE_COPY_NAME_PREFIX")) + originalPage.getTitle();
224        cPage.setTitle(copyTitle);
225        cPage.deleteRule();
226        
227        parentForm.saveChanges();
228        
229        Map<String, Object> eventParams = new HashMap<>();
230        eventParams.put("form", parentForm);
231        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
232        
233        result.put("id", cPage.getId());
234        result.put("formId", parentForm.getId());
235        
236        return result;
237    }
238    
239    /**
240     * Moves an element of the form.
241     * @param id The id of the element to move.
242     * @param oldParent The id of the element's parent.
243     * @param newParent The id of the new element's parent.
244     * @param index The index where to move. null to place the element at the end.
245     * @return A map with the ids of the element, the old parent and the new parent
246     * @throws Exception if an error occurs when moving an element of the form
247     */
248    @Callable
249    public Map<String, String> moveObject (String id, String oldParent, String newParent, long index) throws Exception
250    {
251        Map<String, String> result = new HashMap<>();
252        
253        JCRAmetysObject aoMoved = _resolver.resolveById(id);
254        DefaultTraversableAmetysObject newParentAO = _resolver.resolveById(newParent);
255        JCRAmetysObject  brother = null;
256        long size = newParentAO.getChildren().getSize();
257        if (index != -1 && index < size)
258        {
259            brother = newParentAO.getChildAt(index);
260        }
261        else if (index >= size)
262        {
263            brother = newParentAO.getChildAt(Math.toIntExact(size) - 1);
264        }
265        Form oldForm = getParentForm(aoMoved);
266        if (oldForm != null)
267        {
268            result.put("oldFormId", oldForm.getId());
269        }
270        
271        if (oldParent.equals(newParent) && brother != null)
272        {
273            Node node = aoMoved.getNode();
274            String name = "";
275            try
276            {
277                name = (index == size)
278                            ? null 
279                            : brother.getName();
280                node.getParent().orderBefore(node.getName(), name);
281            }
282            catch (RepositoryException e)
283            {
284                throw new AmetysRepositoryException(String.format("Unable to order AmetysOject '%s' before sibling '%s'", this, name), e);
285            }
286        }
287        else
288        {
289            Node node = aoMoved.getNode();
290            
291            String name = node.getName();
292            // Find unused name on new parent node
293            int localIndex = 2;
294            while (newParentAO.hasChild(name))
295            {
296                name = node.getName() + "-" + localIndex++;
297            }
298            
299            node.getSession().move(node.getPath(), newParentAO.getNode().getPath() + "/" + name);
300            
301            if (brother != null)
302            {
303                node.getParent().orderBefore(node.getName(), brother.getName());
304            }
305        }
306        
307        if (newParentAO.needsSave())
308        {
309            newParentAO.saveChanges();
310        }
311        
312        Form form = getParentForm(aoMoved);
313        if (form != null)
314        {
315            result.put("newFormId", form.getId());
316            
317            Map<String, Object> eventParams = new HashMap<>();
318            eventParams.put("form", form);
319            _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
320        }
321        
322        result.put("id", id);
323        
324        if (aoMoved instanceof FormPage)
325        {
326            result.put("type", "page");
327        }
328        else if (aoMoved instanceof FormQuestion)
329        {
330            result.put("type", "question");
331            result.put("questionType", ((FormQuestion) aoMoved).getType().getId());
332        }
333        
334        result.put("newParentId", newParentAO.getId());
335        result.put("oldParentId", oldParent);
336        
337        return result;
338    }
339    
340    /**
341     * Get the page's properties
342     * @param pageId The form page's id
343     * @return The page properties
344     */
345    @Callable 
346    public Map<String, Object> getPageProperties (String pageId)
347    {
348        try
349        {
350            FormPage page = _resolver.resolveById(pageId);
351            return getPageProperties(page);
352        }
353        catch (UnknownAmetysObjectException e)
354        {
355            getLogger().warn("Can't find page with id: {}. It probably has just been deleted", pageId, e);
356            Map<String, Object> infos = new HashMap<>();
357            infos.put("id", pageId);
358            return infos;
359        }
360    }
361    
362    /**
363     * Get the page's properties
364     * @param page The form page
365     * @return The page properties
366     */
367    public Map<String, Object> getPageProperties (FormPage page)
368    {
369        Map<String, Object> infos = new HashMap<>();
370        
371        List<String> fullPath = new ArrayList<>();
372        fullPath.add(page.getTitle());
373
374        AmetysObject node = page.getParent();
375        fullPath.add(0, node.getName()); 
376        
377        infos.put("type", "page");
378        infos.put("id", page.getId());
379        infos.put("title", page.getTitle());
380        infos.put("formId", page.getForm().getId());
381        infos.put("fullPath", String.join(" > ", fullPath));
382        infos.put("formHasEntries", !page.getForm().getEntries().isEmpty());
383        UserIdentity currentUser = _currentUserProvider.getUser();
384        infos.put("canWrite", _formDAO.hasWriteRightOnForm(currentUser, page.getForm()));
385        infos.put("hasChildren", page.getQuestions().size() > 0);
386        
387        boolean isConfigured = !page.getQuestions().stream().anyMatch(q -> !q.getType().isQuestionConfigured(q));
388        infos.put("isConfigured", isConfigured);
389        
390        return infos;
391    }
392    
393    /**
394     * Gets the ids of the path elements of a form component, i.e. the parent ids.
395     * <br>For instance, if the page path is 'a/b/c', then the result list will be ["id-of-a", "id-of-b", "id-of-c"]
396     * @param id The id of the form component
397     * @return the ids of the path elements of a form
398     */
399    @Callable
400    public List<String> getIdsOfPath(String id)
401    {
402        AmetysObject formComponent = _resolver.resolveById(id);
403        
404        if (!(formComponent instanceof FormPage) && !(formComponent instanceof FormQuestion))
405        {
406            throw new IllegalArgumentException("The given id is not a form component");
407        }
408        
409        List<String> pathElements = new ArrayList<>();
410        AmetysObject current = formComponent.getParent();
411        while (!(current instanceof Form))
412        {
413            pathElements.add(0, current.getId());
414            current = current.getParent();
415        }
416        
417        return pathElements;
418    }
419    
420    /**
421     * Gets all pages for given parent
422     * @param formId The id of the {@link Form}, defining the context from which getting children
423     * @return all forms  for given parent
424     */
425    @Callable
426    public List<Map<String, Object>> getChildPages(String formId)
427    {
428        Form form = _resolver.resolveById(formId);
429        return form.getPages()
430                    .stream()
431                    .map(this::getPageProperties)
432                    .toList();
433    }
434    
435    /**
436     * Get the form containing the given object.
437     * @param obj the object.
438     * @return the parent Form.
439     */
440    protected Form getParentForm(JCRAmetysObject obj)
441    {
442        try
443        {
444            JCRAmetysObject currentAo = obj.getParent();
445            
446            while (!(currentAo instanceof Form))
447            {
448                currentAo = currentAo.getParent();
449            }
450            
451            if (currentAo instanceof Form)
452            {
453                return (Form) currentAo;
454            }
455        }
456        catch (AmetysRepositoryException e)
457        {
458            // Ignore, just return null.
459        }
460        
461        return null;
462    }
463    
464    /**
465     * Determines if a page is the last of form's pages.
466     * @param id The page id
467     * @return True if the page is the last one. 
468     */
469    @Callable
470    public boolean isLastPage (String id)
471    {
472        FormPage page = _resolver.resolveById(id);
473        Form form = page.getForm();
474        
475        List<FormPage> pages = form.getPages();
476        FormPage lastPage = pages.get(pages.size() - 1);
477        
478        return id.equals(lastPage.getId());
479    }
480    
481    /**
482     * Gets the branches for a form page.
483     * @param pageId The id of the form page.
484     * @return The branches
485     */
486    @Callable
487    public Map<String, Object> getBranches (String pageId)
488    {
489        Map<String, Object> result = new HashMap<>();
490        
491        FormPage page = _resolver.resolveById(pageId);
492        
493        result.put("id", pageId);
494        
495        List<Object> questions = new ArrayList<>();
496        List<FormQuestion> questionsAO = page.getQuestions();
497        int index = 1;
498        for (FormQuestion question : questionsAO)
499        {
500            if (question.getType() instanceof ChoicesListQuestionType type && !type.getSourceType(question).remoteData())
501            {
502                try
503                {
504                    questions.add(_formQuestionDAO.getRules(question.getId(), index));
505                }
506                catch (Exception e)
507                {
508                    getLogger().error("an exception occured while getting rules for question " + question.getId());
509                }
510            }
511            index++;
512        }
513        result.put("questions", questions);
514        
515        // SAX page rule
516        result.put("rule", getRule(pageId));
517        
518        return result;
519    }
520    
521    /**
522     * Gets the rule for a form page.
523     * @param id The id of the form page.
524     * @return The rule, or null
525     */
526    @Callable
527    public Map<String, Object> getRule (String id)
528    {
529        Map<String, Object> result = new HashMap<>();
530        
531        FormPage page = _resolver.resolveById(id);
532        FormPageRule rule = page.getRule();
533        
534        if (rule != null)
535        {
536            result.put("type", rule.getType().name());
537            String pageId = rule.getPageId();
538            if (pageId != null)
539            {
540                try
541                {
542                    FormPage pageAO = _resolver.resolveById(pageId);
543                    result.put("page", pageId);
544                    result.put("pageName", pageAO.getTitle());
545                }
546                catch (UnknownAmetysObjectException e)
547                {
548                    // The page does not exist anymore
549                }
550            }
551        }
552        else
553        {
554            result = null;
555        }
556        
557        return result;
558    }
559    
560    /**
561     * Adds a a new rule to a page.
562     * @param id The id of the page
563     * @param rule The rule type
564     * @param page The page to jump or skip
565     * @return An empty map
566     */
567    @Callable
568    public Map<String, Object> addRule (String id, String rule, String page)
569    {
570        FormPage formPage = _resolver.resolveById(id);
571        
572        formPage.setRule(PageRuleType.valueOf(rule), page);
573        formPage.saveChanges();
574        
575        Map<String, Object> eventParams = new HashMap<>();
576        eventParams.put("form", formPage.getForm());
577        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
578        
579        return new HashMap<>();
580    }
581
582    /**
583     * Deletes a rule to a page
584     * @param id The id of the page
585     * @return An empty map
586     */
587    @Callable
588    public Map<String, Object> deleteRule (String id)
589    {
590        FormPage formPage = _resolver.resolveById(id);
591        
592        formPage.deleteRule();
593        formPage.saveChanges();
594        
595        Map<String, Object> eventParams = new HashMap<>();
596        eventParams.put("form", formPage.getForm());
597        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
598        
599        return new HashMap<>();
600    }
601    
602    private void _removeReferencesFromPages (String pageId, Form parent)
603    {
604        List<FormPageRule> rulesWithPageId = parent.getPages().stream()
605            .map(page -> page.getRule())
606            .filter(rule -> rule != null && pageId.equals(rule.getPageId()))
607            .collect(Collectors.toList());
608        
609        for (FormPageRule rule : rulesWithPageId)
610        {
611            rule.remove();
612        }
613        parent.saveChanges();
614    }
615    
616    private void _removeReferencesFromQuestions (String pageId, Form parent)
617    {
618        List<FormPageRule> rulesWithPageId = parent.getQuestions().stream()
619            .map(question -> question.getPageRules())
620            .flatMap(List::stream)
621            .filter(rule -> rule != null && pageId.equals(rule.getPageId()))
622            .collect(Collectors.toList());
623            
624        for (FormPageRule rule : rulesWithPageId)
625        {
626            rule.remove();
627        }
628        parent.saveChanges();
629    }
630    
631    private void _removeReferencesFromQuestionsRules (FormPage page, Form parent)
632    {
633        for (FormQuestion questionToDelete : page.getQuestions())
634        {
635            parent.deleteQuestionsRule(questionToDelete.getId());
636        }
637    }
638    
639    /**
640     * Provides the current user.
641     * @return the user which cannot be <code>null</code>.
642     */
643    protected UserIdentity _getCurrentUser()
644    {      
645        return _currentUserProvider.getUser();
646    }
647}