001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2017-2022 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.webconsole.base; 020 021import java.io.BufferedReader; 022import java.io.IOException; 023import java.io.Reader; 024import java.io.Serializable; 025import java.io.StringWriter; 026import java.net.URL; 027import java.nio.CharBuffer; 028import java.time.Duration; 029import java.time.Instant; 030import java.util.Collection; 031import java.util.Collections; 032import java.util.HashMap; 033import java.util.HashSet; 034import java.util.Iterator; 035import java.util.List; 036import java.util.Locale; 037import java.util.Map; 038import java.util.Map.Entry; 039import java.util.Optional; 040import java.util.ResourceBundle; 041import java.util.Set; 042import java.util.UUID; 043import java.util.WeakHashMap; 044import java.util.concurrent.ConcurrentHashMap; 045import java.util.concurrent.ExecutorService; 046import java.util.concurrent.Future; 047import java.util.function.Supplier; 048import java.util.stream.Collectors; 049import java.util.stream.Stream; 050import org.jgrapes.core.Channel; 051import org.jgrapes.core.Component; 052import org.jgrapes.core.Components; 053import org.jgrapes.core.Components.Timer; 054import org.jgrapes.core.Event; 055import org.jgrapes.core.annotation.Handler; 056import org.jgrapes.core.annotation.HandlerDefinition.ChannelReplacements; 057import org.jgrapes.core.events.Detached; 058import org.jgrapes.http.Session; 059import org.jgrapes.io.IOSubchannel; 060import org.jgrapes.io.events.Closed; 061import org.jgrapes.webconsole.base.Conlet.RenderMode; 062import org.jgrapes.webconsole.base.events.AddConletRequest; 063import org.jgrapes.webconsole.base.events.AddConletType; 064import org.jgrapes.webconsole.base.events.ConletDeleted; 065import org.jgrapes.webconsole.base.events.ConletResourceRequest; 066import org.jgrapes.webconsole.base.events.ConsoleReady; 067import org.jgrapes.webconsole.base.events.DeleteConlet; 068import org.jgrapes.webconsole.base.events.NotifyConletModel; 069import org.jgrapes.webconsole.base.events.NotifyConletView; 070import org.jgrapes.webconsole.base.events.RenderConlet; 071import org.jgrapes.webconsole.base.events.RenderConletRequest; 072import org.jgrapes.webconsole.base.events.RenderConletRequestBase; 073import org.jgrapes.webconsole.base.events.SetLocale; 074import org.jgrapes.webconsole.base.events.UpdateConletType; 075 076/** 077 * Provides a base class for implementing web console components. 078 * The class provides the following support functions: 079 * * "Translate" the conlet related events to invocations 080 * of abstract methods. This is mainly a prerequisite 081 * for implementing the other support functions. 082 * * Optionally manage state for a conlet instance. 083 * * Optionally track the existing previews or views of 084 * a conlet, thus allowing the server side to send update 085 * events (usually when the state changes on the server side). 086 * * Optionally refresh existing previews or views periodically 087 * 088 * # Event handling 089 * 090 * The following diagrams show the events exchanged between 091 * the {@link WebConsole} and a web console component from the 092 * web console component's perspective. If applicable, they also show 093 * how the events are translated by the {@link AbstractConlet} to invocations 094 * of the abstract methods that have to be implemented by the 095 * derived class (the web console component that provides 096 * a specific web console component type). 097 * 098 * ## ConsoleReady 099 * 100 * ![Add web console component type handling](AddConletTypeHandling.svg) 101 * 102 * From the web console's page point of view, a web console component 103 * consists of CSS and JavaScript that is added to the console page by 104 * {@link AddConletType} events and HTML that is provided by 105 * {@link RenderConlet} events (see below). These events must 106 * therefore be generated by a web console component. 107 * 108 * The {@link AbstractConlet} does not provide support for generating 109 * an {@link AddConletType} event. The handler for the 110 * {@link ConsoleReady} that generates this event must be implemented by 111 * the derived class itself. 112 * 113 * ## AddConletRequest 114 * 115 * ![Add web console component handling](AddConletHandling.svg) 116 * 117 * The {@link AddConletRequest} indicates that a new web console component 118 * instance of a given type should be added to the page. The 119 * {@link AbstractConlet} checks the type requested, and if 120 * it matches, invokes {@link #generateInstanceId generateInstanceId} 121 * and {@link #createNewState createNewState}. 122 * If the conlet has associated state, the information is saved with 123 * {@link #putInSession putInSession}. Then 124 * {@link #doRenderConlet doRenderConlet} is invoked, which must 125 * render the conlet in the browser. Information about the rendered views 126 * is returned and used to track the views. 127 * 128 * Method {@link #doRenderConlet doRenderConlet} renders the preview 129 * or view by firing a {@link RenderConlet} event that provides to 130 * the console page the HTML that represents the web console 131 * component on the page. The HTML may be generated using and thus 132 * depending on the component state. 133 * Alternatively, state independent HTML may be provided followed 134 * by a {@link NotifyConletView} event that updates 135 * the HTML (using JavaScript) on the console page. The latter approach 136 * is preferred if the model changes frequently and updating the 137 * rendered representation is more efficient than providing a new one. 138 * 139 * ## RenderConletRequest 140 * 141 * ![Render web console component handling](RenderConletHandling.svg) 142 * 143 * A {@link RenderConletRequest} event indicates that the web console page 144 * needs the HTML for displaying a web console component. This may be caused 145 * by e.g. the initial display, by a refresh or by requesting a full 146 * page view from the preview. 147 * 148 * Upon receiving such an event, the {@link AbstractConlet} 149 * checks if it has state information for the component id 150 * requested. If not, it calls {@link #recreateState recreateState} 151 * which allows the conlet to e.g. retrieve state information from 152 * a backing store. 153 * 154 * Once state information has been obtained, the method 155 * continues as when adding a new conlet by invoking 156 * {@link #doRenderConlet doRenderConlet}. 157 * 158 * ## ConletDeleted 159 * 160 * ![Web console component deleted handling](ConletDeletedHandling.svg) 161 * 162 * When the {@link AbstractConlet} receives a {@link ConletDeleted} 163 * event, it updates the information about the shown conlet views. If 164 * the conlet is no longer used in the browser (no views remain), 165 * it deletes the state information from the session. In any case, it 166 * invokes {@link #doConletDeleted doConletDeleted} with the 167 * state information. 168 * 169 * ## NotifyConletModel 170 * 171 * ![Notify web console component model handling](NotifyConletModelHandling.svg) 172 * 173 * If the web console component views include input elements, actions 174 * on these elements may result in {@link NotifyConletModel} events from 175 * the web console page to the web console. When the {@link AbstractConlet} 176 * receives such events, it retrieves any existing state information. 177 * It then invokes {@link #doUpdateConletState doUpdateConletState} 178 * with the retrieved information. The web console component usually 179 * responds with a {@link NotifyConletView} event. However, it can 180 * also re-render the complete conlet view. 181 * 182 * Support for unsolicited updates 183 * ------------------------------- 184 * 185 * The class tracks the relationship between the known 186 * {@link ConsoleConnection}s and the web console components displayed 187 * in the console pages. The information is available from 188 * {@link #conletInfosByConsoleConnection conletInfosByConsoleConnection}. 189 * It can e.g. be used to send events to the web console(s) in response 190 * to an event on the server side. 191 * 192 * @param <S> the type of the conlet's state information 193 * 194 * @startuml AddConletTypeHandling.svg 195 * hide footbox 196 * 197 * activate WebConsole 198 * WebConsole -> Conlet: ConsoleReady 199 * deactivate WebConsole 200 * activate Conlet 201 * Conlet -> WebConsole: AddConletType 202 * deactivate Conlet 203 * activate WebConsole 204 * deactivate WebConsole 205 * @enduml 206 * 207 * @startuml AddConletHandling.svg 208 * hide footbox 209 * 210 * activate WebConsole 211 * WebConsole -> Conlet: AddConletRequest 212 * deactivate WebConsole 213 * activate Conlet 214 * Conlet -> Conlet: generateInstanceId 215 * activate Conlet 216 * deactivate Conlet 217 * Conlet -> Conlet: createNewState 218 * activate Conlet 219 * deactivate Conlet 220 * opt if state 221 * Conlet -> Conlet: putInSession 222 * activate Conlet 223 * deactivate Conlet 224 * end opt 225 * Conlet -> Conlet: doRenderConlet 226 * activate Conlet 227 * Conlet -> WebConsole: RenderConlet 228 * activate WebConsole 229 * deactivate WebConsole 230 * opt 231 * Conlet -> WebConsole: NotifyConletView 232 * activate WebConsole 233 * deactivate WebConsole 234 * end opt 235 * deactivate Conlet 236 * Conlet -> Conlet: start conlet tracking 237 * @enduml 238 * 239 * @startuml RenderConletHandling.svg 240 * hide footbox 241 * 242 * activate WebConsole 243 * WebConsole -> Conlet: RenderConletRequest 244 * deactivate WebConsole 245 * activate Conlet 246 * Conlet -> Conlet: stateFromSession 247 * activate Conlet 248 * deactivate Conlet 249 * opt if not found 250 * Conlet -> Conlet: recreateState 251 * activate Conlet 252 * deactivate Conlet 253 * opt if state 254 * Conlet -> Conlet: putInSession 255 * activate Conlet 256 * deactivate Conlet 257 * end opt 258 * end opt 259 * Conlet -> Conlet: doRenderConlet 260 * activate Conlet 261 * Conlet -> WebConsole: RenderConlet 262 * activate WebConsole 263 * deactivate WebConsole 264 * opt 265 * Conlet -> WebConsole: NotifyConletView 266 * activate WebConsole 267 * deactivate WebConsole 268 * end opt 269 * deactivate Conlet 270 * Conlet -> Conlet: update conlet tracking 271 * @enduml 272 * 273 * @startuml NotifyConletModelHandling.svg 274 * hide footbox 275 * 276 * activate WebConsole 277 * WebConsole -> Conlet: NotifyConletModel 278 * deactivate WebConsole 279 * activate Conlet 280 * Conlet -> Conlet: stateFromSession 281 * activate Conlet 282 * deactivate Conlet 283 * opt if not found 284 * Conlet -> Conlet: recreateState 285 * activate Conlet 286 * deactivate Conlet 287 * opt if state 288 * Conlet -> Conlet: putInSession 289 * activate Conlet 290 * deactivate Conlet 291 * end opt 292 * end opt 293 * Conlet -> Conlet: doUpdateConletState 294 * activate Conlet 295 * opt 296 * Conlet -> WebConsole: RenderConlet 297 * end opt 298 * opt 299 * Conlet -> WebConsole: NotifyConletView 300 * end opt 301 * deactivate Conlet 302 * deactivate Conlet 303 * @enduml 304 * 305 * @startuml ConletDeletedHandling.svg 306 * hide footbox 307 * 308 * activate WebConsole 309 * WebConsole -> Conlet: ConletDeleted 310 * deactivate WebConsole 311 * activate Conlet 312 * Conlet -> Conlet: stateFromSession 313 * activate Conlet 314 * deactivate Conlet 315 * alt all views deleted 316 * Conlet -> Conlet: removeState 317 * activate Conlet 318 * deactivate Conlet 319 * Conlet -> Conlet: stop conlet tracking 320 * else 321 * Conlet -> Conlet: update conlet tracking 322 * end alt 323 * Conlet -> Conlet: doConletDeleted 324 * activate Conlet 325 * deactivate Conlet 326 * deactivate Conlet 327 * @enduml 328 */ 329@SuppressWarnings({ "PMD.TooManyMethods", 330 "PMD.EmptyMethodInAbstractClassShouldBeAbstract", "PMD.GodClass", 331 "PMD.ExcessiveImports", "PMD.CouplingBetweenObjects" }) 332public abstract class AbstractConlet<S> extends Component { 333 334 /** Separator used between type and instance when generating the id. */ 335 public static final String TYPE_INSTANCE_SEPARATOR = "~"; 336 @SuppressWarnings({ "PMD.FieldNamingConventions", 337 "PMD.VariableNamingConventions", "PMD.UseConcurrentHashMap", 338 "PMD.AvoidDuplicateLiterals" }) 339 private static final Map<Class<?>, 340 Map<Locale, ResourceBundle>> supportedLocales 341 = Collections.synchronizedMap(new WeakHashMap<>()); 342 @SuppressWarnings({ "PMD.FieldNamingConventions", 343 "PMD.VariableNamingConventions", "PMD.UseConcurrentHashMap" }) 344 private static final Map<Class<?>, 345 Map<Locale, ResourceBundle>> l10nBundles 346 = Collections.synchronizedMap(new WeakHashMap<>()); 347 @SuppressWarnings("PMD.LongVariable") 348 private Map<ConsoleConnection, 349 Map<String, ConletTrackingInfo>> conletInfosByConsoleConnection; 350 private Duration refreshInterval; 351 private Supplier<Event<?>> refreshEventSupplier; 352 private Timer refreshTimer; 353 354 /** 355 * Extract the conlet type from a conlet id. 356 * 357 * @param conletId the conlet id 358 * @return the type or {@code null} the conlet id does not contain 359 * a {@link TYPE_INSTANCE_SEPARATOR} 360 */ 361 public static String typeFromId(String conletId) { 362 int sep = conletId.indexOf(TYPE_INSTANCE_SEPARATOR); 363 if (sep < 0) { 364 return null; 365 } 366 return conletId.substring(0, sep); 367 } 368 369 /** 370 * Creates a new component that listens for new events 371 * on the given channel. 372 * 373 * @param channel the channel to listen on 374 */ 375 public AbstractConlet(Channel channel) { 376 this(channel, null); 377 } 378 379 /** 380 * Like {@link #AbstractConlet(Channel)}, but supports 381 * the specification of channel replacements. 382 * 383 * @param channel the channel to listen on 384 * @param channelReplacements the channel replacements (see 385 * {@link Component}) 386 */ 387 @SuppressWarnings("PMD.LooseCoupling") 388 public AbstractConlet(Channel channel, 389 ChannelReplacements channelReplacements) { 390 super(channel, channelReplacements); 391 conletInfosByConsoleConnection 392 = Collections.synchronizedMap(new WeakHashMap<>()); 393 } 394 395 /** 396 * If set to a value different from `null` causes an event 397 * from the given supplier to be fired on all tracked web console 398 * connections periodically. 399 * 400 * @param interval the refresh interval 401 * @param supplier the supplier 402 * @return the web console component for easy chaining 403 */ 404 @SuppressWarnings("PMD.LinguisticNaming") 405 public AbstractConlet<S> setPeriodicRefresh( 406 Duration interval, Supplier<Event<?>> supplier) { 407 refreshInterval = interval; 408 refreshEventSupplier = supplier; 409 if (refreshTimer != null) { 410 refreshTimer.cancel(); 411 refreshTimer = null; 412 } 413 updateRefresh(); 414 return this; 415 } 416 417 private void updateRefresh() { 418 if (refreshInterval == null 419 || conletIdsByConsoleConnection().isEmpty()) { 420 // At least one of the prerequisites is missing, terminate 421 if (refreshTimer != null) { 422 refreshTimer.cancel(); 423 refreshTimer = null; 424 } 425 return; 426 } 427 if (refreshTimer != null) { 428 // Already running. 429 return; 430 } 431 refreshTimer = Components.schedule(tmr -> { 432 tmr.reschedule(tmr.scheduledFor().plus(refreshInterval)); 433 fire(refreshEventSupplier.get(), trackedConnections()); 434 }, Instant.now().plus(refreshInterval)); 435 } 436 437 /** 438 * Returns the web console component type. The default implementation 439 * returns the name of the class. 440 * 441 * @return the type 442 */ 443 protected String type() { 444 return getClass().getName(); 445 } 446 447 /** 448 * A default handler for resource requests. Checks that the request 449 * is directed at this web console component, and calls 450 * {@link #doGetResource}. 451 * 452 * @param event the resource request event 453 * @param channel the channel that the request was recived on 454 */ 455 @Handler 456 public final void onConletResourceRequest( 457 ConletResourceRequest event, IOSubchannel channel) { 458 // For me? 459 if (!event.conletClass().equals(type())) { 460 return; 461 } 462 doGetResource(event, channel); 463 } 464 465 /** 466 * The default implementation searches for a file with the 467 * requested resource URI in the web console component's class 468 * path and sets its {@link URL} as result if found. 469 * 470 * @param event the event. The result will be set to 471 * `true` on success 472 * @param channel the channel 473 */ 474 protected void doGetResource(ConletResourceRequest event, 475 IOSubchannel channel) { 476 URL resourceUrl = this.getClass().getResource( 477 event.resourceUri().getPath()); 478 if (resourceUrl == null) { 479 return; 480 } 481 event.setResult(new ResourceByUrl(event, resourceUrl)); 482 event.stop(); 483 } 484 485 /** 486 * Provides a resource bundle for localization. 487 * The default implementation looks up a bundle using the 488 * package name plus "l10n" as base name. Note that the bundle 489 * returned for a given locale may be the fallback bundle. 490 * 491 * @return the resource bundle 492 */ 493 protected ResourceBundle resourceBundle(Locale locale) { 494 return ResourceBundle.getBundle( 495 getClass().getPackage().getName() + ".l10n", locale, 496 getClass().getClassLoader(), 497 ResourceBundle.Control.getNoFallbackControl( 498 ResourceBundle.Control.FORMAT_DEFAULT)); 499 } 500 501 /** 502 * Returns bundles for the given locales. 503 * 504 * The default implementation uses {@link #resourceBundle(Locale)} 505 * to lookup the bundles. The method is guaranteed to return a 506 * bundle for each requested locale even if it is only the fallback 507 * bundle. The evaluated results are cached for the conlet class. 508 * 509 * @param toGet the locales to get bundles for 510 * @return the map with locales and bundles 511 */ 512 @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", 513 "PMD.AvoidSynchronizedStatement", "PMD.AvoidDuplicateLiterals" }) 514 protected Map<Locale, ResourceBundle> l10nBundles(Set<Locale> toGet) { 515 @SuppressWarnings("PMD.UseConcurrentHashMap") 516 Map<Locale, ResourceBundle> result = new HashMap<>(); 517 for (Locale locale : toGet) { 518 ResourceBundle bundle; 519 synchronized (l10nBundles) { 520 // Due to the nested computeIfAbsent, it is not sufficient 521 // that l10nBundels is thread safe. 522 bundle = l10nBundles 523 .computeIfAbsent(getClass(), 524 cls -> new ConcurrentHashMap<>()) 525 .computeIfAbsent(locale, l -> resourceBundle(locale)); 526 } 527 result.put(locale, bundle); 528 } 529 return Collections.unmodifiableMap(result); 530 } 531 532 /** 533 * Provides localizations for the given key for all requested locales. 534 * 535 * The default implementation uses {@link #l10nBundles(Set)} to obtain 536 * the localizations. 537 * 538 * @param locales the requested locales 539 * @param key the key 540 * @return the result 541 */ 542 protected Map<Locale, String> localizations(Set<Locale> locales, 543 String key) { 544 @SuppressWarnings("PMD.UseConcurrentHashMap") 545 Map<Locale, String> result = new HashMap<>(); 546 Map<Locale, ResourceBundle> bundles = l10nBundles(locales); 547 for (Map.Entry<Locale, ResourceBundle> entry : bundles.entrySet()) { 548 result.put(entry.getKey(), entry.getValue().getString(key)); 549 } 550 return result; 551 } 552 553 /** 554 * Returns the supported locales and the associated bundles. 555 * 556 * The default implementation invokes {@link #resourceBundle(Locale)} 557 * with all available locales and drops results with fallback bundles. 558 * The evaluated results are cached for the conlet class. 559 * 560 * @return the result 561 */ 562 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 563 protected Map<Locale, ResourceBundle> supportedLocales() { 564 return supportedLocales.computeIfAbsent(getClass(), cls -> { 565 ResourceBundle.clearCache(cls.getClassLoader()); 566 @SuppressWarnings("PMD.UseConcurrentHashMap") 567 Map<Locale, ResourceBundle> bundles = new HashMap<>(); 568 for (Locale locale : Locale.getAvailableLocales()) { 569 if ("".equals(locale.getLanguage())) { 570 continue; 571 } 572 ResourceBundle bundle = resourceBundle(locale); 573 if (bundle.getLocale().equals(locale)) { 574 bundles.put(locale, bundle); 575 } 576 } 577 return bundles; 578 }); 579 } 580 581 /** 582 * Create the instance specific part of a conlet id. The default 583 * implementation generates a UUID. Derived classes override this 584 * method if e.g. the instance specific part must include a key that 585 * associates the conlet's state with some backing store. 586 * 587 * @param event the event that triggered the creation of a new conlet, 588 * which may contain required information 589 * (see {@link AddConletRequest#properties()}) 590 * @param connection the console connection; usually not required 591 * but provided as context 592 * 593 * @return the web console component id 594 */ 595 protected String generateInstanceId(AddConletRequest event, 596 ConsoleConnection connection) { 597 return UUID.randomUUID().toString(); 598 } 599 600 /** 601 * Creates an instance of the type that represents the conlet's state, 602 * initialized with default values. The default implementation returns 603 * {@link Optional#isEmpty()}, thus indicating that no state 604 * information is needed or available. 605 * 606 * This method should always be overridden if conlet instances 607 * have associated state. 608 * 609 * @param event the event, which may contain required information 610 * (see {@link AddConletRequest#properties()}) 611 * @param connection the console connection, sometimes required to 612 * send events to components that provide a backing store 613 * @param conletId the conlet id calculated as 614 * `type() + TYPE_INSTANCE_SEPARATOR + generateInstanceId(...)` 615 * @return the state representation or {@link Optional#empty()} if none is 616 * required 617 * @throws Exception if an exception occurs 618 */ 619 @SuppressWarnings({ "PMD.SignatureDeclareThrowsException", 620 "PMD.AvoidDuplicateLiterals" }) 621 protected Optional<S> createStateRepresentation(Event<?> event, 622 ConsoleConnection connection, String conletId) throws Exception { 623 return Optional.empty(); 624 } 625 626 /** 627 * Called by {@link #onAddConletRequest} 628 * when a new conlet instance is created in the browser. The default 629 * implementation simply invokes {@link 630 * #createStateRepresentation} and returns its result. If state 631 * is provided, it is put in the browser session by the invoker. 632 * 633 * This method should only be overridden if the event has associated 634 * information (see {@link AddConletRequest#addProperty}) that 635 * can be used to initialize the state with information that differs 636 * from the defaults used by {@link #createStateRepresentation}. 637 * 638 * @param event the event 639 * @param connection the console connection 640 * @param conletId the conlet id 641 * @return the state representation or {@link Optional#empty()} if none is 642 * required 643 * @throws Exception if an exception occurs 644 */ 645 @SuppressWarnings({ "PMD.SignatureDeclareThrowsException", 646 "PMD.AvoidDuplicateLiterals" }) 647 protected Optional<S> createNewState( 648 AddConletRequest event, ConsoleConnection connection, 649 String conletId) throws Exception { 650 return createStateRepresentation(event, connection, conletId); 651 } 652 653 /** 654 * Called when a previously created conlet (with associated state) 655 * is rendered in a new browser session for the first time. The 656 * default implementation simply invokes 657 * {@link #createStateRepresentation createStateRepresentation} 658 * and returns its result. Conlets with long-term state should 659 * retrieve their state from some storage. If state is returned, 660 * it is put in the browser session by the invoker. 661 * 662 * @param event the event 663 * @param connection the console connection 664 * @param conletId the conlet id 665 * @return the state representation or {@link Optional#empty()} if none is 666 * required 667 * @throws Exception if an exception occurs 668 */ 669 @SuppressWarnings({ "PMD.SignatureDeclareThrowsException", 670 "PMD.AvoidDuplicateLiterals" }) 671 protected Optional<S> recreateState( 672 Event<?> event, ConsoleConnection connection, 673 String conletId) throws Exception { 674 return createStateRepresentation(event, connection, conletId); 675 } 676 677 /** 678 * Returns the tracked connections and conlet ids as map. 679 * 680 * If you need a particular connection's web console component ids, you 681 * should prefer {@link #conletIds(ConsoleConnection)} over calling 682 * this method with `get(consoleConnection)` appended. 683 * 684 * @return the result 685 */ 686 protected Map<ConsoleConnection, Set<String>> 687 conletIdsByConsoleConnection() { 688 return conletInfosByConsoleConnection.entrySet().stream() 689 .collect(Collectors.toMap(Entry::getKey, 690 e -> new HashSet<>(e.getValue().keySet()))); 691 } 692 693 /** 694 * Returns the tracked connections. This is effectively 695 * `conletInfosByConsoleConnection().keySet()` converted to 696 * an array. This representation is especially useful 697 * when the web console connections are used as argument for 698 * {@link #fire(Event, Channel...)}. 699 * 700 * @return the web console connections 701 */ 702 protected ConsoleConnection[] trackedConnections() { 703 Set<ConsoleConnection> connections = new HashSet<>( 704 conletInfosByConsoleConnection.keySet()); 705 return connections.toArray(new ConsoleConnection[0]); 706 } 707 708 /** 709 * Returns the set of web console component ids associated with the 710 * console connection as a {@link Set}. If no web console components 711 * have registered yet, an empty set is returned. 712 * 713 * @param connection the console connection 714 * @return the set 715 */ 716 protected Set<String> conletIds(ConsoleConnection connection) { 717 return new HashSet<>(conletInfosByConsoleConnection.getOrDefault( 718 connection, Collections.emptyMap()).keySet()); 719 } 720 721 /** 722 * Returns a map of all conlet ids and the modes in which 723 * views are currently rendered. 724 * 725 * @param connection the console connection 726 * @return the map 727 */ 728 protected Map<String, Set<RenderMode>> 729 conletViews(ConsoleConnection connection) { 730 return conletInfosByConsoleConnection.getOrDefault( 731 connection, Collections.emptyMap()).entrySet().stream() 732 .collect(Collectors.toMap(Entry::getKey, 733 e -> e.getValue().renderedAs)); 734 } 735 736 /** 737 * Track the given web console component from the given connection. 738 * This is invoked by 739 * {@link #onAddConletRequest(AddConletRequest, ConsoleConnection)} and 740 * {@link #onRenderConletRequest(RenderConletRequest, ConsoleConnection)}. 741 * It needs only be invoked if either method is overridden. 742 * 743 * @param connection the web console connection 744 * @param conletId the conlet id 745 * @param info the info to be added if currently untracked. If `null`, 746 * a new {@link ConletTrackingInfo} is created and added 747 * @return the conlet tracking info 748 */ 749 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 750 protected ConletTrackingInfo trackConlet(ConsoleConnection connection, 751 String conletId, ConletTrackingInfo info) { 752 ConletTrackingInfo result; 753 synchronized (conletInfosByConsoleConnection) { 754 Map<String, ConletTrackingInfo> infos 755 = conletInfosByConsoleConnection.computeIfAbsent(connection, 756 newKey -> new ConcurrentHashMap<>()); 757 result = infos.computeIfAbsent(conletId, 758 key -> Optional.ofNullable(info) 759 .orElse(new ConletTrackingInfo(conletId))); 760 } 761 updateRefresh(); 762 return result; 763 } 764 765 /** 766 * Helper that provides the storage spaces for this 767 * conlet type in the session. 768 * 769 * @param session the session 770 * @return the spaces, non-transient first 771 */ 772 @SuppressWarnings({ "unchecked", "PMD.AvoidSynchronizedStatement" }) 773 private Stream<Map<String, S>> typeContexts(Session session) { 774 synchronized (session) { 775 return List.of(session, session.transientData()).stream() 776 .map(context -> ((Map<Class<?>, 777 Map<String, S>>) (Object) context).computeIfAbsent( 778 AbstractConlet.class, 779 k -> new ConcurrentHashMap<>())); 780 } 781 } 782 783 /** 784 * Puts the given web console component state in the session using the 785 * {@link #type()} and the given web console component id as keys. 786 * If the state representation implements {@link Serializable}, 787 * the information is put in the session, else it is put in the 788 * session's {@link Session#transientData()}. 789 * 790 * @param session the session to use 791 * @param conletId the web console component id 792 * @param conletState the web console component state 793 * @return the component state 794 */ 795 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 796 protected S putInSession(Session session, String conletId, S conletState) { 797 synchronized (session) { 798 var storages = typeContexts(session); 799 if (!(conletState instanceof Serializable)) { 800 storages = storages.skip(1); 801 } 802 storages.findFirst().get().put(conletId, conletState); 803 return conletState; 804 } 805 } 806 807 /** 808 * Returns the state of this web console component's type 809 * with the given id from the session. 810 * 811 * @param session the session to use 812 * @param conletId the web console component id 813 * @return the web console component state 814 */ 815 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 816 protected Optional<S> stateFromSession(Session session, String conletId) { 817 synchronized (session) { 818 return typeContexts(session).map(storage -> storage.get(conletId)) 819 .filter(data -> data != null).findFirst(); 820 } 821 } 822 823 /** 824 * Returns all conlet ids and conlet states of this web console 825 * component's type from the session. 826 * 827 * @param session the console connection 828 * @return the states 829 */ 830 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 831 protected Collection<Map.Entry<String, S>> 832 statesFromSession(Session session) { 833 synchronized (session) { 834 return typeContexts(session).flatMap(storage -> storage.entrySet() 835 .stream()).collect(Collectors.toList()); 836 } 837 } 838 839 /** 840 * Removes the web console component state of the 841 * web console component with the given id from the session. 842 * 843 * @param session the session to use 844 * @param conletId the web console component id 845 * @return the removed state if state existed 846 */ 847 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 848 protected Optional<S> removeState(Session session, String conletId) { 849 synchronized (session) { 850 return typeContexts(session) 851 .map(storage -> storage.remove(conletId)) 852 .filter(data -> data != null).findFirst(); 853 } 854 } 855 856 /** 857 * Checks if the request applies to this component. If so, stops the 858 * event, requests a new conlet id (see {@link #generateInstanceId}). 859 * Stops processing if state for this id already exists (singleton). 860 * Otherwise requests new state information 861 * (see {@link #createNewState}) and saves it in the session 862 * (see {@link #putInSession}). Finally {@link #doRenderConlet} is 863 * called and its result is passed to {@link #trackConlet}. 864 * 865 * @param event the event 866 * @param connection the channel 867 * @throws Exception the exception 868 */ 869 @Handler 870 @SuppressWarnings({ "PMD.SignatureDeclareThrowsException", 871 "PMD.AvoidDuplicateLiterals" }) 872 public final void onAddConletRequest(AddConletRequest event, 873 ConsoleConnection connection) throws Exception { 874 if (!event.conletType().equals(type())) { 875 return; 876 } 877 event.stop(); 878 String conletId = type() + TYPE_INSTANCE_SEPARATOR 879 + generateInstanceId(event, connection); 880 881 // Check if state already exists (indicates singleton), may not be 882 // added again. Only "content conlets" can already have state. 883 if (!event.renderAs().contains(RenderMode.Content) 884 && stateFromSession(connection.session(), conletId).isPresent()) { 885 logger.finer(() -> String.format("Method generateInstanceId " 886 + "returns existing id %s when adding conlet.", conletId)); 887 return; 888 } 889 890 // Create new state and track conlet. 891 Optional<S> state = createNewState(event, connection, conletId); 892 state.ifPresent(s -> putInSession( 893 connection.session(), conletId, s)); 894 event.setResult(conletId); 895 trackConlet(connection, conletId, new ConletTrackingInfo(conletId) 896 .addModes(doRenderConlet(event, connection, conletId, 897 state.orElse(null)))); 898 } 899 900 /** 901 * Checks if the request applies to this component. If so, stops 902 * the event. If the conlet is completely removed from the browser, 903 * removes the web console component state from the 904 * browser session. In all cases, it calls {@link #doConletDeleted} 905 * with the state. 906 * 907 * @param event the event 908 * @param connection the web console connection 909 * @throws Exception the exception 910 */ 911 @Handler 912 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 913 public final void onConletDeleted(ConletDeleted event, 914 ConsoleConnection connection) throws Exception { 915 if (!type().equals(typeFromId(event.conletId()))) { 916 return; 917 } 918 String conletId = event.conletId(); 919 Optional<S> model = stateFromSession(connection.session(), conletId); 920 var trackingInfo = trackConlet(connection, conletId, null) 921 .removeModes(event.renderModes()); 922 if (trackingInfo.renderedAs().isEmpty() 923 || event.renderModes().isEmpty()) { 924 removeState(connection.session(), conletId); 925 for (Iterator<Entry<ConsoleConnection, Map<String, 926 ConletTrackingInfo>>> csi = conletInfosByConsoleConnection 927 .entrySet().iterator(); 928 csi.hasNext();) { 929 Map<String, ConletTrackingInfo> infos = csi.next().getValue(); 930 infos.remove(conletId); 931 if (infos.isEmpty()) { 932 csi.remove(); 933 } 934 } 935 updateRefresh(); 936 } else { 937 trackConlet(connection, conletId, null) 938 .removeModes(event.renderModes()); 939 } 940 event.stop(); 941 doConletDeleted(event, connection, event.conletId(), 942 model.orElse(null)); 943 } 944 945 /** 946 * Called by {@link #onConletDeleted} to propagate the event to derived 947 * classes. 948 * 949 * @param event the event 950 * @param channel the channel 951 * @param conletId the web console component id 952 * @param conletState the conlet's state; may be `null` if the 953 * conlet doesn't have associated state information 954 * @throws Exception if a problem occurs 955 */ 956 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 957 protected void doConletDeleted(ConletDeleted event, 958 ConsoleConnection channel, 959 String conletId, S conletState) 960 throws Exception { 961 // May be defined by derived class. 962 } 963 964 /** 965 * Checks if the request applies to this component by verifying 966 * if the component id starts with {@link #type()} 967 * plus {@link #TYPE_INSTANCE_SEPARATOR}. 968 * If the id matches, sets the event's result to `true`, stops the 969 * event and tries to retrieve the model from the session. If this 970 * fails, {@link #recreateState} is called as another attempt to 971 * obtain state information. 972 * 973 * Finally, {@link #doRenderConlet} is called and the result is added 974 * to the tracking information. 975 * 976 * @param event the event 977 * @param connection the web console connection 978 * @throws Exception the exception 979 */ 980 @Handler 981 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 982 public final void onRenderConletRequest(RenderConletRequest event, 983 ConsoleConnection connection) throws Exception { 984 if (!type().equals(typeFromId(event.conletId()))) { 985 return; 986 } 987 Optional<S> state = stateFromSession( 988 connection.session(), event.conletId()); 989 if (state.isEmpty()) { 990 state = recreateState(event, connection, event.conletId()); 991 state.ifPresent(s -> putInSession(connection.session(), 992 event.conletId(), s)); 993 } 994 event.setResult(true); 995 event.stop(); 996 Set<RenderMode> rendered = doRenderConlet( 997 event, connection, event.conletId(), state.orElse(null)); 998 trackConlet(connection, event.conletId(), null).addModes(rendered); 999 } 1000 1001 /** 1002 * Called by 1003 * {@link #onAddConletRequest(AddConletRequest, ConsoleConnection)} and 1004 * {@link #onRenderConletRequest(RenderConletRequest, ConsoleConnection)} 1005 * to complete rendering the web console component. 1006 * 1007 * The 1008 * 1009 * @param event the event 1010 * @param channel the channel 1011 * @param conletId the component id 1012 * @param conletState the conlet's state; may be `null` if the 1013 * conlet doesn't have associated state information 1014 * @return the rendered modes 1015 * @throws Exception the exception 1016 */ 1017 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 1018 protected abstract Set<RenderMode> doRenderConlet( 1019 RenderConletRequestBase<?> event, ConsoleConnection channel, 1020 String conletId, S conletState) 1021 throws Exception; 1022 1023 /** 1024 * Invokes {@link #doSetLocale(SetLocale, ConsoleConnection, String)} 1025 * for each web console component in the console connection. 1026 * 1027 * If the vent has the reload flag set, does nothing. 1028 * 1029 * The default implementation fires a 1030 * 1031 * @param event the event 1032 * @param connection the web console connection 1033 * @throws Exception the exception 1034 */ 1035 @Handler 1036 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 1037 public void onSetLocale(SetLocale event, ConsoleConnection connection) 1038 throws Exception { 1039 if (event.reload()) { 1040 return; 1041 } 1042 for (String conletId : conletIds(connection)) { 1043 if (!doSetLocale(event, connection, conletId)) { 1044 event.forceReload(); 1045 break; 1046 } 1047 } 1048 } 1049 1050 /** 1051 * Called by {@link #onSetLocale(SetLocale, ConsoleConnection)} for 1052 * each web console component in the console connection. Derived 1053 * classes must send events for updating the representation to 1054 * match the new locale. 1055 * 1056 * If the method returns `false` this indicates that the representation 1057 * cannot be updated without reloading the web console page. 1058 * 1059 * The default implementation fires a {@link RenderConletRequest} 1060 * with tracked render modes (one of or both {@link RenderMode#Preview} 1061 * and {@link RenderMode#View}), thus updating the known representations. 1062 * (Assuming that "Edit" and "Help" modes are represented with modal 1063 * dialogs and therefore locale changes aren't possible while these are 1064 * open.) 1065 * 1066 * @param event the event 1067 * @param channel the channel 1068 * @param conletId the web console component id 1069 * @return true, if adaption to new locale without reload is possible 1070 * @throws Exception the exception 1071 */ 1072 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 1073 protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, 1074 String conletId) throws Exception { 1075 fire(new RenderConletRequest(event.renderSupport(), conletId, 1076 trackConlet(channel, conletId, null).renderedAs()), 1077 channel); 1078 return true; 1079 } 1080 1081 /** 1082 * If {@link #stateFromSession(Session, String)} returns a model, 1083 * calls {@link #doUpdateConletState} with the model. 1084 * 1085 * @param event the event 1086 * @param connection the connection 1087 * @throws Exception the exception 1088 */ 1089 @Handler 1090 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 1091 public final void onNotifyConletModel(NotifyConletModel event, 1092 ConsoleConnection connection) throws Exception { 1093 if (!type().equals(typeFromId(event.conletId()))) { 1094 return; 1095 } 1096 Optional<S> state 1097 = stateFromSession(connection.session(), event.conletId()); 1098 if (state.isEmpty()) { 1099 state = recreateState(event, connection, event.conletId()); 1100 state.ifPresent(s -> putInSession(connection.session(), 1101 event.conletId(), s)); 1102 } 1103 doUpdateConletState(event, connection, state.orElse(null)); 1104 } 1105 1106 /** 1107 * Called by {@link #onNotifyConletModel} to complete handling 1108 * the notification. The default implementation does nothing. 1109 * 1110 * @param event the event 1111 * @param channel the channel 1112 * @param conletState the conlet's state; may be `null` if the 1113 * conlet doesn't have associated state information 1114 */ 1115 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 1116 protected void doUpdateConletState(NotifyConletModel event, 1117 ConsoleConnection channel, S conletState) throws Exception { 1118 // Default is to do nothing. 1119 } 1120 1121 /** 1122 * Removes the {@link ConsoleConnection} from the set of tracked 1123 * connections. If derived web console components need to perform 1124 * extra actions when a console connection is closed, they have to 1125 * override {@link #afterOnClosed(Closed, ConsoleConnection)}. 1126 * 1127 * @param event the closed event 1128 * @param connection the web console connection 1129 */ 1130 @Handler 1131 public final void onClosed(Closed<?> event, ConsoleConnection connection) { 1132 conletInfosByConsoleConnection.remove(connection); 1133 updateRefresh(); 1134 afterOnClosed(event, connection); 1135 } 1136 1137 /** 1138 * Invoked by {@link #onClosed(Closed, ConsoleConnection)} after 1139 * the web console connection has been removed from the set of 1140 * tracked connections. The default implementation does 1141 * nothing. 1142 * 1143 * @param event the closed event 1144 * @param connection the web console connection 1145 */ 1146 protected void afterOnClosed(Closed<?> event, 1147 ConsoleConnection connection) { 1148 // Default is to do nothing. 1149 } 1150 1151 /** 1152 * Calls {@link #doRemoveConletType()} if this component 1153 * is detached. 1154 * 1155 * @param event the event 1156 */ 1157 @Handler 1158 public void onDetached(Detached event) { 1159 if (!equals(event.node())) { 1160 return; 1161 } 1162 doRemoveConletType(); 1163 } 1164 1165 /** 1166 * Iterates over all connections and fires {@link DeleteConlet} 1167 * events for all known conlets and a {@link UpdateConletType} 1168 * (with no render modes) event. 1169 */ 1170 protected void doRemoveConletType() { 1171 conletIdsByConsoleConnection().forEach((connection, conletIds) -> { 1172 conletIds.forEach(conletId -> { 1173 connection.respond( 1174 new DeleteConlet(conletId, RenderMode.basicModes)); 1175 }); 1176 connection.respond(new UpdateConletType(type())); 1177 }); 1178 } 1179 1180 /** 1181 * The information tracked about web console components that are 1182 * used by the console. It includes the component's id and the 1183 * currently rendered views (only preview and view are tracked, 1184 * with "deletable preview" mapped to "preview"). 1185 */ 1186 protected static class ConletTrackingInfo { 1187 private final String conletId; 1188 private final Set<RenderMode> renderedAs; 1189 1190 /** 1191 * Instantiates a new conlet tracking info. 1192 * 1193 * @param conletId the conlet id 1194 */ 1195 public ConletTrackingInfo(String conletId) { 1196 this.conletId = conletId; 1197 renderedAs = new HashSet<>(); 1198 } 1199 1200 /** 1201 * Returns the conlet id. 1202 * 1203 * @return the id 1204 */ 1205 public String conletId() { 1206 return conletId; 1207 } 1208 1209 /** 1210 * The render modes current used. 1211 * 1212 * @return the render modes 1213 */ 1214 public Set<RenderMode> renderedAs() { 1215 return renderedAs; 1216 } 1217 1218 /** 1219 * Adds the given modes. 1220 * 1221 * @param modes the modes 1222 * @return the conlet tracking info 1223 */ 1224 public ConletTrackingInfo addModes(Set<RenderMode> modes) { 1225 if (modes.contains(RenderMode.Preview)) { 1226 renderedAs.add(RenderMode.Preview); 1227 } 1228 if (modes.contains(RenderMode.View)) { 1229 renderedAs.add(RenderMode.View); 1230 } 1231 return this; 1232 } 1233 1234 /** 1235 * Removes the given modes. 1236 * 1237 * @param modes the modes 1238 * @return the conlet tracking info 1239 */ 1240 public ConletTrackingInfo removeModes(Set<RenderMode> modes) { 1241 renderedAs.removeAll(modes); 1242 return this; 1243 } 1244 1245 @Override 1246 public int hashCode() { 1247 return conletId.hashCode(); 1248 } 1249 1250 @Override 1251 public boolean equals(Object obj) { 1252 if (this == obj) { 1253 return true; 1254 } 1255 if (obj == null) { 1256 return false; 1257 } 1258 if (getClass() != obj.getClass()) { 1259 return false; 1260 } 1261 ConletTrackingInfo other = (ConletTrackingInfo) obj; 1262 if (conletId == null) { 1263 if (other.conletId != null) { 1264 return false; 1265 } 1266 } else if (!conletId.equals(other.conletId)) { 1267 return false; 1268 } 1269 return true; 1270 } 1271 } 1272 1273 /** 1274 * Returns a future string providing the result 1275 * from reading everything from the provided reader. 1276 * 1277 * @param request the request, used to obtain the 1278 * {@link ExecutorService} service related with the request being 1279 * processed 1280 * @param contentReader the reader 1281 * @return the future 1282 */ 1283 public Future<String> readContent(RenderConletRequestBase<?> request, 1284 Reader contentReader) { 1285 return readContent( 1286 request.processedBy().map(pby -> pby.executorService()) 1287 .orElse(Components.defaultExecutorService()), 1288 contentReader); 1289 } 1290 1291 /** 1292 * Returns a future string providing the result 1293 * from reading everything from the provided reader. 1294 * 1295 * @param execSvc the executor service for reading the content 1296 * @param contentReader the reader 1297 * @return the future 1298 */ 1299 public Future<String> readContent(ExecutorService execSvc, 1300 Reader contentReader) { 1301 return execSvc.submit(() -> { 1302 StringWriter content = new StringWriter(); 1303 CharBuffer buffer = CharBuffer.allocate(8192); 1304 try (Reader rdr = new BufferedReader(contentReader)) { 1305 while (true) { 1306 if (rdr.read(buffer) < 0) { 1307 break; 1308 } 1309 buffer.flip(); 1310 content.append(buffer); 1311 buffer.clear(); 1312 } 1313 } catch (IOException e) { 1314 throw new IllegalStateException(e); 1315 } 1316 return content.toString(); 1317 }); 1318 } 1319 1320}