Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ public Iterator<MetadataValue> findByValueLike(Context context, String value) th
return metadataValueDAO.findByValueLike(context, value);
}

@Override
public List<MetadataValue> findByAuthorityAndLanguage(Context context, String authority, String language)
throws SQLException {
return metadataValueDAO.findByAuthorityAndLanguage(context, authority, language);
}

@Override
public void deleteByMetadataField(Context context, MetadataField metadataField) throws SQLException {
metadataValueDAO.deleteByMetadataField(context, metadataField);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.content.authority;

import java.sql.SQLException;
import java.util.List;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.dspace.content.MetadataValue;
import org.dspace.content.factory.ContentServiceFactory;
import org.dspace.content.service.MetadataValueService;
import org.dspace.core.Context;
import org.dspace.external.CachingOrcidRestConnector;
import org.dspace.external.provider.orcid.xml.ExpandedSearchConverter;
import org.dspace.utils.DSpace;
import org.dspace.web.ContextUtil;


/**
* ChoiceAuthority using the ORCID API.
* It uses the orcid as the authority value and thus is simpler to use then the * SolrAuthority.
*/
public class SimpleORCIDAuthority implements ChoiceAuthority {

private static final Logger log = LogManager.getLogger(SimpleORCIDAuthority.class);

private String pluginInstanceName;
private final CachingOrcidRestConnector orcidRestConnector = new DSpace().getServiceManager().getServiceByName(
"CachingOrcidRestConnector", CachingOrcidRestConnector.class);
private final MetadataValueService metadataValueService = ContentServiceFactory.getInstance()
.getMetadataValueService();
private static final int maxResults = 100;

/**
* Get all values from the authority that match the preferred value.
* Note that the offering was entered by the user and may contain
* mixed/incorrect case, whitespace, etc so the plugin should be careful
* to clean up user data before making comparisons.
* <p>
* Value of a "Name" field will be in canonical DSpace person name format,
* which is "Lastname, Firstname(s)", e.g. "Smith, John Q.".
* <p>
* Some authorities with a small set of values may simply return the whole
* set for any sample value, although it's a good idea to set the
* defaultSelected index in the Choices instance to the choice, if any,
* that matches the value.
*
* @param text user's value to match
* @param start choice at which to start, 0 is first.
* @param limit maximum number of choices to return, 0 for no limit.
* @param locale explicit localization key if available, or null
* @return a Choices object (never null).
*/
@Override
public Choices getMatches(String text, int start, int limit, String locale) {
log.debug("getMatches: " + text + ", start: " + start + ", limit: " + limit + ", locale: " + locale);
if (text == null || text.trim().isEmpty()) {
return new Choices(true);
}

start = Math.max(start, 0);
if (limit < 1 || limit > maxResults) {
limit = maxResults;
}

ExpandedSearchConverter.Results search = orcidRestConnector.search(text, start, limit);
List<Choice> choices = search.results().stream()
.map(this::toChoice)
.collect(Collectors.toList());


int confidence = !search.isOk() ? Choices.CF_FAILED :
choices.isEmpty() ? Choices.CF_NOTFOUND :
choices.size() == 1 ? Choices.CF_UNCERTAIN
: Choices.CF_AMBIGUOUS;
int total = search.numFound().intValue();
return new Choices(choices.toArray(new Choice[0]), start, total,
confidence, total > (start + limit));
}

/**
* Get the single "best" match (if any) of a value in the authority
* to the given user value. The "confidence" element of Choices is
* expected to be set to a meaningful value about the circumstances of
* this match.
* <p>
* This call is typically used in non-interactive metadata ingest
* where there is no interactive agent to choose from among options.
*
* @param text user's value to match
* @param locale explicit localization key if available, or null
* @return a Choices object (never null) with 1 or 0 values.
*/
@Override
public Choices getBestMatch(String text, String locale) {
log.debug("getBestMatch: " + text);
Choices matches = getMatches(text, 0, 1, locale);
if (matches.values.length != 0 && !matches.values[0].value.equalsIgnoreCase(text)) {
// novalue
matches = new Choices(false);
}
return matches;
}

/**
* Get the canonical user-visible "label" (i.e. short descriptive text)
* for a key in the authority. Can be localized given the implicit
* or explicit locale specification.
* <p>
* This may get called many times while populating a Web page so it should
* be implemented as efficiently as possible.
*
* @param key authority key known to this authority.
* @param locale explicit localization key if available, or null
* @return descriptive label - should always return something, never null.
*/
@Override
public String getLabel(String key, String locale) {
log.debug("getLabel: " + key);
String label = orcidRestConnector.getLabel(key);
if (label != null) {
return label;
}

return resolveLocalLabel(key, locale);
}

private String resolveLocalLabel(String key, String locale) {
Context requestContext = ContextUtil.obtainCurrentRequestContext();
Context context = requestContext;

try {
if (context == null) {
context = createReadOnlyContext();
}
if (context == null) {
return key;
}
return queryLabel(context, key, locale);
} catch (Exception e) {
log.error("Error resolving local label for authority key '{}'", key, e);
return key;
}
// We intentionally do NOT call context.abort() on a locally created Context.
// During CLI operations (e.g. reindexing), the new Context shares the Hibernate
// session (thread-local) with the caller's Context. Calling abort() triggers
// closeDBConnection() -> rollback(), which kills the shared transaction and causes
// Hibernate to clear the persistence context — detaching ALL managed entities.
// This leads to LazyInitializationException when the caller later accesses
// lazy-loaded properties (e.g. DSpaceObject.handles).
// Since we only performed read operations, no cleanup is needed.
// The session/transaction lifecycle is managed by the caller's Context.
}

Context createReadOnlyContext() {
try {
return new Context(Context.Mode.READ_ONLY);
} catch (Exception | ExceptionInInitializerError e) {
log.error("Failed to create read-only context", e);
return null;
}
}

private String queryLabel(Context context, String key, String locale) throws SQLException {
String normalizedLocale = StringUtils.isBlank(locale) ? null : locale;

List<MetadataValue> results = metadataValueService
.findByAuthorityAndLanguage(context, key, normalizedLocale);
if (!results.isEmpty()) {
return results.get(0).getValue();
}

if (normalizedLocale != null) {
List<MetadataValue> fallback = metadataValueService
.findByAuthorityAndLanguage(context, key, null);
if (!fallback.isEmpty()) {
return fallback.get(0).getValue();
}
}

return key;
}
}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extra closing brace here which prematurely ends the class/method block and will cause compilation to fail. Remove the stray '}' so the braces properly match the method/class structure.

Suggested change
}

