Author.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.text.Normalizer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.domain.Gender;
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.fasterxml.jackson.annotation.JsonIgnore;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder.Default;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
/**
* This is the domain class for an author in the CTAN Catalogue.
*
* @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
*/
@Entity
@Table(name = "author")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
@EqualsAndHashCode(callSuper = false)
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
public class Author extends AbstractEntity implements Searchable {
/**
* This enumeration lists known name formats.
*/
public enum NameFormat {
/**
* The field <code>WESTERN</code> indicates a Western name.
*/
WESTERN(0) {
@Override
public String formatName(Author name) {
return Author.fmt(name.givenname, name.von, name.familyname,
name.junior);
}
@Override
public String formatNameSortable(Author name) {
return Author.fmt(name.familyname, ",", name.givenname,
name.von, name.junior);
}
},
/**
* The field <code>FILE</code> contains the indicates a Chinese type of
* name.
*/
CHINESE(1) {
@Override
public String formatName(Author name) {
return Author.fmt(name.familyname, name.von, name.givenname,
name.junior);
}
@Override
public String formatNameSortable(Author name) {
return Author.fmt(name.givenname, ",", name.familyname,
name.von, name.junior);
}
};
/**
* Finder for the message type by its numerical id.
*
* @param id the id
* @return the associated element or {@code null}
*/
static NameFormat of(int id) {
return switch (id) {
case 0 -> WESTERN;
case 1 -> CHINESE;
default -> null;
};
}
/**
* The field <code>id</code> contains the numerical representation.
*/
final int id;
/**
* Creates a new object.
*
* @param id the id
*/
NameFormat(int id) {
this.id = id;
}
/**
* Format the name of the author.
*
* @param author the author
* @return the name of the author
*/
abstract String formatName(Author author);
/**
* The method <code>formatNameSortable</code> provides means to format a
* name for sorting.
*
* @param author the author
* @return the sorting criterion
*/
abstract String formatNameSortable(Author author);
/**
* Getter for the value.
*
* @return the value
*/
public int value() {
return id;
}
}
/**
* The method <code>fmt</code> provides a function of formatting several
* strings into one. The arguments are separated by a single space unless
* the argument is a comma. Then the comma is attached directly.
*
* @param args the arguments
* @return the concatenated string
*/
static String fmt(String... args) {
var buffer = new StringBuilder();
for (String it : args) {
if (it != null && !"".equals(it)) {
if (buffer.length() != 0) {
if (",".equals(it)) {
buffer.append(',');
} else {
buffer.append(' ');
buffer.append(it);
}
} else if (!",".equals(it)) {
buffer.append(it);
}
}
}
return buffer.toString().replaceAll("[ ,]+$", "");
}
/**
* Test for non-equality.
*
* @param a the first parameter
* @param b the second parameter
* @return {@code true} if the two arguments are not equal
*/
static boolean neq(String a, String b) {
return a == null ? b != null : !a.equals(b);
}
/**
* The field <code>key</code> contains the unique key of the author.
*
* <p>
* The key is assigned by the CTAN team.
* </p>
*
* <p>
* By convention the key consists of a lower-case letter followed by
* lower-case letters, digits or hyphens. In the simplest case it is the
* family name. If this is not unique the the initial of the given name is
* added after a hyphen.
* </p>
*/
@Column(length = 96, unique = true, nullable = false)
private String key;
/**
* The field <code>format</code> contains the indicator how the name should
* be formatted.
*/
@Enumerated(EnumType.ORDINAL)
@Column
@JsonIgnore
@Default
@EqualsAndHashCode.Exclude
private NameFormat format = NameFormat.WESTERN;
/**
* The field <code>givenname</code> contains the given or first name of the
* author.
*/
@Column(length = 128)
@EqualsAndHashCode.Exclude
private String givenname;
/**
* The field <code>von</code> contains the von part of the name; cf. BibTeX.
*/
@Column(length = 128)
@EqualsAndHashCode.Exclude
private String von;
/**
* The field <code>familyname</code> contains the family or last name of the
* author. For companies or groups of people the value is empty.
*/
@Column(length = 128)
@EqualsAndHashCode.Exclude
private String familyname;
/**
* The field <code>junior</code> contains the junior part of the name; cf.
* BibTeX.
*/
@Column(length = 128)
@EqualsAndHashCode.Exclude
private String junior;
/**
* The field <code>title</code> contains the optional title of the author.
*/
@Column(length = 128)
@EqualsAndHashCode.Exclude
private String title;
/**
* The field <code>pseudonym</code> contains the optional pseudonym of the
* author. In case a pseudonym is present then the real name of the author
* is not exposed to the public.
*/
@Column(length = 64)
@EqualsAndHashCode.Exclude
private String pseudonym;
/**
* The field <code>sortText</code> contains the cached sort text.
*/
@Column(name = "sort_text", length = 255)
@Default
@EqualsAndHashCode.Exclude
private String sortText = "";
/**
* The field <code>died</code> contains the indicator whether the author is
* known to be dead.
*/
@Column
@Default
@EqualsAndHashCode.Exclude
private Boolean died = Boolean.FALSE;
/**
* The field <code>gender</code> contains the gender classification of the
* author.
*/
@Column(length = 1)
@Enumerated(EnumType.STRING)
@Default
@EqualsAndHashCode.Exclude
private Gender gender = Gender.M;
/**
* The field <code>refs</code> contains references leading to the packages
* of this author.
*/
@ManyToMany(mappedBy = "author", fetch = FetchType.LAZY)
@EqualsAndHashCode.Exclude
@Default
private Set<AuthorRef> refs = new HashSet<>();
/**
* The field <code>emails</code> contains a list of known email addresses of
* the author. Those email addresses are treated confidential and are not
* exposed to the public.
*/
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY,
orphanRemoval = true)
// @JsonBackReference
@JsonIgnore
@Default
@EqualsAndHashCode.Exclude
private List<AuthorEmail> emails = new ArrayList<>();
/**
* Add a reference to a package.
*
* @param pkg the package
* @param active the active indicator
* @return this
*/
public Author addPkg(Pkg pkg, boolean active) {
refs.add(new AuthorRef(pkg, this, active));
return this;
}
/**
* Getter for the first email.
*
* @return the first email or {@code null}
*/
public String getEmail() {
return (emails == null || emails.isEmpty()
? null
: emails.get(0).toString());
}
/**
* {@inheritDoc}
*
* @see org.ctan.site.services.search.base.Searchable#indexPath()
*/
@Override
public String indexPath() {
return "/author/" + key;
}
/**
* The method <code>setEmails</code> provides means to set the emails.
*
* <p>
* If a value starts with * then the address is marked as inactive
*
* @param values the encoded values
*/
public void setEmails(String... values) {
emails = Arrays.asList(values).stream()
.map(it -> AuthorEmail.builder()
.address(it.replaceAll("^[*]", ""))
.inactive(it.startsWith("*"))
.build())
.collect(Collectors.toList());
}
/**
* This method is the setter for family name.
*
* @param familyname the new family name
*/
public void setFamilyname(String familyname) {
if (neq(this.familyname, familyname)) {
this.familyname = familyname;
sortText = updateSortText();
}
}
/**
* This method is the setter for given name.
*
* @param givenname the new given name
*/
public void setGivenname(String givenname) {
if (neq(this.givenname, givenname)) {
this.givenname = givenname;
sortText = updateSortText();
}
}
/**
* This method is the setter for junior name.
*
* @param junior the new junior name part
*/
public void setJunior(String junior) {
if (neq(this.junior, junior)) {
this.junior = junior;
sortText = updateSortText();
}
}
/**
* This method is the setter for pseudonym.
*
* @param pseudonym the new pseudonym
*/
public void setPseudonym(String pseudonym) {
if ("".equals(pseudonym)) {
pseudonym = null;
}
if (neq(this.pseudonym, pseudonym)) {
this.pseudonym = pseudonym;
sortText = updateSortText();
}
}
/**
* This method is the setter for von name.
*
* @param von the new von name part
*/
public void setVon(String von) {
if (neq(this.von, von)) {
this.von = von;
sortText = updateSortText();
}
}
/**
* The method <code>toLexString</code> provides means to get a string
* representation for lexical ordering.
*
* @return the string for lexical ordering
*/
public String toLexString() {
return (pseudonym != null && pseudonym.length() > 0
? pseudonym
: (format != null ? format : NameFormat.WESTERN)
.formatNameSortable(this)).toLowerCase();
}
/**
* 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);
var mails = new ArrayList<Map<String, String>>();
for (var em : emails) {
mails.add(Map.of(
"email", em.getAddress(),
"inactive", em.getInactive() ? "true" : "false",
"note", em.getNote() != null ? em.getNote() : ""));
}
map.put("email", mails);
if (pseudonym != null) {
map.put("pseudonym", pseudonym);
} else {
map.put("title", title);
map.put("familyname", familyname);
map.put("givenname", givenname);
map.put("von", von);
map.put("junior", junior);
}
map.put("died", died);
map.put("format", format.toString());
map.put("gender", gender.getValue());
map.put("name", format.formatName(this));
return map;
}
/**
* {@inheritDoc}
*
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return pseudonym != null && pseudonym.length() > 0
? pseudonym
: (format != null ? format : NameFormat.WESTERN)
.formatName(this);
}
/**
* {@inheritDoc}
*
* @see org.ctan.site.services.search.base.Searchable#updateIndex(IndexingSession)
*/
@Override
public void updateIndex(IndexingSession session)
throws CorruptIndexException,
IOException {
IndexArgs args = IndexArgs.builder()
.type(IndexType.AUTHORS)
.title(toString())
.display(toString())
.content(new String[]{toString()})
.build();
for (var locale : CtanConfig.LOCALES) {
args.setLocale(locale);
session.updateIndex(indexPath(), args);
}
}
/**
* Compute the internal attribute for sorting.
*
* @return the string for ordering
*/
String updateSortText() {
var text = (pseudonym != null
? pseudonym
: (format != null
? format
: NameFormat.WESTERN)
.formatNameSortable(this))
.toLowerCase()
.replaceAll("^ ?(the )?", "")
.replaceAll(" +", " ");
return Normalizer.normalize(text, Normalizer.Form.NFD)
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
}
}