001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2018 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.portlets.markdowndisplay;
020
021import freemarker.core.ParseException;
022import freemarker.template.MalformedTemplateNameException;
023import freemarker.template.Template;
024import freemarker.template.TemplateNotFoundException;
025
026import java.beans.ConstructorProperties;
027import java.io.IOException;
028import java.io.Serializable;
029import java.security.Principal;
030import java.util.HashMap;
031import java.util.HashSet;
032import java.util.Map;
033import java.util.ResourceBundle;
034import java.util.Set;
035
036import org.jdrupes.json.JsonBeanDecoder;
037import org.jdrupes.json.JsonBeanEncoder;
038import org.jdrupes.json.JsonDecodeException;
039import org.jgrapes.core.Channel;
040import org.jgrapes.core.Event;
041import org.jgrapes.core.Manager;
042import org.jgrapes.core.annotation.Handler;
043import org.jgrapes.http.Session;
044import org.jgrapes.io.IOSubchannel;
045import org.jgrapes.portal.base.PortalSession;
046import org.jgrapes.portal.base.PortalUtils;
047import org.jgrapes.portal.base.Portlet.RenderMode;
048import org.jgrapes.portal.base.UserPrincipal;
049import org.jgrapes.portal.base.events.AddPageResources.ScriptResource;
050import org.jgrapes.portal.base.events.AddPortletRequest;
051import org.jgrapes.portal.base.events.AddPortletType;
052import org.jgrapes.portal.base.events.DeletePortlet;
053import org.jgrapes.portal.base.events.DeletePortletRequest;
054import org.jgrapes.portal.base.events.NotifyPortletModel;
055import org.jgrapes.portal.base.events.NotifyPortletView;
056import org.jgrapes.portal.base.events.PortalReady;
057import org.jgrapes.portal.base.events.RenderPortletRequest;
058import org.jgrapes.portal.base.events.RenderPortletRequestBase;
059import org.jgrapes.portal.base.events.UpdatePortletModel;
060import org.jgrapes.portal.base.freemarker.FreeMarkerPortlet;
061
062import org.jgrapes.util.events.KeyValueStoreData;
063import org.jgrapes.util.events.KeyValueStoreQuery;
064import org.jgrapes.util.events.KeyValueStoreUpdate;
065
066/**
067 * A portlet used to display information to the user. Instances
068 * may be used as a kind of note, i.e. created and configured by
069 * a user himself. A typical use case, however, is to create
070 * an instance during startup by a portal policy.
071 */
072@SuppressWarnings("PMD.DataClass")
073public class MarkdownDisplayPortlet extends FreeMarkerPortlet {
074
075    /** Property for forcing a portlet id (used for singleton instaces). */
076    public static final String PORTLET_ID = "PortletId";
077    /** Property for setting a title. */
078    public static final String TITLE = "Title";
079    /** Property for setting the preview source. */
080    public static final String PREVIEW_SOURCE = "PreviewSource";
081    /** Property for setting the view source. */
082    public static final String VIEW_SOURCE = "ViewSource";
083    /** Boolean property that controls if the preview is deletable. */
084    public static final String DELETABLE = "Deletable";
085    /** Property of type `Set<Principal>` for restricting who 
086     * can edit the content. */
087    public static final String EDITABLE_BY = "EditableBy";
088
089    /**
090     * Creates a new component with its channel set to the given 
091     * channel.
092     * 
093     * @param componentChannel the channel that the component's 
094     * handlers listen on by default and that 
095     * {@link Manager#fire(Event, Channel...)} sends the event to 
096     */
097    public MarkdownDisplayPortlet(Channel componentChannel) {
098        super(componentChannel, false);
099    }
100
101    private String storagePath(Session session) {
102        return "/" + PortalUtils.userFromSession(session)
103            .map(UserPrincipal::toString).orElse("")
104            + "/portlets/" + MarkdownDisplayPortlet.class.getName() + "/";
105    }
106
107    /**
108     * On {@link PortalReady}, fire the {@link AddPortletType}.
109     *
110     * @param event the event
111     * @param portalSession the portal session
112     * @throws TemplateNotFoundException the template not found exception
113     * @throws MalformedTemplateNameException the malformed template name exception
114     * @throws ParseException the parse exception
115     * @throws IOException Signals that an I/O exception has occurred.
116     */
117    @Handler
118    public void onPortalReady(PortalReady event, PortalSession portalSession)
119            throws TemplateNotFoundException, MalformedTemplateNameException,
120            ParseException, IOException {
121        ResourceBundle resourceBundle = resourceBundle(portalSession.locale());
122        // Add MarkdownDisplayPortlet resources to page
123        portalSession.respond(new AddPortletType(type())
124            .setDisplayName(resourceBundle.getString("portletName"))
125            .addScript(new ScriptResource()
126                .setRequires(new String[] { "markdown-it.github.io",
127                    "github.com/markdown-it/markdown-it-abbr",
128                    "github.com/markdown-it/markdown-it-container",
129                    "github.com/markdown-it/markdown-it-deflist",
130                    "github.com/markdown-it/markdown-it-emoji",
131                    "github.com/markdown-it/markdown-it-footnote",
132                    "github.com/markdown-it/markdown-it-ins",
133                    "github.com/markdown-it/markdown-it-mark",
134                    "github.com/markdown-it/markdown-it-sub",
135                    "github.com/markdown-it/markdown-it-sup" })
136                .setScriptUri(event.renderSupport().portletResource(
137                    type(), "MarkdownDisplay-functions.ftl.js")))
138            .addCss(event.renderSupport(), PortalUtils.uriFromPath(
139                "MarkdownDisplay-style.css"))
140            .setInstantiable());
141        KeyValueStoreQuery query = new KeyValueStoreQuery(
142            storagePath(portalSession.browserSession()), portalSession);
143        fire(query, portalSession);
144    }
145
146    /**
147     * Restore portlet information, if contained in the event.
148     *
149     * @param event the event
150     * @param channel the channel
151     * @throws JsonDecodeException the json decode exception
152     */
153    @Handler
154    public void onKeyValueStoreData(
155            KeyValueStoreData event, PortalSession channel)
156            throws JsonDecodeException {
157        if (!event.event().query()
158            .equals(storagePath(channel.browserSession()))) {
159            return;
160        }
161        for (String json : event.data().values()) {
162            MarkdownDisplayModel model = JsonBeanDecoder.create(json)
163                .readObject(MarkdownDisplayModel.class);
164            putInSession(channel.browserSession(), model);
165        }
166    }
167
168    /**
169     * Adds the portlet to the portal. The portlet supports the 
170     * following options (see {@link AddPortletRequest#properties()}:
171     * 
172     * * `PORTLET_ID` (String): The portlet id.
173     * 
174     * * `TITLE` (String): The portlet title.
175     * 
176     * * `PREVIEW_SOURCE` (String): The markdown source that is rendered 
177     *   in the portlet preview.
178     * 
179     * * `VIEW_SOURCE` (String): The markdown source that is rendered 
180     *   in the portlet view.
181     * 
182     * * `DELETABLE` (Boolean): Indicates that the portlet may be 
183     *   deleted from the overview page.
184     * 
185     * * `EDITABLE_BY` (Set&lt;Principal&gt;): The principals that may edit 
186     *   the portlet instance.
187     */
188    @Override
189    public String doAddPortlet(AddPortletRequest event,
190            PortalSession portalSession) throws Exception {
191        ResourceBundle resourceBundle = resourceBundle(portalSession.locale());
192
193        // Create new model
194        String portletId = (String) event.properties().get(PORTLET_ID);
195        if (portletId == null) {
196            portletId = generatePortletId();
197        }
198        MarkdownDisplayModel model = putInSession(
199            portalSession.browserSession(),
200            new MarkdownDisplayModel(portletId));
201        model.setTitle((String) event.properties().getOrDefault(TITLE,
202            resourceBundle.getString("portletName")));
203        model.setPreviewContent((String) event.properties().getOrDefault(
204            PREVIEW_SOURCE, ""));
205        model.setViewContent((String) event.properties().getOrDefault(
206            VIEW_SOURCE, ""));
207        model.setDeletable((Boolean) event.properties().getOrDefault(
208            DELETABLE, Boolean.TRUE));
209        @SuppressWarnings("unchecked")
210        Set<Principal> editableBy = (Set<Principal>) event.properties().get(
211            EDITABLE_BY);
212        model.setEditableBy(editableBy);
213
214        // Save model
215        String jsonState = JsonBeanEncoder.create()
216            .writeObject(model).toJson();
217        portalSession.respond(new KeyValueStoreUpdate().update(
218            storagePath(portalSession.browserSession()) + model.getPortletId(),
219            jsonState));
220
221        // Send HTML
222        renderPortlet(event, portalSession, model);
223        return portletId;
224    }
225
226    /*
227     * (non-Javadoc)
228     * 
229     * @see org.jgrapes.portal.AbstractPortlet#doRenderPortlet
230     */
231    @Override
232    protected void doRenderPortlet(RenderPortletRequest event,
233            PortalSession portalSession, String portletId,
234            Serializable retrievedState) throws Exception {
235        MarkdownDisplayModel model = (MarkdownDisplayModel) retrievedState;
236        renderPortlet(event, portalSession, model);
237    }
238
239    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
240    private void renderPortlet(RenderPortletRequestBase<?> event,
241            PortalSession portalSession, MarkdownDisplayModel model)
242            throws TemplateNotFoundException, MalformedTemplateNameException,
243            ParseException, IOException {
244        Set<RenderMode> modes = renderModes(model);
245        if (model.getViewContent() != null
246            && !model.getViewContent().isEmpty()) {
247            modes.add(RenderMode.View);
248        }
249        switch (event.renderMode()) {
250        case Preview:
251        case DeleteablePreview: {
252            Template tpl = freemarkerConfig()
253                .getTemplate("MarkdownDisplay-preview.ftl.html");
254            portalSession.respond(new RenderPortletFromTemplate(event,
255                MarkdownDisplayPortlet.class, model.getPortletId(),
256                tpl, fmModel(event, portalSession, model))
257                    .setRenderMode(event.renderMode()).setSupportedModes(modes)
258                    .setForeground(event.isForeground()));
259            updateView(portalSession, model);
260            break;
261        }
262        case View: {
263            Template tpl = freemarkerConfig()
264                .getTemplate("MarkdownDisplay-view.ftl.html");
265            portalSession.respond(new RenderPortletFromTemplate(event,
266                MarkdownDisplayPortlet.class, model.getPortletId(),
267                tpl, fmModel(event, portalSession, model))
268                    .setRenderMode(RenderMode.View).setSupportedModes(modes)
269                    .setForeground(event.isForeground()));
270            updateView(portalSession, model);
271            break;
272        }
273        case Edit: {
274            Template tpl = freemarkerConfig()
275                .getTemplate("MarkdownDisplay-edit.ftl.html");
276            portalSession.respond(new RenderPortletFromTemplate(event,
277                MarkdownDisplayPortlet.class, model.getPortletId(),
278                tpl, fmModel(event, portalSession, model))
279                    .setRenderMode(RenderMode.Edit).setSupportedModes(modes));
280            break;
281        }
282        default:
283            break;
284        }
285    }
286
287    private Set<RenderMode> renderModes(MarkdownDisplayModel model) {
288        Set<RenderMode> modes = new HashSet<>();
289        modes.add(model.isDeletable() ? RenderMode.DeleteablePreview
290            : RenderMode.Preview);
291        if (model.getViewContent() != null
292            && !model.getViewContent().isEmpty()) {
293            modes.add(RenderMode.View);
294        }
295        if (model.getEditableBy() == null) {
296            modes.add(RenderMode.Edit);
297        }
298        return modes;
299    }
300
301    private void updateView(IOSubchannel channel, MarkdownDisplayModel model) {
302        channel.respond(new NotifyPortletView(type(),
303            model.getPortletId(), "updateAll", model.getTitle(),
304            model.getPreviewContent(), model.getViewContent(),
305            renderModes(model)));
306    }
307
308    /*
309     * (non-Javadoc)
310     * 
311     * @see org.jgrapes.portal.AbstractPortlet#doDeletePortlet
312     */
313    @Override
314    protected void doDeletePortlet(DeletePortletRequest event,
315            PortalSession channel, String portletId,
316            Serializable retrievedState) throws Exception {
317        channel.respond(new KeyValueStoreUpdate().delete(
318            storagePath(channel.browserSession()) + portletId));
319        channel.respond(new DeletePortlet(portletId));
320    }
321
322    /*
323     * (non-Javadoc)
324     * 
325     * @see org.jgrapes.portal.AbstractPortlet#doNotifyPortletModel
326     */
327    @Override
328    protected void doNotifyPortletModel(NotifyPortletModel event,
329            PortalSession portalSession, Serializable portletState)
330            throws Exception {
331        event.stop();
332        @SuppressWarnings("PMD.UseConcurrentHashMap")
333        Map<String, String> properties = new HashMap<>();
334        if (event.params().get(0) != null) {
335            properties.put(TITLE, event.params().asString(0));
336        }
337        if (event.params().get(1) != null) {
338            properties.put(PREVIEW_SOURCE, event.params().asString(1));
339        }
340        if (event.params().get(2) != null) {
341            properties.put(VIEW_SOURCE, event.params().asString(2));
342        }
343        fire(new UpdatePortletModel(event.portletId(), properties),
344            portalSession);
345    }
346
347    /**
348     * Stores the modified properties using a {@link KeyValueStoreUpdate}
349     * event and updates the view with a {@link NotifyPortletView}. 
350     *
351     * @param event the event
352     * @param portalSession the portal session
353     */
354    @SuppressWarnings("unchecked")
355    @Handler
356    public void onUpdatePortletModel(UpdatePortletModel event,
357            PortalSession portalSession) {
358        stateFromSession(portalSession.browserSession(), event.portletId(),
359            MarkdownDisplayModel.class).ifPresent(model -> {
360                event.ifPresent(TITLE,
361                    (key, value) -> model.setTitle((String) value))
362                    .ifPresent(PREVIEW_SOURCE,
363                        (key, value) -> model.setPreviewContent((String) value))
364                    .ifPresent(VIEW_SOURCE,
365                        (key, value) -> model.setViewContent((String) value))
366                    .ifPresent(DELETABLE,
367                        (key, value) -> model.setDeletable((Boolean) value))
368                    .ifPresent(EDITABLE_BY,
369                        (key, value) -> {
370                            model.setEditableBy((Set<Principal>) value);
371                        });
372                try {
373                    String jsonState = JsonBeanEncoder.create()
374                        .writeObject(model).toJson();
375                    portalSession.respond(new KeyValueStoreUpdate().update(
376                        storagePath(portalSession.browserSession())
377                            + model.getPortletId(),
378                        jsonState));
379                    updateView(portalSession, model);
380                } catch (IOException e) { // NOPMD
381                    // Won't happen, uses internal writer
382                }
383            });
384    }
385
386    /**
387     * The portlet's model.
388     */
389    @SuppressWarnings("serial")
390    public static class MarkdownDisplayModel extends PortletBaseModel {
391
392        private String title = "";
393        private String previewContent = "";
394        private String viewContent = "";
395        private boolean deletable = true;
396        private Set<Principal> editableBy;
397
398        /**
399         * Creates a new model with the given type and id.
400         * 
401         * @param portletId the portlet id
402         */
403        @ConstructorProperties({ "portletId" })
404        public MarkdownDisplayModel(String portletId) {
405            super(portletId);
406        }
407
408        /**
409         * @return the title
410         */
411        public String getTitle() {
412            return title;
413        }
414
415        /**
416         * @param title the title to set
417         */
418        public void setTitle(String title) {
419            this.title = title;
420        }
421
422        /**
423         * @return the previewContent
424         */
425        public String getPreviewContent() {
426            return previewContent;
427        }
428
429        /**
430         * @param previewContent the previewContent to set
431         */
432        public void setPreviewContent(String previewContent) {
433            this.previewContent = previewContent;
434        }
435
436        /**
437         * @return the viewContent
438         */
439        public String getViewContent() {
440            return viewContent;
441        }
442
443        /**
444         * @param viewContent the viewContent to set
445         */
446        public void setViewContent(String viewContent) {
447            this.viewContent = viewContent;
448        }
449
450        /**
451         * @return the deletable
452         */
453        public boolean isDeletable() {
454            return deletable;
455        }
456
457        /**
458         * @param deletable the deletable to set
459         */
460        public void setDeletable(boolean deletable) {
461            this.deletable = deletable;
462        }
463
464        /**
465         * @return the editableBy
466         */
467        public Set<Principal> getEditableBy() {
468            return editableBy;
469        }
470
471        /**
472         * @param editableBy the editableBy to set
473         */
474        public void setEditableBy(Set<Principal> editableBy) {
475            this.editableBy = editableBy;
476        }
477
478    }
479
480}