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.Optional;
032import java.util.ResourceBundle;
033import java.util.Set;
034import org.jdrupes.json.JsonBeanDecoder;
035import org.jdrupes.json.JsonBeanEncoder;
036import org.jdrupes.json.JsonDecodeException;
037import org.jgrapes.core.Channel;
038import org.jgrapes.core.Event;
039import org.jgrapes.core.Manager;
040import org.jgrapes.core.annotation.Handler;
041import org.jgrapes.http.Session;
042import org.jgrapes.io.IOSubchannel;
043import org.jgrapes.util.events.KeyValueStoreQuery;
044import org.jgrapes.util.events.KeyValueStoreUpdate;
045import org.jgrapes.webconsole.base.Conlet.RenderMode;
046import org.jgrapes.webconsole.base.ConletBaseModel;
047import org.jgrapes.webconsole.base.ConsoleConnection;
048import org.jgrapes.webconsole.base.ConsoleUser;
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.OpenModalDialog;
058import org.jgrapes.webconsole.base.events.RenderConlet;
059import org.jgrapes.webconsole.base.events.RenderConletRequest;
060import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
061import org.jgrapes.webconsole.base.events.UpdateConletModel;
062import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
063
064/**
065 * A web console component used to display information to the user. Instances
066 * may be used as a kind of note, i.e. created and configured by
067 * a user himself. A typical use case, however, is to create
068 * an instance during startup by a web console policy.
069 */
070@SuppressWarnings({ "PMD.DataClass", "PMD.DataflowAnomalyAnalysis" })
071public class MarkdownDisplayConlet extends
072        FreeMarkerConlet<MarkdownDisplayConlet.MarkdownDisplayModel> {
073
074    /** Property for forcing a conlet id (used for singleton instances). */
075    public static final String CONLET_ID = "ConletId";
076    /** Property for setting a title. */
077    public static final String TITLE = "Title";
078    /** Property for setting the preview source. */
079    public static final String PREVIEW_SOURCE = "PreviewSource";
080    /** Property for setting the view source. */
081    public static final String VIEW_SOURCE = "ViewSource";
082    /** Boolean property that controls if the preview is deletable. */
083    public static final String DELETABLE = "Deletable";
084    /** Property of type `Set<Principal>` for restricting who 
085     * can edit the content. */
086    public static final String EDITABLE_BY = "EditableBy";
087
088    /**
089     * Creates a new component with its channel set to the given 
090     * channel.
091     * 
092     * @param componentChannel the channel that the component's 
093     * handlers listen on by default and that 
094     * {@link Manager#fire(Event, Channel...)} sends the event to 
095     */
096    public MarkdownDisplayConlet(Channel componentChannel) {
097        super(componentChannel);
098    }
099
100    private String storagePath(Session session) {
101        return "/" + WebConsoleUtils.userFromSession(session)
102            .map(ConsoleUser::getName).orElse("")
103            + "/conlets/" + MarkdownDisplayConlet.class.getName() + "/";
104    }
105
106    /**
107     * On {@link ConsoleReady}, fire the {@link AddConletType}.
108     *
109     * @param event the event
110     * @param connection the console connection
111     * @throws TemplateNotFoundException the template not found exception
112     * @throws MalformedTemplateNameException the malformed template name exception
113     * @throws ParseException the parse exception
114     * @throws IOException Signals that an I/O exception has occurred.
115     */
116    @Handler
117    public void onConsoleReady(ConsoleReady event, ConsoleConnection connection)
118            throws TemplateNotFoundException, MalformedTemplateNameException,
119            ParseException, IOException {
120        // Add MarkdownDisplayConlet resources to page
121        connection.respond(new AddConletType(type())
122            .addRenderMode(RenderMode.Preview).setDisplayNames(
123                localizations(connection.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    }
140
141    /**
142     * Generates a new component instance id or uses the one stored in the
143     * event's properties as `CONLET_ID` (see 
144     * {@link AddConletRequest#properties()})
145     */
146    @Override
147    protected String generateInstanceId(AddConletRequest event,
148            ConsoleConnection session) {
149        return Optional.ofNullable((String) event.properties().get(CONLET_ID))
150            .orElse(super.generateInstanceId(event, session));
151    }
152
153    @Override
154    protected Optional<MarkdownDisplayModel> createStateRepresentation(
155            RenderConletRequestBase<?> event, ConsoleConnection channel,
156            String conletId) throws Exception {
157        // Create fallback model
158        ResourceBundle resourceBundle
159            = resourceBundle(channel.session().locale());
160        MarkdownDisplayModel model = new MarkdownDisplayModel(conletId);
161        model.setTitle(resourceBundle.getString("conletName"));
162        model.setPreviewContent("");
163        model.setViewContent("");
164        model.setDeletable(Boolean.TRUE);
165
166        // Save model and return
167        channel.respond(new KeyValueStoreUpdate().update(
168            storagePath(channel.session()) + model.getConletId(),
169            JsonBeanEncoder.create().writeObject(model).toJson()));
170        return Optional.of(model);
171    }
172
173    /**
174     * Creates a new model for the conlet. The following properties
175     * are copied from the {@link AddConletRequest} event
176     * (see {@link AddConletRequest#properties()}:
177     * 
178     * * `CONLET_ID` (String): The web console component id.
179     * 
180     * * `TITLE` (String): The web console component title.
181     * 
182     * * `PREVIEW_SOURCE` (String): The markdown source that is rendered 
183     *   in the web console component preview.
184     * 
185     * * `VIEW_SOURCE` (String): The markdown source that is rendered 
186     *   in the web console component view.
187     * 
188     * * `DELETABLE` (Boolean): Indicates that the web console component may be 
189     *   deleted from the overview page.
190     * 
191     * * `EDITABLE_BY` (Set&lt;Principal&gt;): The principals that may edit 
192     *   the web console component instance.
193     */
194    @Override
195    protected Optional<MarkdownDisplayModel> createNewState(
196            AddConletRequest event, ConsoleConnection session, String conletId)
197            throws Exception {
198        ResourceBundle resourceBundle = resourceBundle(session.locale());
199
200        MarkdownDisplayModel model = new MarkdownDisplayModel(conletId);
201        model.setTitle((String) event.properties().getOrDefault(TITLE,
202            resourceBundle.getString("conletName")));
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        session.respond(new KeyValueStoreUpdate().update(
218            storagePath(session.session()) + model.getConletId(),
219            jsonState));
220
221        // Return model
222        return Optional.of(model);
223    }
224
225    @Override
226    @SuppressWarnings("PMD.EmptyCatchBlock")
227    protected Optional<MarkdownDisplayModel> recreateState(
228            RenderConletRequest event, ConsoleConnection channel,
229            String conletId) throws Exception {
230        KeyValueStoreQuery query = new KeyValueStoreQuery(
231            storagePath(channel.session()) + conletId, channel);
232        newEventPipeline().fire(query, channel);
233        try {
234            if (!query.results().isEmpty()) {
235                var json = query.results().get(0).values().stream().findFirst()
236                    .get();
237                MarkdownDisplayModel model = JsonBeanDecoder.create(json)
238                    .readObject(MarkdownDisplayModel.class);
239                return Optional.of(model);
240            }
241        } catch (InterruptedException | JsonDecodeException e) {
242            // Means we have no result.
243        }
244
245        return createStateRepresentation(event, channel, conletId);
246    }
247
248    @Override
249    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
250            ConsoleConnection consoleConnection, String conletId,
251            MarkdownDisplayModel model)
252            throws Exception {
253        ResourceBundle resourceBundle
254            = resourceBundle(consoleConnection.locale());
255        Set<RenderMode> supported = renderModes(model);
256        Set<RenderMode> renderedAs = new HashSet<>();
257        if (event.renderAs().contains(RenderMode.Preview)) {
258            Template tpl = freemarkerConfig()
259                .getTemplate("MarkdownDisplay-preview.ftl.html");
260            consoleConnection.respond(new RenderConlet(type(),
261                model.getConletId(),
262                processTemplate(event, tpl,
263                    fmModel(event, consoleConnection, conletId, model)))
264                        .setRenderAs(
265                            RenderMode.Preview.addModifiers(event.renderAs()))
266                        .setSupportedModes(supported));
267            updateView(consoleConnection, model);
268            renderedAs.add(RenderMode.Preview);
269        }
270        if (event.renderAs().contains(RenderMode.View)) {
271            Template tpl = freemarkerConfig()
272                .getTemplate("MarkdownDisplay-view.ftl.html");
273            consoleConnection
274                .respond(new RenderConlet(type(), model.getConletId(),
275                    processTemplate(event, tpl,
276                        fmModel(event, consoleConnection, conletId, model)))
277                            .setRenderAs(
278                                RenderMode.View.addModifiers(event.renderAs()))
279                            .setSupportedModes(supported));
280            updateView(consoleConnection, model);
281            renderedAs.add(RenderMode.Preview);
282        }
283        if (event.renderAs().contains(RenderMode.Edit)) {
284            Template tpl = freemarkerConfig()
285                .getTemplate("MarkdownDisplay-edit.ftl.html");
286            consoleConnection.respond(new OpenModalDialog(type(), conletId,
287                processTemplate(event, tpl,
288                    fmModel(event, consoleConnection, conletId, model)))
289                        .addOption("cancelable", true)
290                        .addOption("okayLabel",
291                            resourceBundle.getString("okayLabel")));
292        }
293        return renderedAs;
294    }
295
296    private Set<RenderMode> renderModes(MarkdownDisplayModel model) {
297        Set<RenderMode> modes = new HashSet<>();
298        modes.add(RenderMode.Preview);
299        if (!model.isDeletable()) {
300            modes.add(RenderMode.StickyPreview);
301        }
302        if (model.getViewContent() != null
303            && !model.getViewContent().isEmpty()) {
304            modes.add(RenderMode.View);
305        }
306        if (model.getEditableBy() == null) {
307            modes.add(RenderMode.Edit);
308        }
309        return modes;
310    }
311
312    private void updateView(IOSubchannel channel, MarkdownDisplayModel model) {
313        channel.respond(new NotifyConletView(type(),
314            model.getConletId(), "updateAll", model.getTitle(),
315            model.getPreviewContent(), model.getViewContent(),
316            renderModes(model)));
317    }
318
319    @Override
320    protected void doConletDeleted(ConletDeleted event,
321            ConsoleConnection channel, String conletId,
322            MarkdownDisplayModel retrievedState) throws Exception {
323        if (event.renderModes().isEmpty()) {
324            channel.respond(new KeyValueStoreUpdate().delete(
325                storagePath(channel.session()) + conletId));
326        }
327    }
328
329    @Override
330    protected void doUpdateConletState(NotifyConletModel event,
331            ConsoleConnection connection, MarkdownDisplayModel conletState)
332            throws Exception {
333        event.stop();
334        @SuppressWarnings("PMD.UseConcurrentHashMap")
335        Map<String, String> properties = new HashMap<>();
336        if (event.params().get(0) != null) {
337            properties.put(TITLE, event.params().asString(0));
338        }
339        if (event.params().get(1) != null) {
340            properties.put(PREVIEW_SOURCE, event.params().asString(1));
341        }
342        if (event.params().get(2) != null) {
343            properties.put(VIEW_SOURCE, event.params().asString(2));
344        }
345        fire(new UpdateConletModel(event.conletId(), properties), connection);
346    }
347
348    /**
349     * Stores the modified properties using a {@link KeyValueStoreUpdate}
350     * event and updates the view with a {@link NotifyConletView}. 
351     *
352     * @param event the event
353     * @param connection the console connection
354     */
355    @SuppressWarnings("unchecked")
356    @Handler
357    public void onUpdateConletModel(UpdateConletModel event,
358            ConsoleConnection connection) {
359        stateFromSession(connection.session(), event.conletId())
360            .ifPresent(model -> {
361                event.ifPresent(TITLE,
362                    (key, value) -> model.setTitle((String) value))
363                    .ifPresent(PREVIEW_SOURCE,
364                        (key, value) -> model.setPreviewContent((String) value))
365                    .ifPresent(VIEW_SOURCE,
366                        (key, value) -> model.setViewContent((String) value))
367                    .ifPresent(DELETABLE,
368                        (key, value) -> model.setDeletable((Boolean) value))
369                    .ifPresent(EDITABLE_BY,
370                        (key, value) -> {
371                            model.setEditableBy((Set<Principal>) value);
372                        });
373                try {
374                    String jsonState = JsonBeanEncoder.create()
375                        .writeObject(model).toJson();
376                    connection.respond(new KeyValueStoreUpdate().update(
377                        storagePath(connection.session())
378                            + model.getConletId(),
379                        jsonState));
380                    updateView(connection, model);
381                } catch (IOException e) { // NOPMD
382                    // Won't happen, uses internal writer
383                }
384            });
385    }
386
387    /**
388     * The web console component's model.
389     */
390    public static class MarkdownDisplayModel extends ConletBaseModel {
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 conletId the web console component id
402         */
403        @ConstructorProperties({ "conletId" })
404        public MarkdownDisplayModel(String conletId) {
405            super(conletId);
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}