Rss20Resource.java

/*
 * Copyright © 2025 The CTAN Team and individual authors
 *
 * This file is distributed under the 3-clause BSD license.
 * See file LICENSE for details.
 */
package org.ctan.site.resources.postings;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

import org.ctan.site.CtanConfiguration;
import org.ctan.site.services.DateUtils;
import org.ctan.site.services.postings.PostingCache;

import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import lombok.Builder;
import lombok.Builder.Default;
import lombok.Getter;
import lombok.NonNull;

/**
 * The class <code>Rss20Resource</code> contains the controller for the RSS feed
 * resource.
 *
 * @see <a href="https://www.rssboard.org/rss-specification">RSS 2.0
 *     Specification</a>
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Path("/ctan-ann")
@Produces("application/rss+xml")
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
public class Rss20Resource {

    /**
     * The class <code>Rss</code> contains the top node of RSS 2.0.
     */
    @Getter
    @Builder
    @JacksonXmlRootElement(localName = "rss")
    public static class Rss {

        private RssChannel channel;

        @Default
        @JacksonXmlProperty(isAttribute = true)
        private String version = "2.0";
    }

    /**
     * The class <code>RssChannel</code> contains the channel node of RSS 2.0.
     */
    @Getter
    @Builder
    public static class RssChannel {

        private String title;

        private String link;

        private String description;

        private String pubDate;

        private String language;

        private String ttl;

        private String webMaster;

        @Default
        private String generator = "CTAN site 3.0.0";

        private RssImage image;

        @JacksonXmlProperty(localName = "item")
        @JacksonXmlElementWrapper(useWrapping = false)
        private List<RssItem> items;
    }

    /**
     * The class <code>RssGuid</code> contains the guid node of RSS 2.0.
     */
    @Getter
    @Builder
    public static class RssGuid {

        @JacksonXmlText
        private String value;

        @Default
        @JacksonXmlProperty(isAttribute = true)
        private String isPermaLink = "true";
    }

    /**
     * The class <code>RssImage</code> contains the image node of RSS 2.0.
     */
    @Getter
    @Builder
    public static class RssImage {

        private String title;

        private String url;

        private String link;
    }

    /**
     * The class <code>RssItem</code> contains the item node of RSS 2.0.
     */
    @Getter
    @Builder
    public static class RssItem {

        private String title;

        private String link;

        private String description;

        private String pubDate;

        private RssGuid guid;
    }

    /**
     * The field <code>URL_PREFIX</code> contains the initial part of the URL.
     */
    private static final String URL_PREFIX = "https://ctan.org/ctan-ann/rss";

    /**
     * The field <code>store</code> contains the underlying repository.
     */
    private PostingCache service;

    /**
     * The field <code>version</code> contains the current version number.
     */
    private String version;

    /**
     * This is the constructor for the class <code>RssResource</code>.
     *
     * @param service the underlying service
     * @param config the configuration
     */
    @SuppressFBWarnings(value = {"CT_CONSTRUCTOR_THROW", "EI_EXPOSE_REP2"})
    public Rss20Resource(@NonNull PostingCache service,
        @NonNull CtanConfiguration config) {

        this.service = service;
        this.version = config.getVersion();
    }

    /**
     * The method <code>clip</code> provides means to restrict the length of a
     * string.
     *
     * @param body the text
     * @param len the length
     * @return the clipped text
     */
    String clip(String body, int len) {

        if (body == null) {
            return null;
        }
        if (body.length() > len) {
            var i = body.indexOf('.', len);
            if (i >= 0) {
                body = body.substring(0, i) + "...";
            }
        }
        return body;
    }

    /**
     * The method <code>getRss20</code> provides means to retrieve the RSS 2.0
     * of the latest postings.
     *
     * @param length the maximal number of items returned
     * @return the RSS feed
     */
    @GET
    @Path("/rss")
    public Rss getRss20(
        @QueryParam("length") @DefaultValue("64") int length) {

        List<RssItem> items = new ArrayList<>();
        for (var it : service.listNewest(length)) {
            items.add(RssItem.builder()
                .title(it.getSubject())
                .link(URL_PREFIX + "/id/" + it.getId())
                .guid(RssGuid.builder()
                    .value(URL_PREFIX + "/id/" + it.getId())
                    .build())
                .pubDate(DateUtils.formatFullDateTime(it.getDate()))
                .description(clip(it.getBody(), 150))
                .build());
        }
        return Rss.builder()
            .channel(RssChannel.builder()
                .title("ctan-ann")
                .link(URL_PREFIX)
                .description("CTAN announcements mailing list - recent")
                .pubDate(DateUtils.formatFullDateTime(Instant.now()))
                .language("en-US")
                .ttl("60")
                .webMaster(
                    "webmaster@ctan.org (Comprehensive TeX Archive Network)")
                .generator("CTAN site " + version)
                .image(RssImage.builder()
                    .title("Comprehensive TeX Archive Network")
                    .link(URL_PREFIX)
                    .url(
                        "https://ctan.org/assets/favicon/android-chrome-48x48.png")
                    .build())
                .items(items)
                .build())
            .build();
    }

    /**
     * The method <code>getRss20ByPk</code> provides means to retrieve the RSS
     * 2.0 of the latest postings.
     *
     * @param pkg the CTAN name of the package
     * @param length the maximal number of items returned
     * @return the RSS feed
     */
    @GET
    @Path("/rss/{pkg}.xml")
    public Rss getRss20ByPkg(
        @NonNull @PathParam("pkg") String pkg,
        @QueryParam("length") @DefaultValue("64") int length) {

        List<RssItem> items = new ArrayList<>();
        for (var it : service.listNewest(pkg, length)) {
            items.add(RssItem.builder()
                .title(it.getSubject())
                .link(URL_PREFIX + "/id/" + it.getId())
                .guid(RssGuid.builder()
                    .value(URL_PREFIX + "/id/" + it.getId())
                    .build())
                .pubDate(DateUtils.formatFullDateTime(it.getDate()))
                .description(clip(it.getBody(), 150))
                .build());
        }
        return Rss.builder()
            .channel(RssChannel.builder()
                .title("ctan-ann - " + pkg)
                .link(URL_PREFIX + "/" + pkg + ".xml")
                .description(
                    "CTAN announcements mailing list - for package " + pkg)
                .pubDate(DateUtils.formatFullDateTime(Instant.now()))
                .language("en-US")
                .ttl("60")
                .webMaster(
                    "webmaster@ctan.org (Comprehensive TeX Archive Network)")
                .generator("CTAN site " + version)
                .image(RssImage.builder()
                    .title("Comprehensive TeX Archive Network")
                    .link(URL_PREFIX)
                    .url(
                        "https://ctan.org/assets/favicon/android-chrome-48x48.png")
                    .build())
                .items(items)
                .build())
            .build();
    }
}