diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html index 0e0db894b1d..d667a8f3e9c 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -24,7 +24,17 @@ @for (mdValue of mdEntry.value; track mdValue) { {{mdEntry.key}} - {{mdValue.value}} + + @if (getMetadataLink(mdEntry.key, mdValue.value); as link) { + @if (link.external) { + {{mdValue.value}} + } @else { + {{mdValue.value}} + } + } @else { + + } + {{mdValue.language}} } diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index ac2b4634a83..dbc949bd2f9 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -65,6 +65,72 @@ const mockItem: Item = Object.assign(new Item(), { }, }); +const mockItemWithUrl: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'test item', + }, + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://hdl.handle.net/123456789/1', + }, + ], + 'dc.description': [ + { + language: 'en_US', + value: 'plain text value', + }, + ], + }, +}); + +const mockItemWithSpecialFields: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'test item', + }, + ], + 'local.identifier.doi': [ + { + language: null, + value: '10.1234/test', + }, + ], + 'local.identifier.scopus': [ + { + language: null, + value: '2-s2.0-85012345678', + }, + ], + 'local.identifier.wos': [ + { + language: null, + value: 'WOS:000123456789', + }, + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'Mathematics', + }, + ], + 'dc.contributor.author': [ + { + language: null, + value: 'Novák, Jan', + }, + ], + }, +}); + const mockWithdrawnItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), metadata: [], @@ -265,4 +331,78 @@ describe('FullItemPageComponent', () => { expect(linkHeadService.addTag).toHaveBeenCalledTimes(3); }); }); + + describe('metadata URL rendering', () => { + beforeEach(() => { + routeData.dso = createSuccessfulRemoteDataObject(mockItemWithUrl); + comp.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render URL metadata values as clickable links', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const urlLink = links.find(l => l.nativeElement.textContent.includes('https://hdl.handle.net/123456789/1')); + expect(urlLink).toBeTruthy(); + expect(urlLink.nativeElement.getAttribute('href')).toBe('https://hdl.handle.net/123456789/1'); + expect(urlLink.nativeElement.getAttribute('target')).toBe('_blank'); + expect(urlLink.nativeElement.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('should render non-URL metadata values as plain text without links', () => { + const table = fixture.debugElement.query(By.css('table')); + expect(table.nativeElement.innerHTML).toContain('plain text value'); + const links = fixture.debugElement.queryAll(By.css('table a')); + const plainTextLink = links.find(l => l.nativeElement.textContent.includes('plain text value')); + expect(plainTextLink).toBeFalsy(); + }); + }); + + describe('field-specific metadata link rendering', () => { + beforeEach(() => { + routeData.dso = createSuccessfulRemoteDataObject(mockItemWithSpecialFields); + comp.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render bare DOI as external link to doi.org', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const doiLink = links.find(l => l.nativeElement.textContent.includes('10.1234/test')); + expect(doiLink).toBeTruthy(); + expect(doiLink.nativeElement.getAttribute('href')).toContain('https://doi.org/'); + expect(doiLink.nativeElement.getAttribute('target')).toBe('_blank'); + expect(doiLink.nativeElement.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('should render Scopus ID as external link', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const scopusLink = links.find(l => l.nativeElement.textContent.includes('2-s2.0-85012345678')); + expect(scopusLink).toBeTruthy(); + expect(scopusLink.nativeElement.getAttribute('href')).toContain('scopus.com'); + expect(scopusLink.nativeElement.getAttribute('target')).toBe('_blank'); + }); + + it('should render WOS ID as external link', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const wosLink = links.find(l => l.nativeElement.textContent.includes('WOS:000123456789')); + expect(wosLink).toBeTruthy(); + expect(wosLink.nativeElement.getAttribute('href')).toContain('webofscience.com'); + expect(wosLink.nativeElement.getAttribute('target')).toBe('_blank'); + }); + + it('should render dc.subject as internal search link', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const subjectLink = links.find(l => l.nativeElement.textContent.includes('Mathematics')); + expect(subjectLink).toBeTruthy(); + expect(subjectLink.nativeElement.getAttribute('href')).toContain('/search'); + expect(subjectLink.nativeElement.getAttribute('target')).toBeNull(); + }); + + it('should render dc.contributor.author as internal search link', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const authorLink = links.find(l => l.nativeElement.textContent.trim().includes('Nov')); + expect(authorLink).toBeTruthy(); + expect(authorLink.nativeElement.getAttribute('href')).toContain('/search'); + expect(authorLink.nativeElement.getAttribute('target')).toBeNull(); + }); + }); }); diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 87b01f21e97..2b7da8df65a 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -41,6 +41,10 @@ import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-ed import { hasValue } from '../../shared/empty.util'; import { ErrorComponent } from '../../shared/error/error.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { + getMetadataLink, + makeLinks, +} from '../../shared/utils/make-links'; import { VarDirective } from '../../shared/utils/var.directive'; import { CollectionsComponent } from '../field-components/collections/collections.component'; import { ThemedItemPageTitleFieldComponent } from '../simple/field-components/specific-field/title/themed-item-page-field.component'; @@ -78,6 +82,8 @@ import { ThemedFullFileSectionComponent } from './field-components/file-section/ standalone: true, }) export class FullItemPageComponent extends ItemPageComponent implements OnInit, OnDestroy { + protected readonly makeLinks = makeLinks; + protected readonly getMetadataLink = getMetadataLink; itemRD$: BehaviorSubject>; diff --git a/src/app/shared/utils/make-links.spec.ts b/src/app/shared/utils/make-links.spec.ts new file mode 100644 index 00000000000..14a737c889b --- /dev/null +++ b/src/app/shared/utils/make-links.spec.ts @@ -0,0 +1,169 @@ +import { + getMetadataLink, + makeLinks, +} from './make-links'; + +describe('makeLinks', () => { + it('should convert https URLs to clickable links', () => { + expect(makeLinks('https://example.com')).toContain(' { + expect(makeLinks('http://example.com')).toContain(' { + expect(makeLinks('ftp://files.example.com/resource')).toContain(' { + const result = makeLinks('www.example.com'); + expect(result).toContain('www.example.com'); + }); + + it('should return plain text unchanged (HTML-escaped)', () => { + expect(makeLinks('just some text')).toBe('just some text'); + }); + + it('should handle null/undefined gracefully', () => { + expect(makeLinks(null)).toBeUndefined(); + expect(makeLinks(undefined)).toBeUndefined(); + }); + + it('should handle empty string', () => { + expect(makeLinks('')).toBe(''); + }); + + it('should convert URLs embedded in text', () => { + const result = makeLinks('Visit https://example.com for details'); + expect(result).toContain(' { + const result = makeLinks('https://hdl.handle.net/123456789/1'); + expect(result).toContain(' { + const result = makeLinks('https://doi.org/10.1234/test'); + expect(result).toContain(' { + const result = makeLinks('javascript:alert(1)'); + expect(result).not.toContain(' { + const result = makeLinks('data:text/html,'); + expect(result).not.toContain(' { + const result = makeLinks('See https://a.com and https://b.com'); + expect(result).toContain(' { + const result = makeLinks('https://example.com/search?q=test&page=1'); + expect(result).toContain('href="https://example.com/search?q=test&page=1"'); + }); + + it('should handle URLs with fragments', () => { + const result = makeLinks('https://example.com/page#section'); + expect(result).toContain(' { + const result = makeLinks('https://example.com/path/to/resource'); + expect(result).toContain(' { + const result = makeLinks('(https://example.com)'); + expect(result).toContain(' { + const result = makeLinks('bold https://example.com'); + expect(result).toContain('<b>bold</b>'); + expect(result).toContain(' { + const result = makeLinks('A & B'); + expect(result).toBe('A & B'); + }); +}); + +describe('getMetadataLink', () => { + it('should return DOI resolver link for bare DOI', () => { + const link = getMetadataLink('local.identifier.doi', '10.1234/test'); + expect(link).toBeTruthy(); + expect(link.external).toBeTrue(); + expect(link.href).toBe('https://doi.org/10.1234%2Ftest'); + }); + + it('should return null for DOI that is already a full URL', () => { + expect(getMetadataLink('local.identifier.doi', 'https://doi.org/10.1234/test')).toBeNull(); + }); + + it('should return Scopus link for Scopus ID', () => { + const link = getMetadataLink('local.identifier.scopus', '2-s2.0-85012345678'); + expect(link).toBeTruthy(); + expect(link.external).toBeTrue(); + expect(link.href).toBe('https://www.scopus.com/record/display.uri?eid=2-s2.0-85012345678'); + }); + + it('should return WOS link for WOS ID', () => { + const link = getMetadataLink('local.identifier.wos', 'WOS:000123456789'); + expect(link).toBeTruthy(); + expect(link.external).toBeTrue(); + expect(link.href).toBe('https://www.webofscience.com/wos/woscc/full-record/WOS%3A000123456789'); + }); + + it('should return internal search link for dc.subject', () => { + const link = getMetadataLink('dc.subject', 'Mathematics'); + expect(link).toBeTruthy(); + expect(link.external).toBeFalse(); + expect(link.routerLink).toBe('/search'); + expect(link.queryParams).toEqual({ 'f.subject': 'Mathematics,equals' }); + }); + + it('should return internal search link for dc.contributor.author', () => { + const link = getMetadataLink('dc.contributor.author', 'Novák, Jan'); + expect(link).toBeTruthy(); + expect(link.external).toBeFalse(); + expect(link.routerLink).toBe('/search'); + expect(link.queryParams).toEqual({ 'f.author': 'Nov\u00e1k, Jan,equals' }); + }); + + it('should return null for non-special metadata fields', () => { + expect(getMetadataLink('dc.title', 'some title')).toBeNull(); + expect(getMetadataLink('dc.description', 'some description')).toBeNull(); + }); + + it('should return null for empty or null values', () => { + expect(getMetadataLink('local.identifier.doi', '')).toBeNull(); + expect(getMetadataLink('local.identifier.doi', null)).toBeNull(); + expect(getMetadataLink('local.identifier.doi', undefined)).toBeNull(); + }); + + it('should trim whitespace from values', () => { + const link = getMetadataLink('local.identifier.doi', ' 10.1234/test '); + expect(link.href).toBe('https://doi.org/10.1234%2Ftest'); + }); + + it('should not double-append operator if value already has one', () => { + const link = getMetadataLink('dc.subject', 'Mathematics,equals'); + expect(link.queryParams['f.subject']).toBe('Mathematics,equals'); + }); +}); diff --git a/src/app/shared/utils/make-links.ts b/src/app/shared/utils/make-links.ts new file mode 100644 index 00000000000..e3fe68984ba --- /dev/null +++ b/src/app/shared/utils/make-links.ts @@ -0,0 +1,111 @@ +import { addOperatorToFilterValue } from '../search/search.utils'; + +/** + * Escape HTML special characters so that non-URL parts of a metadata value + * are rendered as plain text when inserted via [innerHTML]. + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Convert raw text URLs into clickable HTML links. + * Detects http, https, ftp URLs and www. addresses. + * Non-URL parts are HTML-escaped to prevent markup injection via [innerHTML]. + * + * Ported from dtq-dev clarin-shared-util.ts to be reused across components. + */ +export function makeLinks(text: string | null | undefined): string | undefined { + if (text == null) { + return undefined; + } + const regex = /(?:https?|ftp):\/\/[^\s)]+|www\.[^\s)]+/g; + let result = ''; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + // Escape the plain text segment before this URL + result += escapeHtml(text.slice(lastIndex, match.index)); + const url = match[0]; + const href = url.startsWith('www.') ? `https://${url}` : url; + result += `${escapeHtml(url)}`; + lastIndex = regex.lastIndex; + } + + // Escape the remaining plain text after the last URL + result += escapeHtml(text.slice(lastIndex)); + return result; +} + +/** + * Metadata link descriptor returned by getMetadataLink(). + */ +export interface MetadataLink { + external: boolean; + /** Full URL for external links */ + href?: string; + /** Router path for internal links */ + routerLink?: string; + /** Query parameters for internal links */ + queryParams?: Record; +} + +// --- External resolver base URLs --- +const DOI_RESOLVER = 'https://doi.org/'; +const SCOPUS_RECORD = 'https://www.scopus.com/record/display.uri?eid='; +const WOS_RECORD = 'https://www.webofscience.com/wos/woscc/full-record/'; + +// --- Internal search configuration --- +const SEARCH_PATH = '/search'; + +/** Maps metadata keys to their corresponding search filter parameter. */ +const SEARCH_FIELD_FILTERS: Record = { + 'dc.subject': 'f.subject', + 'dc.contributor.author': 'f.author', +}; + +/** Maps metadata keys to their external resolver base URL. */ +const EXTERNAL_RESOLVERS: Record = { + 'local.identifier.scopus': SCOPUS_RECORD, + 'local.identifier.wos': WOS_RECORD, +}; + +const HTTP_URL_PATTERN = /^https?:\/\//i; + +/** + * For specific metadata fields, build an appropriate hyperlink. + * Returns null when the field needs no special treatment (fall back to makeLinks). + */ +export function getMetadataLink(key: string, value: string | null | undefined): MetadataLink | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + // DOI: bare identifiers → doi.org resolver; full URLs fall through to makeLinks + if (key === 'local.identifier.doi') { + return HTTP_URL_PATTERN.test(trimmed) + ? null + : { external: true, href: `${DOI_RESOLVER}${encodeURIComponent(trimmed)}` }; + } + + // External resolvers (Scopus, WOS, …) + const resolver = EXTERNAL_RESOLVERS[key]; + if (resolver) { + return { external: true, href: `${resolver}${encodeURIComponent(trimmed)}` }; + } + + // Internal search links (subject, author, …) + const filterParam = SEARCH_FIELD_FILTERS[key]; + if (filterParam) { + return { external: false, routerLink: SEARCH_PATH, queryParams: { [filterParam]: addOperatorToFilterValue(trimmed, 'equals') } }; + } + + return null; +}