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