Topic3Resource.java

/*
 * Copyright © 2023-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.catalogue;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

import org.ctan.site.CtanConfiguration.CtanConfig;
import org.ctan.site.domain.catalogue.PkgCaption;
import org.ctan.site.domain.catalogue.Topic;
import org.ctan.site.services.content.ContentService;
import org.ctan.site.services.content.ContentService.TeaserType;
import org.ctan.site.services.util.ConfigUtils;
import org.ctan.site.stores.TopicStore;
import org.ctan.site.stores.VoteStore;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.dropwizard.hibernate.UnitOfWork;
import jakarta.annotation.security.PermitAll;
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 jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response.Status;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;

/**
 * The class <code>Topic3Resource</code> contains the controller for the topic
 * resource.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Path("/3.0")
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public class Topic3Resource {

    /**
     * The class <code>TopicInfoTo</code> contains the transport object for
     * topic infos.
     */
    @Getter
    @Builder
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    protected static class TopicInfoTo {

        private String details;

        private boolean hasTeaser;

        private String key;

        private long size;

        private String title;
    }

    /**
     * The class <code>TopicPkgTo</code> contains the transport object for
     * packages.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    protected static class TopicPkgTo {

        private String caption;

        private String captionLang;

        private String key;

        private String name;

        private boolean isObsolete;

        private boolean isOrphaned;

        private String update;

        private int stars;

        private int count;
    }

    /**
     * The class <code>TopicSummaryTo</code> contains the transport object for
     * the topic resource in the summary list.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    @JsonInclude(Include.NON_NULL)
    protected static class TopicSummaryTo {

        private String description;

        private String detail;

        private String key;

        private String name;

        private Long number;
    }

    /**
     * The class <code>TopicTo</code> contains the transport object for the
     * topic resource.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    protected static class TopicTo {

        private String description;

        private String detail;

        private String key;

        private String name;

        private Long number;

        private List<TopicPkgTo> pkgs;

        private String teaser;

        private boolean hasTeaser;

        private String title;
    }

    /**
     * The field <code>config</code> contains the configuration.
     */
    private CtanConfig config;

    /**
     * The field <code>contentService</code> contains the content service.
     */
    private @NonNull ContentService contentService;

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

    /**
     * The field <code>topics</code> contains the list of topics.
     */
    private List<Topic> topics = null;

    /**
     * The field <code>voteStore</code> contains the vote store.
     */
    private VoteStore voteStore;

    /**
     * This is the constructor for the class <code>Topic3Resource</code>.
     *
     * @param config the configuration
     * @param store the underlying store
     * @param contentService the content service
     */
    @SuppressFBWarnings(value = {"CT_CONSTRUCTOR_THROW", "EI_EXPOSE_REP2"})
    public Topic3Resource(@NonNull CtanConfig config,
        @NonNull TopicStore store,
        @NonNull ContentService contentService,
        @NonNull VoteStore voteStore) {

        this.config = config;
        this.store = store;
        this.contentService = contentService;
        this.voteStore = voteStore;
    }

    /**
     * The method <code>getRandomTopics</code> provides means to retrieve a
     * random list of topics.
     *
     * @return the configuration
     */
    @GET
    @Path("/rnd/topics")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public List<TopicInfoTo> getRandomTopics(
        @QueryParam("size") @DefaultValue("3") int count,
        @QueryParam("lang") @DefaultValue("en") String lang) {

        if (topics == null) {
            // TODO update from time to time
            topics = store.findAll();
            Collections.shuffle(topics);
        }
        if (Math.random() < .01) {
            Collections.shuffle(topics);
        }
        List<TopicInfoTo> result = new ArrayList<TopicInfoTo>();
        var n = 1;
        for (var it : topics) {
            result.add(TopicInfoTo.builder()
                .key(it.getKey())
                .title(it.getTitle(lang))
                .details(it.getDetails(lang))
                .hasTeaser(contentService.hasTeaser(TeaserType.TOPIC, lang))
                .size(it.getNumber())
                .build());
            if (n++ >= count) {
                break;
            }
        }
        return result;
    }

    /**
     * The method <code>getTopic</code> provides means to retrieve a topic by
     * its id.
     *
     * @param id the id
     * @param lang the language
     * @return the topic or {@code null}
     */
    @GET
    @Path("/topic/{id}")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public TopicTo getTopicByKey(@NonNull @PathParam("id") String id,
        @QueryParam("lang") String lang) {

        try {
            lang = ConfigUtils.fallbackLanguage(config, lang);
        } catch (IllegalArgumentException e) {
            throw new WebApplicationException(e.getMessage(),
                Status.BAD_REQUEST);
        }
        var it = store.getByKey(id);
        if (it == null) {
            throw new WebApplicationException(Status.NOT_FOUND);
        }
        final var loc = lang;
        return TopicTo.builder()
            .key(it.getKey())
            .name(it.getTitle(lang))
            .detail(it.getDetails(lang))
            .hasTeaser(contentService.hasTeaser(TeaserType.TOPIC, it.getKey()))
            .teaser(it.getTeaser(lang))
            .title(it.getTitle(lang))
            .description(it.getDescription(lang))
            .number(it.getNumber())
            .pkgs(it.getPackages().stream()
                .map(p -> {
                    var stars = voteStore.count(p.getKey());
                    PkgCaption caption = p.getCaption(loc);
                    return TopicPkgTo.builder()
                        .key(p.getKey())
                        .name(p.getName())
                        .caption(caption == null ? "" : caption.getCaption())
                        .captionLang(caption == null ? "" : caption.getLang())
                        .isObsolete(p.isObsolete())
                        .isOrphaned(p.isOrphaned())
                        .update(p.getVersionDate())
                        .stars(stars.getSum())
                        .count(stars.getCount())
                        .build();
                })
                .collect(Collectors.toList()))
            .build();
    }

    /**
     * The method <code>getTopics</code> provides means to retrieve a list of
     * authors starting with a given pattern.
     *
     * @param pattern the initial string of the name
     * @param lang the language code
     * @param page the page
     * @param size the page size
     * @return a list of matching author summaries
     */
    @GET
    @Path("/topics/{pattern}")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public List<TopicSummaryTo> getTopics(
        @PathParam("pattern") String pattern,
        @QueryParam("lang") String lang,
        @DefaultValue("0") @QueryParam("page") long page,
        @DefaultValue("16") @QueryParam("size") long size) {

        try {
            lang = ConfigUtils.fallbackLanguage(config, lang);
        } catch (IllegalArgumentException e) {
            throw new WebApplicationException(e.getMessage(),
                Status.BAD_REQUEST);
        }
        final var language = lang;
        if (size < 0L) {
            throw new WebApplicationException("negative size",
                Status.BAD_REQUEST);
        }
        final var p = pattern == null ? "" : pattern.toLowerCase();
        var stream = store.findAll()
            .stream();
        if (!"".equals(p)) {
            stream = stream
                .filter(a -> a.getTitle(language).startsWith(p));
        }
        return stream
            .sorted((a, b) -> a.getTitle(language)
                .compareToIgnoreCase(b.getTitle(language)))
            .skip(page * size)
            .limit(size)
            .map(a -> {
                return TopicSummaryTo.builder()
                    .key(a.getKey())
                    .name(a.getTitle(language))
                    .detail(a.getDetails(language))
                    .description(a.getDescription(language))
                    .number(a.getNumber())
                    .build();
            })
            .collect(Collectors.toList());
    }

    /**
     * The method <code>getTopics0</code> provides means to retrieve a list of
     * topics starting with a given pattern.
     *
     * @param lang the language code
     * @return a list of matching topics
     */
    @GET
    @Path("/topics")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public List<TopicSummaryTo> getTopics0(
        @QueryParam("lang") String lang) {

        try {
            lang = ConfigUtils.fallbackLanguage(config, lang);
        } catch (IllegalArgumentException e) {
            throw new WebApplicationException(e.getMessage(),
                Status.BAD_REQUEST);
        }
        final var language = lang;
        var stream = store.findAll()
            .stream();
        return stream
            .sorted((a, b) -> a.getTitle(language)
                .compareToIgnoreCase(b.getTitle(language)))
            .map(a -> {
                return TopicSummaryTo.builder()
                    .key(a.getKey())
                    .name(a.getTitle(language))
                    .detail(a.getDetails(language))
                    .description(a.getDescription(language))
                    .number(a.getNumber())
                    .build();
            })
            .collect(Collectors.toList());
    }

    /**
     * The method <code>getTopicsList</code> provides means to retrieve a list
     * of topics starting with a given pattern.
     *
     * @param lang the language code
     * @return a list of matching topics
     */
    @GET
    @Path("/topic/list")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public List<TopicSummaryTo> getTopicsList(
        @QueryParam("lang") String lang) {

        try {
            lang = ConfigUtils.fallbackLanguage(config, lang);
        } catch (IllegalArgumentException e) {
            throw new WebApplicationException(e.getMessage(),
                Status.BAD_REQUEST);
        }
        final var language = lang;
        var stream = store.findAll()
            .stream();
        return stream
            .sorted((a, b) -> a.getTitle(language)
                .compareToIgnoreCase(b.getTitle(language)))
            .map(a -> {
                return TopicSummaryTo.builder()
                    .key(a.getKey())
                    .name(a.getTitle(language))
                    .build();
            })
            .collect(Collectors.toList());
    }
}