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