Topic.java

/*
 * Copyright © 2012-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 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 com.google.common.collect.ImmutableMap;

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.ManyToOne;
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>Topic</code> represents a topic in the Catalogue.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Entity
@Data
@EqualsAndHashCode(callSuper = false)
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
public class Topic extends AbstractEntity implements Searchable {

    /**
     * The field <code>key</code> contains the unique reference key for the
     * topic. It is used to construct the URL for the topic.
     *
     * <p>
     * By convention the topic key are made up of lower case letters, digits and
     * the minus sign.
     * </p>
     */
    @Column(length = 64, unique = true, nullable = false)
    private String key;

    /**
     * The field <code>details</code> contains the short abstract text in
     * English for the topic.
     *
     * @deprecated use topicDetails instead
     */
    @Deprecated
    @Column(length = 255, name = "details")
    private String oldDetails;
    // /**
    // * The field <code>title</code> contains the title text in English of the
    // * topic.
    // *
    // * @deprecated use topicDetails instead
    // */
    // @Deprecated
    // @Column(length = 255)
    // private String title;

    /**
     * The field <code>number</code> contains the number of packages contained
     * tagged with the topic. It is a cached value for performance.
     */
    @Column
    @Default
    @EqualsAndHashCode.Exclude
    private long number = 0;

    /**
     * The field <code>parent</code> contains the parent topic in the
     * inheritance hierarchy. It can be {@code null} for the top-level node.
     */
    @ManyToOne
    @EqualsAndHashCode.Exclude
    private Topic parent;

    /**
     * The field <code>alias</code> contains an optional alias for the topic.
     */
    @ManyToOne
    @EqualsAndHashCode.Exclude
    private Topic alias;

    /**
     * The field <code>details</code> contains detail descriptions for the topic
     * in different languages.
     */
    @OneToMany(cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.EAGER)
    @JoinColumn(name = "topic_id")
    @Default
    @EqualsAndHashCode.Exclude
    private List<TopicDetail> details = new ArrayList<TopicDetail>();

    /**
     * The field <code>children</code> contains the children of the topic in the
     * topics tree.
     */
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "parent_id")
    @Default
    @EqualsAndHashCode.Exclude
    private List<Topic> children = new ArrayList<Topic>();

    /**
     * The field <code>aliases</code> contains the list of aliases.
     */
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "alias_id")
    @Default
    @EqualsAndHashCode.Exclude
    private List<Topic> aliases = new ArrayList<Topic>();

    /**
     * The field <code>packages</code> contains the set of associated packages.
     */
    @ManyToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinTable(name = "pkg_topic", //
        joinColumns = {@JoinColumn(name = "topic_id")}, //
        inverseJoinColumns = {@JoinColumn(name = "pkg_topics_id")})
    @Default
    @EqualsAndHashCode.Exclude
    private List<Pkg> packages = new ArrayList<>();

    /**
     * The method <code>getDescription</code> provides means to retrieve the
     * description of a topic in a given language.
     *
     * @param lang the two-letter language code
     * @return the description or the empty string
     */
    public String getDescription(String lang) {

        TopicDetail it = getTopicDetail(lang);
        return it == null ? "" : it.getDescription();
    }

    /**
     * The method <code>getDetails</code> provides means to retrieve the details
     * text of a topic in a given language.
     *
     * @param lang the two-letter language code
     * @return the details text or the empty string
     */
    public String getDetails(String lang) {

        TopicDetail it = getTopicDetail(lang);
        if (it == null) {
            return oldDetails != null ? oldDetails : "";
        }
        return it.getDetail();
    }

    /**
     * The method <code>getTeaser</code> provides means to retrieve the teaser
     * in a given language.
     *
     * @param lang the two-letter language code
     * @return the teaser or the empty string
     */
    public String getTeaser(String lang) {

        TopicDetail it = getTopicDetail(lang);
        return it == null ? "" : it.getTeaser();
    }

    /**
     * The method <code>getTitle</code> provides means to retrieve the title in
     * a given language.
     *
     * @param lang the two-letter language code
     * @return the title or the empty string
     */
    public String getTitle(String lang) {

        TopicDetail it = getTopicDetail(lang);
        return it == null ? "" : it.getTitle();
    }

    /**
     * The method <code>getDetail</code> provides means to retrieve the detail
     * text of a topic in a given language.
     *
     * @param lang the two-letter language code
     * @return the detail
     */
    public TopicDetail getTopicDetail(String lang) {

        TopicDetail en = null;

        for (TopicDetail it : details) {
            if (it.getLang().equals(lang)) {
                return it;
            }
            if (it.getLang().equals("en")) {
                en = it;
            }
        }
        return en != null ? en : null;
    }

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

        return "/topic/" + key;
    }

    /**
     * The method <code>toMap</code> provides means to get the instance as an
     * immutable Map.
     *
     * @return the Map
     */
    public ImmutableMap<String, Object> toMap() {

        return toMap("en");
    }

    /**
     * The method <code>toMap</code> provides means to get the instance as an
     * immutable Map.
     *
     * @param locale the language code
     * @return the Map
     */
    public ImmutableMap<String, Object> toMap(String locale) {

        return ImmutableMap.of("id", (Object) getId(),
            "lang", locale,
            "key", key,
            "details", getDetails(locale));
    }

    /**
     * {@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) {
            session.updateIndex(indexPath(),
                IndexArgs.builder()
                    .type(IndexType.TOPICS)
                    .locale(locale)
                    .title(getTitle(locale))
                    .display(getTeaser(locale))
                    .content(new String[]{
                        key,
                        getTitle(locale),
                        getTeaser(locale),
                        getDescription(locale),
                        getDetails(locale)})
                    .build());
        }
    }
    // static belongsTo = [ parent: Topic, alias: Topic ]
    // static hasMany = [ topicDetails: TopicDetail,
    // children: Topic,
    // aliases: Topic ]
    // static mappedBy = [ children: 'parent',
    // aliases: 'alias' ]
}