Pkg.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.domain.catalogue;

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

import org.apache.lucene.index.CorruptIndexException;
import org.ctan.site.CtanConfiguration.CtanConfig;
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 edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany;
import lombok.AllArgsConstructor;
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
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
public class Pkg extends AbstractEntity implements Searchable {

    /**
     * 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>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>captions</code> contains the captions for different
     * languages.
     */
    @OneToMany(mappedBy = "pkg",
        cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.EAGER)
    @Default
    @EqualsAndHashCode.Exclude
    private List<PkgCaption> captions = new ArrayList();

    /**
     * The field <code>descriptions</code> contains the descriptions for
     * different languages.
     */
    @OneToMany(mappedBy = "pkg", cascade = CascadeType.ALL,
        orphanRemoval = true)
    @Default
    @EqualsAndHashCode.Exclude
    private List<PkgDescription> descriptions = new ArrayList();

    /**
     * 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")
    @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<>();

    /**
     * Getter for a caption in a given language.
     *
     * @param languages the accepted languages in descending order
     * @return the caption for the given language or <code>null</code>
     */
    public PkgCaption getCaption(String... languages) {

        if (captions == null) {
            return null;
        }
        for (String lang : languages) {
            for (PkgCaption cap : captions) {
                if (lang.equals(cap.getLang())) {
                    return cap;
                }
            }
            if ("en".equals(lang)) {
                for (PkgCaption cap : captions) {
                    if (cap.getLang() == null) {
                        return cap;
                    }
                }
            }
        }
        return null;
    }

    /**
     * Getter for a description in a given language.
     *
     * @param languages the languages
     * @return the description for the given language or <code>null</code>
     */
    public PkgDescription getDescription(String... languages) {

        for (String lang : languages) {
            for (PkgDescription desc : descriptions) {
                if (lang.equals(desc.getLang())) {
                    return desc;
                }
            }
            if ("en".equals(lang)) {
                for (PkgDescription desc : descriptions) {
                    if (desc.getLang() == null) {
                        return desc;
                    }
                }
            }
        }
        return null;
    }

    /**
     * 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>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>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>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(IndexingSession)
     */
    @Override
    public void updateIndex(IndexingSession session)
        throws CorruptIndexException,
            IOException {

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