Pkg.java

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

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.apache.lucene.index.CorruptIndexException;
import org.ctan.site.domain.AbstractEntity;
import org.ctan.site.services.search.base.IndexType;
import org.ctan.site.services.search.base.IndexingSession;
import org.ctan.site.services.search.base.IndexingSession.IndexArgs;
import org.ctan.site.services.search.base.Searchable;
import org.ctan.site.services.util.NullCheck;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Builder.Default;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

/**
 * The domain class <code>Pkg</code> contains the description of a package in
 * the Catalogue.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Entity
@Data
@EqualsAndHashCode(callSuper = false)
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder // (toBuilder = true)
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
public class Pkg extends AbstractEntity implements Searchable {

    /**
     * The class <code>PkgTexts</code> contains the localised texts.
     */
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @Embeddable
    public static class PkgTexts {

        /**
         * The field <code>caption</code> contains the caption text.
         */
        @Column(length = 512, nullable = false)
        private String caption;

        /**
         * The field <code>description</code> contains the description in the
         * language.
         */
        @Column(length = 32768, nullable = false)
        private String description;
    }

    /**
     * The field <code>key</code> contains the unique reference key for the
     * package.
     *
     * <p>
     * By convention the key starts with a lower-case letter followed by
     * lower-case letters, digits or the minus sign.
     */
    @Column(length = 64, unique = true, nullable = false)
    private String key;

    /**
     * The field <code>name</code> contains the printable name of the package.
     * As a fallback the key should be used.
     */
    @Column(length = 64, nullable = false)
    private String name;

    /**
     * The field <code>home</code> contains the optional link for the home page
     * of the package.
     */
    @Column(length = 255)
    private String home;

    /**
     * The field <code>bugs</code> contains the optional link for the bugs
     * address.
     */
    @Column(length = 255)
    private String bugs;

    /**
     * The field <code>support</code> contains the optional link for the support
     * address.
     */
    @Column(length = 255)
    private String support;

    /**
     * The field <code>repository</code> contains the optional link to the
     * source code repository.
     */
    @Column(length = 255)
    private String repository;

    /**
     * The field <code>announce</code> contains the optional link to the
     * announcement mailing list.
     */
    @Column(length = 255)
    private String announce;

    /**
     * The field <code>development</code> contains the optional link to the
     * developer's mailing list.
     */
    @Column(length = 255)
    private String development;

    /**
     * The field <code>versionNumber</code> contains the version number.
     */
    @Column(length = 32, name = "version_number")
    private String versionNumber;

    /**
     * The field <code>versionDate</code> contains the version date.
     */
    @Column(length = 32, name = "version_date")
    private String versionDate;

    /**
     * The field <code>ctanPath</code> contains the location in the CTAN archive
     * of the package.
     */
    @Column(length = 256, name = "ctan_path")
    @Default
    private String ctanPath = null;

    /**
     * The field <code>ctanFile</code> contains the indicator that the package
     * consists of a single file only.
     *
     * <p>
     * This practice is deprecated and supported for backward-compatibility
     * only.
     */
    @Column(name = "ctan_file")
    @Default
    @EqualsAndHashCode.Exclude
    private boolean ctanFile = false;

    /**
     * The field <code>ctanZip</code> contains the indicator that a zip file is
     * present.
     */
    @Column(name = "ctan_zip")
    @Default
    @EqualsAndHashCode.Exclude
    private boolean ctanZip = false;

    /**
     * The field <code>miktexLocation</code> contains key of the package in the
     * MiK<span>T<span style=
     * "text-transform:uppercase;font-size:90%;vertical-align:-0.4ex;
     * margin-left:-0.2em;margin-right:-0.1em;line-height: 0;" >e</span>
     * X</span> distribution.
     */
    @Column(length = 64, name = "miktex_location")
    @Default
    private String miktexLocation = null;

    /**
     * The field <code>texliveLocation</code> contains key of the package in the
     * TeXlive distribution.
     */
    @Column(length = 64, name = "texlive_location")
    @Default
    private String texliveLocation = null;

    /**
     * The field <code>tlContribLocation</code> contains key of the package in
     * the distribution TeXlive contrib.
     */
    @Column(length = 64, name = "tl_contrib_location")
    @Default
    private String tlContribLocation = null;

    /**
     * The field <code>extraIndex</code> contains extra words to be used for
     * indexing.
     */
    @Column(length = 4196, name = "extra_index")
    @EqualsAndHashCode.Exclude
    private String extraIndex;

    /**
     * The field <code>extraIndexEn</code> contains extra words to be used for
     * indexing in English.
     */
    @Column(length = 4196, name = "extra_index_en")
    @EqualsAndHashCode.Exclude
    private String extraIndexEn;

    /**
     * The field <code>extraIndexEn</code> contains extra words to be used for
     * indexing in English.
     */
    @Column(length = 4196, name = "extra_index_de")
    @EqualsAndHashCode.Exclude
    private String extraIndexDe;

    /**
     * The field <code>installPath</code> contains the install path.
     */
    @Column(length = 4196, name = "install_path")
    @Default
    @EqualsAndHashCode.Exclude
    private String installPath = null;

    /**
     * The field <code>authors</code> contains the references to the authors.
     */
    @OneToMany(mappedBy = "pkg", cascade = CascadeType.ALL,
        orphanRemoval = true)
    @Default
    private List<AuthorRef> authors = new ArrayList();

    /**
     * The field <code>uploaders</code> contains the references to the
     * uploaders.
     */
    @OneToMany(mappedBy = "pkg", cascade = CascadeType.ALL,
        orphanRemoval = true)
    @Default
    @EqualsAndHashCode.Exclude
    private List<UploaderRef> uploaders = new ArrayList();
    // /**
    // * The field <code>tdsPath</code> contains the TDS path.
    // */
    // @Default
    // private String tdsPath = null;
    // static transients = ['tdsPath']

    /**
     * The field <code>textsEn</code> contains the container for English texts.
     */
    @Embedded
    @Default
    @AttributeOverrides({
        @AttributeOverride(name = "description",
            column = @Column(name = "en_description")),
        @AttributeOverride(name = "caption",
            column = @Column(name = "en_caption"))
    })
    private PkgTexts textsEn = new PkgTexts();

    /**
     * The field <code>textsDe</code> contains the container for German texts.
     */
    @Embedded
    @Default
    @AttributeOverrides({
        @AttributeOverride(name = "description",
            column = @Column(name = "de_description")),
        @AttributeOverride(name = "caption",
            column = @Column(name = "de_caption"))
    })
    private PkgTexts textsDe = new PkgTexts();

    /**
     * The field <code>copy</code> contains the copyright infos. There might be
     * several of them. For instance when the authors have changed over time.
     */
    @OneToMany(mappedBy = "pkg", cascade = CascadeType.ALL,
        orphanRemoval = true)
    @Default
    @EqualsAndHashCode.Exclude
    private List<PkgCopyright> copy = new ArrayList();

    /**
     * The field <code>docs</code> contains the list of documents associated
     * with the packages.
     */
    @OneToMany(mappedBy = "pkg", cascade = CascadeType.ALL,
        orphanRemoval = true)
    @Default
    @EqualsAndHashCode.Exclude
    private List<PkgDoc> docs = new ArrayList();

    /**
     * The field <code>topics</code> contains the set of associated topics.
     */
    @ManyToMany
    @JoinTable(name = "pkg_topic", //
        joinColumns = {@JoinColumn(name = "pkg_topics_id")}, //
        inverseJoinColumns = {@JoinColumn(name = "topic_id")})
    @Default
    @EqualsAndHashCode.Exclude
    private List<Topic> topics = new ArrayList<>();

    /**
     * The field <code>also</code> contains the associated references to other
     * packages.
     */
    @ManyToMany // (mappedBy = "pkg_also_id", cascade = CascadeType.ALL)
    @JoinTable(name = "pkg_pkg", //
        joinColumns = {@JoinColumn(name = "pkg_also_id")}, //
        inverseJoinColumns = {@JoinColumn(name = "pkg_id")})
    @Default
    @EqualsAndHashCode.Exclude
    private List<Pkg> also = new ArrayList();

    /**
     * The field <code>licenses</code> contains the set of associated licenses.
     */
    @ManyToMany
    @JoinTable(name = "pkg_license", //
        joinColumns = {@JoinColumn(name = "pkg_licenses_id")}, //
        inverseJoinColumns = {@JoinColumn(name = "license_id")})
    @Default
    @EqualsAndHashCode.Exclude
    private List<License> licenses = new ArrayList<>();

    /**
     * The field <code>aliases</code> contains the set of associated aliases.
     */
    @OneToMany(mappedBy = "pkg")
    // (mappedBy = "pkg_alias_id", cascade = CascadeType.ALL)
    // @JoinTable(name = "pkg_pkg")
    @Default
    @EqualsAndHashCode.Exclude
    private List<PkgAlias> aliases = new ArrayList<>();

    /**
     * The method <code>getCaption</code> provides means to retrieve the caption
     * text for the given language.
     *
     * @param locale the current language
     * @return the caption or <code>null</code>
     */
    public String getCaption(String locale) {

        return switch (locale) {
            case null -> textsEn == null ? null : textsEn.caption;
            case "en" -> textsEn == null ? null : textsEn.caption;
            case "de" -> textsDe == null ? null : textsDe.caption;
            default -> null;
        };
    }

    /**
     * The method <code>getCaption</code> provides means to retrieve the caption
     * text for the given language or the fallback language.
     *
     * @param locale the current language
     * @param fallback the fallback language
     * @return the caption or <code>null</code>
     */
    public String getCaption(String locale, String fallback) {

        String s = getCaption(locale);
        return s != null ? s : getCaption(fallback);
    }

    /**
     * The method <code>getDescription</code> provides means to retrieve the
     * description text for the given language.
     *
     * @param locale the current language
     * @return the description or <code>null</code>
     */
    public String getDescription(String locale) {

        return switch (locale) {
            case null -> textsEn == null ? null : textsEn.description;
            case "en" -> textsEn == null ? null : textsEn.description;
            case "de" -> textsDe == null ? null : textsDe.description;
            default -> null;
        };
    }

    /**
     * The method <code>getDescription</code> provides means to retrieve the
     * description text for the given language or the fallback language.
     *
     * @param locale the current language
     * @param fallback the fallback language
     * @return the description or <code>null</code>
     */
    public String getDescription(String locale, String fallback) {

        String s = getDescription(locale);
        return s != null ? s : getDescription(fallback);
    }

    /**
     * The method <code>getVers</code> provides means to retrieve the combined
     * fields versionNumber and versionDate. The fields are combined with a
     * separating space if both are defined. Otherwise only one field is
     * returned.
     *
     * @return the joined fields
     */
    public String getVers() {

        if (isEmpty(versionNumber)) {
            return versionDate;
        } else if (isEmpty(versionDate)) {
            return versionNumber;
        } else {
            return versionNumber + " " + versionDate;
        }
    }

    /**
     * {@inheritDoc}
     *
     * @see org.ctan.site.services.search.base.Searchable#indexPath()
     */
    @Override
    public String indexPath() {

        return "/pkg/" + key;
    }

    /**
     * The method <code>isObsolete</code> provides means to retrieve the
     * information about the obsolete state.
     *
     * @return {@code true} iff the package is obsolete
     */
    public boolean isObsolete() {

        return ctanPath == null || ctanPath.startsWith("/obsolete");
    }

    /**
     * The method <code>isOrphanted</code> provides means to determine whether
     * the package is orphaned. This is the case if there is no author which is
     * marked as active and who is not marked as dead.
     *
     * @return the indicator for orphaned packages
     */
    public boolean isOrphaned() {

        for (var it : authors) {
            if (it.isActive() && !it.getAuthor().getDied()) {
                return false;
            }
        }
        return true;
    }

    /**
     * The method <code>toMap</code> provides means to translate the instance
     * into a Map.
     *
     * @return the key-value map for the author
     */
    public Map<String, Object> toMap() {

        Map<String, Object> map = new HashMap<>();
        map.put("key", key);
        map.put("name", name);
        map.put("home", home);
        map.put("bugs", bugs);
        map.put("support", support);
        map.put("repository", repository);
        map.put("announce", announce);
        map.put("development", development);
        map.put("versionNumber", versionNumber);
        map.put("versionDate", versionDate);
        map.put("ctanPath", ctanPath);
        map.put("ctanFile", ctanFile);
        map.put("ctanZip", ctanZip);
        map.put("miktexLocation", miktexLocation);
        map.put("texliveLocation", texliveLocation);
        map.put("tlContribLocation", tlContribLocation);
        map.put("extraIndex", extraIndex);
        map.put("installPath", installPath);
        if (authors != null) {
            map.put("author", authors.stream()
                .map(it -> Map.of("key", it.getAuthor().getKey()))
                .collect(Collectors.toList()));
        }
        if (uploaders != null) {
            map.put("uploader", uploaders.stream()
                .map(it -> Map.of("key", it.getAuthor().getKey()))
                .collect(Collectors.toList()));
        }
        map.put("texts",
            Map.of("en",
                Map.of("caption",
                    NullCheck.copyNonNull(getCaption("en")),
                    "description",
                    NullCheck.copyNonNull(getDescription("en"))),
                "de",
                Map.of("caption",
                    NullCheck.copyNonNull(getCaption("de")),
                    "description",
                    NullCheck.copyNonNull(getDescription("de")))));
        if (copy != null) {
            map.put("copyright", copy.stream()
                .map(it -> Map.of("year", it.getYear(),
                    "owner", it.getOwner()))
                .collect(Collectors.toList()));
        }
        if (docs != null) {
            map.put("docs", docs.stream()
                .map(it -> Map.of("lang", nullOr(it.getLang(), "en"),
                    "href", nullOr(it.getHref(), ""),
                    "title", nullOr(it.getTitle(), ""),
                    "pages", it.getPages() == null ? "" : it.getPages(),
                    "author", nullOr(it.getAuthor(), "")))
                .collect(Collectors.toList()));
        }
        if (topics != null) {
            map.put("topics", topics.stream()
                .map(it -> it.getKey())
                .collect(Collectors.toList()));
        }
        if (also != null) {
            map.put("also", also.stream()
                .map(it -> it.getKey())
                .collect(Collectors.toList()));
        }
        if (aliases != null) {
            map.put("aliases", aliases.stream()
                .map(it -> it.getKey())
                .collect(Collectors.toList()));
        }
        if (licenses != null) {
            map.put("licenses", licenses.stream()
                .map(it -> it.getKey())
                .collect(Collectors.toList()));
        }
        return map;
    }

    /**
     * The method <code>topicsAsString</code> provides means to retrieve the
     * topic keys.
     *
     * @return the topic keys concatenated with "; "
     */
    public String topicsAsString() {

        return topics == null
            ? ""
            : topics.stream()
                .map(it -> it.getKey())
                .collect(Collectors.joining("; "));
    }

    /**
     * {@inheritDoc}
     *
     * @see java.lang.Object#toString()
     */
    @Override
    public String toString() {

        return key;
    }

    /**
     * {@inheritDoc}
     *
     * @see org.ctan.site.services.search.base.Searchable#updateIndex(org.ctan.site.services.search.base.IndexingSession,
     *     java.lang.String)
     */
    @Override
    public void updateIndex(IndexingSession session, String locale)
        throws CorruptIndexException,
            IOException {

        String caption = getCaption(locale);
        session.updateIndex(indexPath(),
            IndexArgs.builder()
                .type(IndexType.PKG)
                .locale(locale)
                .title(name != null ? key : name)
                .display(caption)
                .content(new String[]{
                    key,
                    name,
                    caption,
                    getDescription(locale),
                    getExtraIndex(null),
                    getExtraIndex(locale)})
                .build());
        // TODO provide other args
    }

    /**
     * The method <code>getExtraIndex</code> provides means to get the extra
     * index.
     *
     * @param locale the selected language
     * @return the extra index for the locale or the empty string
     */
    private String getExtraIndex(String locale) {

        return switch (locale) {
            case null -> extraIndex;
            case "en" -> extraIndexEn;
            case "de" -> extraIndexDe;
            default -> "";
        };
    }

    /**
     * The method <code>isEmpty</code> provides means to check the argument for
     * {@code null} or the empty string.
     *
     * @param s the string to check
     * @return {@code true} if the string is {@code null} or empty
     */
    private boolean isEmpty(String s) {

        return s == null || "".equals(s);
    }

    /**
     * The method <code>nullOr</code> provides means to ensure a non-null value.
     *
     * @param value the value
     * @param fallback the fallback value
     * @return the value if not null or the fallback otherwise
     */
    private String nullOr(String value, String fallback) {

        return value != null ? value : fallback;
    }
}