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