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.webconlet.markdowndisplay;
020
021import freemarker.core.ParseException;
022import freemarker.template.MalformedTemplateNameException;
023import freemarker.template.Template;
024import freemarker.template.TemplateNotFoundException;
025import java.beans.ConstructorProperties;
026import java.io.IOException;
027import java.security.Principal;
028import java.util.HashMap;
029import java.util.HashSet;
030import java.util.Map;
031import java.util.ResourceBundle;
032import java.util.Set;
033import org.jdrupes.json.JsonBeanDecoder;
034import org.jdrupes.json.JsonBeanEncoder;
035import org.jdrupes.json.JsonDecodeException;
036import org.jgrapes.core.Channel;
037import org.jgrapes.core.Event;
038import org.jgrapes.core.Manager;
039import org.jgrapes.core.annotation.Handler;
040import org.jgrapes.http.Session;
041import org.jgrapes.io.IOSubchannel;
042import org.jgrapes.util.events.KeyValueStoreData;
043import org.jgrapes.util.events.KeyValueStoreQuery;
044import org.jgrapes.util.events.KeyValueStoreUpdate;
045import org.jgrapes.webconsole.base.AbstractConlet;
046import org.jgrapes.webconsole.base.Conlet.RenderMode;
047import org.jgrapes.webconsole.base.ConsoleSession;
048import org.jgrapes.webconsole.base.UserPrincipal;
049import org.jgrapes.webconsole.base.WebConsoleUtils;
050import org.jgrapes.webconsole.base.events.AddConletRequest;
051import org.jgrapes.webconsole.base.events.AddConletType;
052import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
053import org.jgrapes.webconsole.base.events.ConletDeleted;
054import org.jgrapes.webconsole.base.events.ConsoleReady;
055import org.jgrapes.webconsole.base.events.NotifyConletModel;
056import org.jgrapes.webconsole.base.events.NotifyConletView;
057import org.jgrapes.webconsole.base.events.RenderConletRequest;
058import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
059import org.jgrapes.webconsole.base.events.UpdateConletModel;
060import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
061
062/**
063 * A web console component used to display information to the user. Instances
064 * may be used as a kind of note, i.e. created and configured by
065 * a user himself. A typical use case, however, is to create
066 * an instance during startup by a web console policy.
067 */
068@SuppressWarnings("PMD.DataClass")
069public class MarkdownDisplayConlet
070        extends
071        FreeMarkerConlet<MarkdownDisplayConlet.MarkdownDisplayModel> {
072
073    /** Property for forcing a conlet id (used for singleton instances). */
074    public static final String CONLET_ID = "ConletId";
075    /** Property for setting a title. */
076    public static final String TITLE = "Title";
077    /** Property for setting the preview source. */
078    public static final String PREVIEW_SOURCE = "PreviewSource";
079    /** Property for setting the view source. */
080    public static final String VIEW_SOURCE = "ViewSource";
081    /** Boolean property that controls if the preview is deletable. */
082    public static final String DELETABLE = "Deletable";
083    /** Property of type `Set<Principal>` for restricting who 
084     * can edit the content. */
085    public static final String EDITABLE_BY = "EditableBy";
086
087    /**
088     * Creates a new component with its channel set to the given 
089     * channel.
090     * 
091     * @param componentChannel the channel that the component's 
092     * handlers listen on by default and that 
093     * {@link Manager#fire(Event, Channel...)} sends the event to 
094     */
095    public MarkdownDisplayConlet(Channel componentChannel) {
096        super(componentChannel);
097    }
098
099    private String storagePath(Session session) {
100        return "/" + WebConsoleUtils.userFromSession(session)
101            .map(UserPrincipal::toString).orElse("")
102            + "/conlets/" + MarkdownDisplayConlet.class.getName() + "/";
103    }
104
105    /**
106     * On {@link ConsoleReady}, fire the {@link AddConletType}.
107     *
108     * @param event the event
109     * @param consoleSession the console session
110     * @throws TemplateNotFoundException the template not found exception
111     * @throws MalformedTemplateNameException the malformed template name exception
112     * @throws ParseException the parse exception
113     * @throws IOException Signals that an I/O exception has occurred.
114     */
115    @Handler
116    public void onConsoleReady(ConsoleReady event,
117            ConsoleSession consoleSession)
118            throws TemplateNotFoundException, MalformedTemplateNameException,
119            ParseException, IOException {
120        // Add MarkdownDisplayConlet resources to page
121        consoleSession.respond(new AddConletType(type())
122            .setDisplayNames(
123                localizations(consoleSession.supportedLocales(), "conletName"))
124            .addScript(new ScriptResource()
125                .setRequires(new String[] { "markdown-it.github.io",
126                    "github.com/markdown-it/markdown-it-abbr",
127                    "github.com/markdown-it/markdown-it-container",
128                    "github.com/markdown-it/markdown-it-deflist",
129                    "github.com/markdown-it/markdown-it-emoji",
130                    "github.com/markdown-it/markdown-it-footnote",
131                    "github.com/markdown-it/markdown-it-ins",
132                    "github.com/markdown-it/markdown-it-mark",
133                    "github.com/markdown-it/markdown-it-sub",
134                    "github.com/markdown-it/markdown-it-sup" })
135                .setScriptUri(event.renderSupport().conletResource(
136                    type(), "MarkdownDisplay-functions.ftl.js")))
137            .addCss(event.renderSupport(), WebConsoleUtils.uriFromPath(
138                "MarkdownDisplay-style.css")));
139        KeyValueStoreQuery query = new KeyValueStoreQuery(
140            storagePath(consoleSession.browserSession()), consoleSession);
141        fire(query, consoleSession);
142    }
143
144    /**
145     * Restore web console component information, if contained in the event.
146     *
147     * @param event the event
148     * @param channel the channel
149     * @throws JsonDecodeException the json decode exception
150     */
151    @Handler
152    public void onKeyValueStoreData(
153            KeyValueStoreData event, ConsoleSession channel)
154            throws JsonDecodeException {
155        if (!event.event().query()
156            .equals(storagePath(channel.browserSession()))) {
157            return;
158        }
159        for (String json : event.data().values()) {
160            MarkdownDisplayModel model = JsonBeanDecoder.create(json)
161                .readObject(MarkdownDisplayModel.class);
162            putInSession(channel.browserSession(), model);
163        }
164    }
165
166    /**
167     * Adds the web console component to the console. The web console 
168     * component supports the 
169     * following options (see {@link AddConletRequest#properties()}:
170     * 
171     * * `CONLET_ID` (String): The web console component id.
172     * 
173     * * `TITLE` (String): The web console component title.
174     * 
175     * * `PREVIEW_SOURCE` (String): The markdown source that is rendered 
176     *   in the web console component preview.
177     * 
178     * * `VIEW_SOURCE` (String): The markdown source that is rendered 
179     *   in the web console component view.
180     * 
181     * * `DELETABLE` (Boolean): Indicates that the web console component may be 
182     *   deleted from the overview page.
183     * 
184     * * `EDITABLE_BY` (Set&lt;Principal&gt;): The principals that may edit 
185     *   the web console component instance.
186     */
187    @Override
188    public ConletTrackingInfo doAddConlet(AddConletRequest event,
189            ConsoleSession consoleSession) throws Exception {
190        ResourceBundle resourceBundle = resourceBundle(consoleSession.locale());
191
192        // Create new model
193        String conletId = (String) event.properties().get(CONLET_ID);
194        if (conletId == null) {
195            conletId = generateConletId();
196        }
197        MarkdownDisplayModel model = putInSession(
198            consoleSession.browserSession(),
199            new MarkdownDisplayModel(conletId));
200        model.setTitle((String) event.properties().getOrDefault(TITLE,
201            resourceBundle.getString("conletName")));
202        model.setPreviewContent((String) event.properties().getOrDefault(
203            PREVIEW_SOURCE, ""));
204        model.setViewContent((String) event.properties().getOrDefault(
205            VIEW_SOURCE, ""));
206        model.setDeletable((Boolean) event.properties().getOrDefault(
207            DELETABLE, Boolean.TRUE));
208        @SuppressWarnings("unchecked")
209        Set<Principal> editableBy = (Set<Principal>) event.properties().get(
210            EDITABLE_BY);
211        model.setEditableBy(editableBy);
212
213        // Save model
214        String jsonState = JsonBeanEncoder.create()
215            .writeObject(model).toJson();
216        consoleSession.respond(new KeyValueStoreUpdate().update(
217            storagePath(consoleSession.browserSession()) + model.getConletId(),
218            jsonState));
219
220        // Send HTML
221        return new ConletTrackingInfo(conletId)
222            .addModes(renderConlet(event, consoleSession, model));
223    }
224
225    @Override
226    protected Set<RenderMode> doRenderConlet(RenderConletRequest event,
227            ConsoleSession consoleSession, String conletId,
228            MarkdownDisplayModel model) throws Exception {
229        return renderConlet(event, consoleSession, model);
230    }
231
232    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
233    private Set<RenderMode> renderConlet(RenderConletRequestBase<?> event,
234            ConsoleSession consoleSession, MarkdownDisplayModel model)
235            throws TemplateNotFoundException, MalformedTemplateNameException,
236            ParseException, IOException {
237        Set<RenderMode> supported = renderModes(model);
238        Set<RenderMode> renderedAs = new HashSet<>();
239        if (event.renderAs().contains(RenderMode.Preview)) {
240            Template tpl = freemarkerConfig()
241                .getTemplate("MarkdownDisplay-preview.ftl.html");
242            consoleSession.respond(new RenderConletFromTemplate(event,
243                type(), model.getConletId(),
244                tpl, fmModel(event, consoleSession, model))
245                    .setRenderAs(
246                        RenderMode.Preview.addModifiers(event.renderAs()))
247                    .setSupportedModes(supported));
248            updateView(consoleSession, model);
249            renderedAs.add(RenderMode.Preview);
250        }
251        if (event.renderAs().contains(RenderMode.View)) {
252            Template tpl = freemarkerConfig()
253                .getTemplate("MarkdownDisplay-view.ftl.html");
254            consoleSession.respond(new RenderConletFromTemplate(event,
255                type(), model.getConletId(),
256                tpl, fmModel(event, consoleSession, model))
257                    .setRenderAs(RenderMode.View.addModifiers(event.renderAs()))
258                    .setSupportedModes(supported));
259            updateView(consoleSession, model);
260            renderedAs.add(RenderMode.Preview);
261        }
262        if (event.renderAs().contains(RenderMode.Edit)) {
263            Template tpl = freemarkerConfig()
264                .getTemplate("MarkdownDisplay-edit.ftl.html");
265            consoleSession.respond(new RenderConletFromTemplate(event,
266                type(), model.getConletId(),
267                tpl, fmModel(event, consoleSession, model))
268                    .setRenderAs(RenderMode.Edit.addModifiers(event.renderAs()))
269                    .setSupportedModes(supported));
270        }
271        return renderedAs;
272    }
273
274    private Set<RenderMode> renderModes(MarkdownDisplayModel model) {
275        Set<RenderMode> modes = new HashSet<>();
276        modes.add(RenderMode.Preview);
277        if (!model.isDeletable()) {
278            modes.add(RenderMode.StickyPreview);
279        }
280        if (model.getViewContent() != null
281            && !model.getViewContent().isEmpty()) {
282            modes.add(RenderMode.View);
283        }
284        if (model.getEditableBy() == null) {
285            modes.add(RenderMode.Edit);
286        }
287        return modes;
288    }
289
290    private void updateView(IOSubchannel channel, MarkdownDisplayModel model) {
291        channel.respond(new NotifyConletView(type(),
292            model.getConletId(), "updateAll", model.getTitle(),
293            model.getPreviewContent(), model.getViewContent(),
294            renderModes(model)));
295    }
296
297    @Override
298    protected void doConletDeleted(ConletDeleted event,
299            ConsoleSession channel, String conletId,
300            MarkdownDisplayModel retrievedState) throws Exception {
301        if (event.renderModes().isEmpty()) {
302            channel.respond(new KeyValueStoreUpdate().delete(
303                storagePath(channel.browserSession()) + conletId));
304        }
305    }
306
307    @Override
308    protected void doNotifyConletModel(NotifyConletModel event,
309            ConsoleSession consoleSession, MarkdownDisplayModel conletState)
310            throws Exception {
311        event.stop();
312        @SuppressWarnings("PMD.UseConcurrentHashMap")
313        Map<String, String> properties = new HashMap<>();
314        if (event.params().get(0) != null) {
315            properties.put(TITLE, event.params().asString(0));
316        }
317        if (event.params().get(1) != null) {
318            properties.put(PREVIEW_SOURCE, event.params().asString(1));
319        }
320        if (event.params().get(2) != null) {
321            properties.put(VIEW_SOURCE, event.params().asString(2));
322        }
323        fire(new UpdateConletModel(event.conletId(), properties),
324            consoleSession);
325    }
326
327    /**
328     * Stores the modified properties using a {@link KeyValueStoreUpdate}
329     * event and updates the view with a {@link NotifyConletView}. 
330     *
331     * @param event the event
332     * @param consoleSession the console session
333     */
334    @SuppressWarnings("unchecked")
335    @Handler
336    public void onUpdateConletModel(UpdateConletModel event,
337            ConsoleSession consoleSession) {
338        stateFromSession(consoleSession.browserSession(), event.conletId())
339            .ifPresent(model -> {
340                event.ifPresent(TITLE,
341                    (key, value) -> model.setTitle((String) value))
342                    .ifPresent(PREVIEW_SOURCE,
343                        (key, value) -> model.setPreviewContent((String) value))
344                    .ifPresent(VIEW_SOURCE,
345                        (key, value) -> model.setViewContent((String) value))
346                    .ifPresent(DELETABLE,
347                        (key, value) -> model.setDeletable((Boolean) value))
348                    .ifPresent(EDITABLE_BY,
349                        (key, value) -> {
350                            model.setEditableBy((Set<Principal>) value);
351                        });
352                try {
353                    String jsonState = JsonBeanEncoder.create()
354                        .writeObject(model).toJson();
355                    consoleSession.respond(new KeyValueStoreUpdate().update(
356                        storagePath(consoleSession.browserSession())
357                            + model.getConletId(),
358                        jsonState));
359                    updateView(consoleSession, model);
360                } catch (IOException e) { // NOPMD
361                    // Won't happen, uses internal writer
362                }
363            });
364    }
365
366    /**
367     * The web console component's model.
368     */
369    @SuppressWarnings("serial")
370    public static class MarkdownDisplayModel
371            extends AbstractConlet.ConletBaseModel {
372
373        private String title = "";
374        private String previewContent = "";
375        private String viewContent = "";
376        private boolean deletable = true;
377        private Set<Principal> editableBy;
378
379        /**
380         * Creates a new model with the given type and id.
381         * 
382         * @param conletId the web console component id
383         */
384        @ConstructorProperties({ "conletId" })
385        public MarkdownDisplayModel(String conletId) {
386            super(conletId);
387        }
388
389        /**
390         * @return the title
391         */
392        public String getTitle() {
393            return title;
394        }
395
396        /**
397         * @param title the title to set
398         */
399        public void setTitle(String title) {
400            this.title = title;
401        }
402
403        /**
404         * @return the previewContent
405         */
406        public String getPreviewContent() {
407            return previewContent;
408        }
409
410        /**
411         * @param previewContent the previewContent to set
412         */
413        public void setPreviewContent(String previewContent) {
414            this.previewContent = previewContent;
415        }
416
417        /**
418         * @return the viewContent
419         */
420        public String getViewContent() {
421            return viewContent;
422        }
423
424        /**
425         * @param viewContent the viewContent to set
426         */
427        public void setViewContent(String viewContent) {
428            this.viewContent = viewContent;
429        }
430
431        /**
432         * @return the deletable
433         */
434        public boolean isDeletable() {
435            return deletable;
436        }
437
438        /**
439         * @param deletable the deletable to set
440         */
441        public void setDeletable(boolean deletable) {
442            this.deletable = deletable;
443        }
444
445        /**
446         * @return the editableBy
447         */
448        public Set<Principal> getEditableBy() {
449            return editableBy;
450        }
451
452        /**
453         * @param editableBy the editableBy to set
454         */
455        public void setEditableBy(Set<Principal> editableBy) {
456            this.editableBy = editableBy;
457        }
458
459    }
460
461}