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        "PMD.AvoidSynchronizedStatement", "PMD.AvoidDuplicateLiterals" })
514    protected Map<Locale, ResourceBundle> l10nBundles(Set<Locale> toGet) {
515        @SuppressWarnings("PMD.UseConcurrentHashMap")
516        Map<Locale, ResourceBundle> result = new HashMap<>();
517        for (Locale locale : toGet) {
518            ResourceBundle bundle;
519            synchronized (l10nBundles) {
520                // Due to the nested computeIfAbsent, it is not sufficient
521                // that l10nBundels is thread safe.
522                bundle = l10nBundles
523                    .computeIfAbsent(getClass(),
524                        cls -> new ConcurrentHashMap<>())
525                    .computeIfAbsent(locale, l -> resourceBundle(locale));
526            }
527            result.put(locale, bundle);
528        }
529        return Collections.unmodifiableMap(result);
530    }
531
532    /**
533     * Provides localizations for the given key for all requested locales.
534     * 
535     * The default implementation uses {@link #l10nBundles(Set)} to obtain
536     * the localizations.
537     *
538     * @param locales the requested locales
539     * @param key the key
540     * @return the result
541     */
542    protected Map<Locale, String> localizations(Set<Locale> locales,
543            String key) {
544        @SuppressWarnings("PMD.UseConcurrentHashMap")
545        Map<Locale, String> result = new HashMap<>();
546        Map<Locale, ResourceBundle> bundles = l10nBundles(locales);
547        for (Map.Entry<Locale, ResourceBundle> entry : bundles.entrySet()) {
548            result.put(entry.getKey(), entry.getValue().getString(key));
549        }
550        return result;
551    }
552
553    /**
554     * Returns the supported locales and the associated bundles.
555     * 
556     * The default implementation invokes {@link #resourceBundle(Locale)}
557     * with all available locales and drops results with fallback bundles.
558     * The evaluated results are cached for the conlet class.
559     *
560     * @return the result
561     */
562    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
563    protected Map<Locale, ResourceBundle> supportedLocales() {
564        return supportedLocales.computeIfAbsent(getClass(), cls -> {
565            ResourceBundle.clearCache(cls.getClassLoader());
566            @SuppressWarnings("PMD.UseConcurrentHashMap")
567            Map<Locale, ResourceBundle> bundles = new HashMap<>();
568            for (Locale locale : Locale.getAvailableLocales()) {
569                if ("".equals(locale.getLanguage())) {
570                    continue;
571                }
572                ResourceBundle bundle = resourceBundle(locale);
573                if (bundle.getLocale().equals(locale)) {
574                    bundles.put(locale, bundle);
575                }
576            }
577            return bundles;
578        });
579    }
580
581    /**
582     * Create the instance specific part of a conlet id. The default
583     * implementation generates a UUID. Derived classes override this
584     * method if e.g. the instance specific part must include a key that
585     * associates the conlet's state with some backing store. 
586     * 
587     * @param event the event that triggered the creation of a new conlet,
588     * which may contain required information 
589     * (see {@link AddConletRequest#properties()})
590     * @param connection the console connection; usually not required 
591     * but provided as context
592     *
593     * @return the web console component id
594     */
595    protected String generateInstanceId(AddConletRequest event,
596            ConsoleConnection connection) {
597        return UUID.randomUUID().toString();
598    }
599
600    /**
601     * Creates an instance of the type that represents the conlet's state,
602     * initialized with default values. The default implementation returns 
603     * {@link Optional#isEmpty()}, thus indicating that no state 
604     * information is needed or available.
605     * 
606     * This method should always be overridden if conlet instances
607     * have associated state.
608     *
609     * @param event the event, which may contain required information 
610     * (see {@link AddConletRequest#properties()})
611     * @param connection the console connection, sometimes required to
612     * send events to components that provide a backing store
613     * @param conletId the conlet id calculated as
614     * `type() + TYPE_INSTANCE_SEPARATOR + generateInstanceId(...)` 
615     * @return the state representation or {@link Optional#empty()} if none is
616     * required
617     * @throws Exception if an exception occurs
618     */
619    @SuppressWarnings({ "PMD.SignatureDeclareThrowsException",
620        "PMD.AvoidDuplicateLiterals" })
621    protected Optional<S> createStateRepresentation(Event<?> event,
622            ConsoleConnection connection, String conletId) throws Exception {
623        return Optional.empty();
624    }
625
626    /**
627     * Called by {@link #onAddConletRequest}
628     * when a new conlet instance is created in the browser. The default
629     * implementation simply invokes {@link 
630     * #createStateRepresentation} and returns its result. If state
631     * is provided, it is put in the browser session by the invoker.
632     * 
633     * This method should only be overridden if the event has associated
634     * information (see {@link AddConletRequest#addProperty}) that
635     * can be used to initialize the state with information that differs
636     * from the defaults used by {@link #createStateRepresentation}.
637     *
638     * @param event the event 
639     * @param connection the console connection
640     * @param conletId the conlet id
641     * @return the state representation or {@link Optional#empty()} if none is
642     * required
643     * @throws Exception if an exception occurs
644     */
645    @SuppressWarnings({ "PMD.SignatureDeclareThrowsException",
646        "PMD.AvoidDuplicateLiterals" })
647    protected Optional<S> createNewState(
648            AddConletRequest event, ConsoleConnection connection,
649            String conletId) throws Exception {
650        return createStateRepresentation(event, connection, conletId);
651    }
652
653    /**
654     * Called when a previously created conlet (with associated state)
655     * is rendered in a new browser session for the first time. The 
656     * default implementation simply invokes 
657     * {@link #createStateRepresentation createStateRepresentation}
658     * and returns its result. Conlets with long-term state should
659     * retrieve their state from some storage. If state is returned,
660     * it is put in the browser session by the invoker.
661     *
662     * @param event the event 
663     * @param connection the console connection
664     * @param conletId the conlet id
665     * @return the state representation or {@link Optional#empty()} if none is
666     * required
667     * @throws Exception if an exception occurs
668     */
669    @SuppressWarnings({ "PMD.SignatureDeclareThrowsException",
670        "PMD.AvoidDuplicateLiterals" })
671    protected Optional<S> recreateState(
672            Event<?> event, ConsoleConnection connection,
673            String conletId) throws Exception {
674        return createStateRepresentation(event, connection, conletId);
675    }
676
677    /**
678     * Returns the tracked connections and conlet ids as map.
679     * 
680     * If you need a particular connection's web console component ids, you 
681     * should prefer {@link #conletIds(ConsoleConnection)} over calling
682     * this method with `get(consoleConnection)` appended.
683     * 
684     * @return the result
685     */
686    protected Map<ConsoleConnection, Set<String>>
687            conletIdsByConsoleConnection() {
688        return conletInfosByConsoleConnection.entrySet().stream()
689            .collect(Collectors.toMap(Entry::getKey,
690                e -> new HashSet<>(e.getValue().keySet())));
691    }
692
693    /**
694     * Returns the tracked connections. This is effectively
695     * `conletInfosByConsoleConnection().keySet()` converted to
696     * an array. This representation is especially useful 
697     * when the web console connections are used as argument for 
698     * {@link #fire(Event, Channel...)}.
699     *
700     * @return the web console connections
701     */
702    protected ConsoleConnection[] trackedConnections() {
703        Set<ConsoleConnection> connections = new HashSet<>(
704            conletInfosByConsoleConnection.keySet());
705        return connections.toArray(new ConsoleConnection[0]);
706    }
707
708    /**
709     * Returns the set of web console component ids associated with the 
710     * console connection as a {@link Set}. If no web console components 
711     * have registered yet, an empty set is returned.
712     * 
713     * @param connection the console connection
714     * @return the set
715     */
716    protected Set<String> conletIds(ConsoleConnection connection) {
717        return new HashSet<>(conletInfosByConsoleConnection.getOrDefault(
718            connection, Collections.emptyMap()).keySet());
719    }
720
721    /**
722     * Returns a map of all conlet ids and the modes in which 
723     * views are currently rendered. 
724     *
725     * @param connection the console connection
726     * @return the map
727     */
728    protected Map<String, Set<RenderMode>>
729            conletViews(ConsoleConnection connection) {
730        return conletInfosByConsoleConnection.getOrDefault(
731            connection, Collections.emptyMap()).entrySet().stream()
732            .collect(Collectors.toMap(Entry::getKey,
733                e -> e.getValue().renderedAs));
734    }
735
736    /**
737     * Track the given web console component from the given connection. 
738     * This is invoked by 
739     * {@link #onAddConletRequest(AddConletRequest, ConsoleConnection)} and
740     * {@link #onRenderConletRequest(RenderConletRequest, ConsoleConnection)}.
741     * It needs only be invoked if either method is overridden.
742     *
743     * @param connection the web console connection
744     * @param conletId the conlet id
745     * @param info the info to be added if currently untracked. If `null`,
746     * a new {@link ConletTrackingInfo} is created and added
747     * @return the conlet tracking info
748     */
749    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
750    protected ConletTrackingInfo trackConlet(ConsoleConnection connection,
751            String conletId, ConletTrackingInfo info) {
752        ConletTrackingInfo result;
753        synchronized (conletInfosByConsoleConnection) {
754            Map<String, ConletTrackingInfo> infos
755                = conletInfosByConsoleConnection.computeIfAbsent(connection,
756                    newKey -> new ConcurrentHashMap<>());
757            result = infos.computeIfAbsent(conletId,
758                key -> Optional.ofNullable(info)
759                    .orElse(new ConletTrackingInfo(conletId)));
760        }
761        updateRefresh();
762        return result;
763    }
764
765    /**
766     * Helper that provides the storage spaces for this 
767     * conlet type in the session.
768     *
769     * @param session the session
770     * @return the spaces, non-transient first
771     */
772    @SuppressWarnings({ "unchecked", "PMD.AvoidSynchronizedStatement" })
773    private Stream<Map<String, S>> typeContexts(Session session) {
774        synchronized (session) {
775            return List.of(session, session.transientData()).stream()
776                .map(context -> ((Map<Class<?>,
777                        Map<String, S>>) (Object) context).computeIfAbsent(
778                            AbstractConlet.class,
779                            k -> new ConcurrentHashMap<>()));
780        }
781    }
782
783    /**
784     * Puts the given web console component state in the session using the 
785     * {@link #type()} and the given web console component id as keys.
786     * If the state representation implements {@link Serializable},
787     * the information is put in the session, else it is put in the
788     * session's {@link Session#transientData()}.
789     * 
790     * @param session the session to use
791     * @param conletId the web console component id
792     * @param conletState the web console component state
793     * @return the component state
794     */
795    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
796    protected S putInSession(Session session, String conletId, S conletState) {
797        synchronized (session) {
798            var storages = typeContexts(session);
799            if (!(conletState instanceof Serializable)) {
800                storages = storages.skip(1);
801            }
802            storages.findFirst().get().put(conletId, conletState);
803            return conletState;
804        }
805    }
806
807    /**
808     * Returns the state of this web console component's type 
809     * with the given id from the session.
810     *
811     * @param session the session to use
812     * @param conletId the web console component id
813     * @return the web console component state
814     */
815    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
816    protected Optional<S> stateFromSession(Session session, String conletId) {
817        synchronized (session) {
818            return typeContexts(session).map(storage -> storage.get(conletId))
819                .filter(data -> data != null).findFirst();
820        }
821    }
822
823    /**
824     * Returns all conlet ids and conlet states of this web console 
825     * component's type from the session.
826     *
827     * @param session the console connection
828     * @return the states
829     */
830    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
831    protected Collection<Map.Entry<String, S>>
832            statesFromSession(Session session) {
833        synchronized (session) {
834            return typeContexts(session).flatMap(storage -> storage.entrySet()
835                .stream()).collect(Collectors.toList());
836        }
837    }
838
839    /**
840     * Removes the web console component state of the 
841     * web console component with the given id from the session. 
842     * 
843     * @param session the session to use
844     * @param conletId the web console component id
845     * @return the removed state if state existed
846     */
847    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
848    protected Optional<S> removeState(Session session, String conletId) {
849        synchronized (session) {
850            return typeContexts(session)
851                .map(storage -> storage.remove(conletId))
852                .filter(data -> data != null).findFirst();
853        }
854    }
855
856    /**
857     * Checks if the request applies to this component. If so, stops the 
858     * event, requests a new conlet id (see {@link #generateInstanceId}). 
859     * Stops processing if state for this id already exists (singleton).
860     * Otherwise requests new state information 
861     * (see {@link #createNewState}) and saves it in the session 
862     * (see {@link #putInSession}). Finally {@link #doRenderConlet} is 
863     * called and its result is passed to {@link #trackConlet}.
864     *
865     * @param event the event
866     * @param connection the channel
867     * @throws Exception the exception
868     */
869    @Handler
870    @SuppressWarnings({ "PMD.SignatureDeclareThrowsException",
871        "PMD.AvoidDuplicateLiterals" })
872    public final void onAddConletRequest(AddConletRequest event,
873            ConsoleConnection connection) throws Exception {
874        if (!event.conletType().equals(type())) {
875            return;
876        }
877        event.stop();
878        String conletId = type() + TYPE_INSTANCE_SEPARATOR
879            + generateInstanceId(event, connection);
880
881        // Check if state already exists (indicates singleton), may not be
882        // added again. Only "content conlets" can already have state.
883        if (!event.renderAs().contains(RenderMode.Content)
884            && stateFromSession(connection.session(), conletId).isPresent()) {
885            logger.finer(() -> String.format("Method generateInstanceId "
886                + "returns existing id %s when adding conlet.", conletId));
887            return;
888        }
889
890        // Create new state and track conlet.
891        Optional<S> state = createNewState(event, connection, conletId);
892        state.ifPresent(s -> putInSession(
893            connection.session(), conletId, s));
894        event.setResult(conletId);
895        trackConlet(connection, conletId, new ConletTrackingInfo(conletId)
896            .addModes(doRenderConlet(event, connection, conletId,
897                state.orElse(null))));
898    }
899
900    /**
901     * Checks if the request applies to this component. If so, stops 
902     * the event. If the conlet is completely removed from the browser,
903     * removes the web console component state from the 
904     * browser session. In all cases, it calls {@link #doConletDeleted} 
905     * with the state.
906     * 
907     * @param event the event
908     * @param connection the web console connection
909     * @throws Exception the exception
910     */
911    @Handler
912    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
913    public final void onConletDeleted(ConletDeleted event,
914            ConsoleConnection connection) throws Exception {
915        if (!type().equals(typeFromId(event.conletId()))) {
916            return;
917        }
918        String conletId = event.conletId();
919        Optional<S> model = stateFromSession(connection.session(), conletId);
920        var trackingInfo = trackConlet(connection, conletId, null)
921            .removeModes(event.renderModes());
922        if (trackingInfo.renderedAs().isEmpty()
923            || event.renderModes().isEmpty()) {
924            removeState(connection.session(), conletId);
925            for (Iterator<Entry<ConsoleConnection, Map<String,
926                    ConletTrackingInfo>>> csi = conletInfosByConsoleConnection
927                        .entrySet().iterator();
928                    csi.hasNext();) {
929                Map<String, ConletTrackingInfo> infos = csi.next().getValue();
930                infos.remove(conletId);
931                if (infos.isEmpty()) {
932                    csi.remove();
933                }
934            }
935            updateRefresh();
936        } else {
937            trackConlet(connection, conletId, null)
938                .removeModes(event.renderModes());
939        }
940        event.stop();
941        doConletDeleted(event, connection, event.conletId(),
942            model.orElse(null));
943    }
944
945    /**
946     * Called by {@link #onConletDeleted} to propagate the event to derived
947     * classes.
948     *
949     * @param event the event
950     * @param channel the channel
951     * @param conletId the web console component id
952     * @param conletState the conlet's state; may be `null` if the
953     * conlet doesn't have associated state information
954     * @throws Exception if a problem occurs
955     */
956    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
957    protected void doConletDeleted(ConletDeleted event,
958            ConsoleConnection channel,
959            String conletId, S conletState)
960            throws Exception {
961        // May be defined by derived class.
962    }
963
964    /**
965     * Checks if the request applies to this component by verifying 
966     * if the component id starts with {@link #type()} 
967     * plus {@link #TYPE_INSTANCE_SEPARATOR}.
968     * If the id matches, sets the event's result to `true`, stops the 
969     * event and tries to retrieve the model from the session. If this
970     * fails, {@link #recreateState} is called as another attempt to
971     * obtain state information.
972     *  
973     * Finally, {@link #doRenderConlet} is called and the result is added
974     * to the tracking information. 
975     *
976     * @param event the event
977     * @param connection the web console connection
978     * @throws Exception the exception
979     */
980    @Handler
981    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
982    public final void onRenderConletRequest(RenderConletRequest event,
983            ConsoleConnection connection) throws Exception {
984        if (!type().equals(typeFromId(event.conletId()))) {
985            return;
986        }
987        Optional<S> state = stateFromSession(
988            connection.session(), event.conletId());
989        if (state.isEmpty()) {
990            state = recreateState(event, connection, event.conletId());
991            state.ifPresent(s -> putInSession(connection.session(),
992                event.conletId(), s));
993        }
994        event.setResult(true);
995        event.stop();
996        Set<RenderMode> rendered = doRenderConlet(
997            event, connection, event.conletId(), state.orElse(null));
998        trackConlet(connection, event.conletId(), null).addModes(rendered);
999    }
1000
1001    /**
1002     * Called by 
1003     * {@link #onAddConletRequest(AddConletRequest, ConsoleConnection)} and
1004     * {@link #onRenderConletRequest(RenderConletRequest, ConsoleConnection)} 
1005     * to complete rendering the web console component.
1006     * 
1007     * The 
1008     *
1009     * @param event the event
1010     * @param channel the channel
1011     * @param conletId the component id
1012     * @param conletState the conlet's state; may be `null` if the
1013     * conlet doesn't have associated state information
1014     * @return the rendered modes
1015     * @throws Exception the exception
1016     */
1017    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
1018    protected abstract Set<RenderMode> doRenderConlet(
1019            RenderConletRequestBase<?> event, ConsoleConnection channel,
1020            String conletId, S conletState)
1021            throws Exception;
1022
1023    /**
1024     * Invokes {@link #doSetLocale(SetLocale, ConsoleConnection, String)}
1025     * for each web console component in the console connection.
1026     * 
1027     * If the vent has the reload flag set, does nothing.
1028     * 
1029     * The default implementation fires a 
1030     *
1031     * @param event the event
1032     * @param connection the web console connection
1033     * @throws Exception the exception
1034     */
1035    @Handler
1036    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
1037    public void onSetLocale(SetLocale event, ConsoleConnection connection)
1038            throws Exception {
1039        if (event.reload()) {
1040            return;
1041        }
1042        for (String conletId : conletIds(connection)) {
1043            if (!doSetLocale(event, connection, conletId)) {
1044                event.forceReload();
1045                break;
1046            }
1047        }
1048    }
1049
1050    /**
1051     * Called by {@link #onSetLocale(SetLocale, ConsoleConnection)} for
1052     * each web console component in the console connection. Derived 
1053     * classes must send events for updating the representation to 
1054     * match the new locale.
1055     * 
1056     * If the method returns `false` this indicates that the representation 
1057     * cannot be updated without reloading the web console page.
1058     * 
1059     * The default implementation fires a {@link RenderConletRequest}
1060     * with tracked render modes (one of or both {@link RenderMode#Preview}
1061     * and {@link RenderMode#View}), thus updating the known representations.
1062     * (Assuming that "Edit" and "Help" modes are represented with modal 
1063     * dialogs and therefore locale changes aren't possible while these are 
1064     * open.) 
1065     *
1066     * @param event the event
1067     * @param channel the channel
1068     * @param conletId the web console component id
1069     * @return true, if adaption to new locale without reload is possible
1070     * @throws Exception the exception
1071     */
1072    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
1073    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
1074            String conletId) throws Exception {
1075        fire(new RenderConletRequest(event.renderSupport(), conletId,
1076            trackConlet(channel, conletId, null).renderedAs()),
1077            channel);
1078        return true;
1079    }
1080
1081    /**
1082     * If {@link #stateFromSession(Session, String)} returns a model,
1083     * calls {@link #doUpdateConletState} with the model. 
1084     *
1085     * @param event the event
1086     * @param connection the connection
1087     * @throws Exception the exception
1088     */
1089    @Handler
1090    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
1091    public final void onNotifyConletModel(NotifyConletModel event,
1092            ConsoleConnection connection) throws Exception {
1093        if (!type().equals(typeFromId(event.conletId()))) {
1094            return;
1095        }
1096        Optional<S> state
1097            = stateFromSession(connection.session(), event.conletId());
1098        if (state.isEmpty()) {
1099            state = recreateState(event, connection, event.conletId());
1100            state.ifPresent(s -> putInSession(connection.session(),
1101                event.conletId(), s));
1102        }
1103        doUpdateConletState(event, connection, state.orElse(null));
1104    }
1105
1106    /**
1107     * Called by {@link #onNotifyConletModel} to complete handling
1108     * the notification. The default implementation does nothing.
1109     * 
1110     * @param event the event
1111     * @param channel the channel
1112     * @param conletState the conlet's state; may be `null` if the
1113     * conlet doesn't have associated state information
1114     */
1115    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
1116    protected void doUpdateConletState(NotifyConletModel event,
1117            ConsoleConnection channel, S conletState) throws Exception {
1118        // Default is to do nothing.
1119    }
1120
1121    /**
1122     * Removes the {@link ConsoleConnection} from the set of tracked 
1123     * connections. If derived web console components need to perform 
1124     * extra actions when a console connection is closed, they have to 
1125     * override {@link #afterOnClosed(Closed, ConsoleConnection)}.
1126     * 
1127     * @param event the closed event
1128     * @param connection the web console connection
1129     */
1130    @Handler
1131    public final void onClosed(Closed<?> event, ConsoleConnection connection) {
1132        conletInfosByConsoleConnection.remove(connection);
1133        updateRefresh();
1134        afterOnClosed(event, connection);
1135    }
1136
1137    /**
1138     * Invoked by {@link #onClosed(Closed, ConsoleConnection)} after
1139     * the web console connection has been removed from the set of
1140     * tracked connections. The default implementation does
1141     * nothing.
1142     * 
1143     * @param event the closed event
1144     * @param connection the web console connection
1145     */
1146    protected void afterOnClosed(Closed<?> event,
1147            ConsoleConnection connection) {
1148        // Default is to do nothing.
1149    }
1150
1151    /**
1152     * Calls {@link #doRemoveConletType()} if this component
1153     * is detached.
1154     *
1155     * @param event the event
1156     */
1157    @Handler
1158    public void onDetached(Detached event) {
1159        if (!equals(event.node())) {
1160            return;
1161        }
1162        doRemoveConletType();
1163    }
1164
1165    /**
1166     * Iterates over all connections and fires {@link DeleteConlet}
1167     * events for all known conlets and a {@link UpdateConletType} 
1168     * (with no render modes) event.
1169     */
1170    protected void doRemoveConletType() {
1171        conletIdsByConsoleConnection().forEach((connection, conletIds) -> {
1172            conletIds.forEach(conletId -> {
1173                connection.respond(
1174                    new DeleteConlet(conletId, RenderMode.basicModes));
1175            });
1176            connection.respond(new UpdateConletType(type()));
1177        });
1178    }
1179
1180    /**
1181     * The information tracked about web console components that are
1182     * used by the console. It includes the component's id and the
1183     * currently rendered views (only preview and view are tracked,
1184     * with "deletable preview" mapped to "preview").
1185     */
1186    protected static class ConletTrackingInfo {
1187        private final String conletId;
1188        private final Set<RenderMode> renderedAs;
1189
1190        /**
1191         * Instantiates a new conlet tracking info.
1192         *
1193         * @param conletId the conlet id
1194         */
1195        public ConletTrackingInfo(String conletId) {
1196            this.conletId = conletId;
1197            renderedAs = new HashSet<>();
1198        }
1199
1200        /**
1201         * Returns the conlet id.
1202         *
1203         * @return the id
1204         */
1205        public String conletId() {
1206            return conletId;
1207        }
1208
1209        /**
1210         * The render modes current used.
1211         *
1212         * @return the render modes
1213         */
1214        public Set<RenderMode> renderedAs() {
1215            return renderedAs;
1216        }
1217
1218        /**
1219         * Adds the given modes.
1220         *
1221         * @param modes the modes
1222         * @return the conlet tracking info
1223         */
1224        public ConletTrackingInfo addModes(Set<RenderMode> modes) {
1225            if (modes.contains(RenderMode.Preview)) {
1226                renderedAs.add(RenderMode.Preview);
1227            }
1228            if (modes.contains(RenderMode.View)) {
1229                renderedAs.add(RenderMode.View);
1230            }
1231            return this;
1232        }
1233
1234        /**
1235         * Removes the given modes.
1236         *
1237         * @param modes the modes
1238         * @return the conlet tracking info
1239         */
1240        public ConletTrackingInfo removeModes(Set<RenderMode> modes) {
1241            renderedAs.removeAll(modes);
1242            return this;
1243        }
1244
1245        @Override
1246        public int hashCode() {
1247            return conletId.hashCode();
1248        }
1249
1250        @Override
1251        public boolean equals(Object obj) {
1252            if (this == obj) {
1253                return true;
1254            }
1255            if (obj == null) {
1256                return false;
1257            }
1258            if (getClass() != obj.getClass()) {
1259                return false;
1260            }
1261            ConletTrackingInfo other = (ConletTrackingInfo) obj;
1262            if (conletId == null) {
1263                if (other.conletId != null) {
1264                    return false;
1265                }
1266            } else if (!conletId.equals(other.conletId)) {
1267                return false;
1268            }
1269            return true;
1270        }
1271    }
1272
1273    /**
1274     * Returns a future string providing the result
1275     * from reading everything from the provided reader. 
1276     *
1277     * @param request the request, used to obtain the 
1278     * {@link ExecutorService} service related with the request being
1279     * processed
1280     * @param contentReader the reader
1281     * @return the future
1282     */
1283    public Future<String> readContent(RenderConletRequestBase<?> request,
1284            Reader contentReader) {
1285        return readContent(
1286            request.processedBy().map(pby -> pby.executorService())
1287                .orElse(Components.defaultExecutorService()),
1288            contentReader);
1289    }
1290
1291    /**
1292     * Returns a future string providing the result
1293     * from reading everything from the provided reader. 
1294     *
1295     * @param execSvc the executor service for reading the content
1296     * @param contentReader the reader
1297     * @return the future
1298     */
1299    public Future<String> readContent(ExecutorService execSvc,
1300            Reader contentReader) {
1301        return execSvc.submit(() -> {
1302            StringWriter content = new StringWriter();
1303            CharBuffer buffer = CharBuffer.allocate(8192);
1304            try (Reader rdr = new BufferedReader(contentReader)) {
1305                while (true) {
1306                    if (rdr.read(buffer) < 0) {
1307                        break;
1308                    }
1309                    buffer.flip();
1310                    content.append(buffer);
1311                    buffer.clear();
1312                }
1313            } catch (IOException e) {
1314                throw new IllegalStateException(e);
1315            }
1316            return content.toString();
1317        });
1318    }
1319
1320}