import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { MemoizedSelector, select, Store } from '@ngrx/store'; import { combineLatest as observableCombineLatest } from 'rxjs'; import { Observable } from 'rxjs/internal/Observable'; import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils'; import { AppState, keySelector } from '../../app.reducer'; import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { ReorderableRelationship } from '../../shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; import { RemoveNameVariantAction, SetNameVariantAction } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { Relationship } from '../shared/item-relationships/relationship.model'; import { RELATIONSHIP } from '../shared/item-relationships/relationship.resource-type'; import { Item } from '../shared/item.model'; import { configureRequest, getFirstSucceededRemoteDataPayload, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { ItemDataService } from './item-data.service'; import { PaginatedList } from './paginated-list'; import { RemoteData, RemoteDataState } from './remote-data'; import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; import { RequestService } from './request.service'; const relationshipListsStateSelector = (state: AppState) => state.relationshipLists; const relationshipListStateSelector = (listID: string): MemoizedSelector => { return keySelector(listID, relationshipListsStateSelector); }; const relationshipStateSelector = (listID: string, itemID: string): MemoizedSelector => { return keySelector(itemID, relationshipListStateSelector(listID)); }; /** * Return true if the Item in the payload of the source observable matches * the given Item by UUID * * @param itemCheck the Item to compare with */ const compareItemsByUUID = (itemCheck: Item) => (source: Observable>): Observable => source.pipe( getFirstSucceededRemoteDataPayload(), map((item: Item) => item.uuid === itemCheck.uuid) ); /** * The service handling all relationship requests */ @Injectable() @dataService(RELATIONSHIP) export class RelationshipService extends DataService { protected linkPath = 'relationships'; protected responseMsToLive = 15 * 60 * 1000; constructor(protected itemService: ItemDataService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, protected notificationsService: NotificationsService, protected http: HttpClient, protected comparator: DefaultChangeAnalyzer, protected appStore: Store) { super(); } /** * Get the endpoint for a relationship by ID * @param uuid */ getRelationshipEndpoint(uuid: string) { return this.getBrowseEndpoint().pipe( map((href: string) => `${href}/${uuid}`) ); } /** * Send a delete request for a relationship by ID * @param id */ deleteRelationship(id: string, copyVirtualMetadata: string): Observable { return this.getRelationshipEndpoint(id).pipe( isNotEmptyOperator(), take(1), distinctUntilChanged(), map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL + '?copyVirtualMetadata=' + copyVirtualMetadata) ), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)), getResponseFromEntry(), ); } /** * Method to create a new relationship * @param typeId The identifier of the relationship type * @param item1 The first item of the relationship * @param item2 The second item of the relationship * @param leftwardValue The leftward value of the relationship * @param rightwardValue The rightward value of the relationship */ addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string): Observable { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'text/uri-list'); options.headers = headers; return this.halService.getEndpoint(this.linkPath).pipe( isNotEmptyOperator(), take(1), map((endpointUrl: string) => `${endpointUrl}?relationshipType=${typeId}`), map((endpointUrl: string) => isNotEmpty(leftwardValue) ? `${endpointUrl}&leftwardValue=${leftwardValue}` : endpointUrl), map((endpointUrl: string) => isNotEmpty(rightwardValue) ? `${endpointUrl}&rightwardValue=${rightwardValue}` : endpointUrl), map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, `${item1.self} \n ${item2.self}`, options)), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), tap(() => this.refreshRelationshipItemsInCache(item1)), tap(() => this.refreshRelationshipItemsInCache(item2)), getResponseFromEntry(), ) as Observable; } /** * Method to remove two items of a relationship from the cache using the identifier of the relationship * @param relationshipId The identifier of the relationship */ private refreshRelationshipItemsInCacheByRelationship(relationshipId: string) { this.findById(relationshipId, followLink('leftItem'), followLink('rightItem')).pipe( getSucceededRemoteData(), getRemoteDataPayload(), switchMap((rel: Relationship) => observableCombineLatest( rel.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()), rel.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()) ) ), take(1) ).subscribe(([item1, item2]) => { this.refreshRelationshipItemsInCache(item1); this.refreshRelationshipItemsInCache(item2); }) } /** * Method to remove an item that's part of a relationship from the cache * @param item The item to remove from the cache */ public refreshRelationshipItemsInCache(item) { this.objectCache.remove(item._links.self.href); this.requestService.removeByHrefSubstring(item.uuid); observableCombineLatest([ this.objectCache.hasBySelfLinkObservable(item._links.self.href), this.requestService.hasByHrefObservable(item.self) ]).pipe( filter(([existsInOC, existsInRC]) => !existsInOC && !existsInRC), take(1), switchMap(() => this.itemService.findByHref(item._links.self.href).pipe(take(1))) ).subscribe(); } /** * Get an item's relationships in the form of an array * * @param item The {@link Item} to get {@link Relationship}s for * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s * should be automatically resolved */ getItemRelationshipsArray(item: Item, ...linksToFollow: Array>): Observable { return this.findAllByHref(item._links.relationships.href, undefined, ...linksToFollow).pipe( getSucceededRemoteData(), getRemoteDataPayload(), map((rels: PaginatedList) => rels.page), hasValueOperator(), distinctUntilChanged(compareArraysUsingIds()), ); } /** * Get an array of the labels of an item’s unique relationship types * The array doesn't contain any duplicate labels * @param item */ getRelationshipTypeLabelsByItem(item: Item): Observable { return this.getItemRelationshipsArray(item, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType')).pipe( switchMap((relationships: Relationship[]) => observableCombineLatest(relationships.map((relationship: Relationship) => this.getRelationshipTypeLabelByRelationshipAndItem(relationship, item)))), map((labels: string[]) => Array.from(new Set(labels))) ); } private getRelationshipTypeLabelByRelationshipAndItem(relationship: Relationship, item: Item): Observable { return relationship.leftItem.pipe( getSucceededRemoteData(), map((itemRD: RemoteData) => itemRD.payload), switchMap((otherItem: Item) => relationship.relationshipType.pipe( getSucceededRemoteData(), map((relationshipTypeRD) => relationshipTypeRD.payload), map((relationshipType: RelationshipType) => { if (otherItem.uuid === item.uuid) { return relationshipType.leftwardType; } else { return relationshipType.rightwardType; } }) ) )) } /** * Resolve a given item's relationships into related items and return the items as an array * @param item */ getRelatedItems(item: Item): Observable { return this.getItemRelationshipsArray( item, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType') ).pipe( relationsToItems(item.uuid) ); } /** * Resolve a given item's relationships into related items, filtered by a relationship label * and return the items as an array * @param item * @param label * @param options */ getRelatedItemsByLabel(item: Item, label: string, options?: FindListOptions): Observable>> { return this.getItemRelationshipsByLabel(item, label, options, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType')).pipe(paginatedRelationsToItems(item.uuid)); } /** * Resolve a given item's relationships by label * This should move to the REST API. * * @param item * @param label * @param options */ getItemRelationshipsByLabel(item: Item, label: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { let findListOptions = new FindListOptions(); if (options) { findListOptions = Object.assign(new FindListOptions(), options); } const searchParams = [new RequestParam('label', label), new RequestParam('dso', item.id)]; if (findListOptions.searchParams) { findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; } else { findListOptions.searchParams = searchParams; } return this.searchBy('byLabel', findListOptions, ...linksToFollow); } /** * Method for fetching an item's relationships, but filtered by related item IDs (essentially performing a reverse lookup) * Only relationships where leftItem or rightItem's ID is present in the list provided will be returned * @param item * @param uuids */ getRelationshipsByRelatedItemIds(item: Item, uuids: string[]): Observable { return this.getItemRelationshipsArray(item, followLink('leftItem'), followLink('rightItem')).pipe( switchMap((relationships: Relationship[]) => { return observableCombineLatest(relationships.map((relationship: Relationship) => { const isLeftItem$ = this.isItemInUUIDArray(relationship.leftItem, uuids); const isRightItem$ = this.isItemInUUIDArray(relationship.rightItem, uuids); return observableCombineLatest([isLeftItem$, isRightItem$]).pipe( filter(([isLeftItem, isRightItem]) => isLeftItem || isRightItem), map(() => relationship), startWith(undefined) ); })) }), map((relationships: Relationship[]) => relationships.filter(((relationship) => hasValue(relationship)))), ) } private isItemInUUIDArray(itemRD$: Observable>, uuids: string[]) { return itemRD$.pipe( getSucceededRemoteData(), map((itemRD: RemoteData) => itemRD.payload), map((item: Item) => uuids.includes(item.uuid)) ); } /** * Method to retrieve a relationship based on two items and a relationship type label * @param item1 The first item in the relationship * @param item2 The second item in the relationship * @param label The rightward or leftward type of the relationship */ getRelationshipByItemsAndLabel(item1: Item, item2: Item, label: string, options?: FindListOptions): Observable { return this.getItemRelationshipsByLabel( item1, label, options, followLink('relationshipType'), followLink('leftItem'), followLink('rightItem') ).pipe( getSucceededRemoteData(), // the mergemap below will emit all elements of the list as separate events mergeMap((relationshipListRD: RemoteData>) => relationshipListRD.payload.page), mergeMap((relationship: Relationship) => { return observableCombineLatest([ this.itemService.findByHref(relationship._links.leftItem.href).pipe(compareItemsByUUID(item2)), this.itemService.findByHref(relationship._links.rightItem.href).pipe(compareItemsByUUID(item2)) ]).pipe( map(([isLeftItem, isRightItem]) => isLeftItem || isRightItem), map((isMatch) => isMatch ? relationship : undefined) ); }), filter((relationship) => hasValue(relationship)), take(1) ) } /** * Method to set the name variant for specific list and item * @param listID The list for which to save the name variant * @param itemID The item ID for which to save the name variant * @param nameVariant The name variant to save */ public setNameVariant(listID: string, itemID: string, nameVariant: string) { this.appStore.dispatch(new SetNameVariantAction(listID, itemID, nameVariant)); } /** * Method to retrieve the name variant for a specific list and item * @param listID The list for which to retrieve the name variant * @param itemID The item ID for which to retrieve the name variant */ public getNameVariant(listID: string, itemID: string): Observable { return this.appStore.pipe( select(relationshipStateSelector(listID, itemID)) ); } /** * Method to remove the name variant for specific list and item * @param listID The list for which to remove the name variant * @param itemID The item ID for which to remove the name variant */ public removeNameVariant(listID: string, itemID: string) { this.appStore.dispatch(new RemoveNameVariantAction(listID, itemID)); } /** * Method to retrieve all name variants for a single list * @param listID The id of the list */ public getNameVariantsByListID(listID: string) { return this.appStore.pipe(select(relationshipListStateSelector(listID))); } /** * Method to update the name variant on the server * @param item1 The first item of the relationship * @param item2 The second item of the relationship * @param relationshipLabel The leftward or rightward type of the relationship * @param nameVariant The name variant to set for the matching relationship */ public updateNameVariant(item1: Item, item2: Item, relationshipLabel: string, nameVariant: string): Observable> { return this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel) .pipe( switchMap((relation: Relationship) => relation.relationshipType.pipe( getSucceededRemoteData(), getRemoteDataPayload(), map((type) => { return { relation, type } }) ) ), switchMap((relationshipAndType: { relation: Relationship, type: RelationshipType }) => { const { relation, type } = relationshipAndType; let updatedRelationship; if (relationshipLabel === type.leftwardType) { updatedRelationship = Object.assign(new Relationship(), relation, { rightwardValue: nameVariant }); } else { updatedRelationship = Object.assign(new Relationship(), relation, { leftwardValue: nameVariant }); } return this.update(updatedRelationship); }), ); } /** * Check whether a given item is the left item of a given relationship, as an observable boolean * @param relationship the relationship for which to check whether the given item is the left item * @param item the item for which to check whether it is the left item of the given relationship */ public isLeftItem(relationship: Relationship, item: Item): Observable { return relationship.leftItem.pipe( getSucceededRemoteData(), getRemoteDataPayload(), filter((leftItem: Item) => hasValue(leftItem) && isNotEmpty(leftItem.uuid)), map((leftItem) => leftItem.uuid === item.uuid) ); } /** * Method to update the the right or left place of a relationship * The useLeftItem field in the reorderable relationship determines which place should be updated * @param reoRel */ public updatePlace(reoRel: ReorderableRelationship): Observable> { let updatedRelationship; if (reoRel.useLeftItem) { updatedRelationship = Object.assign(new Relationship(), reoRel.relationship, { rightPlace: reoRel.newIndex }); } else { updatedRelationship = Object.assign(new Relationship(), reoRel.relationship, { leftPlace: reoRel.newIndex }); } const update$ = this.update(updatedRelationship); update$.pipe( filter((relationshipRD: RemoteData) => relationshipRD.state === RemoteDataState.ResponsePending), take(1), ).subscribe((relationshipRD: RemoteData) => { if (relationshipRD.state === RemoteDataState.ResponsePending) { this.refreshRelationshipItemsInCacheByRelationship(reoRel.relationship.id); } }); return update$; } /** * Patch isn't supported on the relationship endpoint, so use put instead. * * @param object the {@link Relationship} to update */ update(object: Relationship): Observable> { return this.put(object); } }