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