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