Topic.java

/*
 * Copyright © 2012-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.List;

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 com.google.common.collect.ImmutableMap;

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.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;
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 class <code>TopicTexts</code> contains the language specific texts
     * for a topic.
     */
    @Data
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    @Embeddable
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    public static class TopicTexts {

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

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

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

            return title;
        }
    }

    /**
     * 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>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>detailsEn</code> contains the English details.
     */
    @Embedded
    @Default
    @AttributeOverrides({
        @AttributeOverride(name = "description",
            column = @Column(name = "en_description")),
        @AttributeOverride(name = "title", column = @Column(name = "en_title"))
    })
    private TopicTexts textsEn = new TopicTexts();

    /**
     * The field <code>detailsDe</code> contains the German details.
     */
    @Embedded
    @Default
    @AttributeOverrides({
        @AttributeOverride(name = "description",
            column = @Column(name = "de_description")),
        @AttributeOverride(name = "title", column = @Column(name = "de_title"))
    })
    private TopicTexts textsDe = new TopicTexts();

    /**
     * The field <code>detailsFr</code> contains the French details.
     */
    @Embedded
    @Default
    @AttributeOverrides({
        @AttributeOverride(name = "description",
            column = @Column(name = "fr_description")),
        @AttributeOverride(name = "title", column = @Column(name = "fr_title"))
    })
    private TopicTexts textsFr = new TopicTexts();

    /**
     * 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) {

        TopicTexts it = getTopicTexts(lang);
        return it == null ? "" : NullCheck.or(it.getDescription(), "");
    }

    /**
     * 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) {

        TopicTexts it = getTopicTexts(lang);
        return it == null ? "" : NullCheck.or(it.getTitle(), "");
    }

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

        return switch (lang) {
            case null -> textsEn;
            case "en" -> textsEn;
            case "de" -> textsDe;
            case "fr" -> textsFr;
            default -> textsEn;
        };
    }

    /**
     * {@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,
            "number", number,
            "description", getDescription(locale));
    }

    /**
     * {@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 {

        session.updateIndex(indexPath(),
            IndexArgs.builder()
                .type(IndexType.TOPICS)
                .locale(locale)
                .title(getTitle(locale))
                .display(getDescription(locale))
                .content(new String[]{
                    key,
                    getTitle(locale),
                    getDescription(locale)})
                .build());
    }
}