diff --git a/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.html b/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.html index 2d46cad5329..ac984b06f26 100644 --- a/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.html +++ b/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.html @@ -5,14 +5,18 @@ {{'item.view.box.author.preview.and' | translate}} - {{ author.name }} -   + {{ author.name }}  
- {{ author.name }} -  ; et al. + {{ author.name }}  ; et al.
@@ -27,8 +31,10 @@ {{'item.view.box.author.preview.and' | translate}} - {{author.name}} -   + {{author.name}}  
diff --git a/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.scss b/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.scss index 6f28ee1ce79..d0a0a47126e 100644 --- a/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.scss +++ b/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.scss @@ -1,3 +1,17 @@ /** This is a styling file for the clarin-item-author-preview component. */ + +.orcid-icon-link { + margin-left: 0.25rem; + text-decoration: none; + + &:hover { + opacity: 0.8; + } +} + +.orcid-icon { + color: #a6ce39; +} + diff --git a/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.spec.ts b/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.spec.ts index f75f22ba072..d8fa38e65c8 100644 --- a/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.spec.ts +++ b/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.spec.ts @@ -1,15 +1,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ClarinItemAuthorPreviewComponent } from './clarin-item-author-preview.component'; -import {of} from 'rxjs'; -import {ConfigurationDataService} from '../../core/data/configuration-data.service'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; describe('ClarinItemAuthorPreviewComponent', () => { let component: ClarinItemAuthorPreviewComponent; let fixture: ComponentFixture; const configurationServiceSpy = jasmine.createSpyObj('configurationService', { - findByPropertyName: of(true), + findByPropertyName: createSuccessfulRemoteDataObject$({ values: ['https://orcid.org'] }), }); beforeEach(async () => { @@ -31,4 +31,11 @@ describe('ClarinItemAuthorPreviewComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('assignOrcidDomainUrl should read orcid.domain-url from backend config', async () => { + component.orcidDomainUrl = null; + await component.assignOrcidDomainUrl(); + expect(component.orcidDomainUrl).toBe('https://orcid.org'); + expect(configurationServiceSpy.findByPropertyName).toHaveBeenCalledWith('orcid.domain-url'); + }); }); diff --git a/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.ts b/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.ts index 336b80120a5..9908f456eaf 100644 --- a/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.ts +++ b/src/app/shared/clarin-item-author-preview/clarin-item-author-preview.component.ts @@ -1,10 +1,12 @@ import { Component, Input, OnInit } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; +import { take } from 'rxjs/operators'; import { getBaseUrl, loadItemAuthors } from '../clarin-shared-util'; import { Item } from '../../core/shared/item.model'; import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; import { ConfigurationDataService } from '../../core/data/configuration-data.service'; import { AuthorNameLink } from '../clarin-item-box-view/clarin-author-name-link.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; @Component({ selector: 'ds-clarin-item-author-preview', @@ -38,11 +40,18 @@ export class ClarinItemAuthorPreviewComponent implements OnInit { */ baseUrl = ''; + /** + * ORCID domain URL loaded from the backend property `orcid.domain-url`. + * When unset (backend does not expose the property) bare ORCID iDs are kept as plain text. + */ + orcidDomainUrl: string | null = null; + constructor(protected configurationService: ConfigurationDataService) { } async ngOnInit(): Promise { await this.assignBaseUrl(); - loadItemAuthors(this.item, this.itemAuthors, this.baseUrl, this.fields); + await this.assignOrcidDomainUrl(); + loadItemAuthors(this.item, this.itemAuthors, this.baseUrl, this.fields, this.orcidDomainUrl); } toggleShowEveryAuthor() { this.showEveryAuthor.next(!this.showEveryAuthor.value); @@ -57,4 +66,20 @@ export class ClarinItemAuthorPreviewComponent implements OnInit { return baseUrlResponse?.values?.[0]; }); } + + /** + * Load `orcid.domain-url` from the backend configuration so that bare ORCID iDs + * can be expanded into a hyperlink to the configured ORCID host. + */ + async assignOrcidDomainUrl(): Promise { + this.orcidDomainUrl = await this.configurationService.findByPropertyName('orcid.domain-url').pipe( + getFirstCompletedRemoteData(), + take(1), + ).toPromise().then((rd) => { + if (rd?.hasSucceeded && rd.payload?.values?.length > 0) { + return rd.payload.values[0]; + } + return null; + }); + } } diff --git a/src/app/shared/clarin-item-box-view/clarin-author-name-link.model.ts b/src/app/shared/clarin-item-box-view/clarin-author-name-link.model.ts index 856655b17fc..74e659c7a84 100644 --- a/src/app/shared/clarin-item-box-view/clarin-author-name-link.model.ts +++ b/src/app/shared/clarin-item-box-view/clarin-author-name-link.model.ts @@ -6,4 +6,6 @@ export class AuthorNameLink { name: string; url: string; isAuthority: boolean; + isOrcid?: boolean; + orcidUrl?: string; } diff --git a/src/app/shared/clarin-shared-util.spec.ts b/src/app/shared/clarin-shared-util.spec.ts new file mode 100644 index 00000000000..b284eab69ba --- /dev/null +++ b/src/app/shared/clarin-shared-util.spec.ts @@ -0,0 +1,105 @@ +import { BehaviorSubject } from 'rxjs'; + +import { loadItemAuthors, ORCID_ID_PATTERN, ORCID_URL_PATTERN } from './clarin-shared-util'; +import { AuthorNameLink } from './clarin-item-box-view/clarin-author-name-link.model'; +import { MetadataValue } from '../core/shared/metadata.models'; + +function mv(value: string, authority?: string): MetadataValue { + return Object.assign(new MetadataValue(), { value, authority }); +} + +function itemWithAuthors(values: MetadataValue[]): any { + return { + allMetadata: () => values, + }; +} + +describe('clarin-shared-util ORCID helpers', () => { + + describe('ORCID_ID_PATTERN', () => { + it('matches a bare ORCID iD', () => { + expect(ORCID_ID_PATTERN.test('0000-0001-2345-6789')).toBeTrue(); + expect(ORCID_ID_PATTERN.test('0000-0001-2345-678X')).toBeTrue(); + }); + + it('does not match a full URL or arbitrary text', () => { + expect(ORCID_ID_PATTERN.test('https://orcid.org/0000-0001-2345-6789')).toBeFalse(); + expect(ORCID_ID_PATTERN.test('not-an-orcid')).toBeFalse(); + }); + }); + + describe('ORCID_URL_PATTERN', () => { + it('matches the production and sandbox ORCID URLs', () => { + expect(ORCID_URL_PATTERN.test('https://orcid.org/0000-0001-2345-6789')).toBeTrue(); + expect(ORCID_URL_PATTERN.test('https://sandbox.orcid.org/0000-0001-2345-678X')).toBeTrue(); + }); + + it('does not match a bare ORCID iD or non-URL strings', () => { + expect(ORCID_URL_PATTERN.test('0000-0001-2345-6789')).toBeFalse(); + expect(ORCID_URL_PATTERN.test('not-an-orcid')).toBeFalse(); + }); + }); + + describe('loadItemAuthors', () => { + const baseUrl = 'http://localhost:4000'; + const fields = ['dc.contributor.author']; + + it('builds the search link for every author with the equals operator', () => { + const subject = new BehaviorSubject([]); + loadItemAuthors(itemWithAuthors([mv('Doe, John')]), subject, baseUrl, fields); + expect(subject.value.length).toBe(1); + expect(subject.value[0].url) + .toBe('http://localhost:4000/search?f.author=Doe%2C%20John,equals'); + expect(subject.value[0].isOrcid).toBeFalse(); + }); + + it('expands a bare ORCID iD using the orcid.domain-url from the backend', () => { + const subject = new BehaviorSubject([]); + loadItemAuthors( + itemWithAuthors([mv('Doe, John', '0000-0001-2345-6789')]), + subject, baseUrl, fields, 'https://sandbox.orcid.org', + ); + expect(subject.value[0].isOrcid).toBeTrue(); + expect(subject.value[0].orcidUrl).toBe('https://sandbox.orcid.org/0000-0001-2345-6789'); + }); + + it('strips a trailing slash from the orcid.domain-url before composing the URL', () => { + const subject = new BehaviorSubject([]); + loadItemAuthors( + itemWithAuthors([mv('Doe, John', '0000-0001-2345-6789')]), + subject, baseUrl, fields, 'https://orcid.org/', + ); + expect(subject.value[0].orcidUrl).toBe('https://orcid.org/0000-0001-2345-6789'); + }); + + it('passes through an authority that already is a full ORCID URL', () => { + const subject = new BehaviorSubject([]); + loadItemAuthors( + itemWithAuthors([mv('Doe, John', 'https://orcid.org/0000-0001-2345-6789')]), + subject, baseUrl, fields, null, + ); + expect(subject.value[0].isOrcid).toBeTrue(); + expect(subject.value[0].orcidUrl).toBe('https://orcid.org/0000-0001-2345-6789'); + }); + + it('does not flag a bare ORCID iD when orcid.domain-url is missing', () => { + const subject = new BehaviorSubject([]); + loadItemAuthors( + itemWithAuthors([mv('Doe, John', '0000-0001-2345-6789')]), + subject, baseUrl, fields, null, + ); + expect(subject.value[0].isOrcid).toBeFalse(); + expect(subject.value[0].orcidUrl).toBeUndefined(); + }); + + it('does not flag a non-ORCID authority', () => { + const subject = new BehaviorSubject([]); + loadItemAuthors( + itemWithAuthors([mv('Smith, Jane', 'some-internal-authority')]), + subject, baseUrl, fields, 'https://orcid.org', + ); + expect(subject.value[0].isOrcid).toBeFalse(); + expect(subject.value[0].isAuthority).toBeTrue(); + }); + }); +}); diff --git a/src/app/shared/clarin-shared-util.ts b/src/app/shared/clarin-shared-util.ts index 44b087a9910..ea52d86bf9e 100644 --- a/src/app/shared/clarin-shared-util.ts +++ b/src/app/shared/clarin-shared-util.ts @@ -5,6 +5,20 @@ import { isNull, isUndefined } from './empty.util'; import { MetadataValue } from '../core/shared/metadata.models'; import { AuthorNameLink } from './clarin-item-box-view/clarin-author-name-link.model'; +/** + * Pattern that matches a bare ORCID iD (16 digits in groups of 4, last char may be X). + * Example: `0000-0001-2345-6789` or `0000-0001-2345-678X`. + */ +export const ORCID_ID_PATTERN = /^(\d{4}-){3}\d{3}[\dX]$/i; + +/** + * Pattern that matches a full ORCID URL authority value. Matches any ORCID-like + * host so the backend `orcid.domain-url` (e.g. `https://orcid.org`, + * `https://sandbox.orcid.org`) drives canonicalisation, not the regex. + * Example: `https://orcid.org/0000-0001-2345-6789` or `https://sandbox.orcid.org/0000-0001-2345-678X`. + */ +export const ORCID_URL_PATTERN = /^https?:\/\/[^/]+\/((\d{4}-){3}\d{3}[\dX])$/i; + /** * Convert raw byte array to the image is not secure - this function make it secure * @param imageByteArray as secure byte array @@ -44,15 +58,19 @@ export function convertMetadataFieldIntoSearchType(field: string[]) { } /** - * Load Authors of the current item into BehaviourSubject - ItemAuthors. This method also compose - * search link for every Author. + * Load Authors of the current item into BehaviourSubject - ItemAuthors. This method also composes + * the search link for every Author and, when the authority value is an ORCID iD / URL, the link + * to the ORCID profile. * * @param item current Item * @param itemAuthors BehaviourSubject (async) of Authors with search links - * @param baseUrl e.g. localhost:8080 + * @param baseUrl e.g. `localhost:8080` * @param fields metadata fields where authors are stored + * @param orcidDomainUrl ORCID domain URL loaded from the backend property `orcid.domain-url` + * (e.g. `https://orcid.org` or `https://sandbox.orcid.org`). When not provided, bare ORCID + * iDs cannot be turned into hyperlinks, but full ORCID URLs are still recognised. */ -export function loadItemAuthors(item, itemAuthors, baseUrl, fields) { +export function loadItemAuthors(item, itemAuthors, baseUrl, fields, orcidDomainUrl: string | null = null) { if (isNull(item) || isNull(itemAuthors) || isNull(baseUrl)) { return; } @@ -61,21 +79,35 @@ export function loadItemAuthors(item, itemAuthors, baseUrl, fields) { if (isUndefined(authorsMV)) { return null; } + const domain = orcidDomainUrl?.endsWith('/') ? orcidDomainUrl.slice(0, -1) : orcidDomainUrl; const itemAuthorsLocal = []; authorsMV.forEach((authorMV: MetadataValue) => { - let value: string, operator: string; + let isOrcid = false; + let orcidUrl: string; + let searchValue: string; + let searchOperator: string; if (authorMV.authority) { - value = encodeURIComponent(authorMV.authority); - operator = 'authority'; + const authority = String(authorMV.authority).trim(); + if (ORCID_URL_PATTERN.test(authority)) { + orcidUrl = authority; + isOrcid = true; + } else if (domain && ORCID_ID_PATTERN.test(authority)) { + orcidUrl = `${domain}/${authority}`; + isOrcid = true; + } + searchValue = encodeURIComponent(authorMV.authority); + searchOperator = 'authority'; } else { - value = encodeURIComponent(authorMV.value); - operator = 'equals'; + searchValue = encodeURIComponent(authorMV.value); + searchOperator = 'equals'; } - const authorSearchLink = baseUrl + '/search?f.author=' + value + ',' + operator; + const authorSearchLink = baseUrl + '/search?f.author=' + searchValue + ',' + searchOperator; const authorNameLink = Object.assign(new AuthorNameLink(), { name: authorMV.value, url: authorSearchLink, - isAuthority: !!authorMV.authority + isAuthority: !!authorMV.authority, + isOrcid: isOrcid, + orcidUrl: orcidUrl }); itemAuthorsLocal.push(authorNameLink); }); diff --git a/src/app/shared/metadata-representation/metadata-representation-loader.component.spec.ts b/src/app/shared/metadata-representation/metadata-representation-loader.component.spec.ts index 7edf1a700e5..a13160cdb79 100644 --- a/src/app/shared/metadata-representation/metadata-representation-loader.component.spec.ts +++ b/src/app/shared/metadata-representation/metadata-representation-loader.component.spec.ts @@ -10,6 +10,8 @@ import { MetadataRepresentationDirective } from './metadata-representation.direc import { METADATA_REPRESENTATION_COMPONENT_FACTORY } from './metadata-representation.decorator'; import { ThemeService } from '../theme-support/theme.service'; import { PlainTextMetadataListElementComponent } from '../object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { ConfigurationDataServiceStub } from '../testing/configuration-data.service.stub'; const testType = 'TestType'; const testContext = Context.Search; @@ -51,6 +53,10 @@ describe('MetadataRepresentationLoaderComponent', () => { { provide: ThemeService, useValue: themeService, + }, + { + provide: ConfigurationDataService, + useClass: ConfigurationDataServiceStub, } ] }).overrideComponent(MetadataRepresentationLoaderComponent, { diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html index 7d416e9f3eb..0f378935b33 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html @@ -7,7 +7,21 @@ target="_blank" [href]="mdRepresentation.getValue()"> {{mdRepresentation.getValue()}} - {{mdRepresentation.getValue()}} + + + {{mdRepresentation.getValue()}} + + + + {{mdRepresentation.getValue()}} + + { TestBed.configureTestingModule({ imports: [], declarations: [PlainTextMetadataListElementComponent], + providers: [ + { provide: ConfigurationDataService, useClass: ConfigurationDataServiceStub }, + ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(PlainTextMetadataListElementComponent, { set: { changeDetection: ChangeDetectionStrategy.Default } diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts index 8a3e1d51a6f..a64655c3054 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts @@ -1,21 +1,50 @@ import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model'; -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { take } from 'rxjs/operators'; import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component'; import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator'; import { VALUE_LIST_BROWSE_DEFINITION } from '../../../../core/shared/value-list-browse-definition.resource-type'; +import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; +import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; +import { ORCID_ID_PATTERN, ORCID_URL_PATTERN } from '../../../clarin-shared-util'; @metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText) // For now, authority controlled fields are rendered the same way as plain text fields @metadataRepresentationComponent('Publication', MetadataRepresentationType.AuthorityControlled) @Component({ selector: 'ds-plain-text-metadata-list-element', - templateUrl: './plain-text-metadata-list-element.component.html' + templateUrl: './plain-text-metadata-list-element.component.html', + styleUrls: ['./plain-text-metadata-list-element.component.scss'] }) /** * A component for displaying MetadataRepresentation objects in the form of plain text * It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text */ -export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent { +export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent implements OnInit { + + /** + * The ORCID domain URL fetched from the backend (`orcid.domain-url`), + * e.g. `https://orcid.org` or `https://sandbox.orcid.org`. + */ + orcidDomainUrl$ = new BehaviorSubject(null); + + constructor(protected configurationService: ConfigurationDataService) { + super(); + } + + ngOnInit(): void { + this.configurationService.findByPropertyName('orcid.domain-url').pipe( + getFirstCompletedRemoteData(), + take(1), + ).subscribe((rd) => { + if (rd?.hasSucceeded && rd.payload?.values?.length > 0) { + this.orcidDomainUrl$.next(rd.payload.values[0]); + } + }); + } + /** * Get the appropriate query parameters for this browse link, depending on whether the browse definition * expects 'startsWith' (eg browse by date) or 'value' (eg browse by title) @@ -27,4 +56,48 @@ export class PlainTextMetadataListElementComponent extends MetadataRepresentatio } return queryParams; } + + /** + * Check whether the authority value of this metadata is an ORCID identifier. + * Accepts either a bare ORCID iD or a full ORCID URL. A full ORCID URL is recognised + * even when the backend does not expose `orcid.domain-url`, mirroring `loadItemAuthors`. + * A bare ORCID iD requires `orcid.domain-url` to canonicalise into a full URL. + */ + isOrcidAuthority(orcidDomainUrl: string | null = this.orcidDomainUrl$.value): boolean { + if (this.mdRepresentation instanceof MetadatumRepresentation) { + const authority = this.mdRepresentation.authority?.trim(); + if (!authority) { + return false; + } + if (ORCID_URL_PATTERN.test(authority)) { + return true; + } + return !!orcidDomainUrl && ORCID_ID_PATTERN.test(authority); + } + return false; + } + + /** + * Build the full ORCID profile URL for the current author. + * Returns an empty string when the authority is not an ORCID value + * or when the ORCID domain URL is required (bare ORCID iD) but not configured on the backend. + * A full ORCID URL authority is returned as-is without requiring `orcid.domain-url`. + */ + getOrcidUrl(orcidDomainUrl: string | null = this.orcidDomainUrl$.value): string { + if (!(this.mdRepresentation instanceof MetadatumRepresentation)) { + return ''; + } + const authority = this.mdRepresentation.authority?.trim(); + if (!authority) { + return ''; + } + if (ORCID_URL_PATTERN.test(authority)) { + return authority; + } + if (orcidDomainUrl && ORCID_ID_PATTERN.test(authority)) { + const domain = orcidDomainUrl.endsWith('/') ? orcidDomainUrl.slice(0, -1) : orcidDomainUrl; + return `${domain}/${authority}`; + } + return ''; + } } diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 7f540553f03..4d0ec104948 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -3420,6 +3420,8 @@ "item.view.box.author.preview.and": "a", // "item.view.box.author.preview.show-everyone": "show everyone:", "item.view.box.author.preview.show-everyone": "zobraz všechny autory", + // "item.view.box.author.preview.orcid-link.title": "View ORCID profile", + "item.view.box.author.preview.orcid-link.title": "Zobrazit ORCID profil", // "item.file.description.not.supported.video": "Your browser does not support the video tag.", "item.file.description.not.supported.video": "Váš prohlížeč nepodporuje videa.", // "item.file.description.name": "Name", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 9a520c64947..1ae3aa647db 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2869,6 +2869,7 @@ "item.view.box.author.preview.show-everyone": "show everyone", + "item.view.box.author.preview.orcid-link.title": "View ORCID profile", "item.file.description.not.supported.video": "Your browser does not support the video tag.",