♻️ Use kotlinx.html to generate HTML
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 7 Oct 2025 09:34:47 +0000 (11:34 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Wed, 8 Oct 2025 13:08:12 +0000 (15:08 +0200)
src/main/java/net/pterodactylus/rhynodge/states/EpisodeState.java

index 4520c8c..52d6f1a 100644 (file)
@@ -28,7 +28,11 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
-import java.util.function.Function;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import kotlin.Unit;
+import kotlin.jvm.functions.Function1;
+import kotlinx.html.Tag;
 import net.pterodactylus.rhynodge.Reaction;
 import net.pterodactylus.rhynodge.State;
 import net.pterodactylus.rhynodge.filters.EpisodeFilter;
@@ -36,12 +40,21 @@ import net.pterodactylus.rhynodge.states.EpisodeState.Episode;
 import net.pterodactylus.rhynodge.states.TorrentState.TorrentFile;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
-import org.apache.commons.lang3.StringEscapeUtils;
 import org.jspecify.annotations.NonNull;
-import org.jspecify.annotations.Nullable;
 
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
+import static kotlinx.html.Gen_consumer_tagsKt.html;
+import static kotlinx.html.Gen_tag_groupsKt.table;
+import static kotlinx.html.Gen_tag_unionsKt.a;
+import static kotlinx.html.Gen_tags_hKt.body;
+import static kotlinx.html.Gen_tags_tKt.caption;
+import static kotlinx.html.Gen_tags_tKt.tbody;
+import static kotlinx.html.Gen_tags_tKt.td;
+import static kotlinx.html.Gen_tags_tKt.th;
+import static kotlinx.html.Gen_tags_tKt.thead;
+import static kotlinx.html.Gen_tags_tKt.tr;
+import static kotlinx.html.stream.StreamKt.createHTML;
 
 /**
  * {@link State} implementation that stores episodes of TV shows, parsed via
@@ -169,59 +182,58 @@ public class EpisodeState extends AbstractState implements Iterable<Episode> {
                return stringBuilder.toString();
        }
 
-       @Nullable
+       @NonNull
        @Override
        protected String htmlText() {
-               StringBuilder htmlBuilder = new StringBuilder();
-               htmlBuilder.append("<html><body>\n");
-               /* show all known episodes. */
-               htmlBuilder.append("<table>\n<caption>All Known Episodes</caption>\n");
-               htmlBuilder.append("<thead>\n");
-               htmlBuilder.append("<tr>");
-               htmlBuilder.append("<th>Season</th>");
-               htmlBuilder.append("<th>Episode</th>");
-               htmlBuilder.append("<th>Filename</th>");
-               htmlBuilder.append("<th>Size</th>");
-               htmlBuilder.append("<th>File(s)</th>");
-               htmlBuilder.append("<th>Seeds</th>");
-               htmlBuilder.append("<th>Leechers</th>");
-               htmlBuilder.append("<th>Magnet</th>");
-               htmlBuilder.append("<th>Download</th>");
-               htmlBuilder.append("</tr>\n");
-               htmlBuilder.append("</thead>\n");
-               htmlBuilder.append("<tbody>\n");
-               Episode lastEpisode = null;
-               for (Map.Entry<Integer, List<Episode>> seasonEntry : episodes.stream().sorted(Comparator.<Episode>naturalOrder().reversed()).collect(groupingBy(Episode::season, LinkedHashMap::new, toList())).entrySet()) {
-                       for (Episode episode : seasonEntry.getValue()) {
-                               for (TorrentFile torrentFile : episode) {
-                                       if (newEpisodes.contains(episode)) {
-                                               htmlBuilder.append("<tr style=\"color: #008000; font-weight: bold;\">");
-                                       } else if (newTorrentFiles.contains(torrentFile)) {
-                                               htmlBuilder.append("<tr style=\"color: #008000;\">");
-                                       } else {
-                                               htmlBuilder.append("<tr>");
-                                       }
-                                       if ((lastEpisode == null) || !lastEpisode.equals(episode)) {
-                                               htmlBuilder.append("<td>").append(episode.season()).append("</td><td>").append(episode.episode()).append("</td>");
-                                       } else {
-                                               htmlBuilder.append("<td colspan=\"2\"></td>");
-                                       }
-                                       htmlBuilder.append("<td class='filename'>").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append("</td>");
-                                       htmlBuilder.append("<td>").append(StringEscapeUtils.escapeHtml4(torrentFile.size())).append("</td>");
-                                       htmlBuilder.append("<td>").append(torrentFile.fileCount()).append("</td>");
-                                       htmlBuilder.append("<td>").append(torrentFile.seedCount()).append("</td>");
-                                       htmlBuilder.append("<td>").append(torrentFile.leechCount()).append("</td>");
-                                       htmlBuilder.append("<td><a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.magnetUri())).append("\">Link</a></td>");
-                                       htmlBuilder.append("<td><a href=\"").append(StringEscapeUtils.escapeHtml4(torrentFile.downloadUri())).append("\">Link</a></td>");
-                                       htmlBuilder.append("</tr>\n");
-                                       lastEpisode = episode;
-                               }
-                       }
-               }
-               htmlBuilder.append("</tbody>\n");
-               htmlBuilder.append("</table>\n");
-               htmlBuilder.append("</body></html>\n");
-               return htmlBuilder.toString();
+               var tagConsumer = createHTML(false, false);
+               return html(tagConsumer, null, wrapper(html -> {
+                       body(html, null, wrapper(body -> {
+                               table(body, null, wrapper(table -> {
+                                       caption(table, null, wrapper(caption -> caption.text("All Known Episodes")));
+                                       thead(table, null, wrapper(tableHead -> tr(tableHead, null, wrapper(row -> {
+                                               th(row, null, null, wrapper(th -> th.text("Season")));
+                                               th(row, null, null, wrapper(th -> th.text("Episode")));
+                                               th(row, null, null, wrapper(th -> th.text("Filename")));
+                                               th(row, null, null, wrapper(th -> th.text("Size")));
+                                               th(row, null, null, wrapper(th -> th.text("File(s)")));
+                                               th(row, null, null, wrapper(th -> th.text("Seeds")));
+                                               th(row, null, null, wrapper(th -> th.text("Leechers")));
+                                               th(row, null, null, wrapper(th -> th.text("Magnet")));
+                                               th(row, null, null, wrapper(th -> th.text("Download")));
+                                       }))));
+                                       tbody(table, null, wrapper(tableBody -> {
+                                               var lastEpisode = new AtomicReference<Episode>();
+                                               for (Map.Entry<Integer, List<Episode>> seasonEntry : episodes.stream().sorted(Comparator.<Episode>naturalOrder().reversed()).collect(groupingBy(Episode::season, LinkedHashMap::new, toList())).entrySet()) {
+                                                       for (Episode episode : seasonEntry.getValue()) {
+                                                               for (TorrentFile torrentFile : episode) {
+                                                                       tr(tableBody, null, wrapper(row -> {
+                                                                               if (newEpisodes.contains(episode)) {
+                                                                                       row.getAttributes().put("style", "color: #008000; font-weight: bold;");
+                                                                               } else if (newTorrentFiles.contains(torrentFile)) {
+                                                                                       row.getAttributes().put("style", "color: #008000;");
+                                                                               }
+                                                                               if ((lastEpisode.get() == null) || !lastEpisode.get().equals(episode)) {
+                                                                                       td(row, null, wrapper(cell -> cell.text(episode.season())));
+                                                                                       td(row, null, wrapper(cell -> cell.text(episode.episode())));
+                                                                               } else {
+                                                                                       td(row, null, wrapper(cell -> cell.getAttributes().put("colspan", "2")));
+                                                                               }
+                                                                               td(row, "filename", wrapper(cell -> cell.text(torrentFile.name())));
+                                                                               td(row, null, wrapper(cell -> cell.text(torrentFile.size())));
+                                                                               td(row, null, wrapper(cell -> cell.text(torrentFile.fileCount())));
+                                                                               td(row, null, wrapper(cell -> cell.text(torrentFile.seedCount())));
+                                                                               td(row, null, wrapper(cell -> cell.text(torrentFile.leechCount())));
+                                                                               td(row, null, wrapper(cell -> a(cell, torrentFile.magnetUri(), null, null, wrapper(link -> link.text("Link")))));
+                                                                               td(row, null, wrapper(cell -> a(cell, torrentFile.downloadUri(), null, null, wrapper(link -> link.text("Link")))));
+                                                                               lastEpisode.set(episode);
+                                                                       }));
+                                                               }
+                                                       }
+                                               }
+                                       }));
+                               }));
+                       }));
+               }));
        }
 
        //
@@ -244,6 +256,13 @@ public class EpisodeState extends AbstractState implements Iterable<Episode> {
                return String.format("%s[episodes=%s]", getClass().getSimpleName(), episodes);
        }
 
+       private static <T extends Tag> Function1<T, Unit> wrapper(Consumer<T> tagConsumer) {
+               return t -> {
+                       tagConsumer.accept(t);
+                       return null;
+               };
+       }
+
        /**
         * Stores attributes for an episode.
         *