Pkg3Resource.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.Pkg;
import org.ctan.site.domain.catalogue.PkgCaption;
import org.ctan.site.domain.catalogue.PkgDescription;
import org.ctan.site.services.catalogue.BibtexService;
import org.ctan.site.services.content.ContentService;
import org.ctan.site.services.content.ContentService.TeaserType;
import org.ctan.site.services.postings.PostingsService;
import org.ctan.site.services.util.ConfigUtils;
import org.ctan.site.stores.PkgAliasStore;
import org.ctan.site.stores.PkgStore;

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.Builder.Default;
import lombok.Getter;
import lombok.NonNull;

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

    /**
     * The author summary.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    protected static class AuthorSummaryTo {

        private String key;

        private String name;

        private String gender;

        @Default
        private Boolean died = Boolean.FALSE;

        @Default
        private Boolean active = Boolean.TRUE;
    }

    /**
     * The document summary.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    protected static class DocSummaryTo {

        private String details;

        private String href;

        private String lang;

        private String title;
    }

    /**
     * The license summary.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    protected static class LicenseSummaryTo {

        private String key;

        private String name;

        private Boolean free;
    }

    /**
     * The class <code>PkgCtanTo</code> contains the package CTAN path transport
     * object.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    protected static class PkgCtanTo {

        private String path;

        private boolean file;
    }

    /**
     * The class <code>PkgInfoTo</code> contains the package info transport
     * object.
     */
    @Getter
    @Builder
    protected static class PkgInfoTo {

        private String pkg;

        private String title;

        private boolean hasTeaser;

        private boolean isObsolete;

        private boolean isOrphaned;
    }

    /**
     * The class <code>PkgSummaryTo</code> contains the transport object for the
     * package resource in the summary list.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    protected static class PkgSummaryTo {

        private String key;

        private String name;

        private String caption;

        private String captionLang;

        private String description;

        private String descriptionLang;

        private boolean obsolete;

        private boolean orphaned;
    }

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

        private Long id;

        private String name;

        private String key;

        private String vers;

        private String caption;

        private String captionLang;

        private Boolean download;

        private String description;

        private String descriptionLang;

        private List<AuthorSummaryTo> authors;

        private String source;

        private List<String> copyright;

        private List<LicenseSummaryTo> licenses;

        private String home;

        private String bugs;

        private String support;

        private String repository;

        private String announce;

        private String development;

        private PkgCtanTo ctan;

        private Boolean hasTeaser;

        private List<TopicSummaryTo> topics;

        private List<DocSummaryTo> docs;

        private String miktexLocation;

        private String texliveLocation;

        private String tlcontribLocation;

        private String bibtex;

        private List<PostingsSummaryTo> postings;

        private String alias;
    }

    /**
     * The postings summary.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    @JsonInclude(Include.NON_NULL)
    protected static class PostingsSummaryTo {

        private String key;

        private String subject;

        private String date;
    }

    /**
     * The topic summary.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    protected static class TopicSummaryTo {

        private String key;

        private String name;
    }

    /**
     * The field <code>service</code> contains the underlying service.
     */
    private BibtexService bibtexService;

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

    /**
     * The field <code>aliasStore</code> contains the underlying repository for
     * aliases.
     */
    private PkgAliasStore aliasStore;

    /**
     * The field <code>defaultLanguage</code> contains the fallback value for
     * the language if nothing else fits.
     */
    private String defaultLanguage;

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

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

    /**
     * The field <code>postingService</code> contains the posting cache.
     */
    private PostingsService postingService;

    /**
     * The field <code>randomPackageCache</code> contains a list of random
     * packages.
     */
    private List<Pkg> randomPackageCache = null;

    /**
     * This is the constructor for <code>Pkg3Resource</code>.
     *
     * @param config the configuration
     * @param store the underlying store
     * @param contentService the content service
     * @param bibtexService the BibTeX service
     * @param postingService the posting cache
     * @param aliasStore the store for package aliases
     */
    @SuppressFBWarnings(value = {"CT_CONSTRUCTOR_THROW", "EI_EXPOSE_REP2"})
    public Pkg3Resource(@NonNull CtanConfig config,
        @NonNull PkgStore store,
        @NonNull ContentService contentService,
        @NonNull BibtexService bibtexService,
        @NonNull PostingsService postingService,
        @NonNull PkgAliasStore aliasStore) {

        this.config = config;
        this.store = store;
        this.contentService = contentService;
        this.defaultLanguage = ConfigUtils.defaultLanguage(config);
        this.bibtexService = bibtexService;
        this.postingService = postingService;
        this.aliasStore = aliasStore;
    }

    /**
     * The method <code>captionOrNull</code> provides means to check whether the
     * caption is present.
     *
     * @param pkg the package
     * @param locale the locale
     * @return the caption or <code>null</code>
     */
    private String captionOrNull(Pkg pkg, String locale) {

        PkgCaption caption = pkg.getCaption(locale, "en");
        return caption == null ? null : caption.getCaption();
    }

    /**
     * The method <code>descriptionOrNull</code> provides means to check whether
     * the description is present.
     *
     * @param pkg the package
     * @param locale the locale
     * @return the description or <code>null</code>
     */
    private String descriptionOrNull(Pkg pkg, String locale) {

        PkgDescription description = pkg.getDescription(locale, "en");
        return description == null ? null : description.getDescription();
    }

    /**
     * The method <code>getPkgAlias</code> provides means to retrieve an alias.
     *
     * @param key the key
     * @return the transport object with the alias
     * @throws WebApplicationException in case that the key is not an alias
     */
    private PkgTo getPkgAlias(String key) {

        var alias = aliasStore.getByKey(key);
        if (alias == null) {
            throw new WebApplicationException(Status.NOT_FOUND);
        }
        return PkgTo.builder()
            .key(alias.getKey())
            .alias(alias.getPkg().getKey())
            .build();
    }

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

        try {
            language = ConfigUtils.fallbackLanguage(config, language);
        } catch (IllegalArgumentException e) {
            throw new WebApplicationException(e.getMessage(),
                Status.BAD_REQUEST);
        }
        final var lang = language;
        var pkg = store.getByKey(key);
        if (pkg == null) {
            return getPkgAlias(key);
            // throw new WebApplicationException(Status.NOT_FOUND);
        }
        var topics = pkg.getTopics().stream()
            .map(t -> TopicSummaryTo.builder()
                .key(t.getKey())
                .name(t.getTitle(lang))
                .build())
            .collect(Collectors.toList());
        var docs = pkg.getDocs().stream()
            .map(d -> DocSummaryTo.builder()
                .href(d.getHref())
                .lang(d.getLang())
                .details(d.getDetails())
                .title(d.getTitle())
                .build())
            .collect(Collectors.toList());
        var licenses = pkg.getLicenses().stream()
            .map(d -> LicenseSummaryTo.builder()
                .key(d.getKey())
                .name(d.getName())
                .build())
            .collect(Collectors.toList());
        var authors = pkg.getAuthors().stream()
            .map((a) -> {
                var author = a.getAuthor();
                return AuthorSummaryTo.builder()
                    .key(author.getKey())
                    .name(author.toString())
                    .gender(author.getGender().getValue())
                    .died(author.getDied())
                    .active(a.isActive())
                    .build();
            })
            .collect(Collectors.toList());
        var copyright = pkg.getCopy().stream()
            .map(c -> c.getYear() + " " + c.getOwner())
            .collect(Collectors.toList());
        List<PostingsSummaryTo> postings = postingService
            .listByPackage(key, 255)
            .stream()
            .map(it -> PostingsSummaryTo.builder()
                .key(it.getId())
                .subject(it.getSubject())
                .date(it.getDateFormatted())
                .build())
            .collect(Collectors.toList());
        PkgCaption caption = pkg.getCaption(lang, defaultLanguage);
        PkgDescription description = pkg.getDescription(lang, defaultLanguage);
        return PkgTo.builder()
            .key(pkg.getKey())
            .name(pkg.getName())
            .caption(caption == null ? null : caption.getCaption())
            .captionLang(caption == null ? null : caption.getLang())
            .vers(pkg.getVers())
            .download(pkg.isCtanZip())
            .description(
                description == null ? null : description.getDescription())
            .descriptionLang(description == null ? null : description.getLang())
            .authors(authors)
            .copyright(copyright)
            .licenses(licenses)
            .home(pkg.getHome())
            .bugs(pkg.getBugs())
            .support(pkg.getSupport())
            .repository(pkg.getRepository())
            .announce(pkg.getAnnounce())
            .development(pkg.getDevelopment())
            .hasTeaser(contentService.hasTeaser(TeaserType.PKG, key))
            .ctan(PkgCtanTo.builder()
                .path(pkg.getCtanPath())
                .file(pkg.isCtanFile())
                .build())
            .topics(topics)
            .docs(docs)
            .miktexLocation(pkg.getMiktexLocation())
            .texliveLocation(pkg.getTexliveLocation())
            .tlcontribLocation(pkg.getTlContribLocation())
            .bibtex(bibtexService.toBibtex(null, pkg))
            .postings(postings)
            .build();
    }

    /**
     * The method <code>getPkgs</code> provides means to retrieve a list of all
     * packages.
     *
     * @param lang the language code
     * @return a list of matching package summaries
     */
    @GET
    @Path("/pkgs")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public List<PkgSummaryTo> getPkgs(
        @QueryParam("lang") String lang) {

        return getPkgs(null, lang);
    }

    /**
     * The method <code>getPkgs</code> provides means to retrieve a list of
     * packages starting with a given pattern.
     *
     * @param pattern the initial string of the key
     * @param lang the language code
     * @return a list of matching package summaries
     */
    @GET
    @Path("/pkgs/{pattern}")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public List<PkgSummaryTo> getPkgs(
        @PathParam("pattern") String pattern,
        @QueryParam("lang") String lang) {

        try {
            lang = ConfigUtils.fallbackLanguage(config, lang);
        } catch (IllegalArgumentException e) {
            throw new WebApplicationException(e.getMessage(),
                Status.BAD_REQUEST);
        }
        final var locale = lang;
        return store.findAllByNameStartingWith(pattern != null ? pattern : "")
            .stream()
            .map(pkg -> {
                return PkgSummaryTo.builder()
                    .name(pkg.getName())
                    .key(pkg.getKey())
                    .caption(captionOrNull(pkg, locale))
                    .description(descriptionOrNull(pkg, locale))
                    .obsolete(pkg.getCtanPath() != null
                        && pkg.getCtanPath().contains("obsolete"))
                    .orphaned(pkg.isOrphaned())
                    .build();
            })
            .collect(Collectors.toList());
    }

    /**
     * The method <code>getRandomPackages</code> provides means to retrieve a
     * random list of packages.
     *
     * @param count the number of packages
     * @param lang the locale
     * @param obsolete the indicator whether to include obsolete packages
     * @param orphaned the indicator whether to include orphaned packages
     * @return the configuration
     */
    @GET
    @Path("/rnd/pkgs")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public List<PkgInfoTo> getRandomPackages(
        @QueryParam("size") @DefaultValue("3") int count,
        @QueryParam("lang") @DefaultValue("en") String lang,
        @QueryParam("obsolete") @DefaultValue("false") boolean obsolete,
        @QueryParam("orphaned") @DefaultValue("true") boolean orphaned) {

        if (randomPackageCache == null) {
            randomPackageCache = store.findAll();
            Collections.shuffle(randomPackageCache);
        } else if (Math.random() < .01) {
            Collections.shuffle(randomPackageCache);
        }
        List<PkgInfoTo> result = new ArrayList<PkgInfoTo>();
        var n = 0;
        for (var it : randomPackageCache) {
            if (!obsolete && it.isObsolete()) {
                continue;
            }
            if (!orphaned && it.isOrphaned()) {
                continue;
            }
            result.add(PkgInfoTo.builder()
                .pkg(it.getKey())
                .title(captionOrNull(it, lang))
                .hasTeaser(contentService.hasTeaser(TeaserType.PKG, lang))
                .isObsolete(obsolete)
                .isOrphaned(it.isOrphaned())
                .build());
            if (++n >= count) {
                break;
            }
        }
        return result;
    }
}