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