Copilot uses AI. Check for mistakes.

/**
* Get the instance's particular name.
* Returns the name by which the class was chosen when
* this instance was created. Only works for instances created
* by <code>PluginService</code>, or if someone remembers to call <code>setPluginName.</code>
* <p>
* Useful when the implementation class wants to be configured differently
* when it is invoked under different names.
*
* @return name or null if not available.
*/
@Override
public String getPluginInstanceName() {
return pluginInstanceName;
}

/**
* Set the name under which this plugin was instantiated.
* Not to be invoked by application code, it is
* called automatically by <code>PluginService.getNamedPlugin()</code>
* when the plugin is instantiated.
*
* @param name -- name used to select this class.
*/
@Override
public void setPluginInstanceName(String name) {
this.pluginInstanceName = name;
}

private Choice toChoice(ExpandedSearchConverter.Result result) {
Choice c = new Choice(result.authority(), result.value(), result.label());
//add orcid to extras so it's shown
c.extras.put("orcid", result.authority());
// add the value to extra information only if it is present
//in dspace-angular the extras are keys for translation form.other-information.<extra>
result.creditName().ifPresent(val -> c.extras.put("credit-name", val));
result.otherNames().ifPresent(val -> c.extras.put("other-names", val));
result.institutionNames().ifPresent(val -> c.extras.put("institution", val));

return c;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public Iterator<MetadataValue> findItemValuesByFieldAndValue(Context context,

public Iterator<MetadataValue> findByValueLike(Context context, String value) throws SQLException;

public List<MetadataValue> findByAuthorityAndLanguage(Context context, String authority, String language)
throws SQLException;

public void deleteByMetadataField(Context context, MetadataField metadataField) throws SQLException;

public MetadataValue getMinimum(Context context, int metadataFieldId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,30 @@ public Iterator<MetadataValue> findByValueLike(Context context, String value) th
return iterate(query);
}

@Override
public List<MetadataValue> findByAuthorityAndLanguage(Context context, String authority, String language)
throws SQLException {
StringBuilder queryString = new StringBuilder();
queryString.append("SELECT m FROM MetadataValue m WHERE m.authority = :authority");

if (language != null) {
queryString.append(" AND m.language = :language");
}

queryString.append(" ORDER BY m.place ASC");

Query query = createQuery(context, queryString.toString());
query.setParameter("authority", authority);

if (language != null) {
query.setParameter("language", language);
}

Comment on lines +79 to +93
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When language is null, this query currently returns all languages for the authority (no language predicate). Callers (e.g. local-label fallback) appear to use language=null to mean "language IS NULL"; returning all languages can pick an arbitrary label and break locale fallback. Update the query to explicitly filter for NULL language when the parameter is null (and consider treating blank as null too).

Copilot uses AI. Check for mistakes.
@SuppressWarnings("unchecked")
List<MetadataValue> results = query.getResultList();
return results;
}

@Override
public void deleteByMetadataField(Context context, MetadataField metadataField) throws SQLException {
String queryString = "delete from MetadataValue where metadataField= :metadataField";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ public void update(Context context, MetadataValue metadataValue, boolean modifyP

public Iterator<MetadataValue> findByValueLike(Context context, String value) throws SQLException;

/**
* Retrieves matching MetadataValues for a given authority and language.
*
* @param context dspace context
* @param authority The authority value that must match
* @param language The language that must match (can be null)
* @return the matching MetadataValues
* @throws SQLException if database error
*/
public List<MetadataValue> findByAuthorityAndLanguage(Context context, String authority, String language)
throws SQLException;

public void deleteByMetadataField(Context context, MetadataField metadataField) throws SQLException;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,13 @@ public <E extends ReloadableEntity> void uncacheEntity(E entity) throws SQLExcep
} else if (entity instanceof Bundle) {
Bundle bundle = (Bundle) entity;

if (Hibernate.isInitialized(bundle.getBitstreams())) {
// Bundle.getBitstreams() creates a defensive copy via new ArrayList<>(bitstreams)
// which iterates the Hibernate proxy, triggering lazy loading unconditionally.
// Unlike Item.getBundles() which returns the raw proxy, we cannot safely call
// getBitstreams() when the bundle is detached from the session.
// Guard with session.contains(): if the bundle is still managed,
// lazy loading will work; if detached (e.g. after session.clear()), we skip.
if (getSession().contains(bundle)) {
for (Bitstream bitstream : Utils.emptyIfNull(bundle.getBitstreams())) {
uncacheEntity(bitstream);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,13 @@ public void updateIndex(Context context, boolean force, String type) {
final Iterator<IndexableObject> indexableObjects = indexableObjectService.findAll(context);
while (indexableObjects.hasNext()) {
final IndexableObject indexableObject = indexableObjects.next();
indexContent(context, indexableObject, force);
try {
indexContent(context, indexableObject, force);
} catch (RuntimeException e) {
log.error("Failed to index object {} (type={}): {}",
indexableObject.getUniqueIndexID(), indexableObject.getType(),
e.getMessage(), e);
}
context.uncacheEntity(indexableObject.getIndexedObject());
indexObject++;
if ((indexObject % 100) == 0 && indexableObjectService instanceof ItemIndexFactory) {
Expand Down
Loading
Loading