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<Principal>): 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}