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

    /**
     * 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>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;
    }

    /**
     * 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<String, Object>();
        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()));
        }
        if (captions != null) {
            map.put("captions", captions.stream()
                .map(it -> Map.of("lang", it.getLang(), "caption",
                    it.getCaption()))
                .collect(Collectors.toList()));
        }
        if (descriptions != null) {
            map.put("descriptions", descriptions.stream()
                .map(it -> Map.of("lang",
                    it.getLang() != null ? it.getLang() : "en",
                    "description", it.getDescription()))
                .collect(Collectors.toList()));
        }
        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 {

        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
    }
}