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