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