package org.jabref.model.database; import java.math.BigInteger; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.List; 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 java.util.stream.Collectors; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import org.jabref.model.database.event.EntryAddedEvent; import org.jabref.model.database.event.EntryRemovedEvent; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.BibtexString; import org.jabref.model.entry.FieldName; import org.jabref.model.entry.InternalBibtexFields; import org.jabref.model.entry.Month; import org.jabref.model.entry.event.EntryChangedEvent; import org.jabref.model.entry.event.EntryEventSource; import org.jabref.model.entry.event.FieldChangedEvent; import org.jabref.model.strings.StringUtil; import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; /** * A bibliography database. */ public class BibDatabase { private static final Log LOGGER = LogFactory.getLog(BibDatabase.class); private static final Pattern RESOLVE_CONTENT_PATTERN = Pattern.compile(".*#[^#]+#.*"); /** * State attributes */ private final ObservableList<BibEntry> entries = FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); private final Map<String, BibtexString> bibtexStrings = new ConcurrentHashMap<>(); /** * this is kept in sync with the database (upon adding/removing an entry, it is updated as well) */ private final DuplicationChecker duplicationChecker = new DuplicationChecker(); /** * contains all entry.getID() of the current database */ private final Set<String> internalIDs = new HashSet<>(); private final EventBus eventBus = new EventBus(); private String preamble; // All file contents below the last entry in the file private String epilog = ""; private String sharedDatabaseID; public BibDatabase() { this.eventBus.register(duplicationChecker); this.registerListener(new KeyChangeListener(this)); } /** * @param toResolve maybenull The text to resolve. * @param database maybenull The database to use for resolving the text. * @return The resolved text or the original text if either the text or the database are null * @deprecated use {@link BibDatabase#resolveForStrings(String)} * * Returns a text with references resolved according to an optionally given database. */ @Deprecated public static String getText(String toResolve, BibDatabase database) { if ((toResolve != null) && (database != null)) { return database.resolveForStrings(toResolve); } return toResolve; } /** * Returns the number of entries. */ public int getEntryCount() { return entries.size(); } /** * Checks if the database contains entries. */ public boolean hasEntries() { return !entries.isEmpty(); } /** * Returns an EntrySorter with the sorted entries from this base, * sorted by the given Comparator. */ public synchronized EntrySorter getSorter(Comparator<BibEntry> comp) { return new EntrySorter(new ArrayList<>(getEntries()), comp); } /** * Returns whether an entry with the given ID exists (-> entry_type + hashcode). */ public boolean containsEntryWithId(String id) { return internalIDs.contains(id); } public ObservableList<BibEntry> getEntries() { return FXCollections.unmodifiableObservableList(entries); } /** * Returns a set of Strings, that contains all field names that are visible. This means that the fields * are not internal fields. Internal fields are fields, that are starting with "_". * * @return set of fieldnames, that are visible */ public Set<String> getAllVisibleFields() { Set<String> allFields = new TreeSet<>(); for (BibEntry e : getEntries()) { allFields.addAll(e.getFieldNames()); } return allFields.stream().filter(field -> !InternalBibtexFields.isInternalField(field)) .collect(Collectors.toSet()); } /** * Returns the entry with the given bibtex key. */ public synchronized Optional<BibEntry> getEntryByKey(String key) { for (BibEntry entry : entries) { if (key.equals(entry.getCiteKeyOptional().orElse(null))) { return Optional.of(entry); } } return Optional.empty(); } /** * Collects entries having the specified BibTeX key and returns these entries as list. * The order of the entries is the order they appear in the database. * * @param key * @return list of entries that contains the given key */ public synchronized List<BibEntry> getEntriesByKey(String key) { List<BibEntry> result = new ArrayList<>(); for (BibEntry entry : entries) { entry.getCiteKeyOptional().ifPresent(entryKey -> { if (key.equals(entryKey)) { result.add(entry); } }); } return result; } /** * Finds the entry with a specified ID. * * @param id * @return The entry that has the given id */ public synchronized Optional<BibEntry> getEntryById(String id) { return entries.stream().filter(entry -> entry.getId().equals(id)).findFirst(); } /** * Inserts the entry, given that its ID is not already in use. * use Util.createId(...) to make up a unique ID for an entry. * * @param entry BibEntry to insert into the database * @return false if the insert was done without a duplicate warning * @throws KeyCollisionException thrown if the entry id ({@link BibEntry#getId()}) is already present in the database */ public synchronized boolean insertEntry(BibEntry entry) throws KeyCollisionException { return insertEntry(entry, EntryEventSource.LOCAL); } /** * Inserts the entry, given that its ID is not already in use. * use Util.createId(...) to make up a unique ID for an entry. * * @param entry BibEntry to insert * @param eventSource Source the event is sent from * @return false if the insert was done without a duplicate warning */ public synchronized boolean insertEntry(BibEntry entry, EntryEventSource eventSource) throws KeyCollisionException { insertEntries(Collections.singletonList(entry), eventSource); return duplicationChecker.isDuplicateCiteKeyExisting(entry); } public synchronized void insertEntries(BibEntry... entries) throws KeyCollisionException { insertEntries(Arrays.asList(entries), EntryEventSource.LOCAL); } public synchronized void insertEntries(List<BibEntry> entries) throws KeyCollisionException { insertEntries(entries, EntryEventSource.LOCAL); } private synchronized void insertEntries(List<BibEntry> newEntries, EntryEventSource eventSource) throws KeyCollisionException { Objects.requireNonNull(newEntries); for (BibEntry entry : newEntries) { String id = entry.getId(); if (containsEntryWithId(id)) { throw new KeyCollisionException("ID is already in use, please choose another"); } internalIDs.add(id); entry.registerListener(this); eventBus.post(new EntryAddedEvent(entry, eventSource)); } entries.addAll(newEntries); } /** * Removes the given entry. * The Entry is removed based on the id {@link BibEntry#id} * @param toBeDeleted Entry to delete */ public synchronized void removeEntry(BibEntry toBeDeleted) { removeEntry(toBeDeleted, EntryEventSource.LOCAL); } /** * Removes the given entry. * The Entry is removed based on the id {@link BibEntry#id} * * @param toBeDeleted Entry to delete * @param eventSource Source the event is sent from */ public synchronized void removeEntry(BibEntry toBeDeleted, EntryEventSource eventSource) { Objects.requireNonNull(toBeDeleted); boolean anyRemoved = entries.removeIf(entry -> entry.getId().equals(toBeDeleted.getId())); if (anyRemoved) { internalIDs.remove(toBeDeleted.getId()); eventBus.post(new EntryRemovedEvent(toBeDeleted, eventSource)); } } /** * Returns the database's preamble. * If the preamble text consists only of whitespace, then also an empty optional is returned. */ public synchronized Optional<String> getPreamble() { if (StringUtil.isBlank(preamble)) { return Optional.empty(); } else { return Optional.of(preamble); } } /** * Sets the database's preamble. */ public synchronized void setPreamble(String preamble) { this.preamble = preamble; } /** * Inserts a Bibtex String. */ public synchronized void addString(BibtexString string) throws KeyCollisionException { if (hasStringLabel(string.getName())) { throw new KeyCollisionException("A string with that label already exists"); } if (bibtexStrings.containsKey(string.getId())) { throw new KeyCollisionException("Duplicate BibTeX string id."); } bibtexStrings.put(string.getId(), string); } /** * Removes the string with the given id. */ public void removeString(String id) { bibtexStrings.remove(id); } /** * Returns a Set of keys to all BibtexString objects in the database. * These are in no sorted order. */ public Set<String> getStringKeySet() { return bibtexStrings.keySet(); } /** * Returns a Collection of all BibtexString objects in the database. * These are in no particular order. */ public Collection<BibtexString> getStringValues() { return bibtexStrings.values(); } /** * Returns the string with the given id. */ public BibtexString getString(String id) { return bibtexStrings.get(id); } /** * Returns the number of strings. */ public int getStringCount() { return bibtexStrings.size(); } /** * Check if there are strings. */ public boolean hasNoStrings() { return bibtexStrings.isEmpty(); } /** * Copies the preamble of another BibDatabase. * * @param database another BibDatabase */ public void copyPreamble(BibDatabase database) { setPreamble(database.getPreamble().orElse("")); } /** * Copies all Strings from another BibDatabase. * * @param database another BibDatabase */ public void copyStrings(BibDatabase database) { for (String key : database.getStringKeySet()) { BibtexString string = database.getString(key); addString(string); } } /** * Returns true if a string with the given label already exists. */ public synchronized boolean hasStringLabel(String label) { for (BibtexString value : bibtexStrings.values()) { if (value.getName().equals(label)) { return true; } } return false; } /** * Resolves any references to strings contained in this field content, * if possible. */ public String resolveForStrings(String content) { Objects.requireNonNull(content, "Content for resolveForStrings must not be null."); return resolveContent(content, new HashSet<>(), new HashSet<>()); } /** * Get all strings used in the entries. */ public Collection<BibtexString> getUsedStrings(Collection<BibEntry> entries) { List<BibtexString> result = new ArrayList<>(); Set<String> allUsedIds = new HashSet<>(); // All entries for (BibEntry entry : entries) { for (String fieldContent : entry.getFieldValues()) { resolveContent(fieldContent, new HashSet<>(), allUsedIds); } } // Preamble if (preamble != null) { resolveContent(preamble, new HashSet<>(), allUsedIds); } for (String stringId : allUsedIds) { result.add((BibtexString) bibtexStrings.get(stringId).clone()); } return result; } /** * Take the given collection of BibEntry and resolve any string * references. * * @param entriesToResolve A collection of BibtexEntries in which all strings of the form * #xxx# will be resolved against the hash map of string * references stored in the database. * @param inPlace If inPlace is true then the given BibtexEntries will be modified, if false then copies of the BibtexEntries are made before resolving the strings. * @return a list of bibtexentries, with all strings resolved. It is dependent on the value of inPlace whether copies are made or the given BibtexEntries are modified. */ public List<BibEntry> resolveForStrings(Collection<BibEntry> entriesToResolve, boolean inPlace) { Objects.requireNonNull(entriesToResolve, "entries must not be null."); List<BibEntry> results = new ArrayList<>(entriesToResolve.size()); for (BibEntry entry : entriesToResolve) { results.add(this.resolveForStrings(entry, inPlace)); } return results; } /** * Take the given BibEntry and resolve any string references. * * @param entry A BibEntry in which all strings of the form #xxx# will be * resolved against the hash map of string references stored in * the database. * @param inPlace If inPlace is true then the given BibEntry will be * modified, if false then a copy is made using close made before * resolving the strings. * @return a BibEntry with all string references resolved. It is * dependent on the value of inPlace whether a copy is made or the * given BibtexEntries is modified. */ public BibEntry resolveForStrings(BibEntry entry, boolean inPlace) { BibEntry resultingEntry; if (inPlace) { resultingEntry = entry; } else { resultingEntry = (BibEntry) entry.clone(); } for (Map.Entry<String, String> field : resultingEntry.getFieldMap().entrySet()) { resultingEntry.setField(field.getKey(), this.resolveForStrings(field.getValue())); } return resultingEntry; } /** * If the label represents a string contained in this database, returns * that string's content. Resolves references to other strings, taking * care not to follow a circular reference pattern. * If the string is undefined, returns null. */ private String resolveString(String label, Set<String> usedIds, Set<String> allUsedIds) { Objects.requireNonNull(label); Objects.requireNonNull(usedIds); Objects.requireNonNull(allUsedIds); for (BibtexString string : bibtexStrings.values()) { if (string.getName().equalsIgnoreCase(label)) { // First check if this string label has been resolved // earlier in this recursion. If so, we have a // circular reference, and have to stop to avoid // infinite recursion. if (usedIds.contains(string.getId())) { LOGGER.info("Stopped due to circular reference in strings: " + label); return label; } // If not, log this string's ID now. usedIds.add(string.getId()); if (allUsedIds != null) { allUsedIds.add(string.getId()); } // Ok, we found the string. Now we must make sure we // resolve any references to other strings in this one. String result = string.getContent(); result = resolveContent(result, usedIds, allUsedIds); // Finished with recursing this branch, so we remove our // ID again: usedIds.remove(string.getId()); return result; } } // If we get to this point, the string has obviously not been defined locally. // Check if one of the standard BibTeX month strings has been used: Optional<Month> month = Month.getMonthByShortName(label); return month.map(Month::getFullName).orElse(null); } private String resolveContent(String result, Set<String> usedIds, Set<String> allUsedIds) { String res = result; if (RESOLVE_CONTENT_PATTERN.matcher(res).matches()) { StringBuilder newRes = new StringBuilder(); int piv = 0; int next; while ((next = res.indexOf('#', piv)) >= 0) { // We found the next string ref. Append the text // up to it. if (next > 0) { newRes.append(res.substring(piv, next)); } int stringEnd = res.indexOf('#', next + 1); if (stringEnd >= 0) { // We found the boundaries of the string ref, // now resolve that one. String refLabel = res.substring(next + 1, stringEnd); String resolved = resolveString(refLabel, usedIds, allUsedIds); if (resolved == null) { // Could not resolve string. Display the # // characters rather than removing them: newRes.append(res.substring(next, stringEnd + 1)); } else { // The string was resolved, so we display its meaning only, // stripping the # characters signifying the string label: newRes.append(resolved); } piv = stringEnd + 1; } else { // We did not find the boundaries of the string ref. This // makes it impossible to interpret it as a string label. // So we should just append the rest of the text and finish. newRes.append(res.substring(next)); piv = res.length(); break; } } if (piv < (res.length() - 1)) { newRes.append(res.substring(piv)); } res = newRes.toString(); } return res; } public String getEpilog() { return epilog; } public void setEpilog(String epilog) { this.epilog = epilog; } /** * Registers an listener object (subscriber) to the internal event bus. * The following events are posted: * * - {@link EntryAddedEvent} * - {@link EntryChangedEvent} * - {@link EntryRemovedEvent} * * @param listener listener (subscriber) to add */ public void registerListener(Object listener) { this.eventBus.register(listener); } /** * Unregisters an listener object. * @param listener listener (subscriber) to remove */ public void unregisterListener(Object listener) { try { this.eventBus.unregister(listener); } catch (IllegalArgumentException e) { // occurs if the event source has not been registered, should not prevent shutdown LOGGER.debug(e); } } @Subscribe private void relayEntryChangeEvent(FieldChangedEvent event) { eventBus.post(event); } public Optional<BibEntry> getReferencedEntry(BibEntry entry) { return entry.getField(FieldName.CROSSREF).flatMap(this::getEntryByKey); } public Optional<String> getSharedDatabaseID() { return Optional.ofNullable(this.sharedDatabaseID); } public void setSharedDatabaseID(String sharedDatabaseID) { this.sharedDatabaseID = sharedDatabaseID; } public boolean isShared() { return getSharedDatabaseID().isPresent(); } public void clearSharedDatabaseID() { this.sharedDatabaseID = null; } /** * Generates and sets a random ID which is globally unique. * * @return The generated sharedDatabaseID */ public String generateSharedDatabaseID() { this.sharedDatabaseID = new BigInteger(128, new SecureRandom()).toString(32); return this.sharedDatabaseID; } public DuplicationChecker getDuplicationChecker() { return duplicationChecker; } }