diff --git a/.github/workflows/PM-label-review-process.yml b/.github/workflows/PM-label-review-process.yml new file mode 100644 index 000000000000..76574d5225b4 --- /dev/null +++ b/.github/workflows/PM-label-review-process.yml @@ -0,0 +1,22 @@ +name: Reviewer Label Management + +on: + pull_request: + types: [review_requested] + pull_request_review: + types: [submitted] + +permissions: + pull-requests: write + +jobs: + manage-reviewer-labels: + runs-on: ubuntu-latest + steps: + - name: Apply reviewer labels + uses: mazoea/ga-maz/label-review@master + with: + target-reviewer: 'vidiecan' + assigned-label: 'REVIEW-in-progress' + completed-label: 'REVIEW-done' + github-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/dspace-api/src/main/java/org/dspace/app/itemupdate/ItemFilesMetadataRepair.java b/dspace-api/src/main/java/org/dspace/app/itemupdate/ItemFilesMetadataRepair.java new file mode 100644 index 000000000000..970e8440ccc1 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/app/itemupdate/ItemFilesMetadataRepair.java @@ -0,0 +1,257 @@ +/** + * 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.app.itemupdate; + +import java.util.Iterator; +import java.util.List; +import java.util.UUID; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.CommandLineParser; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.content.Bundle; +import org.dspace.content.Collection; +import org.dspace.content.Item; +import org.dspace.content.MetadataValue; +import org.dspace.content.factory.ClarinServiceFactory; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.CollectionService; +import org.dspace.content.service.ItemService; +import org.dspace.content.service.clarin.ClarinItemService; +import org.dspace.core.Constants; +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.factory.EPersonServiceFactory; + +/** + * Documentation for this class: https://github.com/ufal/clarin-dspace/pull/1243#issue-3236707035 + */ +public class ItemFilesMetadataRepair { + + private static final Logger log = LogManager.getLogger(ItemFilesMetadataRepair.class); + + private ItemFilesMetadataRepair() { + } + + public static void main(String[] args) throws Exception { + log.info("Fixing item files metadata started."); + + Options options = new Options(); + options.addRequiredOption("e", "email", true, "admin email"); + options.addOption("c", "collection", true, "collection UUID"); + options.addOption("i", "item", true, "item UUID"); + options.addOption("d", "dry-run", false, "dry run - with no repair"); + options.addOption("h", "help", false, "help"); + options.addOption("v", "verbose", false, "verbose output"); + + CommandLineParser parser = new DefaultParser(); + try { + CommandLine line = parser.parse(options, args); + if (line.hasOption('h') || !line.hasOption('e')) { + printHelpAndExit(options); + } + String adminEmail = line.getOptionValue('e'); + String collectionUuid = line.getOptionValue('c'); + String itemUuid = line.getOptionValue('i'); + boolean verboseOutput = line.hasOption('v'); + boolean dryRun = line.hasOption('d'); + run(adminEmail, collectionUuid, itemUuid, dryRun, verboseOutput); + } catch (ParseException e) { + System.err.println("Failed to parse command line options: " + e.getMessage()); + printHelpAndExit(options); + } + + log.info("Fixing item files metadata finished."); + } + + private static void run(String adminEmail, + String collectionUuid, + String itemUuid, + boolean dryRun, + boolean verboseOutput) throws Exception { + + System.out.println("ItemFilesMetadataRepair Started.\n"); + + try (Context context = new Context(Context.Mode.READ_WRITE)) { + ItemService itemService = ContentServiceFactory.getInstance().getItemService(); + ClarinItemService clarinItemService = ClarinServiceFactory.getInstance().getClarinItemService(); + + EPerson eperson = EPersonServiceFactory.getInstance().getEPersonService().findByEmail(context, adminEmail); + context.turnOffAuthorisationSystem(); + context.setCurrentUser(eperson); + context.restoreAuthSystemState(); + + String messagePrefix = dryRun ? "Found incorrect files metadata in" : "Updated"; + if (itemUuid != null) { + // fixing only one item + Item item = itemService.find(context, UUID.fromString(itemUuid)); + if (item == null) { + throw new IllegalArgumentException("Item not found with the provided UUID"); + } + boolean updated = updateItem(item, context, clarinItemService, itemService, dryRun, verboseOutput); + if (updated) { + System.out.println(dryRun ? "Files metadata are incorrect." : "Files metadata were updated."); + } else { + System.out.println("Files metadata are correct."); + } + } else if (collectionUuid != null) { + // fixing items in collection + CollectionService collectionService = ContentServiceFactory.getInstance().getCollectionService(); + Collection collection = collectionService.find(context, UUID.fromString(collectionUuid)); + if (collection == null) { + throw new IllegalArgumentException("Invalid Collection UUID"); + } + Iterator itemIterator = itemService.findAllByCollection(context, collection); + Results results = + updateItems(itemIterator, context, clarinItemService, itemService, dryRun, verboseOutput); + System.out.printf("Checked %d items in Collection: \"%s\".\n", + results.getItemsCount(), collection.getName()); + System.out.printf("%s %d items.\n", messagePrefix, results.getUpdatedItemsCount()); + } else { + // fixing all items + Iterator itemIterator = itemService.findAll(context); + Results results = + updateItems(itemIterator, context, clarinItemService, itemService, dryRun, verboseOutput); + System.out.printf("Checked %d items.\n", results.getItemsCount()); + System.out.printf("%s %d items.\n", messagePrefix, results.getUpdatedItemsCount()); + } + context.complete(); + } + + System.out.println("\nItemFilesMetadataRepair Finished"); + } + + private static Results updateItems(Iterator itemIterator, + Context context, + ClarinItemService clarinItemService, + ItemService itemService, + boolean dryRun, + boolean verboseOutput) throws Exception { + int itemsCount = 0; + int updatedItemsCount = 0; + while (itemIterator.hasNext()) { + itemsCount++; + boolean updated = + updateItem(itemIterator.next(), context, clarinItemService, itemService, dryRun, verboseOutput); + if (updated) { + updatedItemsCount++; + } + } + + return new Results(itemsCount, updatedItemsCount); + } + + private static boolean updateItem(Item item, + Context context, + ClarinItemService clarinItemService, + ItemService itemService, + boolean dryRun, + boolean verboseOutput) throws Exception { + boolean updated = false; + + List filesCountValues = + itemService.getMetadata(item, "local", "files", "count", Item.ANY); + List filesSizeValues = + itemService.getMetadata(item, "local", "files", "size", Item.ANY); + List hasFilesValues = + itemService.getMetadata(item, "local", "has", "files", Item.ANY); + + int filesCount = 0; + String filesCountValue = "undefined"; + if (!filesCountValues.isEmpty()) { + filesCountValue = filesCountValues.get(0).getValue(); + try { + filesCount = Integer.parseInt(filesCountValue); + } catch (NumberFormatException ex) { + // filesCount = 0 + } + } + long filesSize = 0; + String filesSizeValue = "undefined"; + if (!filesSizeValues.isEmpty()) { + filesSizeValue = filesSizeValues.get(0).getValue(); + try { + filesSize = Long.parseLong(filesSizeValue); + } catch (NumberFormatException ex) { + // filesSize = 0 + } + } + String hasFiles = hasFilesValues.isEmpty() ? "no" : hasFilesValues.get(0).getValue(); + + List originalBundles = item.getBundles(Constants.CONTENT_BUNDLE_NAME); + if (!CollectionUtils.isEmpty(originalBundles)) { + Bundle bundle = originalBundles.get(0); + boolean hasBitstreams = !CollectionUtils.isEmpty(bundle.getBitstreams()); + if (hasBitstreams && (filesCount == 0 || filesSize == 0 || !"yes".equals(hasFiles))) { + if (verboseOutput) { + String message = "Incorrect metadata: [files.count: %s, files.size: %s, has.files: %s], " + + "in item '%s' with files."; + System.out.printf((message) + "%n", filesCountValue, filesSizeValue, hasFiles, item.getHandle()); + } + if (!dryRun) { + clarinItemService.updateItemFilesMetadata(context, item, bundle); + } + updated = true; + } else if (!hasBitstreams && (filesCount > 0 || filesSize > 0 || "yes".equals(hasFiles))) { + if (verboseOutput) { + String message = "Incorrect metadata: [files.count: %s, files.size: %s, has.files: %s], " + + "in item '%s' without files."; + System.out.printf((message) + "%n", filesCountValue, filesSizeValue, hasFiles, item.getHandle()); + } + if (!dryRun) { + itemService.clearMetadata( + context, item, "local", "has", "files", Item.ANY); + itemService.clearMetadata( + context, item, "local", "files", "count", Item.ANY); + itemService.clearMetadata( + context, item, "local", "files", "size", Item.ANY); + itemService.addMetadata( + context, item, "local", "has", "files", Item.ANY, "no"); + itemService.addMetadata( + context, item, "local", "files", "count", Item.ANY, "0"); + itemService.addMetadata( + context, item, "local", "files", "size", Item.ANY, "0"); + } + updated = true; + } + } + return updated; + } + + private static void printHelpAndExit(Options options) { + // print the help message + HelpFormatter myHelp = new HelpFormatter(); + myHelp.printHelp("dsrun org.dspace.app.itemupdate.ItemFilesMetadataRepair \n", options); + System.exit(0); + } + + private static class Results { + private final int itemsCount; + private final int updatedItemsCount; + + public Results(int itemsCount, int updatedItemsCount) { + this.itemsCount = itemsCount; + this.updatedItemsCount = updatedItemsCount; + } + + public int getItemsCount() { + return itemsCount; + } + + public int getUpdatedItemsCount() { + return updatedItemsCount; + } + } + +} diff --git a/dspace-api/src/main/java/org/dspace/xmlworkflow/XmlWorkflowServiceImpl.java b/dspace-api/src/main/java/org/dspace/xmlworkflow/XmlWorkflowServiceImpl.java index 51292fd4773a..fddb86eb7260 100644 --- a/dspace-api/src/main/java/org/dspace/xmlworkflow/XmlWorkflowServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/xmlworkflow/XmlWorkflowServiceImpl.java @@ -264,7 +264,11 @@ public void alertUsersOnTaskActivation(Context c, XmlWorkflowItem wfi, String em mail.addArgument(argument); } for (EPerson anEpa : epa) { - mail.addRecipient(anEpa.getEmail()); + String email = anEpa.getEmail(); + if (email != null && email.contains(";")) { + email = email.split(";")[0].trim(); + } + mail.addRecipient(email); } mail.send(); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinBitstreamImportController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinBitstreamImportController.java index 1620cb0609fc..940bbf85d193 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinBitstreamImportController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinBitstreamImportController.java @@ -33,6 +33,7 @@ import org.dspace.content.service.BundleService; import org.dspace.content.service.ItemService; import org.dspace.content.service.clarin.ClarinBitstreamService; +import org.dspace.content.service.clarin.ClarinItemService; import org.dspace.core.Constants; import org.dspace.core.Context; import org.springframework.beans.factory.annotation.Autowired; @@ -72,6 +73,8 @@ public class ClarinBitstreamImportController { private Utils utils; @Autowired private MostRecentChecksumService checksumService; + @Autowired + protected ClarinItemService clarinItemService; /** * Endpoint for import bitstream, whose file already exists in assetstore under internal_id @@ -197,6 +200,9 @@ public BitstreamRest importBitstreamForExistingFile(HttpServletRequest request) throw new AccessDeniedException("You do not have write rights to update the Bundle's item"); } if (item != null) { + // Update item file metadata after the bitstream size has changed + clarinItemService.updateItemFilesMetadata(context, + item, bundle); itemService.update(context, item); } bundleService.update(context, bundle); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinRefBoxController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinRefBoxController.java index 05600878b97f..3e889361609a 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinRefBoxController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinRefBoxController.java @@ -25,6 +25,7 @@ import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -47,15 +48,22 @@ import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; import org.dspace.app.rest.converter.ConverterService; +import org.dspace.app.rest.exception.UnprocessableEntityException; import org.dspace.app.rest.model.ClarinFeaturedServiceRest; +import org.dspace.app.rest.model.refbox.ExportFormatDTO; +import org.dspace.app.rest.model.refbox.FeaturedServiceDTO; +import org.dspace.app.rest.model.refbox.FeaturedServiceLinkDTO; +import org.dspace.app.rest.model.refbox.RefBoxDTO; import org.dspace.app.rest.utils.ContextUtil; import org.dspace.app.rest.utils.Utils; +import org.dspace.content.DSpaceObject; import org.dspace.content.Item; import org.dspace.content.MetadataValue; import org.dspace.content.clarin.ClarinFeaturedService; import org.dspace.content.clarin.ClarinFeaturedServiceLink; import org.dspace.content.service.ItemService; import org.dspace.core.Context; +import org.dspace.handle.service.HandleService; import org.dspace.services.ConfigurationService; import org.dspace.xoai.services.api.config.XOAIManagerResolver; import org.dspace.xoai.services.api.config.XOAIManagerResolverException; @@ -90,6 +98,13 @@ public class ClarinRefBoxController { private final static String BIBTEX_TYPE = "bibtex"; + /** + * Default language for the RefBox metadata values + * This will be changed in the future to support multiple languages, probably fetching the language from the + * request, but for now there is a mess in the metadata value languages, so we will use the default. + */ + private final static String DEFAULT_LANGUAGE = "*"; + private final Logger log = org.apache.logging.log4j.LogManager.getLogger(ClarinRefBoxController.class); @Autowired @@ -119,6 +134,9 @@ public class ClarinRefBoxController { @Autowired private ItemRepositoryResolver itemRepositoryResolver; + @Autowired + private HandleService handleService; + private final DSpaceResumptionTokenFormatter resumptionTokenFormat = new DSpaceResumptionTokenFormatter(); /** @@ -163,7 +181,7 @@ public Page getServices(@RequestParam(name = "id") UU // Check if the item has the metadata for this featured service, if it doesn't have - do NOT return the // featured service. List itemMetadata = itemService.getMetadata(item, "local", "featuredService", - featuredServiceName, Item.ANY, false); + featuredServiceName, DEFAULT_LANGUAGE); if (CollectionUtils.isEmpty(itemMetadata)) { continue; } @@ -286,6 +304,190 @@ public ResponseEntity getCitationText(@RequestParam(name = "type") String type, return new ResponseEntity<>(oaiMetadataWrapper, HttpStatus.valueOf(SC_OK)); } + /** + * Get the RefBox information based on the handle. + * It returns the display text, export formats and featured services. + */ + @RequestMapping(method = RequestMethod.GET, produces = "application/json") + public ResponseEntity getRefboxInfo( + @RequestParam(name = "handle") String handle, + HttpServletRequest request) throws SQLException { + + Context context = ContextUtil.obtainContext(request); + if (context == null) { + throw new RuntimeException("Cannot obtain the context from the request."); + } + + DSpaceObject dSpaceObject = handleService.resolveToObject(context, handle); + if (!(dSpaceObject instanceof Item)) { + throw new UnprocessableEntityException("The handle does not resolve to an Item."); + } + Item item = (Item) dSpaceObject; + + String title = itemService.getMetadataFirstValue(item, "dc", "title", null, DEFAULT_LANGUAGE); + String displayText = buildDisplayText(context, item); + + // Build exportFormats as a map with "exportFormat" key + Map> exportFormatsMap = new HashMap<>(); + exportFormatsMap.put("exportFormat", buildExportFormats(item)); + + // Build featuredServices as a map with "featuredService" key + Map> featuredServicesMap = new HashMap<>(); + featuredServicesMap.put("featuredService", buildFeaturedServices(context, item)); + + // Pass these maps to RefBoxDTO + RefBoxDTO refBoxDTO = new RefBoxDTO( + displayText, + exportFormatsMap, + featuredServicesMap, + title != null ? title : "" + ); + return ResponseEntity.ok(refBoxDTO); + } + + /** + * Build the display text for the RefBox based on the Item Metadata. + */ + private String buildDisplayText(Context context, Item item) { + // 1. Authors + List authors = itemService.getMetadata(item, "dc", "contributor", "author", DEFAULT_LANGUAGE) + .stream().map(MetadataValue::getValue).collect(Collectors.toList()); + // If there are no authors, try to get the publisher metadata + if (authors.isEmpty()) { + authors = itemService.getMetadata(item, "dc", "publisher", null, DEFAULT_LANGUAGE) + .stream().map(MetadataValue::getValue).collect(Collectors.toList()); + } + String authorText = formatAuthors(authors); + + // 2. Year + String year = ""; + String issued = itemService.getMetadataFirstValue(item, "dc", "date", "issued", DEFAULT_LANGUAGE); + if (issued != null && !issued.isEmpty()) { + // The issued date is in the format YYYY-MM-DD, we take the year part + year = issued.split("-")[0]; + } + + // 3. Title + String title = itemService.getMetadataFirstValue(item, "dc", "title", null, DEFAULT_LANGUAGE); + + // 4. Repository name + String repository = configurationService.getProperty("dspace.name"); + + // 5. Identifier URI (prefer DOI) + String identifier = itemService.getMetadataFirstValue(item, "dc", "identifier", "doi", DEFAULT_LANGUAGE); + if (identifier == null) { + identifier = itemService.getMetadataFirstValue(item, "dc", "identifier", "uri", DEFAULT_LANGUAGE); + } + + // 6. Format + // Using html tags to format the output because this display text will be rendered in the UI + StringBuilder sb = new StringBuilder(); + if (authorText != null && !authorText.isEmpty()) { + sb.append(authorText); + } + if (year != null && !year.isEmpty()) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(year); + } + sb.append(", \n ").append(title != null ? title : "").append(""); + if (repository != null && !repository.isEmpty()) { + sb.append(", ").append(repository); + } + sb.append(", \n ") + .append(identifier != null ? identifier : "").append("."); + return sb.toString(); + } + + /** + * Format the authors for the display text. + * If there is one author, it will return that author. + * If there are 2-5 authors, it will join them with "; " and replace the last ";" with " and". + * If there are more than 5 authors, it will return the first author and "et al.". + */ + private String formatAuthors(List authors) { + String authorText = ""; + if (authors.size() == 1) { + authorText = authors.get(0); + } else if (authors.size() <= 5) { + authorText = String.join("; ", authors); + authorText = authorText.replaceAll("; ([^;]*)$", " and $1"); + } else { + authorText = authors.get(0) + "; et al."; + } + return authorText; + } + + /** + * Build the export formats for the RefBox based on the Item handle. + * It returns a list of ExportFormatDTO objects with the URL to the citation data. + */ + private List buildExportFormats(Item item) { + List exportFormats = new ArrayList<>(); + String itemHandle = item.getHandle(); + if (itemHandle != null) { + String baseUrl = configurationService.getProperty("dspace.server.url") + + "/api/core/refbox/citations?handle=/" + Utils.getCanonicalHandleUrlNoProtocol(item); + + String bibtexUrl = baseUrl + "&type=bibtex"; + String cmdiUrl = baseUrl + "&type=cmdi"; + + exportFormats.add(new ExportFormatDTO("bibtex", bibtexUrl, "json", "")); + exportFormats.add(new ExportFormatDTO("cmdi", cmdiUrl, "json", "")); + } else { + log.error("Item with ID {} does not have a handle, export formats cannot be built.", item.getID()); + } + return exportFormats; + } + + /** + * Build the featured services for the RefBox based on the Item Metadata. + * This method retrieves the metadata values for the featured services, + * groups them by service name (qualifier), + * and constructs a list of FeaturedServiceDTO objects + * with the full name, URL, description, and links. + */ + private List buildFeaturedServices(Context context, Item item) { + List fsMeta = itemService.getMetadata(item, "local", "featuredService", "*", DEFAULT_LANGUAGE); + Map> serviceLinksMap = new HashMap<>(); + + // Group links by service name (qualifier) + for (MetadataValue mv : fsMeta) { + String qualifier = mv.getMetadataField().getQualifier(); + if (qualifier == null) { + continue; + } + String[] parts = mv.getValue().split("\\|"); + if (parts.length == 2) { + serviceLinksMap + .computeIfAbsent(qualifier, k -> new ArrayList<>()) + .add(new FeaturedServiceLinkDTO(parts[0], parts[1])); + } else { + log.error("Invalid metadata value format for featured service: {}. " + + "Expected format is '|'.", mv.getValue()); + } + } + + List featuredServiceList = new ArrayList<>(); + // Iterate over the grouped service links and create FeaturedServiceDTO objects + for (Map.Entry> entry : serviceLinksMap.entrySet()) { + String name = entry.getKey(); + String fullname = configurationService.getProperty("featured.service." + name + ".fullname"); + String url = configurationService.getProperty("featured.service." + name + ".url"); + String description = configurationService.getProperty("featured.service." + name + ".description"); + Map> linksMap = new HashMap<>(); + linksMap.put("entry", entry.getValue()); + featuredServiceList.add(new FeaturedServiceDTO( + fullname != null ? fullname : name, + url != null ? url : "", + description != null ? description : "", + linksMap + )); + } + return featuredServiceList; + } + private void closeContext(Context context) { if (Objects.nonNull(context) && context.isValid()) { context.abort(); @@ -422,20 +624,17 @@ public String toString() { * For better response parsing wrap the OAI data to the object. */ class OaiMetadataWrapper { - private String metadata; - - public OaiMetadataWrapper() { - } + private String value; - public OaiMetadataWrapper(String metadata) { - this.metadata = metadata; + public OaiMetadataWrapper(String value) { + this.value = value; } public String getMetadata() { - return metadata; + return value; } - public void setMetadata(String metadata) { - this.metadata = metadata; + public void setMetadata(String value) { + this.value = value; } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/ExportFormatDTO.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/ExportFormatDTO.java new file mode 100644 index 000000000000..8ff178e20404 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/ExportFormatDTO.java @@ -0,0 +1,57 @@ +/** + * 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.app.rest.model.refbox; + +import java.io.Serializable; + +/** + * DTO for export formats in the reference box. + * This class represents the export format details including its name, URL, data type, and extraction. + * @author Milan Majchrak (dspace at dataquest.sk) + */ +public class ExportFormatDTO implements Serializable { + private String name; + private String url; + private String dataType; + private String extract; + + public ExportFormatDTO(String name, String url, String dataType, String extract) { + this.name = name; + this.url = url; + this.dataType = dataType; + this.extract = extract; + } + + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + + public String getUrl() { + return url; + } + public void setUrl(String url) { + this.url = url; + } + + public String getDataType() { + return dataType; + } + public void setDataType(String dataType) { + this.dataType = dataType; + } + + public String getExtract() { + return extract; + } + public void setExtract(String extract) { + this.extract = extract; + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/FeaturedServiceDTO.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/FeaturedServiceDTO.java new file mode 100644 index 000000000000..0810beacd551 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/FeaturedServiceDTO.java @@ -0,0 +1,64 @@ +/** + * 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.app.rest.model.refbox; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +/** + * DTO for featured services in the reference box. + * This class represents the featured service details including its name, URL, description, and links. + * @author Milan Majchrak (dspace at dataquest.sk) + */ +public class FeaturedServiceDTO implements Serializable { + private String name; + private String url; + private String description; + private Map> links; + + public FeaturedServiceDTO(String name, String url, String description, Map> links) { + this.name = name; + this.url = url; + this.description = description; + this.links = links; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Map> getLinks() { + return links; + } + + public void setLinks(Map> links) { + this.links = links; + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/FeaturedServiceLinkDTO.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/FeaturedServiceLinkDTO.java new file mode 100644 index 000000000000..856659aa5cd4 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/FeaturedServiceLinkDTO.java @@ -0,0 +1,40 @@ +/** + * 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.app.rest.model.refbox; + +import java.io.Serializable; + +/** + * DTO for links in the featured services of the reference box. + * This class represents a link with a key and value, typically used to + * represent a service link in the featured services section. + * @author Milan Majchrak (dspace at dataquest.sk) + */ +public class FeaturedServiceLinkDTO implements Serializable { + private String key; + private String value; + + public FeaturedServiceLinkDTO(String key, String value) { + this.key = key; + this.value = value; + } + + public String getKey() { + return key; + } + public void setKey(String key) { + this.key = key; + } + + public String getValue() { + return value; + } + public void setValue(String value) { + this.value = value; + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/RefBoxDTO.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/RefBoxDTO.java new file mode 100644 index 000000000000..83f60dc967c1 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/refbox/RefBoxDTO.java @@ -0,0 +1,67 @@ +/** + * 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.app.rest.model.refbox; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +/** + * DTO for the reference box in DSpace. + * This class represents a reference box containing export formats and featured services. + * It includes display text, export formats, featured services, and a title. + * @author Milan Majchrak (dspace at dataquest.sk) + */ +public class RefBoxDTO implements Serializable { + private String displayText; + private Map> exportFormats; + private Map> featuredServices; + private String title; + + public RefBoxDTO(String displayText, + Map> exportFormats, + Map> featuredServices, + String title) { + this.displayText = displayText; + this.exportFormats = exportFormats; + this.featuredServices = featuredServices; + this.title = title; + } + + public String getDisplayText() { + return displayText; + } + + public void setDisplayText(String displayText) { + this.displayText = displayText; + } + + public Map> getExportFormats() { + return exportFormats; + } + + public void setExportFormats(Map> exportFormats) { + this.exportFormats = exportFormats; + } + + public Map> getFeaturedServices() { + return featuredServices; + } + + public void setFeaturedServices(Map> featuredServices) { + this.featuredServices = featuredServices; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ClarinEPersonImportController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ClarinEPersonImportController.java index ac5c31b211e6..780daa882248 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ClarinEPersonImportController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ClarinEPersonImportController.java @@ -21,6 +21,7 @@ import javax.servlet.http.HttpServletRequest; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.codec.DecoderException; import org.apache.commons.lang3.StringUtils; import org.dspace.app.rest.converter.ConverterService; import org.dspace.app.rest.exception.UnprocessableEntityException; @@ -32,6 +33,7 @@ import org.dspace.content.service.clarin.ClarinUserRegistrationService; import org.dspace.core.Context; import org.dspace.eperson.EPerson; +import org.dspace.eperson.PasswordHash; import org.dspace.eperson.service.EPersonService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; @@ -74,7 +76,7 @@ public class ClarinEPersonImportController { @PreAuthorize("hasAuthority('ADMIN')") @RequestMapping(method = RequestMethod.POST, value = "/eperson") public EPersonRest importEPerson(HttpServletRequest request) - throws AuthorizeException, SQLException { + throws AuthorizeException, SQLException, DecoderException { Context context = obtainContext(request); if (Objects.isNull(context)) { throw new RuntimeException("Context is null!"); @@ -84,6 +86,9 @@ public EPersonRest importEPerson(HttpServletRequest request) String selfRegisteredString = request.getParameter("selfRegistered"); boolean selfRegistered = getBooleanFromString(selfRegisteredString); String lastActiveString = request.getParameter("lastActive"); + String passwordHashStr = request.getParameter("passwordHashStr"); + String algorithm = request.getParameter("digestAlgorithm"); + String salt = request.getParameter("salt"); Date lastActive; try { lastActive = getDateFromString(lastActiveString); @@ -95,6 +100,9 @@ public EPersonRest importEPerson(HttpServletRequest request) EPerson eperson = ePersonService.find(context, UUID.fromString(epersonRest.getUuid())); eperson.setSelfRegistered(selfRegistered); eperson.setLastActive(lastActive); + //the password is already hashed in the request + PasswordHash passwordHash = new PasswordHash(algorithm, salt, passwordHashStr); + ePersonService.setPasswordHash(eperson, passwordHash); ePersonService.update(context, eperson); epersonRest = converter.toRest(eperson, utils.obtainProjection()); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/Utils.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/Utils.java index 4d47e6484b64..bb5357f30524 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/Utils.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/Utils.java @@ -93,9 +93,11 @@ import org.dspace.app.rest.repository.ReloadableEntityObjectRepository; import org.dspace.content.BitstreamFormat; import org.dspace.content.DSpaceObject; +import org.dspace.content.Item; import org.dspace.content.service.BitstreamFormatService; import org.dspace.content.service.DSpaceObjectService; import org.dspace.core.Context; +import org.dspace.handle.HandlePlugin; import org.dspace.services.ConfigurationService; import org.dspace.services.RequestService; import org.dspace.services.factory.DSpaceServicesFactory; @@ -1244,4 +1246,18 @@ public static Predicate distinctByKey(Function keyExtr Map map = new ConcurrentHashMap<>(); return t -> map.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) == null; } + + /** + * Get the formatted canonical handle URL without the protocol (http:// or https://). + * This is used to create a clean URL for the export formats. + */ + public static String getCanonicalHandleUrlNoProtocol(Item item) { + String itemHandle = item.getHandle(); + if (StringUtils.isBlank(itemHandle)) { + return ""; + } + String canonicalHandleUrl = HandlePlugin.getCanonicalHandlePrefix() + itemHandle; + // Remove protocol (http:// or https://) if present + return canonicalHandleUrl.replaceFirst("^https?://", ""); + } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinEPersonImportControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinEPersonImportControllerIT.java index aef652333829..5058cfddf001 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinEPersonImportControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinEPersonImportControllerIT.java @@ -74,7 +74,11 @@ public void createEpersonTest() throws Exception { .contentType(contentType) .param("projection", "full") .param("selfRegistered", "true") - .param("lastActive", "2018-02-10T13:21:29.733")) + .param("lastActive", "2018-02-10T13:21:29.733") + .param("passwordHashStr", + "5b62ec4a3492ec34f1e659e93a6c204c8a72b2f53d1a60e83f48bdb2e5718ebf") + .param("salt", "7f9d4e63bde7a0d546d8e6f4e32870f3") + .param("digestAlgorithm", "SHA-512")) .andExpect(status().isOk()) .andDo(result -> idRef .set(UUID.fromString(read(result.getResponse().getContentAsString(), "$.id")))); @@ -88,7 +92,7 @@ public void createEpersonTest() throws Exception { assertFalse(createdEperson.getRequireCertificate()); assertEquals(createdEperson.getFirstName(), "John"); assertEquals(createdEperson.getLastName(), "Doe"); - + assertTrue(createdEperson.hasPasswordSet()); } finally { EPersonBuilder.deleteEPerson(idRef.get()); } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinRefBoxControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinRefBoxControllerIT.java index 9a00c5f4ece0..ce87f39bff7d 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinRefBoxControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinRefBoxControllerIT.java @@ -7,23 +7,31 @@ */ package org.dspace.app.rest; +import static org.hamcrest.Matchers.hasItem; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.dspace.app.rest.test.AbstractControllerIntegrationTest; +import org.dspace.app.rest.utils.Utils; import org.dspace.builder.CollectionBuilder; import org.dspace.builder.CommunityBuilder; import org.dspace.builder.ItemBuilder; import org.dspace.content.Collection; import org.dspace.content.Item; +import org.dspace.services.ConfigurationService; import org.junit.Before; import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; /** * The Integration Test class for the ClarinRefBoxController. */ public class ClarinRefBoxControllerIT extends AbstractControllerIntegrationTest { + @Autowired + ConfigurationService configurationService; + // FS = featuredService private Item itemWithFS; private Item item; @@ -65,4 +73,256 @@ public void returnFeaturedServiceWithLinks() throws Exception { getClient(token).perform(get("/api/core/refbox/services?id=" + itemWithFS.getID())) .andExpect(status().isOk()); } + + @Test + public void testReturnAllRefboxInfoForItemWithFeaturedService() throws Exception { + String token = getAuthToken(admin.getEmail(), password); + String handle = itemWithFS.getHandle(); + String baseUrl = configurationService.getProperty("dspace.server.url") + + "/api/core/refbox/citations?handle=/" + Utils.getCanonicalHandleUrlNoProtocol(itemWithFS); + String bibtexUrl = baseUrl + "&type=bibtex"; + String cmdiUrl = baseUrl + "&type=cmdi"; + + getClient(token).perform(get("/api/core/refbox?handle=" + handle)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").value(org.hamcrest.Matchers.containsString("Test author"))) + .andExpect(jsonPath("$.title").value("Public item 1")) + // For exportFormats + .andExpect(jsonPath("$.exportFormats.exportFormat[*].name", hasItem("bibtex"))) + .andExpect(jsonPath("$.exportFormats.exportFormat[*].name", hasItem("cmdi"))) + .andExpect(jsonPath("$.exportFormats.exportFormat[?(@.name=='bibtex')].url").value(hasItem(bibtexUrl))) + .andExpect(jsonPath("$.exportFormats.exportFormat[?(@.name=='cmdi')].url").value(hasItem(cmdiUrl))) + // For featuredServices + .andExpect(jsonPath("$.featuredServices.featuredService[*].name", hasItem("KonText"))) + .andExpect(jsonPath("$.featuredServices.featuredService[*].name", hasItem("PML-TQ"))) + .andExpect(jsonPath("$.featuredServices.featuredService[?(@.name=='KonText')].links.entry[*].key" + , hasItem("Slovak"))) + .andExpect(jsonPath("$.featuredServices.featuredService[?(@.name=='KonText')].links.entry[*].value" + , hasItem("URLSlovak"))) + .andExpect(jsonPath("$.featuredServices.featuredService[?(@.name=='KonText')].links.entry[*].key" + , hasItem("Czech"))) + .andExpect(jsonPath("$.featuredServices.featuredService[?(@.name=='KonText')].links.entry[*].value" + , hasItem("URLCzech"))) + .andExpect(jsonPath("$.featuredServices.featuredService[?(@.name=='PML-TQ')].links.entry[*].key" + , hasItem("Arabic"))) + .andExpect(jsonPath("$.featuredServices.featuredService[?(@.name=='PML-TQ')].links.entry[*].value" + , hasItem("URLArabic"))); + } + + @Test + public void testReturnAllRefboxInfoForItemWithEmptyFeaturedService() throws Exception { + String token = getAuthToken(admin.getEmail(), password); + String handle = item.getHandle(); + String baseUrl = configurationService.getProperty("dspace.server.url") + + "/api/core/refbox/citations?handle=/" + Utils.getCanonicalHandleUrlNoProtocol(item); + String bibtexUrl = baseUrl + "&type=bibtex"; + String cmdiUrl = baseUrl + "&type=cmdi"; + + getClient(token).perform(get("/api/core/refbox?handle=" + handle)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText") + .value(org.hamcrest.Matchers.containsString("Test author 2"))) + .andExpect(jsonPath("$.displayText") + .value(org.hamcrest.Matchers.containsString(item.getHandle()))) + .andExpect(jsonPath("$.title").value("Public item 2")) + .andExpect(jsonPath("$.exportFormats.exportFormat[*].name", hasItem("bibtex"))) + .andExpect(jsonPath("$.exportFormats.exportFormat[*].name", hasItem("cmdi"))) + .andExpect(jsonPath("$.exportFormats.exportFormat[?(@.name=='bibtex')].url") + .value(hasItem(bibtexUrl))) + .andExpect(jsonPath("$.exportFormats.exportFormat[?(@.name=='cmdi')].url") + .value(hasItem(cmdiUrl))) + .andExpect(jsonPath("$.exportFormats.exportFormat[?(@.name=='bibtex')].extract") + .value(hasItem(""))) + .andExpect(jsonPath("$.exportFormats.exportFormat[?(@.name=='cmdi')].extract") + .value(hasItem(""))) + .andExpect(jsonPath("$.exportFormats.exportFormat[?(@.name=='bibtex')].dataType") + .value(hasItem("json"))) + .andExpect(jsonPath("$.exportFormats.exportFormat[?(@.name=='cmdi')].dataType") + .value(hasItem("json"))) + .andExpect(jsonPath("$.featuredServices.featuredService").isEmpty()); + } + + @Test + public void testRefboxInfoWithNullHandleParam() throws Exception { + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox")) + .andExpect(status().is4xxClientError()); + } + + @Test + public void testRefboxInfoWithMalformedHandle() throws Exception { + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=notAHandle")) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + public void testRefboxInfoWithOnlyPublisher() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemPublisher = ItemBuilder.createItem(context, collection) + .withMetadata("dc", "publisher", null, "Test Publisher") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemPublisher.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").value(org.hamcrest.Matchers.containsString("Test Publisher"))); + } + + @Test + public void testRefboxInfoWithOnlyYear() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemYear = ItemBuilder.createItem(context, collection) + .withIssueDate("2022") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemYear.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").value(org.hamcrest.Matchers.containsString("2022"))); + } + + @Test + public void testRefboxInfoWithOnlyTitle() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemTitle = ItemBuilder.createItem(context, collection) + .withTitle("Title Only") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemTitle.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").value(org.hamcrest.Matchers.containsString("Title Only"))); + } + + @Test + public void testRefboxInfoWithDOIAndHandle() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemDOI = ItemBuilder.createItem(context, collection) + .withTitle("DOI Item") + .withDoiIdentifier("10.1234/abcd") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemDOI.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").value(org.hamcrest.Matchers.containsString("10.1234/abcd"))); + } + + @Test + public void testRefboxInfoWithWhitespaceMetadata() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemWhitespace = ItemBuilder.createItem(context, collection) + .withTitle(" ") + .withAuthor(" ") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemWhitespace.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").exists()); + } + + @Test + public void testFeaturedServiceWithDuplicateEntries() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemDupFS = ItemBuilder.createItem(context, collection) + .withMetadata("local", "featuredService", "kontext", "Key1|Value1") + .withMetadata("local", "featuredService", "kontext", "Key1|Value1") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemDupFS.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.featuredServices.featuredService[0].links.entry.length()").value(2)); + } + + @Test + public void testFeaturedServiceWithMalformedLink() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemMalformed = ItemBuilder.createItem(context, collection) + .withMetadata("local", "featuredService", "kontext", "NoPipeDelimiter") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemMalformed.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.featuredServices.featuredService").isEmpty()); + } + + @Test + public void testDisplayTextWithOneAuthor() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemOneAuthor = ItemBuilder.createItem(context, collection) + .withAuthor("Single Author") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemOneAuthor.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").value(org.hamcrest.Matchers.containsString("Single Author"))); + } + + @Test + public void testDisplayTextWithTwoAuthors() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemTwoAuthors = ItemBuilder.createItem(context, collection) + .withAuthor("Author One") + .withAuthor("Author Two") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemTwoAuthors.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").value(org.hamcrest.Matchers.containsString( + "Author One and Author Two"))); + } + + @Test + public void testDisplayTextWithFiveAuthors() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemFiveAuthors = ItemBuilder.createItem(context, collection) + .withAuthor("A1") + .withAuthor("A2") + .withAuthor("A3") + .withAuthor("A4") + .withAuthor("A5") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemFiveAuthors.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").value(org.hamcrest.Matchers.containsString( + "A1; A2; A3; A4 and A5"))); + } + + @Test + public void testDisplayTextWithMoreThanFiveAuthors() throws Exception { + context.turnOffAuthorisationSystem(); + Item itemManyAuthors = ItemBuilder.createItem(context, collection) + .withAuthor("First Author") + .withAuthor("Second Author") + .withAuthor("Third Author") + .withAuthor("Fourth Author") + .withAuthor("Fifth Author") + .withAuthor("Sixth Author") + .build(); + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get("/api/core/refbox?handle=" + itemManyAuthors.getHandle())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayText").value(org.hamcrest.Matchers.containsString( + "First Author; et al."))); + } } diff --git a/dspace/config/spring/api/versioning-service.xml b/dspace/config/spring/api/versioning-service.xml index 69dacbcbe028..1a5358edd777 100644 --- a/dspace/config/spring/api/versioning-service.xml +++ b/dspace/config/spring/api/versioning-service.xml @@ -22,6 +22,7 @@ dc.date.accessioned dc.description.provenance + dc.identifier.uri