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.RenderConletRequestBase;
060import org.jgrapes.webconsole.base.events.UpdateConletModel;
061import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
062
063/**
064 * A web console component used to display information to the user. Instances
065 * may be used as a kind of note, i.e. created and configured by
066 * a user himself. A typical use case, however, is to create
067 * an instance during startup by a web console policy.
068 */
069@SuppressWarnings({ "PMD.DataClass", "PMD.DataflowAnomalyAnalysis" })
070public class MarkdownDisplayConlet 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(ConsoleUser::getName).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 connection the console connection
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, ConsoleConnection connection)
117            throws TemplateNotFoundException, MalformedTemplateNameException,
118            ParseException, IOException {
119        // Add MarkdownDisplayConlet resources to page
120        connection.respond(new AddConletType(type())
121            .addRenderMode(RenderMode.Preview).setDisplayNames(
122                localizations(connection.supportedLocales(), "conletName"))
123            .addScript(new ScriptResource()
124                .setRequires("markdown-it")
125                .setScriptUri(event.renderSupport().conletResource(
126                    type(), "MarkdownDisplay-functions.ftl.js")))
127            .addCss(event.renderSupport(), WebConsoleUtils.uriFromPath(
128                "MarkdownDisplay-style.css")));
129    }
130
131    /**
132     * Generates a new component instance id or uses the one stored in the
133     * event's properties as `CONLET_ID` (see 
134     * {@link AddConletRequest#properties()})
135     */
136    @Override
137    protected String generateInstanceId(AddConletRequest event,
138            ConsoleConnection session) {
139        return Optional.ofNullable((String) event.properties().get(CONLET_ID))
140            .orElse(super.generateInstanceId(event, session));
141    }
142
143    @Override
144    protected Optional<MarkdownDisplayModel> createStateRepresentation(
145            Event<?> event, ConsoleConnection channel, String conletId)
146            throws Exception {
147        // Create fallback model
148        ResourceBundle resourceBundle
149            = resourceBundle(channel.session().locale());
150        MarkdownDisplayModel model = new MarkdownDisplayModel(conletId);
151        model.setTitle(resourceBundle.getString("conletName"));
152        model.setPreviewContent("");
153        model.setViewContent("");
154        model.setDeletable(Boolean.TRUE);
155
156        // Save model and return
157        channel.respond(new KeyValueStoreUpdate().update(
158            storagePath(channel.session()) + model.getConletId(),
159            JsonBeanEncoder.create().writeObject(model).toJson()));
160        return Optional.of(model);
161    }
162
163    /**
164     * Creates a new model for the conlet. The following properties
165     * are copied from the {@link AddConletRequest} event
166     * (see {@link AddConletRequest#properties()}:
167     * 
168     * * `CONLET_ID` (String): The web console component id.
169     * 
170     * * `TITLE` (String): The web console component title.
171     * 
172     * * `PREVIEW_SOURCE` (String): The markdown source that is rendered 
173     *   in the web console component preview.
174     * 
175     * * `VIEW_SOURCE` (String): The markdown source that is rendered 
176     *   in the web console component view.
177     * 
178     * * `DELETABLE` (Boolean): Indicates that the web console component may be 
179     *   deleted from the overview page.
180     * 
181     * * `EDITABLE_BY` (Set&lt;Principal&gt;): The principals that may edit 
182     *   the web console component instance.
183     */
184    @Override
185    protected Optional<MarkdownDisplayModel> createNewState(
186            AddConletRequest event, ConsoleConnection session, String conletId)
187            throws Exception {
188        ResourceBundle resourceBundle = resourceBundle(session.locale());
189
190        MarkdownDisplayModel model = new MarkdownDisplayModel(conletId);
191        model.setTitle((String) event.properties().getOrDefault(TITLE,
192            resourceBundle.getString("conletName")));
193        model.setPreviewContent((String) event.properties().getOrDefault(
194            PREVIEW_SOURCE, ""));
195        model.setViewContent((String) event.properties().getOrDefault(
196            VIEW_SOURCE, ""));
197        model.setDeletable((Boolean) event.properties().getOrDefault(
198            DELETABLE, Boolean.TRUE));
199        @SuppressWarnings("unchecked")
200        Set<Principal> editableBy = (Set<Principal>) event.properties().get(
201            EDITABLE_BY);
202        model.setEditableBy(editableBy);
203
204        // Save model
205        String jsonState = JsonBeanEncoder.create()
206            .writeObject(model).toJson();
207        session.respond(new KeyValueStoreUpdate().update(
208            storagePath(session.session()) + model.getConletId(),
209            jsonState));
210
211        // Return model
212        return Optional.of(model);
213    }
214
215    @Override
216    @SuppressWarnings("PMD.EmptyCatchBlock")
217    protected Optional<MarkdownDisplayModel> recreateState(Event<?> event,
218            ConsoleConnection channel, String conletId) throws Exception {
219        KeyValueStoreQuery query = new KeyValueStoreQuery(
220            storagePath(channel.session()) + conletId, channel);
221        newEventPipeline().fire(query, channel);
222        try {
223            if (!query.results().isEmpty()) {
224                var json = query.results().get(0).values().stream().findFirst()
225                    .get();
226                MarkdownDisplayModel model = JsonBeanDecoder.create(json)
227                    .readObject(MarkdownDisplayModel.class);
228                return Optional.of(model);
229            }
230        } catch (InterruptedException | JsonDecodeException e) {
231            // Means we have no result.
232        }
233
234        return createStateRepresentation(event, channel, conletId);
235    }
236
237    @Override
238    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
239            ConsoleConnection consoleConnection, String conletId,
240            MarkdownDisplayModel model)
241            throws Exception {
242        ResourceBundle resourceBundle
243            = resourceBundle(consoleConnection.locale());
244        Set<RenderMode> supported = renderModes(model);
245        Set<RenderMode> renderedAs = new HashSet<>();
246        if (event.renderAs().contains(RenderMode.Preview)) {
247            Template tpl = freemarkerConfig()
248                .getTemplate("MarkdownDisplay-preview.ftl.html");
249            consoleConnection.respond(new RenderConlet(type(),
250                model.getConletId(),
251                processTemplate(event, tpl,
252                    fmModel(event, consoleConnection, conletId, model)))
253                        .setRenderAs(
254                            RenderMode.Preview.addModifiers(event.renderAs()))
255                        .setSupportedModes(supported));
256            updateView(consoleConnection, model);
257            renderedAs.add(RenderMode.Preview);
258        }
259        if (event.renderAs().contains(RenderMode.View)) {
260            Template tpl = freemarkerConfig()
261                .getTemplate("MarkdownDisplay-view.ftl.html");
262            consoleConnection
263                .respond(new RenderConlet(type(), model.getConletId(),
264                    processTemplate(event, tpl,
265                        fmModel(event, consoleConnection, conletId, model)))
266                            .setRenderAs(
267                                RenderMode.View.addModifiers(event.renderAs()))
268                            .setSupportedModes(supported));
269            updateView(consoleConnection, model);
270            renderedAs.add(RenderMode.Preview);
271        }
272        if (event.renderAs().contains(RenderMode.Edit)) {
273            Template tpl = freemarkerConfig()
274                .getTemplate("MarkdownDisplay-edit.ftl.html");
275            consoleConnection.respond(new OpenModalDialog(type(), conletId,
276                processTemplate(event, tpl,
277                    fmModel(event, consoleConnection, conletId, model)))
278                        .addOption("cancelable", true)
279                        .addOption("okayLabel",
280                            resourceBundle.getString("okayLabel")));
281        }
282        return renderedAs;
283    }
284
285    private Set<RenderMode> renderModes(MarkdownDisplayModel model) {
286        Set<RenderMode> modes = new HashSet<>();
287        modes.add(RenderMode.Preview);
288        if (!model.isDeletable()) {
289            modes.add(RenderMode.StickyPreview);
290        }
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 NotifyConletView(type(),
303            model.getConletId(), "updateAll", model.getTitle(),
304            model.getPreviewContent(), model.getViewContent(),
305            renderModes(model)));
306    }
307
308    @Override
309    protected void doConletDeleted(ConletDeleted event,
310            ConsoleConnection channel, String conletId,
311            MarkdownDisplayModel retrievedState) throws Exception {
312        if (event.renderModes().isEmpty()) {
313            channel.respond(new KeyValueStoreUpdate().delete(
314                storagePath(channel.session()) + conletId));
315        }
316    }
317
318    @Override
319    protected void doUpdateConletState(NotifyConletModel event,
320            ConsoleConnection connection, MarkdownDisplayModel conletState)
321            throws Exception {
322        event.stop();
323        @SuppressWarnings("PMD.UseConcurrentHashMap")
324        Map<String, String> properties = new HashMap<>();
325        if (event.params().get(0) != null) {
326            properties.put(TITLE, event.params().asString(0));
327        }
328        if (event.params().get(1) != null) {
329            properties.put(PREVIEW_SOURCE, event.params().asString(1));
330        }
331        if (event.params().get(2) != null) {
332            properties.put(VIEW_SOURCE, event.params().asString(2));
333        }
334        fire(new UpdateConletModel(event.conletId(), properties), connection);
335    }
336
337    /**
338     * Stores the modified properties using a {@link KeyValueStoreUpdate}
339     * event and updates the view with a {@link NotifyConletView}. 
340     *
341     * @param event the event
342     * @param connection the console connection
343     */
344    @SuppressWarnings("unchecked")
345    @Handler
346    public void onUpdateConletModel(UpdateConletModel event,
347            ConsoleConnection connection) {
348        stateFromSession(connection.session(), event.conletId())
349            .ifPresent(model -> {
350                event.ifPresent(TITLE,
351                    (key, value) -> model.setTitle((String) value))
352                    .ifPresent(PREVIEW_SOURCE,
353                        (key, value) -> model.setPreviewContent((String) value))
354                    .ifPresent(VIEW_SOURCE,
355                        (key, value) -> model.setViewContent((String) value))
356                    .ifPresent(DELETABLE,
357                        (key, value) -> model.setDeletable((Boolean) value))
358                    .ifPresent(EDITABLE_BY,
359                        (key, value) -> {
360                            model.setEditableBy((Set<Principal>) value);
361                        });
362                try {
363                    String jsonState = JsonBeanEncoder.create()
364                        .writeObject(model).toJson();
365                    connection.respond(new KeyValueStoreUpdate().update(
366                        storagePath(connection.session())
367                            + model.getConletId(),
368                        jsonState));
369                    updateView(connection, model);
370                } catch (IOException e) { // NOPMD
371                    // Won't happen, uses internal writer
372                }
373            });
374    }
375
376    /**
377     * The web console component's model.
378     */
379    public static class MarkdownDisplayModel extends ConletBaseModel {
380
381        private String title = "";
382        private String previewContent = "";
383        private String viewContent = "";
384        private boolean deletable = true;
385        private Set<Principal> editableBy;
386
387        /**
388         * Creates a new model with the given type and id.
389         * 
390         * @param conletId the web console component id
391         */
392        @ConstructorProperties({ "conletId" })
393        public MarkdownDisplayModel(String conletId) {
394            super(conletId);
395        }
396
397        /**
398         * @return the title
399         */
400        public String getTitle() {
401            return title;
402        }
403
404        /**
405         * @param title the title to set
406         */
407        public void setTitle(String title) {
408            this.title = title;
409        }
410
411        /**
412         * @return the previewContent
413         */
414        public String getPreviewContent() {
415            return previewContent;
416        }
417
418        /**
419         * @param previewContent the previewContent to set
420         */
421        public void setPreviewContent(String previewContent) {
422            this.previewContent = previewContent;
423        }
424
425        /**
426         * @return the viewContent
427         */
428        public String getViewContent() {
429            return viewContent;
430        }
431
432        /**
433         * @param viewContent the viewContent to set
434         */
435        public void setViewContent(String viewContent) {
436            this.viewContent = viewContent;
437        }
438
439        /**
440         * @return the deletable
441         */
442        public boolean isDeletable() {
443            return deletable;
444        }
445
446        /**
447         * @param deletable the deletable to set
448         */
449        public void setDeletable(boolean deletable) {
450            this.deletable = deletable;
451        }
452
453        /**
454         * @return the editableBy
455         */
456        public Set<Principal> getEditableBy() {
457            return editableBy;
458        }
459
460        /**
461         * @param editableBy the editableBy to set
462         */
463        public void setEditableBy(Set<Principal> editableBy) {
464            this.editableBy = editableBy;
465        }
466
467    }
468
469}