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