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