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