001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.marshall.html;
018
019import static org.apache.juneau.commons.utils.CollectionUtils.*;
020import static org.apache.juneau.commons.utils.IoUtils.*;
021import static org.apache.juneau.commons.utils.ObjectUtils.*;
022import static org.apache.juneau.commons.utils.Shorts.*;
023import static org.apache.juneau.commons.utils.StringUtils.*;
024import static org.apache.juneau.commons.utils.ThrowableUtils.*;
025import static org.apache.juneau.marshall.xml.XmlSerializerSession.ContentResult.*;
026
027import java.io.*;
028import java.time.*;
029import java.time.temporal.*;
030import java.util.*;
031import java.util.function.*;
032import java.util.regex.*;
033
034import org.apache.juneau.commons.bean.*;
035import org.apache.juneau.commons.lang.*;
036import org.apache.juneau.marshall.*;
037import org.apache.juneau.marshall.serializer.*;
038import org.apache.juneau.marshall.swap.spi.*;
039import org.apache.juneau.marshall.xml.*;
040
041/**
042 * Session object that lives for the duration of a single use of {@link HtmlSerializer}.
043 *
044 * <h5 class='section'>Notes:</h5><ul>
045 *    <li class='warn'>This class is not thread safe and is typically discarded after one use.
046 * </ul>
047 *
048 * <h5 class='section'>See Also:</h5><ul>
049 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/HtmlSupport">HTML</a>
050
051 * </ul>
052 */
053@SuppressWarnings({
054   "java:S110", // Inheritance depth acceptable for this class hierarchy
055   "java:S115", // Constants use UPPER_snakeCase naming convention
056   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
057   "java:S1452", // Builder<?> create(), doSerialize()/getHtmlWriter() returning XmlWriter<?>/HtmlWriter<?>, and getRender() returning HtmlRender<?> intentionally expose wildcard types.
058   "java:S3776", // Branching is inherent to writeAnything(), writeCollection() and getTableHeaders(), which decide table/list/bean/map layout per type.
059   "java:S6541", // Brain method acceptable for writeAnything; refactoring would reduce clarity
060   "rawtypes", // Raw types necessary for generic type handling throughout HTML serializer session
061   "resource", // Resource management handled externally
062   "unchecked" // Type erasure requires unchecked casts throughout HTML serializer session
063})
064public class HtmlSerializerSession extends XmlSerializerSession {
065
066   /**
067    * Builder class.
068    */
069   @SuppressWarnings({
070      "java:S119" // 'SELF' (CRTP self-type) is intentional and clearer than a single-letter name.
071   })
072   public abstract static class Builder<SELF extends Builder<SELF>> extends XmlSerializerSession.Builder<SELF> {
073
074      private boolean addKeyValueTableHeaders;
075      private boolean detectLabelParameters;
076      private boolean detectLinksInStrings;
077      private String labelParameter;
078      private AnchorText uriAnchorText;
079      private final HtmlSerializer ctx;
080
081      /**
082       * Constructor
083       *
084       * @param ctx The context creating this session.
085       *    <br>Cannot be <jk>null</jk>.
086       */
087      protected Builder(HtmlSerializer ctx) {
088         super(reqnn("ctx", ctx));
089         this.ctx = ctx;
090         addKeyValueTableHeaders = ctx.isAddKeyValueTableHeaders();
091         detectLabelParameters = ctx.isDetectLabelParameters();
092         detectLinksInStrings = ctx.isDetectLinksInStrings();
093         labelParameter = ctx.getLabelParameter();
094         uriAnchorText = ctx.getUriAnchorText();
095      }
096
097      @Override
098      public HtmlSerializerSession build() {
099         return new HtmlSerializerSession(this);
100      }
101
102      /**
103       * Add key/value headers on bean/map tables.
104       *
105       * @param value The new value for this setting.
106       * @return This object.
107       */
108      public SELF addKeyValueTableHeaders(boolean value) {
109         addKeyValueTableHeaders = value;
110         return self();
111      }
112
113      /**
114       * Look for link labels in URIs.
115       *
116       * @param value The new value for this setting.
117       * @return This object.
118       */
119      public SELF detectLabelParameters(boolean value) {
120         detectLabelParameters = value;
121         return self();
122      }
123
124      /**
125       * Look for URLs in Strings.
126       *
127       * @param value The new value for this setting.
128       * @return This object.
129       */
130      public SELF detectLinksInStrings(boolean value) {
131         detectLinksInStrings = value;
132         return self();
133      }
134
135      /**
136       * The label parameter name.
137       *
138       * @param value The new value for this setting.
139       * @return This object.
140       */
141      public SELF labelParameter(String value) {
142         labelParameter = value;
143         return self();
144      }
145
146      /**
147       * The anchor text to use for URL objects.
148       *
149       * @param value The new value for this setting.
150       * @return This object.
151       */
152      public SELF uriAnchorText(AnchorText value) {
153         uriAnchorText = value;
154         return self();
155      }
156
157      @Override /* Overridden from Builder */
158      public SELF property(String key, Object value) {
159         if (key == null) { super.property(key, value); return self(); }
160         switch (key) {
161            case "addKeyValueTableHeaders", "HtmlSerializerSession.addKeyValueTableHeaders":
162               return addKeyValueTableHeaders(cvt(value, Boolean.class));
163            case "detectLabelParameters", "HtmlSerializerSession.detectLabelParameters":
164               return detectLabelParameters(cvt(value, Boolean.class));
165            case "detectLinksInStrings", "HtmlSerializerSession.detectLinksInStrings":
166               return detectLinksInStrings(cvt(value, Boolean.class));
167            case "labelParameter", "HtmlSerializerSession.labelParameter":
168               return labelParameter(cvt(value, String.class));
169            case "uriAnchorText", "HtmlSerializerSession.uriAnchorText":
170               return uriAnchorText(cvt(value, AnchorText.class));
171            default:
172               super.property(key, value);
173               return self();
174         }
175      }
176
177   }
178
179   /**
180    * Concrete default builder leaf for the non-subclassed {@code create()} path (CRTP terminal).
181    */
182   public static final class DefaultBuilder extends Builder<DefaultBuilder> {
183
184      DefaultBuilder(HtmlSerializer ctx) {
185         super(ctx);
186      }
187   }
188
189   /**
190    * Creates a new builder for this object.
191    *
192    * @param ctx The context creating this session.
193    *    <br>Cannot be <jk>null</jk>.
194    * @return A new builder.
195    */
196   public static Builder<?> create(HtmlSerializer ctx) {
197      return new DefaultBuilder(reqnn("ctx", ctx));
198   }
199
200   private final HtmlSerializer ctx;
201   private final boolean addKeyValueTableHeaders;
202   private final boolean detectLabelParameters;
203   private final boolean detectLinksInStrings;
204   private final String labelParameter;
205   private final AnchorText uriAnchorText;
206   private final Pattern labelPattern;
207
208   /**
209    * Constructor.
210    *
211    * @param builder The builder for this object.
212    */
213   protected HtmlSerializerSession(Builder<?> builder) {
214      super(builder);
215      ctx = builder.ctx;
216      addKeyValueTableHeaders = builder.addKeyValueTableHeaders;
217      detectLabelParameters = builder.detectLabelParameters;
218      detectLinksInStrings = builder.detectLinksInStrings;
219      labelParameter = builder.labelParameter;
220      uriAnchorText = builder.uriAnchorText;
221      labelPattern = Pattern.compile("[\\?\\&]" + Pattern.quote(labelParameter) + "=([^\\&]*)");
222   }
223
224   /**
225    * Returns the anchor text to use for the specified URL object.
226    *
227    * @param pMeta
228    *    The property metadata of the bean property of the object.
229    *    Can be <jk>null</jk> if the object isn't from a bean property; the {@code PROPERTY_NAME} anchor-text mode
230    *    then falls back to the URL string itself.
231    * @param o The URL object.
232    * @return The anchor text to use for the specified URL object.
233    */
234   public String getAnchorText(BeanPropertyMeta pMeta, Object o) {
235      var s = o.toString();
236      if (isDetectLabelParameters()) {
237         Matcher m = labelPattern.matcher(s);
238         if (m.find())
239            return urlDecode(m.group(1));
240      }
241      return switch (getUriAnchorText()) {
242         case LAST_TOKEN -> {
243            s = resolveUri(s);
244            if (s.indexOf('/') != -1)
245               s = s.substring(s.lastIndexOf('/') + 1);
246            if (s.indexOf('?') != -1)
247               s = s.substring(0, s.indexOf('?'));
248            if (s.indexOf('#') != -1)
249               s = s.substring(0, s.indexOf('#'));
250            if (s.isEmpty())
251               s = "/";
252            yield urlDecode(s);
253         }
254         case URI_ANCHOR -> {
255            if (s.indexOf('#') != -1)
256               s = s.substring(s.lastIndexOf('#') + 1);
257            yield urlDecode(s);
258         }
259         case PROPERTY_NAME -> pMeta == null ? s : pMeta.getName();
260         case URI -> resolveUri(s);
261         case CONTEXT_RELATIVE -> relativizeUri("context:/", s);
262         case SERVLET_RELATIVE -> relativizeUri("servlet:/", s);
263         case PATH_RELATIVE -> relativizeUri("request:/", s);
264         default /* TO_STRING */ -> s;
265      };
266   }
267
268   @Override /* Overridden from XmlSerializer */
269   public boolean isHtmlMode() { return true; }
270
271   /**
272    * Returns <jk>true</jk> if the specified object is a URL.
273    *
274    * @param cm The ClassMeta of the object being serialized.
275    * @param pMeta
276    *    The property metadata of the bean property of the object.
277    *    Can be <jk>null</jk> if the object isn't from a bean property; bean-property-level URI detection is then
278    *    skipped, relying solely on <c>cm</c>.
279    * @param o The object.
280    * @return <jk>true</jk> if the specified object is a URL.
281    */
282   public boolean isUri(ClassMeta<?> cm, BeanPropertyMeta pMeta, Object o) {
283      return (cm.isUri() || (nn(pMeta) && pMeta.isUri()))
284         || (isDetectLinksInStrings() && o instanceof CharSequence && isHttpUrl(o.toString()));
285   }
286
287   // Programmatic equivalent of the full match https?\:\/\/.* (Pattern.matcher(s).matches()).
288   // The trailing .* does not cross a line terminator under matches(), so the remainder must contain none.
289   private static boolean isHttpUrl(String s) {
290      int start;
291      if (s.startsWith("http://"))
292         start = 7;
293      else if (s.startsWith("https://"))
294         start = 8;
295      else
296         return false;
297      for (var i = start; i < s.length(); i++) {
298         var c = s.charAt(i);
299         if (c == '\n' || c == '\r' || c == '\u0085' || c == '\u2028' || c == '\u2029')
300            return false;
301      }
302      return true;
303   }
304
305   /**
306    * Main serialization routine.
307    *
308    * @param o The object being serialized.
309    * @param w The writer to serialize to.
310    * @return The same writer passed in.
311    * @throws SerializeException If a problem occurred trying to send output to the writer.
312    */
313   private XmlWriter<?> doSerialize(Object o, XmlWriter<?> w) throws SerializeException {
314      writeAnything(w, o, getExpectedRootType(o), null, null, getInitialDepth() - 1, true, false);
315      return w;
316   }
317
318   private String getAnchorText(BeanPropertyMeta pMeta) {
319      return pMeta == null ? null : getHtmlBeanPropertyMeta(pMeta).getAnchorText();
320   }
321
322   private String getLink(BeanPropertyMeta pMeta) {
323      return pMeta == null ? null : getHtmlBeanPropertyMeta(pMeta).getLink();
324   }
325
326   private HtmlRender<?> getRender(HtmlSerializerSession session, BeanPropertyMeta pMeta, Object value) {
327      if (pMeta == null)
328         return null;
329      HtmlRender<?> render = getHtmlBeanPropertyMeta(pMeta).getRender();
330      if (nn(render))
331         return render;
332      var cMeta = session.getClassMetaForObject(value);
333      render = cMeta == null ? null : getHtmlClassMeta(cMeta).getRender();
334      return render;
335   }
336
337   
338   private String getStyle(HtmlSerializerSession session, BeanPropertyMeta pMeta, Object value) {
339      // Check for annotation style first
340      if (nn(pMeta)) {
341         String annotationStyle = getHtmlBeanPropertyMeta(pMeta).getStyle();
342         if (nn(annotationStyle))
343            return annotationStyle;
344      }
345      // Fall back to render
346      HtmlRender render = getRender(session, pMeta, value);
347      return render == null ? null : render.getStyle(session, value);
348   }
349
350   /**
351    * Returns the property names of a bean map that can be read, skipping write-only properties.
352    */
353   private static Object[] readableKeys(BeanMap<?> bm) {
354      return bm.keySet().stream().filter(k -> bm.getProperty(k).getMeta().canRead()).toArray(String[]::new);
355   }
356
357   /*
358    * Returns the table column headers for the specified collection of objects.
359    * Returns null if collection should not be serialized as a 2-dimensional table.
360    * Returns an empty array if it should be treated as a table but without headers.
361    * 2-dimensional tables are used for collections of objects that all have the same set of property names.
362    *
363    * Null vs empty array have distinct semantics here (don't use table vs table with no headers).
364    * Consider Optional or separate method to avoid null return.
365    */
366   @SuppressWarnings({
367      "java:S1168" // Null vs empty array have distinct semantics (don't use table vs table with no headers)
368   })
369   private Object[] getTableHeaders(Collection c, HtmlBeanPropertyMeta bpHtml) throws SerializeException {
370
371      if (c.isEmpty())
372         return null;
373
374      c = sort(c);
375
376      Object o1 = null;
377      for (var o : c)
378         if (nn(o)) {
379            o1 = o;
380            break;
381         }
382      if (o1 == null)
383         return null;
384
385      var cm1 = getClassMetaForObject(o1);
386
387      ObjectSwap swap = cm1.getSwap(this);
388      o1 = swap(swap, o1);
389      if (nn(swap))
390         cm1 = swap.getSwapClassMeta(this);
391
392      if (cm1 == null || ! cm1.isMapOrBean() || getAnnotationProvider().has(HtmlLink.class, cm1))
393         return null;
394
395      HtmlClassMeta cHtml = getHtmlClassMeta(cm1);
396
397      if (cHtml.isNoTables() || bpHtml.isNoTables() || cHtml.isXml() || bpHtml.isXml() || canIgnoreValue(cm1, null, o1))
398         return null;
399
400      if (cHtml.isNoTableHeaders() || bpHtml.isNoTableHeaders())
401         return new Object[0];
402
403      // If it's a non-bean map, only use table if all entries are also maps.
404      if (cm1.isMap() && ! cm1.isBeanMap()) {
405
406         Set<Object> set = set();
407         for (var o : c) {
408            o = swap(swap, o);
409            if (! canIgnoreValue(cm1, null, o)) {
410               if (! cm1.isInstance(o))
411                  return null;
412               forEachEntry((Map)o, x -> set.add(x.getKey()));
413            }
414         }
415         return set.toArray(new Object[set.size()]);
416      }
417
418      // Must be a bean or BeanMap.
419      for (var o : c) {
420         o = swap(swap, o);
421         if (! canIgnoreValue(cm1, null, o) && ! cm1.isInstance(o))
422            return null;
423      }
424
425      return readableKeys(toBeanMap(o1));
426   }
427
428   private void writeBeanMap(XmlWriter<?> out, BeanMap<?> m, ClassMeta<?> eType, BeanPropertyMeta ppMeta) throws SerializeException {
429
430      var mcm = (ClassMeta<?>) m.getBeanInfo();
431      HtmlClassMeta cHtml = getHtmlClassMeta(mcm);
432      HtmlBeanPropertyMeta bpHtml = getHtmlBeanPropertyMeta(ppMeta);
433
434      int i = indent;
435
436      out.oTag(i, "table");
437
438      String typeName = m.getMeta().getDictionaryName();
439      if (nn(typeName) && eType != mcm)
440         out.attr(getBeanTypePropertyName(mcm), typeName);
441
442      out.w('>').nl(i);
443      if (isAddKeyValueTableHeaders() && ! (cHtml.isNoTableHeaders() || bpHtml.isNoTableHeaders())) {
444         out.sTag(i + 1, "tr").nl(i + 1);
445         out.sTag(i + 2, "th").append("key").eTag("th").nl(i + 2);
446         out.sTag(i + 2, "th").append("value").eTag("th").nl(i + 2);
447         out.ie(i + 1).eTag("tr").nl(i + 1);
448      }
449
450      Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
451
452      m.forEachValue(checkNull, (pMeta, key, value, thrown) -> {
453         var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
454
455         if (nn(thrown))
456            onBeanGetterException(pMeta, thrown);
457
458         if (canIgnoreValue(pMeta, key, value))
459            return;
460
461         String link = null;
462         String anchorText = null;
463         if (! cMeta.isCollectionOrArray()) {
464            link = m.resolveVars(getLink(pMeta));
465            anchorText = m.resolveVars(getAnchorText(pMeta));
466         }
467
468         if (nn(anchorText))
469            value = anchorText;
470
471         out.sTag(i + 1, "tr").nl(i + 1);
472         out.sTag(i + 2, "td").text(key).eTag("td").nl(i + 2);
473         out.oTag(i + 2, "td");
474         String style = getStyle(this, pMeta, value);
475         if (nn(style))
476            out.attr("style", style);
477         out.cTag();
478
479         try {
480            if (nn(link))
481               out.oTag(i + 3, "a").attrUri("href", link).cTag();
482            ContentResult cr = writeAnything(out, value, cMeta, key, pMeta, 2, false, true);
483            if (cr == CR_ELEMENTS)
484               out.i(i + 2);
485            if (nn(link))
486               out.eTag("a");
487         } catch (SerializeException e) {
488            throw e;
489         } catch (Exception e) {
490            onBeanGetterException(pMeta, e);
491         }
492         out.eTag("td").nl(i + 2);
493         out.ie(i + 1).eTag("tr").nl(i + 1);
494      });
495
496      out.ie(i).eTag("table").nl(i);
497   }
498
499   
500   private void writeCollection(XmlWriter<?> out, Object in, ClassMeta<?> sType, ClassMeta<?> eType, String name, BeanPropertyMeta ppMeta) throws SerializeException {
501
502      HtmlClassMeta cHtml = getHtmlClassMeta(sType);
503      HtmlBeanPropertyMeta bpHtml = getHtmlBeanPropertyMeta(ppMeta);
504
505      Collection c = (sType.isCollection() ? (Collection)in : toList(sType.inner(), in));
506
507      boolean isCdc = cHtml.isHtmlCdc() || bpHtml.isHtmlCdc();
508      boolean isSdc = cHtml.isHtmlSdc() || bpHtml.isHtmlSdc();
509      boolean isDc = isCdc || isSdc;
510
511      int i = indent;
512      if (c.isEmpty()) {
513         out.appendln(i, "<ul></ul>");
514         return;
515      }
516
517      String type2 = null;
518      if (sType != eType)
519         type2 = sType.getBeanDictionaryName();
520      if (type2 == null)
521         type2 = "array";
522
523      c = sort(c);
524
525      String btpn = getBeanTypePropertyName(eType);
526
527      // Look at the objects to see how we're going to handle them.  Check the first object to see how we're going to
528      // handle this.
529      // If it's a map or bean, then we'll create a table.
530      // Otherwise, we'll create a list.
531      Object[] th = getTableHeaders(c, bpHtml);
532
533      if (nn(th)) {
534
535         out.oTag(i, "table").attr(btpn, type2).w('>').nl(i + 1);
536         if (th.length > 0) {
537            out.sTag(i + 1, "tr").nl(i + 2);
538            for (var key : th) {
539               out.sTag(i + 2, "th");
540               out.text(convertToType(key, String.class));
541               out.eTag("th").nl(i + 2);
542            }
543            out.ie(i + 1).eTag("tr").nl(i + 1);
544         } else {
545            th = null;
546         }
547
548         for (var o : c) {
549            var cm = getClassMetaForObject(o);
550
551            if (nn(cm) && nn(cm.getSwap(this))) {
552               ObjectSwap swap = cm.getSwap(this);
553               o = swap(swap, o);
554               cm = swap.getSwapClassMeta(this);
555            }
556
557            out.oTag(i + 1, "tr");
558            String typeName = (cm == null ? null : cm.getBeanDictionaryName());
559            String typeProperty = getBeanTypePropertyName(cm);
560
561            if (nn(typeName) && eType.getElementType() != cm)
562               out.attr(typeProperty, typeName);
563            out.cTag().nl(i + 2);
564
565            if (cm == null) {
566               out.i(i + 2);
567               writeAnything(out, o, null, null, null, 1, false, false);
568               out.nl(0);
569
570            } else if (cm.isMap() && ! (cm.isBeanMap())) {
571               Map m2 = sort((Map)o);
572
573               if (th == null)
574                  th = m2.keySet().toArray(new Object[m2.size()]);
575
576               for (var k : th) {
577                  out.sTag(i + 2, "td");
578                  ContentResult cr = writeAnything(out, m2.get(k), eType.getElementType(), toString(k), null, 2, false, true);
579                  if (cr == CR_ELEMENTS)
580                     out.i(i + 2);
581                  out.eTag("td").nl(i + 2);
582               }
583            } else {
584               BeanMap m2 = toBeanMap(o);
585
586               if (th == null)
587                  th = readableKeys(m2);
588
589               for (var k : th) {
590                  BeanMapEntry p = m2.getProperty(toString(k));
591                  BeanPropertyMeta pMeta = p.getMeta();
592                  if (pMeta.canRead()) {
593                     Object value = p.getValue();
594
595                     String link = null;
596                     String anchorText = null;
597                     if (! pMeta.getBeanInfo().isCollectionOrArray()) {
598                        link = m2.resolveVars(getLink(pMeta));
599                        anchorText = m2.resolveVars(getAnchorText(pMeta));
600                     }
601
602                     if (nn(anchorText))
603                        value = anchorText;
604
605                     String style = getStyle(this, pMeta, value);
606                     out.oTag(i + 2, "td");
607                     if (nn(style))
608                        out.attr("style", style);
609                     out.cTag();
610                     if (nn(link))
611                        out.oTag("a").attrUri("href", link).cTag();
612                     ContentResult cr = writeAnything(out, value, (ClassMeta<?>) pMeta.getBeanInfo(), p.getKey(), pMeta, 2, false, true);
613                     if (cr == CR_ELEMENTS)
614                        out.i(i + 2);
615                     if (nn(link))
616                        out.eTag("a");
617                     out.eTag("td").nl(i + 2);
618                  }
619               }
620            }
621            out.ie(i + 1).eTag("tr").nl(i + 1);
622         }
623         out.ie(i).eTag("table").nl(i);
624
625      } else {
626         out.oTag(i, isDc ? "p" : "ul");
627         if (! type2.equals("array"))
628            out.attr(btpn, type2);
629         out.w('>').nl(i + 1);
630         var isFirst = true;
631         for (var o : c) {
632            if (isDc && ! isFirst)
633               out.append(isCdc ? ", " : " ");
634            if (! isDc)
635               out.oTag(i + 1, "li");
636            String style = getStyle(this, ppMeta, o);
637            String link = getLink(ppMeta);
638            if (nn(style) && ! isDc)
639               out.attr("style", style);
640            if (! isDc)
641               out.cTag();
642            if (nn(link))
643               out.oTag(i + 2, "a").attrUri("href", link.replace("{#}", s(o))).cTag();
644            ContentResult cr = writeAnything(out, o, eType.getElementType(), name, null, 1, false, true);
645            if (nn(link))
646               out.eTag("a");
647            if (cr == CR_ELEMENTS)
648               out.ie(i + 1);
649            if (! isDc)
650               out.eTag("li").nl(i + 1);
651            isFirst = false;
652         }
653         out.ie(i).eTag(isDc ? "p" : "ul").nl(i);
654      }
655   }
656
657   
658   private void writeMap(XmlWriter<?> out, Map m, ClassMeta<?> sType, ClassMeta<?> eKeyType, ClassMeta<?> eValueType, String typeName, BeanPropertyMeta ppMeta) throws SerializeException {
659
660      var keyType = eKeyType == null ? string() : eKeyType;
661      var valueType = eValueType == null ? object() : eValueType;
662      var aType = getClassMetaForObject(m);       // The actual type
663      HtmlClassMeta cHtml = getHtmlClassMeta(aType);
664      HtmlBeanPropertyMeta bpHtml = getHtmlBeanPropertyMeta(ppMeta);
665
666      int i = indent;
667
668      out.oTag(i, "table");
669
670      if (nn(typeName) && nn(ppMeta) && ppMeta.getBeanInfo() != aType)
671         out.attr(getBeanTypePropertyName(sType), typeName);
672
673      out.append(">").nl(i + 1);
674      if (isAddKeyValueTableHeaders() && ! (cHtml.isNoTableHeaders() || bpHtml.isNoTableHeaders())) {
675         out.sTag(i + 1, "tr").nl(i + 2);
676         out.sTag(i + 2, "th").append("key").eTag("th").nl(i + 3);
677         out.sTag(i + 2, "th").append("value").eTag("th").nl(i + 3);
678         out.ie(i + 1).eTag("tr").nl(i + 2);
679      }
680
681      forEachEntry(m, x -> writeMapEntry(out, x, keyType, valueType, i, ppMeta));
682
683      out.ie(i).eTag("table").nl(i);
684   }
685
686   private void writeMapEntry(XmlWriter<?> out, Map.Entry e, ClassMeta<?> keyType, ClassMeta<?> valueType, int i, BeanPropertyMeta ppMeta) throws SerializeException {
687      Object key = generalize(e.getKey(), keyType);
688      Object value = null;
689      try {
690         value = e.getValue();
691      } catch (StackOverflowError t) {
692         throw t;
693      } catch (Exception t) {
694         onError(t, "Could not call getValue() on property '%s', %s", e.getKey(), localizedMessage(t));
695      }
696
697      String link = getLink(ppMeta);
698      String style = getStyle(this, ppMeta, value);
699
700      out.sTag(i + 1, "tr").nl(i + 2);
701      out.oTag(i + 2, "td");
702      if (nn(style))
703         out.attr("style", style);
704      out.cTag();
705      if (nn(link))
706         out.oTag(i + 3, "a").attrUri("href", link.replace("{#}", s(value))).cTag();
707      ContentResult cr = writeAnything(out, key, keyType, null, null, 2, false, false);
708      if (nn(link))
709         out.eTag("a");
710      if (cr == CR_ELEMENTS)
711         out.i(i + 2);
712      out.eTag("td").nl(i + 2);
713      out.sTag(i + 2, "td");
714      cr = writeAnything(out, value, valueType, (key == null ? "_x0000_" : toString(key)), null, 2, false, true);
715      if (cr == CR_ELEMENTS)
716         out.ie(i + 2);
717      out.eTag("td").nl(i + 2);
718      out.ie(i + 1).eTag("tr").nl(i + 1);
719
720   }
721
722   @Override /* Overridden from Serializer */
723   protected void doWrite(SerializerPipe out, Object o) throws IOException, SerializeException {
724      doSerialize(o, getHtmlWriter(out));
725   }
726
727   /**
728    * Returns the language-specific metadata on the specified bean property.
729    *
730    * @param bpm The bean property to return the metadata on.
731    * @return The metadata.
732    */
733   protected HtmlBeanPropertyMeta getHtmlBeanPropertyMeta(BeanPropertyMeta bpm) {
734      return ctx.getHtmlBeanPropertyMeta(bpm);
735   }
736
737   /**
738    * Returns the language-specific metadata on the specified class.
739    *
740    * @param cm The class to return the metadata on.
741    * @return The metadata.
742    */
743   protected HtmlClassMeta getHtmlClassMeta(ClassMeta<?> cm) {
744      return ctx.getHtmlClassMeta(cm);
745   }
746
747   /**
748    * Converts the specified output target object to an {@link HtmlWriter}.
749    *
750    * @param out The output target object.
751    *    <br>Must not be <jk>null</jk>.
752    * @return The output target object wrapped in an {@link HtmlWriter}.
753    */
754   protected final HtmlWriter<?> getHtmlWriter(SerializerPipe out) {
755      Object output = out.getRawOutput();
756      if (output instanceof HtmlWriter<?> output2)
757         return output2;
758      var w = new BasicHtmlWriter(out.getWriter(), isUseWhitespace(), getMaxIndent(), isTrimStrings(), getQuoteChar(), getUriResolver());
759      out.setWriter(w);
760      return w;
761   }
762
763   /**
764    * Link label parameter name.
765    *
766    * @see HtmlSerializer.Builder#labelParameter(String)
767    * @return
768    *    The parameter name to look for when resolving link labels.
769    */
770   protected final String getLabelParameter() { return labelParameter; }
771
772   /**
773    * Anchor text source.
774    *
775    * @see HtmlSerializer.Builder#uriAnchorText(AnchorText)
776    * @return
777    *    When creating anchor tags (e.g. <code><xt>&lt;a</xt> <xa>href</xa>=<xs>'...'</xs>
778    *    <xt>&gt;</xt>text<xt>&lt;/a&gt;</xt></code>) in HTML, this setting defines what to set the inner text to.
779    */
780   protected final AnchorText getUriAnchorText() { return uriAnchorText; }
781
782   @Override
783   public final boolean isAddBeanTypes() { return super.isAddBeanTypes(); }
784
785   /**
786    * Add key/value headers on bean/map tables.
787    *
788    * @see HtmlSerializer.Builder#addKeyValueTableHeaders()
789    * @return
790    *    <jk>true</jk> if <bc>key</bc> and <bc>value</bc> column headers are added to tables.
791    */
792   protected final boolean isAddKeyValueTableHeaders() { return addKeyValueTableHeaders; }
793
794   /**
795    * Look for link labels in URIs.
796    *
797    * @see HtmlSerializer.Builder#disableDetectLabelParameters()
798    * @return
799    *    <jk>true</jk> if we should ook for URL label parameters (e.g. <js>"?label=foobar"</js>).
800    */
801   protected final boolean isDetectLabelParameters() { return detectLabelParameters; }
802
803   /**
804    * Look for URLs in {@link String Strings}.
805    *
806    * @see HtmlSerializer.Builder#disableDetectLinksInStrings()
807    * @return
808    *    <jk>true</jk> if we should automatically convert strings to URLs if they look like a URL.
809    */
810   protected final boolean isDetectLinksInStrings() { return detectLinksInStrings; }
811
812   /**
813    * Serialize the specified object to the specified writer.
814    *
815    * @param out The writer.
816    * @param o The object to serialize.
817    * @param eType The expected type of the object if this is a bean property.
818    * @param name
819    *    The attribute name of this object if this object was a field in a JSON object (i.e. key of a
820    *    {@link Map.Entry} or property name of a bean).
821    * @param pMeta The bean property being serialized, or <jk>null</jk> if we're not serializing a bean property.
822    * @param xIndent The current indentation value.
823    * @param isRoot <jk>true</jk> if this is the root element of the document.
824    * @param nlIfElement <jk>true</jk> if we should add a newline to the output before serializing only if the object is an element and not text.
825    * @return The type of content encountered.  Either simple (no whitespace) or normal (elements with whitespace).
826    * @throws SerializeException Generic serialization error occurred.
827    */
828   
829   @SuppressWarnings({
830      "java:S107", // Method has many parameters; acceptable for builder/configuration methods
831      "null" // Null handling verified by context or framework
832   })
833   protected ContentResult writeAnything(XmlWriter<?> out, Object o, ClassMeta<?> eType, String name, BeanPropertyMeta pMeta, int xIndent, boolean isRoot, boolean nlIfElement)
834      throws SerializeException {
835
836      ClassMeta<?> aType = null;       // The actual type
837      ClassMeta<?> wType = null;     // The wrapped type (delegate)
838      ClassMeta<?> sType;   // The serialized type
839
840      var addJsonTags = isAddJsonTags();
841
842      if (eType == null)
843         eType = object();
844
845      aType = push2(name, o, eType);
846
847      // Handle recursion
848      if (aType == null) {
849         o = null;
850         aType = object();
851      }
852
853      // Handle Optional<X>
854      if (isOptional(aType)) {
855         o = getOptionalValue(o);
856         eType = getOptionalType(eType);
857         aType = getClassMetaForObject(o, object());
858      }
859
860      indent += xIndent;
861
862      ContentResult cr = CR_ELEMENTS;
863
864      // Determine the type.
865      if (o == null || (aType.isChar() && ((Character)o).charValue() == 0)) {
866         if (addJsonTags)
867            out.tag("null");
868         cr = ContentResult.CR_MIXED;
869
870      } else {
871
872         if (aType.isDelegate()) {
873            wType = aType;
874            aType = (ClassMeta)((Delegate)o).getBeanInfo();
875         }
876
877         sType = aType;
878
879         String typeName = null;
880         if (isAddBeanTypes() && ! eType.equals(aType))
881            typeName = aType.getBeanDictionaryName();
882
883         // Swap if necessary
884         ObjectSwap swap = aType.getSwap(this);
885         if (nn(swap)) {
886            o = swap(swap, o);
887            sType = swap.getSwapClassMeta(this);
888
889            // If the getSwapClass() method returns Object, we need to figure out
890            // the actual type now.
891            if (sType.isObject())
892               sType = getClassMetaForObject(o);
893         }
894
895         // Handle the case where we're serializing a raw stream or re-serializable raw content.
896         if (sType.isReader() || sType.isInputStream() || o instanceof RawContent) {
897            pop();
898            indent -= xIndent;
899            if (sType.isReader())
900               pipe((Reader)o, out, SerializerSession::handleThrown);
901            else if (sType.isInputStream())
902               pipe((InputStream)o, out, SerializerSession::handleThrown);
903            else
904               out.append(o.toString());
905            return ContentResult.CR_MIXED;
906         }
907
908         HtmlClassMeta cHtml = getHtmlClassMeta(sType);
909         HtmlBeanPropertyMeta bpHtml = getHtmlBeanPropertyMeta(pMeta);
910
911         HtmlRender render = coalesce(bpHtml.getRender(), cHtml.getRender());
912
913         if (nn(render)) {
914            Object o2 = render.getContent(this, o);
915            if (o2 != o) {
916               indent -= xIndent;
917               pop();
918               out.nl(indent);
919               return writeAnything(out, o2, null, typeName, null, xIndent, false, false);
920            }
921         }
922
923         if (cHtml.isXml() || bpHtml.isXml()) {
924            pop();
925            indent++;
926            if (nlIfElement)
927               out.nl(0);
928            super.writeAnything(out, o, null, null, null, null, false, XmlFormat.MIXED, false, false, null);
929            indent -= xIndent + 1;
930            return cr;
931
932         } else if (cHtml.isPlainText() || bpHtml.isPlainText()) {
933            out.w(o == null ? "null" : o.toString());
934            cr = CR_MIXED;
935
936         } else if (o == null || (sType.isChar() && ((Character)o).charValue() == 0)) {
937            if (addJsonTags)
938               out.tag("null");
939            cr = CR_MIXED;
940
941         } else if (sType.isBean()) {
942            BeanMap m = toBeanMap(o);
943            if (getAnnotationProvider().has(HtmlLink.class, aType)) {
944               var uriProperty = Holder.<String>empty();
945               var nameProperty = Holder.<String>empty();
946               aType.forEachAnnotation(HtmlLink.class, x -> ine(x.uriProperty()), x -> uriProperty.set(x.uriProperty()));
947               aType.forEachAnnotation(HtmlLink.class, x -> ine(x.nameProperty()), x -> nameProperty.set(x.nameProperty()));
948               Object urlProp = m.get(uriProperty.orElse(""));
949               Object nameProp = m.get(nameProperty.orElse(""));
950
951               out.oTag("a").attrUri("href", urlProp).w('>').text(nameProp).eTag("a");
952               cr = CR_MIXED;
953            } else {
954               out.nlIf(! isRoot, xIndent + 2);
955               writeBeanMap(out, m, eType, pMeta);
956            }
957
958         } else if (sType.isMap() || (nn(wType) && wType.isMap())) {
959            out.nlIf(! isRoot, xIndent + 1);
960            if (o instanceof BeanMap o2)
961               writeBeanMap(out, o2, eType, pMeta);
962            else
963               writeMap(out, (Map)o, sType, eType.getKeyType(), eType.getValueType(), typeName, pMeta);
964
965         } else if (sType.isCollection() || sType.isArray() || (nn(wType) && wType.isCollection())) {
966            out.nlIf(! isRoot, xIndent + 1);
967            writeCollection(out, o, sType, eType, name, pMeta);
968
969         } else if (sType.isStreamable()) {
970            // HTML must inspect elements to decide table vs. list layout (getTableHeaders), so materialization is unavoidable.
971            out.nlIf(! isRoot, xIndent + 1);
972            var list = toListFromStreamable(o, sType);
973            writeCollection(out, list, getClassMeta(List.class), eType, name, pMeta);
974
975         } else if (sType.isNumber()) {
976            if (eType.isNumber() && ! (isRoot && addJsonTags))
977               out.append(o);
978            else
979               out.sTag("number").append(o).eTag("number");
980            cr = CR_MIXED;
981
982         } else if (sType.isBoolean()) {
983            if (eType.isBoolean() && ! (isRoot && addJsonTags))
984               out.append(o);
985            else
986               out.sTag("boolean").append(o).eTag("boolean");
987            cr = CR_MIXED;
988
989         } else if (isUri(sType, pMeta, o)) {
990            String label = getAnchorText(pMeta, o);
991            out.oTag("a").attrUri("href", o).w('>');
992            out.text(label);
993            out.eTag("a");
994            cr = CR_MIXED;
995
996         } else if (sType.isDate()) {
997            String s = writeDate((Date)o, sType);
998            if (isRoot && addJsonTags)
999               out.sTag("string").text(s).eTag("string");
1000            else
1001               out.text(s);
1002            cr = CR_MIXED;
1003
1004         } else if (sType.isCalendar()) {
1005            String s = writeCalendar(o, sType);
1006            if (isRoot && addJsonTags)
1007               out.sTag("string").text(s).eTag("string");
1008            else
1009               out.text(s);
1010            cr = CR_MIXED;
1011
1012         } else if (sType.isTemporal()) {
1013            String s = writeTemporal((TemporalAccessor)o, sType);
1014            if (isRoot && addJsonTags)
1015               out.sTag("string").text(s).eTag("string");
1016            else
1017               out.text(s);
1018            cr = CR_MIXED;
1019
1020         } else if (sType.isDuration()) {
1021            String s = writeDuration((Duration)o);
1022            if (isRoot && addJsonTags)
1023               out.sTag("string").text(s).eTag("string");
1024            else
1025               out.text(s);
1026            cr = CR_MIXED;
1027
1028         } else if (sType.isPeriod()) {
1029            String s = writePeriod((Period)o);
1030            if (isRoot && addJsonTags)
1031               out.sTag("string").text(s).eTag("string");
1032            else
1033               out.text(s);
1034            cr = CR_MIXED;
1035
1036         } else {
1037            if (isRoot && addJsonTags)
1038               out.sTag("string").text(toString(o)).eTag("string");
1039            else
1040               out.text(toString(o));
1041            cr = CR_MIXED;
1042         }
1043      }
1044      pop();
1045      indent -= xIndent;
1046      return cr;
1047   }
1048
1049   @Override /* Overridden from XmlSerializerSession */
1050   protected ContentResult writeAnything(XmlWriter<?> out, Object o, ClassMeta<?> eType, String keyName, String elementName, Namespace elementNamespace, boolean addNamespaceUris, XmlFormat format,
1051      boolean isMixed, boolean preserveWhitespace, BeanPropertyMeta pMeta) throws SerializeException {
1052
1053      // If this is a bean, then we want to serialize it as HTML unless it's @Html(format=XML).
1054      var type = push2(elementName, o, eType);
1055      pop();
1056
1057      if (type == null)
1058         type = object();
1059      else if (type.isDelegate())
1060         type = (ClassMeta)((Delegate)o).getBeanInfo();
1061      ObjectSwap swap = type.getSwap(this);
1062      if (nn(swap)) {
1063         o = swap(swap, o);
1064         type = swap.getSwapClassMeta(this);
1065         if (type.isObject())
1066            type = getClassMetaForObject(o);
1067      }
1068
1069      HtmlClassMeta cHtml = getHtmlClassMeta(type);
1070
1071      if (type.isMapOrBean() && ! cHtml.isXml())
1072         return writeAnything(out, o, eType, elementName, pMeta, 0, false, false);
1073
1074      return super.writeAnything(out, o, eType, keyName, elementName, elementNamespace, addNamespaceUris, format, isMixed, preserveWhitespace, pMeta);
1075   }
1076}