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