package org.jabref.model.entry; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; import javafx.collections.FXCollections; import javafx.collections.ObservableMap; import org.jabref.model.EntryTypes; import org.jabref.model.FieldChange; import org.jabref.model.database.BibDatabase; import org.jabref.model.database.BibDatabaseMode; import org.jabref.model.entry.event.EntryEventSource; import org.jabref.model.entry.event.FieldChangedEvent; import org.jabref.model.entry.identifier.DOI; import org.jabref.model.strings.LatexToUnicodeAdapter; import org.jabref.model.strings.StringUtil; import com.google.common.base.Strings; import com.google.common.eventbus.EventBus; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; public class BibEntry implements Cloneable { public static final String TYPE_HEADER = "entrytype"; public static final String OBSOLETE_TYPE_HEADER = "bibtextype"; public static final String KEY_FIELD = "bibtexkey"; public static final String DEFAULT_TYPE = "misc"; protected static final String ID_FIELD = "id"; private static final Log LOGGER = LogFactory.getLog(BibEntry.class); private static final Pattern REMOVE_TRAILING_WHITESPACE = Pattern.compile("\\s+$"); private final SharedBibEntryData sharedBibEntryData; /** * Map to store the words in every field */ private final Map<String, Set<String>> fieldsAsWords = new HashMap<>(); /** * Cache that stores latex free versions of fields. */ private final Map<String, String> latexFreeFields = new ConcurrentHashMap<>(); private final EventBus eventBus = new EventBus(); private String id; private String type; private ObservableMap<String, String> fields = FXCollections.observableMap(new ConcurrentHashMap<>()); // Search and grouping status is stored in boolean fields for quick reference: private boolean searchHit; private boolean groupHit; private String parsedSerialization; private String commentsBeforeEntry = ""; /** * Marks whether the complete serialization, which was read from file, should be used. * * Is set to false, if parts of the entry change. This causes the entry to be serialized based on the internal state (and not based on the old serialization) */ private boolean changed; /** * Constructs a new BibEntry. The internal ID is set to IdGenerator.next() */ public BibEntry() { this(IdGenerator.next(), DEFAULT_TYPE); } /** * Constructs a new BibEntry with the given type * * @param type The type to set. May be null or empty. In that case, DEFAULT_TYPE is used. */ public BibEntry(String type) { this(IdGenerator.next(), type); } /** * Constructs a new BibEntry with the given ID and given type * * @param id The ID to be used * @param type The type to set. May be null or empty. In that case, DEFAULT_TYPE is used. */ private BibEntry(String id, String type) { Objects.requireNonNull(id, "Every BibEntry must have an ID"); this.id = id; setType(type); this.sharedBibEntryData = new SharedBibEntryData(); } public Optional<FieldChange> setMonth(Month parsedMonth) { return setField(FieldName.MONTH, parsedMonth.getJabRefFormat()); } public Optional<FieldChange> replaceKeywords(KeywordList keywordsToReplace, Optional<Keyword> newValue, Character keywordDelimiter) { KeywordList keywordList = getKeywords(keywordDelimiter); if (newValue.isPresent()) { keywordList.replaceAll(keywordsToReplace, newValue.get()); } return putKeywords(keywordList, keywordDelimiter); } /** * Returns the text stored in the given field of the given bibtex entry * which belongs to the given database. * <p> * If a database is given, this function will try to resolve any string * references in the field-value. * Also, if a database is given, this function will try to find values for * unset fields in the entry linked by the "crossref" field, if any. * * @param field The field to return the value of. * @param database maybenull * The database of the bibtex entry. * @return The resolved field value or null if not found. */ public Optional<String> getResolvedFieldOrAlias(String field, BibDatabase database) { Objects.requireNonNull(this, "entry cannot be null"); if (TYPE_HEADER.equals(field) || OBSOLETE_TYPE_HEADER.equals(field)) { Optional<EntryType> entryType = EntryTypes.getType(getType(), BibDatabaseMode.BIBLATEX); if (entryType.isPresent()) { return Optional.of(entryType.get().getName()); } else { return Optional.of(StringUtil.capitalizeFirst(getType())); } } if (KEY_FIELD.equals(field)) { return getCiteKeyOptional(); } Optional<String> result = getFieldOrAlias(field); // If this field is not set, and the entry has a crossref, try to look up the // field in the referred entry: Do not do this for the bibtex key. if (!result.isPresent() && (database != null)) { Optional<BibEntry> referred = database.getReferencedEntry(this); result = referred.flatMap(entry -> entry.getFieldOrAlias(field)); } return result.map(resultText -> BibDatabase.getText(resultText, database)); } /** * Returns this entry's ID. */ public String getId() { return id; } /** * Sets this entry's ID, provided the database containing it * doesn't veto the change. * * @param id The ID to be used */ public void setId(String id) { Objects.requireNonNull(id, "Every BibEntry must have an ID"); String oldId = this.id; eventBus.post(new FieldChangedEvent(this, BibEntry.ID_FIELD, id, oldId)); this.id = id; changed = true; } /** * Returns the cite key AKA citation key AKA BibTeX key, or null if it is not set. * Note: this is <emph>not</emph> the internal Id of this entry. The internal Id is always present, whereas the BibTeX key might not be present. */ @Deprecated public String getCiteKey() { return fields.get(KEY_FIELD); } /** * Sets the cite key AKA citation key AKA BibTeX key. Note: This is <emph>not</emph> the internal Id of this entry. * The internal Id is always present, whereas the BibTeX key might not be present. * * @param newCiteKey The cite key to set. Must not be null; use {@link #clearCiteKey()} to remove the cite key. */ public void setCiteKey(String newCiteKey) { setField(KEY_FIELD, newCiteKey); } public Optional<String> getCiteKeyOptional() { return Optional.ofNullable(fields.get(KEY_FIELD)); } public boolean hasCiteKey() { return !Strings.isNullOrEmpty(getCiteKey()); } /** * Returns this entry's type. */ public String getType() { return type; } /** * Sets this entry's type. */ public void setType(String type) { setType(type, EntryEventSource.LOCAL); } /** * Sets this entry's type. */ public void setType(EntryType type) { this.setType(type.getName()); } /** * Sets this entry's type. */ public void setType(String type, EntryEventSource eventSource) { String newType; if (Strings.isNullOrEmpty(type)) { newType = DEFAULT_TYPE; } else { newType = type; } String oldType = getField(TYPE_HEADER).orElse(null); // We set the type before throwing the changeEvent, to enable // the change listener to access the new value if the change // sets off a change in database sorting etc. this.type = newType.toLowerCase(Locale.ENGLISH); changed = true; eventBus.post(new FieldChangedEvent(this, TYPE_HEADER, newType, oldType, eventSource)); } /** * Returns an set containing the names of all fields that are * set for this particular entry. * * @return a set of existing field names */ public Set<String> getFieldNames() { return new TreeSet<>(fields.keySet()); } /** * Returns the contents of the given field as an Optional. */ public Optional<String> getField(String name) { return Optional.ofNullable(fields.get(toLowerCase(name))); } /** * Returns true if the entry has the given field, or false if it is not set. */ public boolean hasField(String name) { return fields.containsKey(toLowerCase(name)); } private String toLowerCase(String fieldName) { Objects.requireNonNull(fieldName, "field name must not be null"); return fieldName.toLowerCase(Locale.ENGLISH); } /** * Internal method used to get the content of a field (or its alias) * * Used by {@link #getFieldOrAlias(String)} and {@link #getFieldOrAliasLatexFree(String)} * * @param name name of the field * @param getFieldInterface * * @return determined field value */ private Optional<String> genericGetFieldOrAlias(String name, GetFieldInterface getFieldInterface) { Optional<String> fieldValue = getFieldInterface.getValueForField(toLowerCase(name)); if (fieldValue.isPresent() && !fieldValue.get().isEmpty()) { return fieldValue; } // No value of this field found, so look at the alias String aliasForField = EntryConverter.FIELD_ALIASES.get(name); if (aliasForField != null) { return getFieldInterface.getValueForField(aliasForField); } // Finally, handle dates if (FieldName.DATE.equals(name)) { Optional<Date> date = Date.parse( getFieldInterface.getValueForField(FieldName.YEAR), getFieldInterface.getValueForField(FieldName.MONTH), getFieldInterface.getValueForField(FieldName.DAY)); return date.map(Date::getNormalized); } if (FieldName.YEAR.equals(name) || FieldName.MONTH.equals(name) || FieldName.DAY.equals(name)) { Optional<String> date = getFieldInterface.getValueForField(FieldName.DATE); if (!date.isPresent()) { return Optional.empty(); } Optional<Date> parsedDate = Date.parse(date.get()); if (parsedDate.isPresent()) { if (FieldName.YEAR.equals(name)) { return parsedDate.get().getYear().map(Object::toString); } if (FieldName.MONTH.equals(name)) { return parsedDate.get().getMonth().map(Month::getJabRefFormat); } if (FieldName.DAY.equals(name)) { return parsedDate.get().getDay().map(Object::toString); } } else { LOGGER.warn("Could not parse date " + date.get()); return Optional.empty(); // Date field not in valid format } } return Optional.empty(); } public Optional<DOI> getDOI() { return getField(FieldName.DOI).flatMap(DOI::parse); } /** * Return the LaTeX-free contents of the given field or its alias an an Optional * * For details see also {@link #getFieldOrAlias(String)} * * @param name the name of the field * @return the stored latex-free content of the field (or its alias) */ public Optional<String> getFieldOrAliasLatexFree(String name) { return genericGetFieldOrAlias(name, this::getLatexFreeField); } /** * Returns the contents of the given field or its alias as an Optional * <p> * The following aliases are considered (old bibtex <-> new biblatex) based * on the biblatex documentation, chapter 2.2.5:<br> * address <-> location <br> * annote <-> annotation <br> * archiveprefix <-> eprinttype <br> * journal <-> journaltitle <br> * key <-> sortkey <br> * pdf <-> file <br * primaryclass <-> eprintclass <br> * school <-> institution <br> * These work bidirectional. <br> * </p> * * <p> * Special attention is paid to dates: (see the biblatex documentation, * chapter 2.3.8) * The fields 'year' and 'month' are used if the 'date' * field is empty. Conversely, getFieldOrAlias("year") also tries to * extract the year from the 'date' field (analogously for 'month'). * </p> */ public Optional<String> getFieldOrAlias(String name) { return genericGetFieldOrAlias(name, this::getField); } /** * Sets a number of fields simultaneously. The given HashMap contains field * names as keys, each mapped to the value to set. */ public void setField(Map<String, String> fields) { Objects.requireNonNull(fields, "fields must not be null"); fields.forEach(this::setField); } /** * Set a field, and notify listeners about the change. * * @param name The field to set * @param value The value to set * @param eventSource Source the event is sent from */ public Optional<FieldChange> setField(String name, String value, EntryEventSource eventSource) { Objects.requireNonNull(name, "field name must not be null"); Objects.requireNonNull(value, "field value must not be null"); String fieldName = toLowerCase(name); if (value.isEmpty()) { return clearField(fieldName); } String oldValue = getField(fieldName).orElse(null); if (value.equals(oldValue)) { return Optional.empty(); } if (BibEntry.ID_FIELD.equals(fieldName)) { throw new IllegalArgumentException("The field name '" + name + "' is reserved"); } changed = true; fields.put(fieldName, value.intern()); invalidateFieldCache(fieldName); FieldChange change = new FieldChange(this, fieldName, oldValue, value); eventBus.post(new FieldChangedEvent(change, eventSource)); return Optional.of(change); } public Optional<FieldChange> setField(String name, Optional<String> value, EntryEventSource eventSource) { if (value.isPresent()) { return setField(name, value.get(), eventSource); } return Optional.empty(); } /** * Set a field, and notify listeners about the change. * * @param name The field to set. * @param value The value to set. */ public Optional<FieldChange> setField(String name, String value) { return setField(name, value, EntryEventSource.LOCAL); } /** * Remove the mapping for the field name, and notify listeners about * the change. * * @param name The field to clear. */ public Optional<FieldChange> clearField(String name) { return clearField(name, EntryEventSource.LOCAL); } /** * Remove the mapping for the field name, and notify listeners about * the change including the {@link EntryEventSource}. * * @param name The field to clear. * @param eventSource the source a new {@link FieldChangedEvent} should be posten from. */ public Optional<FieldChange> clearField(String name, EntryEventSource eventSource) { String fieldName = toLowerCase(name); if (BibEntry.ID_FIELD.equals(fieldName)) { throw new IllegalArgumentException("The field name '" + name + "' is reserved"); } Optional<String> oldValue = getField(fieldName); if (!oldValue.isPresent()) { return Optional.empty(); } changed = true; fields.remove(fieldName); invalidateFieldCache(fieldName); FieldChange change = new FieldChange(this, fieldName, oldValue.get(), null); eventBus.post(new FieldChangedEvent(change, eventSource)); return Optional.of(change); } /** * Determines whether this entry has all the given fields present. If a non-null * database argument is given, this method will try to look up missing fields in * entries linked by the "crossref" field, if any. * * @param allFields An array of field names to be checked. * @param database The database in which to look up crossref'd entries, if any. This * argument can be null, meaning that no attempt will be made to follow crossrefs. * @return true if all fields are set or could be resolved, false otherwise. */ public boolean allFieldsPresent(List<String> allFields, BibDatabase database) { for (String field : allFields) { String fieldName = toLowerCase(field); // OR fields if (fieldName.contains(FieldName.FIELD_SEPARATOR)) { String[] altFields = field.split(FieldName.FIELD_SEPARATOR); if (!atLeastOnePresent(altFields, database)) { return false; } } else { if (!this.getResolvedFieldOrAlias(fieldName, database).isPresent()) { return false; } } } return true; } private boolean atLeastOnePresent(String[] fieldsToCheck, BibDatabase database) { for (String field : fieldsToCheck) { String fieldName = toLowerCase(field); Optional<String> value = this.getResolvedFieldOrAlias(fieldName, database); if ((value.isPresent()) && !value.get().isEmpty()) { return true; } } return false; } /** * Returns a clone of this entry. Useful for copying. * This will set a new ID for the cloned entry to be able to distinguish both copies. */ @Override public Object clone() { BibEntry clone = new BibEntry(type); clone.fields = FXCollections.observableMap(new ConcurrentHashMap<>(fields)); return clone; } /** * This returns a canonical BibTeX serialization. Special characters such as "{" or "&" are NOT escaped, but written * as is * <p> * Serializes all fields, even the JabRef internal ones. Does NOT serialize "KEY_FIELD" as field, but as key */ @Override public String toString() { return CanonicalBibtexEntry.getCanonicalRepresentation(this); } public boolean isSearchHit() { return searchHit; } public void setSearchHit(boolean searchHit) { this.searchHit = searchHit; } public boolean isGroupHit() { return groupHit; } public void setGroupHit(boolean groupHit) { this.groupHit = groupHit; } /** * @param maxCharacters The maximum number of characters (additional * characters are replaced with "..."). Set to 0 to disable truncation. * @return A short textual description of the entry in the format: * Author1, Author2: Title (Year) */ public String getAuthorTitleYear(int maxCharacters) { String[] s = new String[] {getField(FieldName.AUTHOR).orElse("N/A"), getField(FieldName.TITLE).orElse("N/A"), getField(FieldName.YEAR).orElse("N/A")}; String text = s[0] + ": \"" + s[1] + "\" (" + s[2] + ')'; if ((maxCharacters <= 0) || (text.length() <= maxCharacters)) { return text; } return text.substring(0, maxCharacters + 1) + "..."; } /** * Returns the title of the given BibTeX entry as an Optional. * * @return an Optional containing the title of a BibTeX entry in case it exists, otherwise return an empty Optional. */ public Optional<String> getTitle() { return getField(FieldName.TITLE); } /** * Will return the publication date of the given bibtex entry conforming to ISO 8601, i.e. either YYYY or YYYY-MM. * * @return will return the publication date of the entry or null if no year was found. */ public Optional<String> getPublicationDate() { return getFieldOrAlias(FieldName.DATE); } public String getParsedSerialization() { return parsedSerialization; } public void setParsedSerialization(String parsedSerialization) { changed = false; this.parsedSerialization = parsedSerialization; } public void setCommentsBeforeEntry(String parsedComments) { this.commentsBeforeEntry = parsedComments; } public boolean hasChanged() { return changed; } public void setChanged(boolean changed) { this.changed = changed; } public Optional<FieldChange> putKeywords(List<String> keywords, Character delimiter) { Objects.requireNonNull(delimiter); return putKeywords(new KeywordList(keywords), delimiter); } public Optional<FieldChange> putKeywords(KeywordList keywords, Character delimiter) { Objects.requireNonNull(keywords); Optional<String> oldValue = this.getField(FieldName.KEYWORDS); if (keywords.isEmpty()) { // Clear keyword field if (oldValue.isPresent()) { return this.clearField(FieldName.KEYWORDS); } else { return Optional.empty(); } } // Set new keyword field String newValue = keywords.getAsString(delimiter); return this.setField(FieldName.KEYWORDS, newValue); } /** * Check if a keyword already exists (case insensitive), if not: add it * * @param keyword Keyword to add */ public void addKeyword(String keyword, Character delimiter) { Objects.requireNonNull(keyword, "keyword must not be null"); if (keyword.isEmpty()) { return; } addKeyword(new Keyword(keyword), delimiter); } public void addKeyword(Keyword keyword, Character delimiter) { KeywordList keywords = this.getKeywords(delimiter); keywords.add(keyword); this.putKeywords(keywords, delimiter); } /** * Add multiple keywords to entry * * @param keywords Keywords to add */ public void addKeywords(Collection<String> keywords, Character delimiter) { Objects.requireNonNull(keywords); keywords.forEach(keyword -> addKeyword(keyword, delimiter)); } public KeywordList getKeywords(Character delimiter) { Optional<String> keywordsContent = getField(FieldName.KEYWORDS); if (keywordsContent.isPresent()) { return KeywordList.parse(keywordsContent.get(), delimiter); } else { return new KeywordList(); } } public Collection<String> getFieldValues() { return fields.values(); } public Map<String, String> getFieldMap() { return fields; } public SharedBibEntryData getSharedBibEntryData() { return sharedBibEntryData; } @Override public boolean equals(Object o) { if (this == o) { return true; } if ((o == null) || (getClass() != o.getClass())) { return false; } BibEntry entry = (BibEntry) o; return Objects.equals(type, entry.type) && Objects.equals(fields, entry.fields); } @Override public int hashCode() { return Objects.hash(type, fields); } public void registerListener(Object object) { this.eventBus.register(object); } public void unregisterListener(Object object) { try { this.eventBus.unregister(object); } catch (IllegalArgumentException e) { // occurs if the event source has not been registered, should not prevent shutdown LOGGER.debug(e); } } public BibEntry withField(String field, String value) { setField(field, value); return this; } /* * Returns user comments (arbitrary text before the entry), if they exist. If not, returns the empty String */ public String getUserComments() { // delete trailing whitespaces (between entry and text) from stored serialization return REMOVE_TRAILING_WHITESPACE.matcher(commentsBeforeEntry).replaceFirst(""); } public List<ParsedEntryLink> getEntryLinkList(String fieldName, BibDatabase database) { return getField(fieldName).map(fieldValue -> EntryLinkList.parse(fieldValue, database)) .orElse(Collections.emptyList()); } public Optional<FieldChange> setEntryLinkList(String fieldName, List<ParsedEntryLink> list) { return setField(fieldName, EntryLinkList.serialize(list)); } public Set<String> getFieldAsWords(String field) { String fieldName = toLowerCase(field); Set<String> storedList = fieldsAsWords.get(fieldName); if (storedList != null) { return storedList; } else { String fieldValue = fields.get(fieldName); if (fieldValue == null) { return Collections.emptySet(); } else { HashSet<String> words = new HashSet<>(StringUtil.getStringAsWords(fieldValue)); fieldsAsWords.put(fieldName, words); return words; } } } public Optional<FieldChange> clearCiteKey() { return clearField(KEY_FIELD); } private void invalidateFieldCache(String fieldName) { latexFreeFields.remove(fieldName); fieldsAsWords.remove(fieldName); } public Optional<String> getLatexFreeField(String name) { if (!hasField(name)) { return Optional.empty(); } else if (latexFreeFields.containsKey(name)) { return Optional.ofNullable(latexFreeFields.get(toLowerCase(name))); } else if (KEY_FIELD.equals(name)) { // the key field should not be converted Optional<String> citeKey = getCiteKeyOptional(); latexFreeFields.put(name, citeKey.get()); return citeKey; } else { String latexFreeField = LatexToUnicodeAdapter.format(getField(name).get()).intern(); latexFreeFields.put(name, latexFreeField); return Optional.of(latexFreeField); } } public Optional<FieldChange> setFiles(List<LinkedFile> files) { Optional<String> oldValue = this.getField(FieldName.FILE); String newValue = FileFieldWriter.getStringRepresentation(files); if (oldValue.isPresent() && oldValue.get().equals(newValue)) { return Optional.empty(); } return this.setField(FieldName.FILE, newValue); } /** * Gets a list of linked files. * * @return the list of linked files, is never null but can be empty */ public List<LinkedFile> getFiles() { //Extract the path Optional<String> oldValue = getField(FieldName.FILE); if (!oldValue.isPresent()) { return Collections.emptyList(); } return FileFieldParser.parse(oldValue.get()); } public void setDate(Date date) { date.getYear().ifPresent(year -> setField(FieldName.YEAR, year.toString())); date.getMonth().ifPresent(this::setMonth); date.getDay().ifPresent(day -> setField(FieldName.DAY, day.toString())); } public Optional<Month> getMonth() { return getFieldOrAlias(FieldName.MONTH).flatMap(Month::parse); } public ObjectBinding<String> getFieldBinding(String fieldName) { return Bindings.valueAt(fields, fieldName); } private interface GetFieldInterface { Optional<String> getValueForField(String fieldName); } }