From b8e87d6da35c240000410eecd616ad49972e233b Mon Sep 17 00:00:00 2001 From: Matus Kasak Date: Mon, 25 May 2026 13:26:15 +0200 Subject: [PATCH 1/4] Redirect author option both on orcid and search --- .../clarin-item-author-preview.component.html | 18 ++-- .../clarin-item-author-preview.component.scss | 14 +++ .../clarin-author-name-link.model.ts | 2 + src/app/shared/clarin-shared-util.ts | 24 +++-- ...-text-metadata-list-element.component.html | 14 ++- ...-text-metadata-list-element.component.scss | 17 ++++ ...in-text-metadata-list-element.component.ts | 91 ++++++++++++++++++- 7 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.scss 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..edc9fa93c5d 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-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.ts b/src/app/shared/clarin-shared-util.ts index 44b087a9910..921cbbc6a4f 100644 --- a/src/app/shared/clarin-shared-util.ts +++ b/src/app/shared/clarin-shared-util.ts @@ -61,21 +61,29 @@ export function loadItemAuthors(item, itemAuthors, baseUrl, fields) { if (isUndefined(authorsMV)) { return null; } + const ORCID_ID_PATTERN = /^(\d{4}-){3}\d{3}[\dX]$/i; + const ORCID_URL_PATTERN = /^https?:\/\/(sandbox\.)?orcid\.org\/(\d{4}-){3}\d{3}[\dX]$/i; const itemAuthorsLocal = []; authorsMV.forEach((authorMV: MetadataValue) => { - let value: string, operator: string; + let isOrcid = false; + let orcidUrl: string; if (authorMV.authority) { - value = encodeURIComponent(authorMV.authority); - operator = 'authority'; - } else { - value = encodeURIComponent(authorMV.value); - operator = 'equals'; + const authority = String(authorMV.authority).trim(); + if (ORCID_ID_PATTERN.test(authority)) { + orcidUrl = 'https://orcid.org/' + authority; + isOrcid = true; + } else if (ORCID_URL_PATTERN.test(authority)) { + orcidUrl = authority; + isOrcid = true; + } } - const authorSearchLink = baseUrl + '/search?f.author=' + value + ',' + operator; + const authorSearchLink = baseUrl + '/search?f.author=' + encodeURIComponent(authorMV.value) + ',equals'; 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/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..741a7928bd0 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,19 @@ target="_blank" [href]="mdRepresentation.getValue()"> {{mdRepresentation.getValue()}} - {{mdRepresentation.getValue()}} + + + {{mdRepresentation.getValue()}} + + + + {{mdRepresentation.getValue()}} + + (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 +67,49 @@ 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. + * Requires the backend to expose `orcid.domain-url`; otherwise returns false. + */ + isOrcidAuthority(orcidDomainUrl: string | null = this.orcidDomainUrl$.value): boolean { + if (!orcidDomainUrl) { + return false; + } + if (this.mdRepresentation instanceof MetadatumRepresentation) { + const authority = this.mdRepresentation.authority?.trim(); + if (!authority) { + return false; + } + return ORCID_ID_PATTERN.test(authority) || ORCID_URL_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 not configured on the backend. + */ + getOrcidUrl(orcidDomainUrl: string | null = this.orcidDomainUrl$.value): string { + if (!orcidDomainUrl) { + return ''; + } + if (!(this.mdRepresentation instanceof MetadatumRepresentation)) { + return ''; + } + const authority = this.mdRepresentation.authority?.trim(); + if (!authority) { + return ''; + } + if (ORCID_URL_PATTERN.test(authority)) { + return authority; + } + if (ORCID_ID_PATTERN.test(authority)) { + const domain = orcidDomainUrl.endsWith('/') ? orcidDomainUrl.slice(0, -1) : orcidDomainUrl; + return `${domain}/${authority}`; + } + return ''; + } } From 6fd445c08ea36164b1bd42059efe5306a47447c0 Mon Sep 17 00:00:00 2001 From: Matus Kasak Date: Mon, 25 May 2026 14:17:24 +0200 Subject: [PATCH 2/4] Validation fixes --- src/app/shared/clarin-shared-util.ts | 3 ++- .../metadata-representation-loader.component.spec.ts | 6 ++++++ .../plain-text-metadata-list-element.component.html | 4 +++- .../plain-text-metadata-list-element.component.spec.ts | 5 +++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/app/shared/clarin-shared-util.ts b/src/app/shared/clarin-shared-util.ts index 921cbbc6a4f..6f29ae448db 100644 --- a/src/app/shared/clarin-shared-util.ts +++ b/src/app/shared/clarin-shared-util.ts @@ -5,6 +5,8 @@ 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'; +const ORCID_ID_PATTERN = /^(\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 @@ -61,7 +63,6 @@ export function loadItemAuthors(item, itemAuthors, baseUrl, fields) { if (isUndefined(authorsMV)) { return null; } - const ORCID_ID_PATTERN = /^(\d{4}-){3}\d{3}[\dX]$/i; const ORCID_URL_PATTERN = /^https?:\/\/(sandbox\.)?orcid\.org\/(\d{4}-){3}\d{3}[\dX]$/i; const itemAuthorsLocal = []; authorsMV.forEach((authorMV: MetadataValue) => { 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 741a7928bd0..022bd66e105 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 @@ -12,7 +12,9 @@ class="dont-break-out orcid-author-link" [href]="getOrcidUrl()" target="_blank" - rel="noopener noreferrer"> + rel="noopener noreferrer" + title="View ORCID profile" + [attr.aria-label]="'View ORCID profile of ' + mdRepresentation.getValue()"> {{mdRepresentation.getValue()}} diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts index 91d7db35620..efd77cdf03d 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts @@ -4,6 +4,8 @@ import { PlainTextMetadataListElementComponent } from './plain-text-metadata-lis import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; import { By } from '@angular/platform-browser'; import { mockData } from '../../../testing/browse-definition-data-service.stub'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; +import { ConfigurationDataServiceStub } from '../../../testing/configuration-data.service.stub'; // Render the mock representation with the default mock author browse definition so it is also rendered as a link // without affecting other tests @@ -20,6 +22,9 @@ describe('PlainTextMetadataListElementComponent', () => { TestBed.configureTestingModule({ imports: [], declarations: [PlainTextMetadataListElementComponent], + providers: [ + { provide: ConfigurationDataService, useClass: ConfigurationDataServiceStub }, + ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(PlainTextMetadataListElementComponent, { set: { changeDetection: ChangeDetectionStrategy.Default } From beef80f2d9119eb4fc634384240987ca0fbd526c Mon Sep 17 00:00:00 2001 From: Matus Kasak Date: Mon, 25 May 2026 15:51:36 +0200 Subject: [PATCH 3/4] Removed duplicates, fetch orcid from be, move message to json5 files --- .../clarin-item-author-preview.component.html | 6 +- ...arin-item-author-preview.component.spec.ts | 13 ++- .../clarin-item-author-preview.component.ts | 27 ++++- src/app/shared/clarin-shared-util.spec.ts | 105 ++++++++++++++++++ src/app/shared/clarin-shared-util.ts | 36 ++++-- ...-text-metadata-list-element.component.html | 4 +- ...in-text-metadata-list-element.component.ts | 13 +-- src/assets/i18n/cs.json5 | 2 + src/assets/i18n/en.json5 | 1 + 9 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 src/app/shared/clarin-shared-util.spec.ts 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 edc9fa93c5d..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 @@ -7,7 +7,7 @@ {{ author.name }}   @@ -15,7 +15,7 @@
{{ author.name }}  ; et al.
@@ -33,7 +33,7 @@ {{author.name}}  
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-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 6f29ae448db..8091c0ad02c 100644 --- a/src/app/shared/clarin-shared-util.ts +++ b/src/app/shared/clarin-shared-util.ts @@ -5,7 +5,19 @@ 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'; -const ORCID_ID_PATTERN = /^(\d{4}-){3}\d{3}[\dX]$/i; +/** + * 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 @@ -46,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; } @@ -63,19 +79,19 @@ export function loadItemAuthors(item, itemAuthors, baseUrl, fields) { if (isUndefined(authorsMV)) { return null; } - const ORCID_URL_PATTERN = /^https?:\/\/(sandbox\.)?orcid\.org\/(\d{4}-){3}\d{3}[\dX]$/i; + const domain = orcidDomainUrl?.endsWith('/') ? orcidDomainUrl.slice(0, -1) : orcidDomainUrl; const itemAuthorsLocal = []; authorsMV.forEach((authorMV: MetadataValue) => { let isOrcid = false; let orcidUrl: string; if (authorMV.authority) { const authority = String(authorMV.authority).trim(); - if (ORCID_ID_PATTERN.test(authority)) { - orcidUrl = 'https://orcid.org/' + authority; - isOrcid = true; - } else if (ORCID_URL_PATTERN.test(authority)) { + if (ORCID_URL_PATTERN.test(authority)) { orcidUrl = authority; isOrcid = true; + } else if (domain && ORCID_ID_PATTERN.test(authority)) { + orcidUrl = `${domain}/${authority}`; + isOrcid = true; } } const authorSearchLink = baseUrl + '/search?f.author=' + encodeURIComponent(authorMV.value) + ',equals'; 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 022bd66e105..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 @@ -13,8 +13,8 @@ [href]="getOrcidUrl()" target="_blank" rel="noopener noreferrer" - title="View ORCID profile" - [attr.aria-label]="'View ORCID profile of ' + mdRepresentation.getValue()"> + [title]="'item.view.box.author.preview.orcid-link.title' | translate" + [attr.aria-label]="('item.view.box.author.preview.orcid-link.title' | translate) + ' ' + mdRepresentation.getValue()"> {{mdRepresentation.getValue()}} 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 302766f77b9..1236bc0a9d5 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 @@ -8,18 +8,7 @@ import { VALUE_LIST_BROWSE_DEFINITION } from '../../../../core/shared/value-list 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'; - -/** - * 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 - */ -const ORCID_ID_PATTERN = /^(\d{4}-){3}\d{3}[\dX]$/i; - -/** - * Pattern that matches a full ORCID URL authority value. - * Example: https://orcid.org/0000-0001-2345-6789 or https://sandbox.orcid.org/0000-0001-2345-678X - */ -const ORCID_URL_PATTERN = /^https?:\/\/[^/]+\/((\d{4}-){3}\d{3}[\dX])$/i; +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 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.", From 83f65ed3d6a8b21bca301a64a065414a2c2eaf26 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 26 May 2026 10:09:47 +0200 Subject: [PATCH 4/4] Address Copilot review: restore authority operator and align ORCID URL handling - clarin-shared-util: use ',authority' operator when authority is present (regression fix); only fall back to ',equals' when no authority. - plain-text-metadata-list-element: accept full ORCID profile URLs without requiring orcid.domain-url config; bare ORCID iDs still need the domain to be expanded. --- src/app/shared/clarin-shared-util.ts | 9 +++++++- ...in-text-metadata-list-element.component.ts | 21 +++++++++---------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/app/shared/clarin-shared-util.ts b/src/app/shared/clarin-shared-util.ts index 8091c0ad02c..ea52d86bf9e 100644 --- a/src/app/shared/clarin-shared-util.ts +++ b/src/app/shared/clarin-shared-util.ts @@ -84,6 +84,8 @@ export function loadItemAuthors(item, itemAuthors, baseUrl, fields, orcidDomainU authorsMV.forEach((authorMV: MetadataValue) => { let isOrcid = false; let orcidUrl: string; + let searchValue: string; + let searchOperator: string; if (authorMV.authority) { const authority = String(authorMV.authority).trim(); if (ORCID_URL_PATTERN.test(authority)) { @@ -93,8 +95,13 @@ export function loadItemAuthors(item, itemAuthors, baseUrl, fields, orcidDomainU orcidUrl = `${domain}/${authority}`; isOrcid = true; } + searchValue = encodeURIComponent(authorMV.authority); + searchOperator = 'authority'; + } else { + searchValue = encodeURIComponent(authorMV.value); + searchOperator = 'equals'; } - const authorSearchLink = baseUrl + '/search?f.author=' + encodeURIComponent(authorMV.value) + ',equals'; + const authorSearchLink = baseUrl + '/search?f.author=' + searchValue + ',' + searchOperator; const authorNameLink = Object.assign(new AuthorNameLink(), { name: authorMV.value, url: authorSearchLink, 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 1236bc0a9d5..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 @@ -59,19 +59,20 @@ export class PlainTextMetadataListElementComponent extends MetadataRepresentatio /** * Check whether the authority value of this metadata is an ORCID identifier. - * Accepts either a bare ORCID iD or a full ORCID URL. - * Requires the backend to expose `orcid.domain-url`; otherwise returns false. + * 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 (!orcidDomainUrl) { - return false; - } if (this.mdRepresentation instanceof MetadatumRepresentation) { const authority = this.mdRepresentation.authority?.trim(); if (!authority) { return false; } - return ORCID_ID_PATTERN.test(authority) || ORCID_URL_PATTERN.test(authority); + if (ORCID_URL_PATTERN.test(authority)) { + return true; + } + return !!orcidDomainUrl && ORCID_ID_PATTERN.test(authority); } return false; } @@ -79,12 +80,10 @@ export class PlainTextMetadataListElementComponent extends MetadataRepresentatio /** * 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 not configured on the backend. + * 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 (!orcidDomainUrl) { - return ''; - } if (!(this.mdRepresentation instanceof MetadatumRepresentation)) { return ''; } @@ -95,7 +94,7 @@ export class PlainTextMetadataListElementComponent extends MetadataRepresentatio if (ORCID_URL_PATTERN.test(authority)) { return authority; } - if (ORCID_ID_PATTERN.test(authority)) { + if (orcidDomainUrl && ORCID_ID_PATTERN.test(authority)) { const domain = orcidDomainUrl.endsWith('/') ? orcidDomainUrl.slice(0, -1) : orcidDomainUrl; return `${domain}/${authority}`; }