diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index d0bbbbaae8..159fbb38f0 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -29,7 +29,7 @@ import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { authReducer } from './core/auth/auth.reducer'; import { AuthService } from './core/auth/auth.service'; import { LocaleService } from './core/locale/locale.service'; -import { MetadataService } from './core/metadata/metadata.service'; +import { HeadTagService } from './core/metadata/head-tag.service'; import { RouteService } from './core/services/route.service'; import { NativeWindowRef, @@ -42,7 +42,7 @@ import { MenuService } from './shared/menu/menu.service'; import { MockActivatedRoute } from './shared/mocks/active-router.mock'; import { AngularticsProviderMock } from './shared/mocks/angulartics-provider.service.mock'; import { AuthServiceMock } from './shared/mocks/auth.service.mock'; -import { MetadataServiceMock } from './shared/mocks/metadata-service.mock'; +import { HeadTagServiceMock } from './shared/mocks/head-tag-service.mock'; import { RouterMock } from './shared/mocks/router.mock'; import { getMockThemeService } from './shared/mocks/theme-service.mock'; import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock'; @@ -87,7 +87,7 @@ describe('App component', () => { ], providers: [ { provide: NativeWindowService, useValue: new NativeWindowRef() }, - { provide: MetadataService, useValue: new MetadataServiceMock() }, + { provide: HeadTagService, useValue: new HeadTagServiceMock() }, { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, { provide: AuthService, useValue: new AuthServiceMock() }, { provide: Router, useValue: new RouterMock() }, diff --git a/src/app/core/data/relationship-data.service.spec.ts b/src/app/core/data/relationship-data.service.spec.ts index 21365e52e1..753d07b21e 100644 --- a/src/app/core/data/relationship-data.service.spec.ts +++ b/src/app/core/data/relationship-data.service.spec.ts @@ -1,5 +1,9 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; import { of as observableOf } from 'rxjs'; +import { PAGINATED_RELATIONS_TO_ITEMS_OPERATOR } from '../../item-page/simple/item-types/shared/item-relationships-utils'; import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { @@ -11,7 +15,9 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { followLink } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { Relationship } from '../shared/item-relationships/relationship.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; @@ -20,6 +26,7 @@ import { MetadataRepresentationType } from '../shared/metadata-representation/me import { PageInfo } from '../shared/page-info.model'; import { testSearchDataImplementation } from './base/search-data.spec'; import { FindListOptions } from './find-list-options.model'; +import { ItemDataService } from './item-data.service'; import { buildPaginatedList } from './paginated-list.model'; import { RelationshipDataService } from './relationship-data.service'; import { DeleteRequest } from './request.models'; @@ -123,18 +130,6 @@ describe('RelationshipDataService', () => { findByHref: createSuccessfulRemoteDataObject$(relatedItems[0]), }); - function initTestService() { - return new RelationshipDataService( - requestService, - rdbService, - halService, - objectCache as ObjectCacheService, - itemService, - null, - jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v), - ); - } - const getRequestEntry$ = (successful: boolean) => { return observableOf({ response: { isSuccessful: successful, payload: relationships } as any, @@ -143,11 +138,25 @@ describe('RelationshipDataService', () => { beforeEach(() => { requestService = getMockRequestService(getRequestEntry$(true)); - service = initTestService(); + + TestBed.configureTestingModule({ + providers: [ + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: HALEndpointService, useValue: halService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: ItemDataService, useValue: itemService }, + { provide: RequestService, useValue: requestService }, + { provide: PAGINATED_RELATIONS_TO_ITEMS_OPERATOR, useValue: jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v) }, + { provide: Store, useValue: provideMockStore() }, + RelationshipDataService, + ], + }); + service = TestBed.inject(RelationshipDataService); }); describe('composition', () => { - const initService = () => new RelationshipDataService(null, null, null, null, null, null, null); + const initService = () => new RelationshipDataService(null, null, null, null, null, null, null, null); testSearchDataImplementation(initService); }); diff --git a/src/app/core/data/relationship-data.service.ts b/src/app/core/data/relationship-data.service.ts index 0e8740e2ae..5eab9d6b39 100644 --- a/src/app/core/data/relationship-data.service.ts +++ b/src/app/core/data/relationship-data.service.ts @@ -54,6 +54,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { MetadataService } from '../metadata/metadata.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; @@ -128,6 +129,7 @@ export class RelationshipDataService extends IdentifiableDataService, @Inject(PAGINATED_RELATIONS_TO_ITEMS_OPERATOR) private paginatedRelationsToItems: (thisId: string) => (source: Observable>>) => Observable>>, @@ -602,8 +604,8 @@ export class RelationshipDataService extends IdentifiableDataService { - if (metadatum.isVirtual) { - return this.findById(metadatum.virtualValue, true, false, followLink('leftItem'), followLink('rightItem')).pipe( + if (this.metadataService.isVirtual(metadatum)) { + return this.findById(this.metadataService.virtualValue(metadatum), true, false, followLink('leftItem'), followLink('rightItem')).pipe( getFirstSucceededRemoteData(), switchMap((relRD: RemoteData) => observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe( diff --git a/src/app/core/metadata/head-tag.service.spec.ts b/src/app/core/metadata/head-tag.service.spec.ts new file mode 100644 index 0000000000..2fbae88f12 --- /dev/null +++ b/src/app/core/metadata/head-tag.service.spec.ts @@ -0,0 +1,508 @@ +import { + fakeAsync, + tick, +} from '@angular/core/testing'; +import { + Meta, + Title, +} from '@angular/platform-browser'; +import { + NavigationEnd, + Router, +} from '@angular/router'; +import { createMockStore } from '@ngrx/store/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, + of, +} from 'rxjs'; + +import { AppConfig } from '../../../config/app-config.interface'; +import { + ItemMock, + MockBitstream1, + MockBitstream2, + MockBitstream3, +} from '../../shared/mocks/item.mock'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { DSONameService } from '../breadcrumbs/dso-name.service'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { RootDataService } from '../data/root-data.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { Bundle } from '../shared/bundle.model'; +import { Item } from '../shared/item.model'; +import { MetadataValue } from '../shared/metadata.models'; +import { HeadTagService } from './head-tag.service'; +import { + AddMetaTagAction, + ClearMetaTagAction, +} from './meta-tag.actions'; + +describe('HeadTagService', () => { + let headTagService: HeadTagService; + + let meta: Meta; + + let title: Title; + + let dsoNameService: DSONameService; + + let bundleDataService; + let rootService: RootDataService; + let translateService: TranslateService; + let hardRedirectService: HardRedirectService; + let authorizationService: AuthorizationDataService; + + let router: Router; + let store; + + let appConfig: AppConfig; + + const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] } } }; + + + beforeEach(() => { + rootService = jasmine.createSpyObj({ + findRoot: createSuccessfulRemoteDataObject$({ dspaceVersion: 'mock-dspace-version' }), + }); + bundleDataService = jasmine.createSpyObj({ + findByItemAndName: mockBundleRD$([MockBitstream3]), + }); + translateService = getMockTranslateService(); + meta = jasmine.createSpyObj('meta', { + addTag: {}, + removeTag: {}, + }); + title = jasmine.createSpyObj({ + setTitle: {}, + }); + dsoNameService = jasmine.createSpyObj({ + getName: ItemMock.firstMetadataValue('dc.title'), + }); + router = { + url: '/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357', + events: of(new NavigationEnd(1, '', '')), + routerState: { + root: {}, + }, + } as any as Router; + hardRedirectService = jasmine.createSpyObj( { + getCurrentOrigin: 'https://request.org', + }); + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true), + }); + + store = createMockStore({ initialState }); + spyOn(store, 'dispatch'); + + appConfig = { + item: { + bitstream: { + pageSize: 5, + }, + }, + } as any; + + headTagService = new HeadTagService( + router, + translateService, + meta, + title, + dsoNameService, + bundleDataService, + rootService, + store, + hardRedirectService, + appConfig, + authorizationService, + ); + }); + + it('items page should set meta tags', fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + }, + }, + }); + tick(); + expect(title.setTitle).toHaveBeenCalledWith('Test PowerPoint Document'); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_title', + content: 'Test PowerPoint Document', + }); + expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_author', content: 'Doe, Jane' }); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_publication_date', + content: '1650-06-26', + }); + expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_issn', content: '123456789' }); + expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_language', content: 'en' }); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_keywords', + content: 'keyword1; keyword2; keyword3', + }); + })); + + it('items page should set meta tags as published Thesis', fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_dissertation_name', + content: 'Test PowerPoint Document', + }); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_pdf_url', + content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download', + }); + })); + + it('items page should set meta tags as published Technical Report', fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_technical_report_institution', + content: 'Mock Publisher', + }); + })); + + it('route titles should overwrite dso titles', fakeAsync(() => { + (translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Translated Route Title')); + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + title: 'route.title.key', + }, + }, + }); + tick(); + expect(title.setTitle).toHaveBeenCalledTimes(2); + expect((title.setTitle as jasmine.Spy).calls.argsFor(0)).toEqual(['Test PowerPoint Document']); + expect((title.setTitle as jasmine.Spy).calls.argsFor(1)).toEqual(['DSpace :: Translated Route Title']); + })); + + it('other navigation should add title and description', fakeAsync(() => { + (translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!')); + (headTagService as any).processRouteChange({ + data: { + value: { + title: 'Dummy Title', + description: 'This is a dummy item component for testing!', + }, + }, + }); + tick(); + expect(title.setTitle).toHaveBeenCalledWith('DSpace :: Dummy Title'); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'title', + content: 'DSpace :: Dummy Title', + }); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'description', + content: 'This is a dummy item component for testing!', + }); + })); + + describe(`listenForRouteChange`, () => { + it(`should call processRouteChange`, fakeAsync(() => { + spyOn(headTagService as any, 'processRouteChange').and.callFake(() => undefined); + headTagService.listenForRouteChange(); + tick(); + expect((headTagService as any).processRouteChange).toHaveBeenCalled(); + })); + it(`should add Generator`, fakeAsync(() => { + spyOn(headTagService as any, 'processRouteChange').and.callFake(() => undefined); + headTagService.listenForRouteChange(); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'Generator', + content: 'mock-dspace-version', + }); + })); + }); + + describe('citation_abstract_html_url', () => { + it('should use dc.identifier.uri if available', fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockUri(ItemMock, 'https://ddg.gg')), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_abstract_html_url', + content: 'https://ddg.gg', + }); + })); + + it('should use current route as fallback', fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockUri(ItemMock)), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_abstract_html_url', + content: 'https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357', + }); + })); + }); + + describe('citation_*_institution / citation_publisher', () => { + it('should use citation_dissertation_institution tag for dissertations', fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_dissertation_institution', + content: 'Mock Publisher', + }); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_technical_report_institution' })); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_publisher' })); + })); + + it('should use citation_tech_report_institution tag for tech reports', fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))), + }, + }, + }); + tick(); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_dissertation_institution' })); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_technical_report_institution', + content: 'Mock Publisher', + }); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_publisher' })); + })); + + it('should use citation_publisher for other item types', fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Some Other Type'))), + }, + }, + }); + tick(); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_dissertation_institution' })); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_technical_report_institution' })); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_publisher', + content: 'Mock Publisher', + }); + })); + }); + + describe('citation_pdf_url', () => { + it('should link to primary Bitstream URL regardless of format', fakeAsync(() => { + (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([], MockBitstream3)); + + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_pdf_url', + content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download', + }); + })); + + describe('bitstream not download allowed', () => { + it('should not have citation_pdf_url', fakeAsync(() => { + (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3])); + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); + + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + }, + }, + }); + tick(); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_pdf_url' })); + })); + + }); + + describe('no primary Bitstream', () => { + it('should link to first and only Bitstream regardless of format', fakeAsync(() => { + (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3])); + + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_pdf_url', + content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download', + }); + })); + + describe(`when there's a bitstream with an allowed format on the first page`, () => { + let bitstreams; + + beforeEach(() => { + bitstreams = [MockBitstream2, MockBitstream3, MockBitstream1]; + (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams)); + }); + + it('should link to first Bitstream with allowed format', fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'citation_pdf_url', + content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download', + }); + })); + + }); + + }); + }); + + describe(`when there's no bitstream with an allowed format on the first page`, () => { + let bitstreams; + + beforeEach(() => { + bitstreams = [MockBitstream1, MockBitstream3, MockBitstream2]; + (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams)); + }); + + it(`shouldn't add a citation_pdf_url meta tag`, fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + }, + }, + }); + tick(); + expect(meta.addTag).not.toHaveBeenCalledWith({ + name: 'citation_pdf_url', + content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download', + }); + })); + + }); + + + describe('tagstore', () => { + beforeEach(fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + }, + }, + }); + tick(); + })); + + it('should remove previous tags on route change', fakeAsync(() => { + expect(meta.removeTag).toHaveBeenCalledWith('name=\'title\''); + expect(meta.removeTag).toHaveBeenCalledWith('name=\'description\''); + })); + + it('should clear all tags and add new ones on route change', () => { + expect(store.dispatch.calls.argsFor(0)).toEqual([new ClearMetaTagAction()]); + expect(store.dispatch.calls.argsFor(1)).toEqual([new AddMetaTagAction('title')]); + expect(store.dispatch.calls.argsFor(2)).toEqual([new AddMetaTagAction('description')]); + }); + }); + + const mockType = (mockItem: Item, type: string): Item => { + const typedMockItem = Object.assign(new Item(), mockItem) as Item; + typedMockItem.metadata['dc.type'] = [{ value: type }] as MetadataValue[]; + return typedMockItem; + }; + + const mockPublisher = (mockItem: Item): Item => { + const publishedMockItem = Object.assign(new Item(), mockItem) as Item; + publishedMockItem.metadata['dc.publisher'] = [ + { + language: 'en_US', + value: 'Mock Publisher', + }, + ] as MetadataValue[]; + return publishedMockItem; + }; + + const mockUri = (mockItem: Item, uri?: string): Item => { + const publishedMockItem = Object.assign(new Item(), mockItem) as Item; + publishedMockItem.metadata['dc.identifier.uri'] = [{ value: uri }] as MetadataValue[]; + return publishedMockItem; + }; + + const mockBundleRD$ = (bitstreams: Bitstream[], primary?: Bitstream): Observable> => { + return createSuccessfulRemoteDataObject$( + Object.assign(new Bundle(), { + name: 'ORIGINAL', + bitstreams: createSuccessfulRemoteDataObject$(mockBitstreamPages$(bitstreams)[0]), + primaryBitstream: createSuccessfulRemoteDataObject$(primary), + }), + ); + }; + + const mockBitstreamPages$ = (bitstreams: Bitstream[]): PaginatedList[] => { + return bitstreams.map((bitstream, index) => Object.assign(createPaginatedList([bitstream]), { + pageInfo: { + totalElements: bitstreams.length, // announce multiple elements/pages + }, + _links: index < bitstreams.length - 1 + ? { next: { href: 'not empty' } } // fake link to the next bitstream page + : { next: { href: undefined } }, // last page has no link + })); + }; +}); diff --git a/src/app/core/metadata/head-tag.service.ts b/src/app/core/metadata/head-tag.service.ts new file mode 100644 index 0000000000..270e5fde72 --- /dev/null +++ b/src/app/core/metadata/head-tag.service.ts @@ -0,0 +1,527 @@ +import { + Inject, + Injectable, +} from '@angular/core'; +import { + Meta, + MetaDefinition, + Title, +} from '@angular/platform-browser'; +import { + ActivatedRoute, + NavigationEnd, + Router, +} from '@angular/router'; +import { + createSelector, + select, + Store, +} from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest, + concat as observableConcat, + EMPTY, + Observable, + of as observableOf, +} from 'rxjs'; +import { + filter, + map, + mergeMap, + switchMap, + take, +} from 'rxjs/operators'; + +import { + APP_CONFIG, + AppConfig, +} from '../../../config/app-config.interface'; +import { getBitstreamDownloadRoute } from '../../app-routing-paths'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { DSONameService } from '../breadcrumbs/dso-name.service'; +import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; +import { BundleDataService } from '../data/bundle-data.service'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { RootDataService } from '../data/root-data.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { getDownloadableBitstream } from '../shared/bitstream.operators'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { Bundle } from '../shared/bundle.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { Item } from '../shared/item.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../shared/operators'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { + AddMetaTagAction, + ClearMetaTagAction, +} from './meta-tag.actions'; +import { MetaTagState } from './meta-tag.reducer'; + +/** + * The base selector function to select the metaTag section in the store + */ +const metaTagSelector = createSelector( + coreSelector, + (state: CoreState) => state.metaTag, +); + +/** + * Selector function to select the tags in use from the MetaTagState + */ +const tagsInUseSelector = + createSelector( + metaTagSelector, + (state: MetaTagState) => state.tagsInUse, + ); + +@Injectable({ + providedIn: 'root', +}) +export class HeadTagService { + + private currentObject: BehaviorSubject = new BehaviorSubject(undefined); + + /** + * When generating the citation_pdf_url meta tag for Items with more than one Bitstream (and no primary Bitstream), + * the first Bitstream to match one of the following MIME types is selected. + * See {@linkcode getFirstAllowedFormatBitstreamLink} + * @private + */ + private readonly CITATION_PDF_URL_MIMETYPES = [ + 'application/pdf', // .pdf + 'application/postscript', // .ps + 'application/msword', // .doc + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx + 'application/rtf', // .rtf + 'application/epub+zip', // .epub + ]; + + constructor( + protected router: Router, + protected translate: TranslateService, + protected meta: Meta, + protected title: Title, + protected dsoNameService: DSONameService, + protected bundleDataService: BundleDataService, + protected rootService: RootDataService, + protected store: Store, + protected hardRedirectService: HardRedirectService, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + protected authorizationService: AuthorizationDataService, + ) { + } + + public listenForRouteChange(): void { + // This never changes, set it only once + this.setGenerator(); + + this.router.events.pipe( + filter((event) => event instanceof NavigationEnd), + map(() => this.router.routerState.root), + map((route: ActivatedRoute) => { + route = this.getCurrentRoute(route); + return { params: route.params, data: route.data }; + })).subscribe((routeInfo: any) => { + this.processRouteChange(routeInfo); + }); + } + + protected processRouteChange(routeInfo: any): void { + this.clearMetaTags(); + + if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) { + this.currentObject.next(routeInfo.data.value.dso.payload); + this.setDSOMetaTags(); + } + + if (routeInfo.data.value.title) { + const titlePrefix = this.translate.get('repository.title.prefix'); + const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value); + combineLatest([titlePrefix, title]).pipe(take(1)).subscribe(([translatedTitlePrefix, translatedTitle]: [string, string]) => { + this.addMetaTag('title', translatedTitlePrefix + translatedTitle); + this.title.setTitle(translatedTitlePrefix + translatedTitle); + }); + } + if (routeInfo.data.value.description) { + this.translate.get(routeInfo.data.value.description).pipe(take(1)).subscribe((translatedDescription: string) => { + this.addMetaTag('description', translatedDescription); + }); + } + } + + protected getCurrentRoute(route: ActivatedRoute): ActivatedRoute { + while (route.firstChild) { + route = route.firstChild; + } + return route; + } + + protected setDSOMetaTags(): void { + + this.setTitleTag(); + this.setDescriptionTag(); + + this.setCitationTitleTag(); + this.setCitationAuthorTags(); + this.setCitationPublicationDateTag(); + this.setCitationISSNTag(); + this.setCitationISBNTag(); + + this.setCitationLanguageTag(); + this.setCitationKeywordsTag(); + + this.setCitationAbstractUrlTag(); + this.setCitationPdfUrlTag(); + this.setCitationPublisherTag(); + + if (this.isDissertation()) { + this.setCitationDissertationNameTag(); + } + + // this.setCitationJournalTitleTag(); + // this.setCitationVolumeTag(); + // this.setCitationIssueTag(); + // this.setCitationFirstPageTag(); + // this.setCitationLastPageTag(); + // this.setCitationDOITag(); + // this.setCitationPMIDTag(); + + // this.setCitationFullTextTag(); + + // this.setCitationConferenceTag(); + + // this.setCitationPatentCountryTag(); + // this.setCitationPatentNumberTag(); + + } + + /** + * Add to the + */ + protected setTitleTag(): void { + const value = this.dsoNameService.getName(this.currentObject.getValue()); + this.addMetaTag('title', value); + this.title.setTitle(value); + } + + /** + * Add to the + */ + protected setDescriptionTag(): void { + // TODO: truncate abstract + const value = this.getMetaTagValue('dc.description.abstract'); + this.addMetaTag('description', value); + } + + /** + * Add to the + */ + protected setCitationTitleTag(): void { + const value = this.getMetaTagValue('dc.title'); + this.addMetaTag('citation_title', value); + } + + /** + * Add to the + */ + protected setCitationAuthorTags(): void { + const values: string[] = this.getMetaTagValues(['dc.author', 'dc.contributor.author', 'dc.creator']); + this.addMetaTags('citation_author', values); + } + + /** + * Add to the + */ + protected setCitationPublicationDateTag(): void { + const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']); + this.addMetaTag('citation_publication_date', value); + } + + /** + * Add to the + */ + protected setCitationISSNTag(): void { + const value = this.getMetaTagValue('dc.identifier.issn'); + this.addMetaTag('citation_issn', value); + } + + /** + * Add to the + */ + protected setCitationISBNTag(): void { + const value = this.getMetaTagValue('dc.identifier.isbn'); + this.addMetaTag('citation_isbn', value); + } + + /** + * Add to the + */ + protected setCitationLanguageTag(): void { + const value = this.getFirstMetaTagValue(['dc.language', 'dc.language.iso']); + this.addMetaTag('citation_language', value); + } + + /** + * Add to the + */ + protected setCitationDissertationNameTag(): void { + const value = this.getMetaTagValue('dc.title'); + this.addMetaTag('citation_dissertation_name', value); + } + + /** + * Add dc.publisher to the . The tag name depends on the item type. + */ + protected setCitationPublisherTag(): void { + const value = this.getMetaTagValue('dc.publisher'); + if (this.isDissertation()) { + this.addMetaTag('citation_dissertation_institution', value); + } else if (this.isTechReport()) { + this.addMetaTag('citation_technical_report_institution', value); + } else { + this.addMetaTag('citation_publisher', value); + } + } + + /** + * Add to the + */ + protected setCitationKeywordsTag(): void { + const value = this.getMetaTagValuesAndCombine('dc.subject'); + this.addMetaTag('citation_keywords', value); + } + + /** + * Add to the + */ + protected setCitationAbstractUrlTag(): void { + if (this.currentObject.value instanceof Item) { + let url = this.getMetaTagValue('dc.identifier.uri'); + if (hasNoValue(url)) { + url = new URLCombiner(this.hardRedirectService.getCurrentOrigin(), this.router.url).toString(); + } + this.addMetaTag('citation_abstract_html_url', url); + } + } + + /** + * Add to the + */ + protected setCitationPdfUrlTag(): void { + if (this.currentObject.value instanceof Item) { + const item = this.currentObject.value as Item; + + // Retrieve the ORIGINAL bundle for the item + this.bundleDataService.findByItemAndName( + item, + 'ORIGINAL', + true, + true, + followLink('primaryBitstream'), + followLink('bitstreams', { + findListOptions: { + // limit the number of bitstreams used to find the citation pdf url to the number + // shown by default on an item page + elementsPerPage: this.appConfig.item.bitstream.pageSize, + }, + }, followLink('format')), + ).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((bundle: Bundle) => + // First try the primary bitstream + bundle.primaryBitstream.pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (hasValue(rd.payload)) { + return rd.payload; + } else { + return null; + } + }), + getDownloadableBitstream(this.authorizationService), + // return the bundle as well so we can use it again if there's no primary bitstream + map((bitstream: Bitstream) => [bundle, bitstream]), + ), + ), + switchMap(([bundle, primaryBitstream]: [Bundle, Bitstream]) => { + if (hasValue(primaryBitstream)) { + // If there was a downloadable primary bitstream, emit its link + return [getBitstreamDownloadRoute(primaryBitstream)]; + } else { + // Otherwise consider the regular bitstreams in the bundle + return bundle.bitstreams.pipe( + getFirstCompletedRemoteData(), + switchMap((bitstreamRd: RemoteData>) => { + if (hasValue(bitstreamRd.payload) && bitstreamRd.payload.totalElements === 1) { + // If there's only one bitstream in the bundle, emit its link if its downloadable + return this.getBitLinkIfDownloadable(bitstreamRd.payload.page[0], bitstreamRd); + } else { + // Otherwise check all bitstreams to see if one matches the format whitelist + return this.getFirstAllowedFormatBitstreamLink(bitstreamRd); + } + }), + ); + } + }), + take(1), + ).subscribe((link: string) => { + // Use the found link to set the tag + this.addMetaTag( + 'citation_pdf_url', + new URLCombiner(this.hardRedirectService.getCurrentOrigin(), link).toString(), + ); + }); + } + } + + getBitLinkIfDownloadable(bitstream: Bitstream, bitstreamRd: RemoteData>): Observable { + return observableOf(bitstream).pipe( + getDownloadableBitstream(this.authorizationService), + switchMap((bit: Bitstream) => { + if (hasValue(bit)) { + return [getBitstreamDownloadRoute(bit)]; + } else { + // Otherwise check all bitstreams to see if one matches the format whitelist + return this.getFirstAllowedFormatBitstreamLink(bitstreamRd); + } + }), + ); + } + + /** + * For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream + * with a MIME type. + * + * Note this will only check the current page (page size determined item.bitstream.pageSize in the + * config) of bitstreams for performance reasons. + * See https://github.com/DSpace/DSpace/issues/8648 for more info + * + * included in {@linkcode CITATION_PDF_URL_MIMETYPES} + * @param bitstreamRd + * @private + */ + protected getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData>): Observable { + if (hasValue(bitstreamRd.payload) && isNotEmpty(bitstreamRd.payload.page)) { + // Retrieve the formats of all bitstreams in the page sequentially + return observableConcat( + ...bitstreamRd.payload.page.map((bitstream: Bitstream) => bitstream.format.pipe( + getFirstSucceededRemoteDataPayload(), + // Keep the original bitstream, because it, not the format, is what we'll need + // for the link at the end + map((format: BitstreamFormat) => [bitstream, format]), + )), + ).pipe( + // Verify that the bitstream is downloadable + mergeMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe( + getDownloadableBitstream(this.authorizationService), + map((bit: Bitstream) => [bit, format]), + )), + // Filter out only pairs with whitelisted formats and non-null bitstreams, null from download check + filter(([bitstream, format]: [Bitstream, BitstreamFormat]) => + hasValue(format) && hasValue(bitstream) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)), + // We only need 1 + take(1), + // Emit the link of the match + // tap((v) => console.log('result', v)), + map(([bitstream, format]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream)), + ); + } else { + return EMPTY; + } + } + + /** + * Add to the containing the current DSpace version + */ + protected setGenerator(): void { + this.rootService.findRoot().pipe(getFirstSucceededRemoteDataPayload()).subscribe((root) => { + this.meta.addTag({ name: 'Generator', content: root.dspaceVersion }); + }); + } + + protected hasType(value: string): boolean { + return this.currentObject.value.hasMetadata('dc.type', { value: value, ignoreCase: true }); + } + + /** + * Returns true if this._item is a dissertation + * + * @returns {boolean} + * true if this._item has a dc.type equal to 'Thesis' + */ + protected isDissertation(): boolean { + return this.hasType('thesis'); + } + + /** + * Returns true if this._item is a technical report + * + * @returns {boolean} + * true if this._item has a dc.type equal to 'Technical Report' + */ + protected isTechReport(): boolean { + return this.hasType('technical report'); + } + + protected getMetaTagValue(key: string): string { + return this.currentObject.value.firstMetadataValue(key); + } + + protected getFirstMetaTagValue(keys: string[]): string { + return this.currentObject.value.firstMetadataValue(keys); + } + + protected getMetaTagValuesAndCombine(key: string): string { + return this.getMetaTagValues([key]).join('; '); + } + + protected getMetaTagValues(keys: string[]): string[] { + return this.currentObject.value.allMetadataValues(keys); + } + + protected addMetaTag(name: string, content: string): void { + if (content) { + const tag = { name, content } as MetaDefinition; + this.meta.addTag(tag); + this.storeTag(name); + } + } + + protected addMetaTags(name: string, content: string[]): void { + for (const value of content) { + this.addMetaTag(name, value); + } + } + + protected storeTag(key: string): void { + this.store.dispatch(new AddMetaTagAction(key)); + } + + protected clearMetaTags(): void { + this.store.pipe( + select(tagsInUseSelector), + take(1), + ).subscribe((tagsInUse: string[]) => { + for (const name of tagsInUse) { + this.meta.removeTag('name=\'' + name + '\''); + } + this.store.dispatch(new ClearMetaTagAction()); + }); + } + + +} diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 66715f71f0..79158c7ec0 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -1,520 +1,16 @@ -import { - fakeAsync, - tick, -} from '@angular/core/testing'; -import { - Meta, - Title, -} from '@angular/platform-browser'; -import { - NavigationEnd, - Router, -} from '@angular/router'; -import { createMockStore } from '@ngrx/store/testing'; -import { TranslateService } from '@ngx-translate/core'; -import { - Observable, - of as observableOf, - of, -} from 'rxjs'; +import { TestBed } from '@angular/core/testing'; -import { AppConfig } from '../../../config/app-config.interface'; -import { - ItemMock, - MockBitstream1, - MockBitstream2, - MockBitstream3, -} from '../../shared/mocks/item.mock'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { - createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$, -} from '../../shared/remote-data.utils'; -import { createPaginatedList } from '../../shared/testing/utils.test'; -import { DSONameService } from '../breadcrumbs/dso-name.service'; -import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; -import { PaginatedList } from '../data/paginated-list.model'; -import { RemoteData } from '../data/remote-data'; -import { RootDataService } from '../data/root-data.service'; -import { HardRedirectService } from '../services/hard-redirect.service'; -import { Bitstream } from '../shared/bitstream.model'; -import { Bundle } from '../shared/bundle.model'; -import { Item } from '../shared/item.model'; -import { MetadataValue } from '../shared/metadata.models'; -import { - AddMetaTagAction, - ClearMetaTagAction, -} from './meta-tag.actions'; import { MetadataService } from './metadata.service'; describe('MetadataService', () => { - let metadataService: MetadataService; - - let meta: Meta; - - let title: Title; - - let dsoNameService: DSONameService; - - let bundleDataService; - let bitstreamDataService; - let rootService: RootDataService; - let translateService: TranslateService; - let hardRedirectService: HardRedirectService; - let authorizationService: AuthorizationDataService; - - let router: Router; - let store; - - let appConfig: AppConfig; - - const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] } } }; - + let service: MetadataService; beforeEach(() => { - rootService = jasmine.createSpyObj({ - findRoot: createSuccessfulRemoteDataObject$({ dspaceVersion: 'mock-dspace-version' }), - }); - bitstreamDataService = jasmine.createSpyObj({ - findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([MockBitstream3])), - }); - bundleDataService = jasmine.createSpyObj({ - findByItemAndName: mockBundleRD$([MockBitstream3]), - }); - translateService = getMockTranslateService(); - meta = jasmine.createSpyObj('meta', { - addTag: {}, - removeTag: {}, - }); - title = jasmine.createSpyObj({ - setTitle: {}, - }); - dsoNameService = jasmine.createSpyObj({ - getName: ItemMock.firstMetadataValue('dc.title'), - }); - router = { - url: '/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357', - events: of(new NavigationEnd(1, '', '')), - routerState: { - root: {}, - }, - } as any as Router; - hardRedirectService = jasmine.createSpyObj( { - getCurrentOrigin: 'https://request.org', - }); - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true), - }); - - store = createMockStore({ initialState }); - spyOn(store, 'dispatch'); - - appConfig = { - item: { - bitstream: { - pageSize: 5, - }, - }, - } as any; - - metadataService = new MetadataService( - router, - translateService, - meta, - title, - dsoNameService, - bundleDataService, - bitstreamDataService, - undefined, - rootService, - store, - hardRedirectService, - appConfig, - authorizationService, - ); + TestBed.configureTestingModule({}); + service = TestBed.inject(MetadataService); }); - it('items page should set meta tags', fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(ItemMock), - }, - }, - }); - tick(); - expect(title.setTitle).toHaveBeenCalledWith('Test PowerPoint Document'); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_title', - content: 'Test PowerPoint Document', - }); - expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_author', content: 'Doe, Jane' }); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_publication_date', - content: '1650-06-26', - }); - expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_issn', content: '123456789' }); - expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_language', content: 'en' }); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_keywords', - content: 'keyword1; keyword2; keyword3', - }); - })); - - it('items page should set meta tags as published Thesis', fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))), - }, - }, - }); - tick(); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_dissertation_name', - content: 'Test PowerPoint Document', - }); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_pdf_url', - content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download', - }); - })); - - it('items page should set meta tags as published Technical Report', fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))), - }, - }, - }); - tick(); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_technical_report_institution', - content: 'Mock Publisher', - }); - })); - - it('route titles should overwrite dso titles', fakeAsync(() => { - (translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Translated Route Title')); - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(ItemMock), - title: 'route.title.key', - }, - }, - }); - tick(); - expect(title.setTitle).toHaveBeenCalledTimes(2); - expect((title.setTitle as jasmine.Spy).calls.argsFor(0)).toEqual(['Test PowerPoint Document']); - expect((title.setTitle as jasmine.Spy).calls.argsFor(1)).toEqual(['DSpace :: Translated Route Title']); - })); - - it('other navigation should add title and description', fakeAsync(() => { - (translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!')); - (metadataService as any).processRouteChange({ - data: { - value: { - title: 'Dummy Title', - description: 'This is a dummy item component for testing!', - }, - }, - }); - tick(); - expect(title.setTitle).toHaveBeenCalledWith('DSpace :: Dummy Title'); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'title', - content: 'DSpace :: Dummy Title', - }); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'description', - content: 'This is a dummy item component for testing!', - }); - })); - - describe(`listenForRouteChange`, () => { - it(`should call processRouteChange`, fakeAsync(() => { - spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined); - metadataService.listenForRouteChange(); - tick(); - expect((metadataService as any).processRouteChange).toHaveBeenCalled(); - })); - it(`should add Generator`, fakeAsync(() => { - spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined); - metadataService.listenForRouteChange(); - tick(); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'Generator', - content: 'mock-dspace-version', - }); - })); + it('should be created', () => { + expect(service).toBeTruthy(); }); - - describe('citation_abstract_html_url', () => { - it('should use dc.identifier.uri if available', fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(mockUri(ItemMock, 'https://ddg.gg')), - }, - }, - }); - tick(); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_abstract_html_url', - content: 'https://ddg.gg', - }); - })); - - it('should use current route as fallback', fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(mockUri(ItemMock)), - }, - }, - }); - tick(); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_abstract_html_url', - content: 'https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357', - }); - })); - }); - - describe('citation_*_institution / citation_publisher', () => { - it('should use citation_dissertation_institution tag for dissertations', fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))), - }, - }, - }); - tick(); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_dissertation_institution', - content: 'Mock Publisher', - }); - expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_technical_report_institution' })); - expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_publisher' })); - })); - - it('should use citation_tech_report_institution tag for tech reports', fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))), - }, - }, - }); - tick(); - expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_dissertation_institution' })); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_technical_report_institution', - content: 'Mock Publisher', - }); - expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_publisher' })); - })); - - it('should use citation_publisher for other item types', fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Some Other Type'))), - }, - }, - }); - tick(); - expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_dissertation_institution' })); - expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_technical_report_institution' })); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_publisher', - content: 'Mock Publisher', - }); - })); - }); - - describe('citation_pdf_url', () => { - it('should link to primary Bitstream URL regardless of format', fakeAsync(() => { - (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([], MockBitstream3)); - - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(ItemMock), - }, - }, - }); - tick(); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_pdf_url', - content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download', - }); - })); - - describe('bitstream not download allowed', () => { - it('should not have citation_pdf_url', fakeAsync(() => { - (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3])); - (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); - - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(ItemMock), - }, - }, - }); - tick(); - expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_pdf_url' })); - })); - - }); - - describe('no primary Bitstream', () => { - it('should link to first and only Bitstream regardless of format', fakeAsync(() => { - (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3])); - - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(ItemMock), - }, - }, - }); - tick(); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_pdf_url', - content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download', - }); - })); - - describe(`when there's a bitstream with an allowed format on the first page`, () => { - let bitstreams; - - beforeEach(() => { - bitstreams = [MockBitstream2, MockBitstream3, MockBitstream1]; - (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams)); - (bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues( - ...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)), - ); - }); - - it('should link to first Bitstream with allowed format', fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(ItemMock), - }, - }, - }); - tick(); - expect(meta.addTag).toHaveBeenCalledWith({ - name: 'citation_pdf_url', - content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download', - }); - })); - - }); - - }); - }); - - describe(`when there's no bitstream with an allowed format on the first page`, () => { - let bitstreams; - - beforeEach(() => { - bitstreams = [MockBitstream1, MockBitstream3, MockBitstream2]; - (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams)); - (bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues( - ...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)), - ); - }); - - it(`shouldn't add a citation_pdf_url meta tag`, fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(ItemMock), - }, - }, - }); - tick(); - expect(meta.addTag).not.toHaveBeenCalledWith({ - name: 'citation_pdf_url', - content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download', - }); - })); - - }); - - - describe('tagstore', () => { - beforeEach(fakeAsync(() => { - (metadataService as any).processRouteChange({ - data: { - value: { - dso: createSuccessfulRemoteDataObject(ItemMock), - }, - }, - }); - tick(); - })); - - it('should remove previous tags on route change', fakeAsync(() => { - expect(meta.removeTag).toHaveBeenCalledWith('name=\'title\''); - expect(meta.removeTag).toHaveBeenCalledWith('name=\'description\''); - })); - - it('should clear all tags and add new ones on route change', () => { - expect(store.dispatch.calls.argsFor(0)).toEqual([new ClearMetaTagAction()]); - expect(store.dispatch.calls.argsFor(1)).toEqual([new AddMetaTagAction('title')]); - expect(store.dispatch.calls.argsFor(2)).toEqual([new AddMetaTagAction('description')]); - }); - }); - - const mockType = (mockItem: Item, type: string): Item => { - const typedMockItem = Object.assign(new Item(), mockItem) as Item; - typedMockItem.metadata['dc.type'] = [{ value: type }] as MetadataValue[]; - return typedMockItem; - }; - - const mockPublisher = (mockItem: Item): Item => { - const publishedMockItem = Object.assign(new Item(), mockItem) as Item; - publishedMockItem.metadata['dc.publisher'] = [ - { - language: 'en_US', - value: 'Mock Publisher', - }, - ] as MetadataValue[]; - return publishedMockItem; - }; - - const mockUri = (mockItem: Item, uri?: string): Item => { - const publishedMockItem = Object.assign(new Item(), mockItem) as Item; - publishedMockItem.metadata['dc.identifier.uri'] = [{ value: uri }] as MetadataValue[]; - return publishedMockItem; - }; - - const mockBundleRD$ = (bitstreams: Bitstream[], primary?: Bitstream): Observable> => { - return createSuccessfulRemoteDataObject$( - Object.assign(new Bundle(), { - name: 'ORIGINAL', - bitstreams: createSuccessfulRemoteDataObject$(mockBitstreamPages$(bitstreams)[0]), - primaryBitstream: createSuccessfulRemoteDataObject$(primary), - }), - ); - }; - - const mockBitstreamPages$ = (bitstreams: Bitstream[]): PaginatedList[] => { - return bitstreams.map((bitstream, index) => Object.assign(createPaginatedList([bitstream]), { - pageInfo: { - totalElements: bitstreams.length, // announce multiple elements/pages - }, - _links: index < bitstreams.length - 1 - ? { next: { href: 'not empty' } } // fake link to the next bitstream page - : { next: { href: undefined } }, // last page has no link - })); - }; }); diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index ea4b25b774..1aacb76295 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -1,529 +1,37 @@ -import { - Inject, - Injectable, -} from '@angular/core'; -import { - Meta, - MetaDefinition, - Title, -} from '@angular/platform-browser'; -import { - ActivatedRoute, - NavigationEnd, - Router, -} from '@angular/router'; -import { - createSelector, - select, - Store, -} from '@ngrx/store'; -import { TranslateService } from '@ngx-translate/core'; -import { - BehaviorSubject, - combineLatest, - concat as observableConcat, - EMPTY, - Observable, - of as observableOf, -} from 'rxjs'; -import { - filter, - map, - mergeMap, - switchMap, - take, -} from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { hasValue } from '../../shared/empty.util'; import { - APP_CONFIG, - AppConfig, -} from '../../../config/app-config.interface'; -import { getBitstreamDownloadRoute } from '../../app-routing-paths'; -import { - hasNoValue, - hasValue, - isNotEmpty, -} from '../../shared/empty.util'; -import { followLink } from '../../shared/utils/follow-link-config.model'; -import { DSONameService } from '../breadcrumbs/dso-name.service'; -import { coreSelector } from '../core.selectors'; -import { CoreState } from '../core-state.model'; -import { BitstreamDataService } from '../data/bitstream-data.service'; -import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; -import { BundleDataService } from '../data/bundle-data.service'; -import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; -import { PaginatedList } from '../data/paginated-list.model'; -import { RemoteData } from '../data/remote-data'; -import { RootDataService } from '../data/root-data.service'; -import { HardRedirectService } from '../services/hard-redirect.service'; -import { Bitstream } from '../shared/bitstream.model'; -import { getDownloadableBitstream } from '../shared/bitstream.operators'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { Bundle } from '../shared/bundle.model'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { Item } from '../shared/item.model'; -import { - getFirstCompletedRemoteData, - getFirstSucceededRemoteDataPayload, -} from '../shared/operators'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { - AddMetaTagAction, - ClearMetaTagAction, -} from './meta-tag.actions'; -import { MetaTagState } from './meta-tag.reducer'; + MetadataValue, + VIRTUAL_METADATA_PREFIX, +} from '../shared/metadata.models'; /** - * The base selector function to select the metaTag section in the store + * Service for working with DSpace object metadata. */ -const metaTagSelector = createSelector( - coreSelector, - (state: CoreState) => state.metaTag, -); - -/** - * Selector function to select the tags in use from the MetaTagState - */ -const tagsInUseSelector = - createSelector( - metaTagSelector, - (state: MetaTagState) => state.tagsInUse, - ); - -@Injectable({ providedIn: 'root' }) +@Injectable({ + providedIn: 'root', +}) export class MetadataService { - private currentObject: BehaviorSubject = new BehaviorSubject(undefined); - /** - * When generating the citation_pdf_url meta tag for Items with more than one Bitstream (and no primary Bitstream), - * the first Bitstream to match one of the following MIME types is selected. - * See {@linkcode getFirstAllowedFormatBitstreamLink} - * @private + * Returns true if this Metadata authority key starts with 'virtual::' */ - private readonly CITATION_PDF_URL_MIMETYPES = [ - 'application/pdf', // .pdf - 'application/postscript', // .ps - 'application/msword', // .doc - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx - 'application/rtf', // .rtf - 'application/epub+zip', // .epub - ]; - - constructor( - private router: Router, - private translate: TranslateService, - private meta: Meta, - private title: Title, - private dsoNameService: DSONameService, - private bundleDataService: BundleDataService, - private bitstreamDataService: BitstreamDataService, - private bitstreamFormatDataService: BitstreamFormatDataService, - private rootService: RootDataService, - private store: Store, - private hardRedirectService: HardRedirectService, - @Inject(APP_CONFIG) private appConfig: AppConfig, - private authorizationService: AuthorizationDataService, - ) { - } - - public listenForRouteChange(): void { - // This never changes, set it only once - this.setGenerator(); - - this.router.events.pipe( - filter((event) => event instanceof NavigationEnd), - map(() => this.router.routerState.root), - map((route: ActivatedRoute) => { - route = this.getCurrentRoute(route); - return { params: route.params, data: route.data }; - })).subscribe((routeInfo: any) => { - this.processRouteChange(routeInfo); - }); - } - - private processRouteChange(routeInfo: any): void { - this.clearMetaTags(); - - if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) { - this.currentObject.next(routeInfo.data.value.dso.payload); - this.setDSOMetaTags(); - } - - if (routeInfo.data.value.title) { - const titlePrefix = this.translate.get('repository.title.prefix'); - const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value); - combineLatest([titlePrefix, title]).pipe(take(1)).subscribe(([translatedTitlePrefix, translatedTitle]: [string, string]) => { - this.addMetaTag('title', translatedTitlePrefix + translatedTitle); - this.title.setTitle(translatedTitlePrefix + translatedTitle); - }); - } - if (routeInfo.data.value.description) { - this.translate.get(routeInfo.data.value.description).pipe(take(1)).subscribe((translatedDescription: string) => { - this.addMetaTag('description', translatedDescription); - }); - } - } - - private getCurrentRoute(route: ActivatedRoute): ActivatedRoute { - while (route.firstChild) { - route = route.firstChild; - } - return route; - } - - private setDSOMetaTags(): void { - - this.setTitleTag(); - this.setDescriptionTag(); - - this.setCitationTitleTag(); - this.setCitationAuthorTags(); - this.setCitationPublicationDateTag(); - this.setCitationISSNTag(); - this.setCitationISBNTag(); - - this.setCitationLanguageTag(); - this.setCitationKeywordsTag(); - - this.setCitationAbstractUrlTag(); - this.setCitationPdfUrlTag(); - this.setCitationPublisherTag(); - - if (this.isDissertation()) { - this.setCitationDissertationNameTag(); - } - - // this.setCitationJournalTitleTag(); - // this.setCitationVolumeTag(); - // this.setCitationIssueTag(); - // this.setCitationFirstPageTag(); - // this.setCitationLastPageTag(); - // this.setCitationDOITag(); - // this.setCitationPMIDTag(); - - // this.setCitationFullTextTag(); - - // this.setCitationConferenceTag(); - - // this.setCitationPatentCountryTag(); - // this.setCitationPatentNumberTag(); - + public isVirtual(metadataValue: MetadataValue | undefined): boolean { + return hasValue(metadataValue?.authority) && metadataValue.authority.startsWith(VIRTUAL_METADATA_PREFIX); } /** - * Add to the + * If this is a virtual Metadata, it returns everything in the authority key after 'virtual::'. + * + * Returns undefined otherwise. */ - private setTitleTag(): void { - const value = this.dsoNameService.getName(this.currentObject.getValue()); - this.addMetaTag('title', value); - this.title.setTitle(value); - } - - /** - * Add to the - */ - private setDescriptionTag(): void { - // TODO: truncate abstract - const value = this.getMetaTagValue('dc.description.abstract'); - this.addMetaTag('description', value); - } - - /** - * Add to the - */ - private setCitationTitleTag(): void { - const value = this.getMetaTagValue('dc.title'); - this.addMetaTag('citation_title', value); - } - - /** - * Add to the - */ - private setCitationAuthorTags(): void { - const values: string[] = this.getMetaTagValues(['dc.author', 'dc.contributor.author', 'dc.creator']); - this.addMetaTags('citation_author', values); - } - - /** - * Add to the - */ - private setCitationPublicationDateTag(): void { - const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']); - this.addMetaTag('citation_publication_date', value); - } - - /** - * Add to the - */ - private setCitationISSNTag(): void { - const value = this.getMetaTagValue('dc.identifier.issn'); - this.addMetaTag('citation_issn', value); - } - - /** - * Add to the - */ - private setCitationISBNTag(): void { - const value = this.getMetaTagValue('dc.identifier.isbn'); - this.addMetaTag('citation_isbn', value); - } - - /** - * Add to the - */ - private setCitationLanguageTag(): void { - const value = this.getFirstMetaTagValue(['dc.language', 'dc.language.iso']); - this.addMetaTag('citation_language', value); - } - - /** - * Add to the - */ - private setCitationDissertationNameTag(): void { - const value = this.getMetaTagValue('dc.title'); - this.addMetaTag('citation_dissertation_name', value); - } - - /** - * Add dc.publisher to the . The tag name depends on the item type. - */ - private setCitationPublisherTag(): void { - const value = this.getMetaTagValue('dc.publisher'); - if (this.isDissertation()) { - this.addMetaTag('citation_dissertation_institution', value); - } else if (this.isTechReport()) { - this.addMetaTag('citation_technical_report_institution', value); + public virtualValue(metadataValue: MetadataValue | undefined): string { + if (this.isVirtual) { + return metadataValue.authority.substring(metadataValue.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length); } else { - this.addMetaTag('citation_publisher', value); + return undefined; } } - /** - * Add to the - */ - private setCitationKeywordsTag(): void { - const value = this.getMetaTagValuesAndCombine('dc.subject'); - this.addMetaTag('citation_keywords', value); - } - - /** - * Add to the - */ - private setCitationAbstractUrlTag(): void { - if (this.currentObject.value instanceof Item) { - let url = this.getMetaTagValue('dc.identifier.uri'); - if (hasNoValue(url)) { - url = new URLCombiner(this.hardRedirectService.getCurrentOrigin(), this.router.url).toString(); - } - this.addMetaTag('citation_abstract_html_url', url); - } - } - - /** - * Add to the - */ - private setCitationPdfUrlTag(): void { - if (this.currentObject.value instanceof Item) { - const item = this.currentObject.value as Item; - - // Retrieve the ORIGINAL bundle for the item - this.bundleDataService.findByItemAndName( - item, - 'ORIGINAL', - true, - true, - followLink('primaryBitstream'), - followLink('bitstreams', { - findListOptions: { - // limit the number of bitstreams used to find the citation pdf url to the number - // shown by default on an item page - elementsPerPage: this.appConfig.item.bitstream.pageSize, - }, - }, followLink('format')), - ).pipe( - getFirstSucceededRemoteDataPayload(), - switchMap((bundle: Bundle) => - // First try the primary bitstream - bundle.primaryBitstream.pipe( - getFirstCompletedRemoteData(), - map((rd: RemoteData) => { - if (hasValue(rd.payload)) { - return rd.payload; - } else { - return null; - } - }), - getDownloadableBitstream(this.authorizationService), - // return the bundle as well so we can use it again if there's no primary bitstream - map((bitstream: Bitstream) => [bundle, bitstream]), - ), - ), - switchMap(([bundle, primaryBitstream]: [Bundle, Bitstream]) => { - if (hasValue(primaryBitstream)) { - // If there was a downloadable primary bitstream, emit its link - return [getBitstreamDownloadRoute(primaryBitstream)]; - } else { - // Otherwise consider the regular bitstreams in the bundle - return bundle.bitstreams.pipe( - getFirstCompletedRemoteData(), - switchMap((bitstreamRd: RemoteData>) => { - if (hasValue(bitstreamRd.payload) && bitstreamRd.payload.totalElements === 1) { - // If there's only one bitstream in the bundle, emit its link if its downloadable - return this.getBitLinkIfDownloadable(bitstreamRd.payload.page[0], bitstreamRd); - } else { - // Otherwise check all bitstreams to see if one matches the format whitelist - return this.getFirstAllowedFormatBitstreamLink(bitstreamRd); - } - }), - ); - } - }), - take(1), - ).subscribe((link: string) => { - // Use the found link to set the tag - this.addMetaTag( - 'citation_pdf_url', - new URLCombiner(this.hardRedirectService.getCurrentOrigin(), link).toString(), - ); - }); - } - } - - getBitLinkIfDownloadable(bitstream: Bitstream, bitstreamRd: RemoteData>): Observable { - return observableOf(bitstream).pipe( - getDownloadableBitstream(this.authorizationService), - switchMap((bit: Bitstream) => { - if (hasValue(bit)) { - return [getBitstreamDownloadRoute(bit)]; - } else { - // Otherwise check all bitstreams to see if one matches the format whitelist - return this.getFirstAllowedFormatBitstreamLink(bitstreamRd); - } - }), - ); - } - - /** - * For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream - * with a MIME type. - * - * Note this will only check the current page (page size determined item.bitstream.pageSize in the - * config) of bitstreams for performance reasons. - * See https://github.com/DSpace/DSpace/issues/8648 for more info - * - * included in {@linkcode CITATION_PDF_URL_MIMETYPES} - * @param bitstreamRd - * @private - */ - private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData>): Observable { - if (hasValue(bitstreamRd.payload) && isNotEmpty(bitstreamRd.payload.page)) { - // Retrieve the formats of all bitstreams in the page sequentially - return observableConcat( - ...bitstreamRd.payload.page.map((bitstream: Bitstream) => bitstream.format.pipe( - getFirstSucceededRemoteDataPayload(), - // Keep the original bitstream, because it, not the format, is what we'll need - // for the link at the end - map((format: BitstreamFormat) => [bitstream, format]), - )), - ).pipe( - // Verify that the bitstream is downloadable - mergeMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe( - getDownloadableBitstream(this.authorizationService), - map((bit: Bitstream) => [bit, format]), - )), - // Filter out only pairs with whitelisted formats and non-null bitstreams, null from download check - filter(([bitstream, format]: [Bitstream, BitstreamFormat]) => - hasValue(format) && hasValue(bitstream) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)), - // We only need 1 - take(1), - // Emit the link of the match - // tap((v) => console.log('result', v)), - map(([bitstream ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream)), - ); - } else { - return EMPTY; - } - } - - /** - * Add to the containing the current DSpace version - */ - private setGenerator(): void { - this.rootService.findRoot().pipe(getFirstSucceededRemoteDataPayload()).subscribe((root) => { - this.meta.addTag({ name: 'Generator', content: root.dspaceVersion }); - }); - } - - private hasType(value: string): boolean { - return this.currentObject.value.hasMetadata('dc.type', { value: value, ignoreCase: true }); - } - - /** - * Returns true if this._item is a dissertation - * - * @returns {boolean} - * true if this._item has a dc.type equal to 'Thesis' - */ - private isDissertation(): boolean { - return this.hasType('thesis'); - } - - /** - * Returns true if this._item is a technical report - * - * @returns {boolean} - * true if this._item has a dc.type equal to 'Technical Report' - */ - private isTechReport(): boolean { - return this.hasType('technical report'); - } - - private getMetaTagValue(key: string): string { - return this.currentObject.value.firstMetadataValue(key); - } - - private getFirstMetaTagValue(keys: string[]): string { - return this.currentObject.value.firstMetadataValue(keys); - } - - private getMetaTagValuesAndCombine(key: string): string { - return this.getMetaTagValues([key]).join('; '); - } - - private getMetaTagValues(keys: string[]): string[] { - return this.currentObject.value.allMetadataValues(keys); - } - - private addMetaTag(name: string, content: string): void { - if (content) { - const tag = { name, content } as MetaDefinition; - this.meta.addTag(tag); - this.storeTag(name); - } - } - - private addMetaTags(name: string, content: string[]): void { - for (const value of content) { - this.addMetaTag(name, value); - } - } - - private storeTag(key: string): void { - this.store.dispatch(new AddMetaTagAction(key)); - } - - public clearMetaTags() { - this.store.pipe( - select(tagsInUseSelector), - take(1), - ).subscribe((tagsInUse: string[]) => { - for (const name of tagsInUse) { - this.meta.removeTag('name=\'' + name + '\''); - } - this.store.dispatch(new ClearMetaTagAction()); - }); - } - - } diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index 0f26d7ff59..15cfeb285e 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -6,8 +6,6 @@ import { } from 'cerialize'; import { v4 as uuidv4 } from 'uuid'; -import { hasValue } from '../../shared/empty.util'; - export const VIRTUAL_METADATA_PREFIX = 'virtual::'; /** A single metadata value and its properties. */ @@ -58,24 +56,6 @@ export class MetadataValue implements MetadataValueInterface { @autoserialize confidence: number; - /** - * Returns true if this Metadatum's authority key starts with 'virtual::' - */ - get isVirtual(): boolean { - return hasValue(this.authority) && this.authority.startsWith(VIRTUAL_METADATA_PREFIX); - } - - /** - * If this is a virtual Metadatum, it returns everything in the authority key after 'virtual::'. - * Returns undefined otherwise. - */ - get virtualValue(): string { - if (this.isVirtual) { - return this.authority.substring(this.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length); - } else { - return undefined; - } - } } /** Constraints for matching metadata values. */ diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html index 0c1088e3fd..d746248191 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -1,4 +1,4 @@ -
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index 1aefe5af23..12c3336ee7 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -45,6 +45,7 @@ import { NotificationsService } from 'src/app/shared/notifications/notifications import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { ItemDataService } from '../../../core/data/item-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service'; +import { MetadataService } from '../../../core/metadata/metadata.service'; import { Collection } from '../../../core/shared/collection.model'; import { ConfidenceType } from '../../../core/shared/confidence-type'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; @@ -212,14 +213,17 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { private isScrollableVocabulary$: Observable; private isSuggesterVocabulary$: Observable; - constructor(protected relationshipService: RelationshipDataService, - protected dsoNameService: DSONameService, - protected vocabularyService: VocabularyService, - protected itemService: ItemDataService, - protected cdr: ChangeDetectorRef, - protected registryService: RegistryService, - protected notificationsService: NotificationsService, - protected translate: TranslateService) { + constructor( + protected relationshipService: RelationshipDataService, + protected dsoNameService: DSONameService, + protected vocabularyService: VocabularyService, + protected itemService: ItemDataService, + protected cdr: ChangeDetectorRef, + protected registryService: RegistryService, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected metadataService: MetadataService, + ) { } ngOnInit(): void { @@ -231,7 +235,7 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { * Initialise potential properties of a virtual metadata value */ initVirtualProperties(): void { - this.mdRepresentation$ = this.mdValue.newValue.isVirtual ? + this.mdRepresentation$ = this.metadataService.isVirtual(this.mdValue.newValue) ? this.relationshipService.resolveMetadataRepresentation(this.mdValue.newValue, this.dso, 'Item') .pipe( map((mdRepresentation: MetadataRepresentation) => diff --git a/src/app/init.service.spec.ts b/src/app/init.service.spec.ts index 9cb27e0075..1e22ae2113 100644 --- a/src/app/init.service.spec.ts +++ b/src/app/init.service.spec.ts @@ -31,7 +31,6 @@ import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { authReducer } from './core/auth/auth.reducer'; import { AuthService } from './core/auth/auth.service'; import { LocaleService } from './core/locale/locale.service'; -import { MetadataService } from './core/metadata/metadata.service'; import { RouteService } from './core/services/route.service'; import { CorrelationIdService } from './correlation-id/correlation-id.service'; import { InitService } from './init.service'; @@ -49,6 +48,9 @@ import createSpyObj = jasmine.createSpyObj; import SpyObj = jasmine.SpyObj; import { getTestScheduler } from 'jasmine-marbles'; +import { HeadTagService } from './core/metadata/head-tag.service'; +import { HeadTagServiceMock } from './shared/mocks/head-tag-service.mock'; + let spy: SpyObj; @Injectable() @@ -138,7 +140,7 @@ describe('InitService', () => { let correlationIdServiceSpy; let dspaceTransferStateSpy; let transferStateSpy; - let metadataServiceSpy; + let headTagService: HeadTagServiceMock; let breadcrumbsServiceSpy; let menuServiceSpy; @@ -164,9 +166,7 @@ describe('InitService', () => { breadcrumbsServiceSpy = jasmine.createSpyObj('breadcrumbsServiceSpy', [ 'listenForRouteChanges', ]); - metadataServiceSpy = jasmine.createSpyObj('metadataService', [ - 'listenForRouteChange', - ]); + headTagService = new HeadTagServiceMock(); menuServiceSpy = jasmine.createSpyObj('menuServiceSpy', [ 'listenForRouteChanges', ]); @@ -190,7 +190,7 @@ describe('InitService', () => { { provide: APP_CONFIG, useValue: environment }, { provide: LocaleService, useValue: getMockLocaleService() }, { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, - { provide: MetadataService, useValue: metadataServiceSpy }, + { provide: HeadTagService, useValue: headTagService }, { provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy }, { provide: AuthService, useValue: new AuthServiceMock() }, { provide: Router, useValue: new RouterMock() }, @@ -206,9 +206,9 @@ describe('InitService', () => { describe('initRouteListeners', () => { it('should call listenForRouteChanges', inject([InitService], (service) => { - // @ts-ignore + spyOn(headTagService, 'listenForRouteChange'); service.initRouteListeners(); - expect(metadataServiceSpy.listenForRouteChange).toHaveBeenCalledTimes(1); + expect(headTagService.listenForRouteChange).toHaveBeenCalledTimes(1); expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1); expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1); })); diff --git a/src/app/init.service.ts b/src/app/init.service.ts index 47b2d8b02c..d99d2d4c6b 100644 --- a/src/app/init.service.ts +++ b/src/app/init.service.ts @@ -38,7 +38,7 @@ import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { isAuthenticationBlocking } from './core/auth/selectors'; import { LAZY_DATA_SERVICES } from './core/data-services-map'; import { LocaleService } from './core/locale/locale.service'; -import { MetadataService } from './core/metadata/metadata.service'; +import { HeadTagService } from './core/metadata/head-tag.service'; import { CorrelationIdService } from './correlation-id/correlation-id.service'; import { dsDynamicFormControlMapFn } from './shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-map-fn'; import { MenuService } from './shared/menu/menu.service'; @@ -70,7 +70,7 @@ export abstract class InitService { protected translate: TranslateService, protected localeService: LocaleService, protected angulartics2DSpace: Angulartics2DSpace, - protected metadata: MetadataService, + protected headTagService: HeadTagService, protected breadcrumbsService: BreadcrumbsService, protected themeService: ThemeService, protected menuService: MenuService, @@ -207,13 +207,13 @@ export abstract class InitService { /** * Start route-listening subscriptions - * - {@link MetadataService.listenForRouteChange} + * - {@link HeadTagService.listenForRouteChange} * - {@link BreadcrumbsService.listenForRouteChanges} * - {@link ThemeService.listenForRouteChanges} * @protected */ protected initRouteListeners(): void { - this.metadata.listenForRouteChange(); + this.headTagService.listenForRouteChange(); this.breadcrumbsService.listenForRouteChanges(); this.themeService.listenForRouteChanges(); this.menuService.listenForRouteChanges(); 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 01faa08044..bf2acbee7d 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 @@ -28,12 +28,13 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/ import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { SignpostingDataService } from '../../core/data/signposting-data.service'; -import { MetadataService } from '../../core/metadata/metadata.service'; +import { HeadTagService } from '../../core/metadata/head-tag.service'; import { LinkHeadService } from '../../core/services/link-head.service'; import { ServerResponseService } from '../../core/services/server-response.service'; import { Item } from '../../core/shared/item.model'; import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { HeadTagServiceMock } from '../../shared/mocks/head-tag-service.mock'; import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { @@ -74,13 +75,6 @@ const mockWithdrawnItem: Item = Object.assign(new Item(), { isWithdrawn: true, }); -const metadataServiceStub = { - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - processRemoteData: () => { - }, - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ -}; - describe('FullItemPageComponent', () => { let comp: FullItemPageComponent; let fixture: ComponentFixture; @@ -93,6 +87,7 @@ describe('FullItemPageComponent', () => { let signpostingDataService: jasmine.SpyObj; let linkHeadService: jasmine.SpyObj; let notifyInfoService: jasmine.SpyObj; + let headTagService: HeadTagServiceMock; const mocklink = { href: 'http://test.org', @@ -143,6 +138,8 @@ describe('FullItemPageComponent', () => { getInboxRelationLink: observableOf('http://test.org'), }); + headTagService = new HeadTagServiceMock(); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -153,7 +150,7 @@ describe('FullItemPageComponent', () => { providers: [ { provide: ActivatedRoute, useValue: routeStub }, { provide: ItemDataService, useValue: {} }, - { provide: MetadataService, useValue: metadataServiceStub }, + { provide: HeadTagService, useValue: headTagService }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationDataService }, { provide: ServerResponseService, useValue: serverResponseService }, diff --git a/src/app/item-page/simple/item-page.component.spec.ts b/src/app/item-page/simple/item-page.component.spec.ts index 827af05b65..695fa8fac3 100644 --- a/src/app/item-page/simple/item-page.component.spec.ts +++ b/src/app/item-page/simple/item-page.component.spec.ts @@ -26,7 +26,6 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/ import { ItemDataService } from '../../core/data/item-data.service'; import { SignpostingDataService } from '../../core/data/signposting-data.service'; import { SignpostingLink } from '../../core/data/signposting-links.model'; -import { MetadataService } from '../../core/metadata/metadata.service'; import { LinkDefinition, LinkHeadService, @@ -92,12 +91,6 @@ describe('ItemPageComponent', () => { let linkHeadService: jasmine.SpyObj; let notifyInfoService: jasmine.SpyObj; - const mockMetadataService = { - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - processRemoteData: () => { - }, - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - }; const mockRoute = Object.assign(new ActivatedRouteStub(), { data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) }), }); @@ -141,7 +134,6 @@ describe('ItemPageComponent', () => { providers: [ { provide: ActivatedRoute, useValue: mockRoute }, { provide: ItemDataService, useValue: {} }, - { provide: MetadataService, useValue: mockMetadataService }, { provide: Router, useValue: {} }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationDataService }, diff --git a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts index bf747e8085..39d393a6e0 100644 --- a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts +++ b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -17,6 +17,7 @@ import { map } from 'rxjs/operators'; import { BrowseService } from '../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../core/browse/browse-definition-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service'; +import { MetadataService } from '../../../core/metadata/metadata.service'; import { Item } from '../../../core/shared/item.model'; import { MetadataValue } from '../../../core/shared/metadata.models'; import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; @@ -76,6 +77,7 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList constructor( public relationshipService: RelationshipDataService, protected browseDefinitionDataService: BrowseDefinitionDataService, + protected metadataService: MetadataService, ) { super(); } @@ -101,7 +103,7 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList .slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy) .map((metadatum: any) => Object.assign(new MetadataValue(), metadatum)) .map((metadatum: MetadataValue) => { - if (metadatum.isVirtual) { + if (this.metadataService.isVirtual(metadatum)) { return this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType); } else { // Check for a configured browse link and return a standard metadata representation diff --git a/src/app/root/root.component.spec.ts b/src/app/root/root.component.spec.ts index 9f7f8f4f0e..1abc3ffd17 100644 --- a/src/app/root/root.component.spec.ts +++ b/src/app/root/root.component.spec.ts @@ -5,46 +5,22 @@ import { TestBed, } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { - ActivatedRoute, - Router, -} from '@angular/router'; -import { StoreModule } from '@ngrx/store'; -import { provideMockStore } from '@ngrx/store/testing'; -import { - TranslateLoader, - TranslateModule, -} from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { ThemedAdminSidebarComponent } from '../admin/admin-sidebar/themed-admin-sidebar.component'; -import { storeModuleConfig } from '../app.reducer'; import { ThemedBreadcrumbsComponent } from '../breadcrumbs/themed-breadcrumbs.component'; -import { authReducer } from '../core/auth/auth.reducer'; -import { AuthService } from '../core/auth/auth.service'; -import { LocaleService } from '../core/locale/locale.service'; -import { MetadataService } from '../core/metadata/metadata.service'; -import { RouteService } from '../core/services/route.service'; -import { - NativeWindowRef, - NativeWindowService, -} from '../core/services/window.service'; import { ThemedFooterComponent } from '../footer/themed-footer.component'; import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component'; import { HostWindowService } from '../shared/host-window.service'; import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; import { MenuService } from '../shared/menu/menu.service'; -import { MockActivatedRoute } from '../shared/mocks/active-router.mock'; -import { AngularticsProviderMock } from '../shared/mocks/angulartics-provider.service.mock'; -import { AuthServiceMock } from '../shared/mocks/auth.service.mock'; -import { MetadataServiceMock } from '../shared/mocks/metadata-service.mock'; import { RouterMock } from '../shared/mocks/router.mock'; -import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; import { NotificationsBoardComponent } from '../shared/notifications/notifications-board/notifications-board.component'; import { CSSVariableService } from '../shared/sass-helper/css-variable.service'; import { CSSVariableServiceStub } from '../shared/testing/css-variable-service.stub'; import { HostWindowServiceStub } from '../shared/testing/host-window-service.stub'; import { MenuServiceStub } from '../shared/testing/menu-service.stub'; -import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banner/system-wide-alert-banner.component'; import { RootComponent } from './root.component'; @@ -57,29 +33,14 @@ describe('RootComponent', () => { imports: [ CommonModule, NoopAnimationsModule, - StoreModule.forRoot(authReducer, storeModuleConfig), - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock, - }, - }), + TranslateModule.forRoot(), RootComponent, ], providers: [ - { provide: NativeWindowService, useValue: new NativeWindowRef() }, - { provide: MetadataService, useValue: new MetadataServiceMock() }, - { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, - { provide: AuthService, useValue: new AuthServiceMock() }, { provide: Router, useValue: new RouterMock() }, - { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, { provide: MenuService, useValue: new MenuServiceStub() }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, - { provide: LocaleService, useValue: {} }, - provideMockStore({ core: { auth: { loading: false } } } as any), - RootComponent, - RouteService, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 606db29db3..7224f1843d 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -10,7 +10,7 @@
+ 'd-none': this.metadataService.isVirtual(value) && (model.hasSelectableMetadata || context?.index > 0)}">
@@ -53,7 +53,7 @@
- + { { provide: DsDynamicTypeBindRelationService, useValue: getMockDsDynamicTypeBindRelationService() }, { provide: RelationshipDataService, useValue: {} }, { provide: SelectableListService, useValue: {} }, - { provide: ItemDataService, useValue: {} }, { provide: Store, useValue: {} }, { provide: RelationshipDataService, useValue: {} }, { provide: SelectableListService, useValue: {} }, - { provide: FormService, useValue: {} }, { provide: FormBuilderService, useValue: {} }, { provide: SubmissionService, useValue: {} }, { @@ -241,7 +237,6 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { findById: () => observableOf(createSuccessfulRemoteDataObject(testWSI)), }, }, - { provide: NgZone, useValue: new NgZone({}) }, { provide: APP_CONFIG, useValue: environment }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index c26df24fea..595a1304a0 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -14,7 +14,6 @@ import { EventEmitter, Inject, Input, - NgZone, OnChanges, OnDestroy, OnInit, @@ -77,10 +76,10 @@ import { AppConfig, } from '../../../../../config/app-config.interface'; import { AppState } from '../../../../app.reducer'; -import { ItemDataService } from '../../../../core/data/item-data.service'; import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RelationshipDataService } from '../../../../core/data/relationship-data.service'; import { RemoteData } from '../../../../core/data/remote-data'; +import { MetadataService } from '../../../../core/metadata/metadata.service'; import { Collection } from '../../../../core/shared/collection.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { Item } from '../../../../core/shared/item.model'; @@ -112,7 +111,6 @@ import { SelectableListService } from '../../../object-list/selectable-list/sele import { SearchResult } from '../../../search/models/search-result.model'; import { followLink } from '../../../utils/follow-link-config.model'; import { itemLinksToFollow } from '../../../utils/relation-query.utils'; -import { FormService } from '../../form.service'; import { FormBuilderService } from '../form-builder.service'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { RelationshipOptions } from '../models/relationship-options.model'; @@ -202,17 +200,15 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo protected typeBindRelationService: DsDynamicTypeBindRelationService, protected translateService: TranslateService, protected relationService: DynamicFormRelationService, - private modalService: NgbModal, - private relationshipService: RelationshipDataService, - private selectableListService: SelectableListService, - private itemService: ItemDataService, - private zone: NgZone, - private store: Store, - private submissionObjectService: SubmissionObjectDataService, - private ref: ChangeDetectorRef, - private formService: FormService, - public formBuilderService: FormBuilderService, - private submissionService: SubmissionService, + protected modalService: NgbModal, + protected relationshipService: RelationshipDataService, + protected selectableListService: SelectableListService, + protected store: Store, + protected submissionObjectService: SubmissionObjectDataService, + protected ref: ChangeDetectorRef, + protected formBuilderService: FormBuilderService, + protected submissionService: SubmissionService, + protected metadataService: MetadataService, @Inject(APP_CONFIG) protected appConfig: AppConfig, @Inject(DYNAMIC_FORM_CONTROL_MAP_FN) protected dynamicFormControlFn: DynamicFormControlMapFn, ) { @@ -277,8 +273,8 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.value = Object.assign(new FormFieldMetadataValueObject(), this.model.value); } - if (hasValue(this.value) && this.value.isVirtual) { - const relationship$ = this.relationshipService.findById(this.value.virtualValue, + if (hasValue(this.value) && this.metadataService.isVirtual(this.value)) { + const relationship$ = this.relationshipService.findById(this.metadataService.virtualValue(this.value), true, true, ... itemLinksToFollow(this.fetchThumbnail)).pipe( diff --git a/src/app/shared/mocks/metadata-service.mock.ts b/src/app/shared/mocks/head-tag-service.mock.ts similarity index 78% rename from src/app/shared/mocks/metadata-service.mock.ts rename to src/app/shared/mocks/head-tag-service.mock.ts index f07c923d81..48cb6f7eb0 100644 --- a/src/app/shared/mocks/metadata-service.mock.ts +++ b/src/app/shared/mocks/head-tag-service.mock.ts @@ -1,5 +1,5 @@ -export class MetadataServiceMock { +export class HeadTagServiceMock { // eslint-disable-next-line no-empty, @typescript-eslint/no-empty-function public listenForRouteChange(): void { diff --git a/src/modules/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts index 799910efc6..014a8f5daa 100644 --- a/src/modules/app/browser-init.service.ts +++ b/src/modules/app/browser-init.service.ts @@ -34,7 +34,7 @@ import { AuthService } from '../../app/core/auth/auth.service'; import { coreSelector } from '../../app/core/core.selectors'; import { RootDataService } from '../../app/core/data/root-data.service'; import { LocaleService } from '../../app/core/locale/locale.service'; -import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { HeadTagService } from '../../app/core/metadata/head-tag.service'; import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; import { InitService } from '../../app/init.service'; import { KlaroService } from '../../app/shared/cookies/klaro.service'; @@ -73,7 +73,7 @@ export class BrowserInitService extends InitService { protected localeService: LocaleService, protected angulartics2DSpace: Angulartics2DSpace, protected googleAnalyticsService: GoogleAnalyticsService, - protected metadata: MetadataService, + protected headTagService: HeadTagService, protected breadcrumbsService: BreadcrumbsService, protected klaroService: KlaroService, protected authService: AuthService, @@ -89,7 +89,7 @@ export class BrowserInitService extends InitService { translate, localeService, angulartics2DSpace, - metadata, + headTagService, breadcrumbsService, themeService, menuService, diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts index 6db4f4a48b..e22feec09a 100644 --- a/src/modules/app/server-init.service.ts +++ b/src/modules/app/server-init.service.ts @@ -18,7 +18,7 @@ import { take } from 'rxjs/operators'; import { AppState } from '../../app/app.reducer'; import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; import { LocaleService } from '../../app/core/locale/locale.service'; -import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { HeadTagService } from '../../app/core/metadata/head-tag.service'; import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; import { InitService } from '../../app/init.service'; import { MenuService } from '../../app/shared/menu/menu.service'; @@ -44,7 +44,7 @@ export class ServerInitService extends InitService { protected translate: TranslateService, protected localeService: LocaleService, protected angulartics2DSpace: Angulartics2DSpace, - protected metadata: MetadataService, + protected headTagService: HeadTagService, protected breadcrumbsService: BreadcrumbsService, protected themeService: ThemeService, protected menuService: MenuService, @@ -56,7 +56,7 @@ export class ServerInitService extends InitService { translate, localeService, angulartics2DSpace, - metadata, + headTagService, breadcrumbsService, themeService, menuService,