forked from hazza/dspace-angular
569 lines
20 KiB
TypeScript
569 lines
20 KiB
TypeScript
import { Injectable } from '@angular/core';
|
|
import { TranslateService } from '@ngx-translate/core';
|
|
import { MoveOperation } from 'fast-json-patch';
|
|
import {
|
|
BehaviorSubject,
|
|
Observable,
|
|
zip as observableZip,
|
|
} from 'rxjs';
|
|
import {
|
|
map,
|
|
switchMap,
|
|
take,
|
|
tap,
|
|
} from 'rxjs/operators';
|
|
|
|
import { getBitstreamDownloadRoute } from '../../../app-routing-paths';
|
|
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
|
import { BitstreamDataService } from '../../../core/data/bitstream-data.service';
|
|
import { BundleDataService } from '../../../core/data/bundle-data.service';
|
|
import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model';
|
|
import { FieldUpdate } from '../../../core/data/object-updates/field-update.model';
|
|
import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model';
|
|
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
|
import { RemoteData } from '../../../core/data/remote-data';
|
|
import { RequestService } from '../../../core/data/request.service';
|
|
import { Bitstream } from '../../../core/shared/bitstream.model';
|
|
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
|
|
import { Bundle } from '../../../core/shared/bundle.model';
|
|
import { NoContent } from '../../../core/shared/NoContent.model';
|
|
import {
|
|
getFirstCompletedRemoteData,
|
|
getFirstSucceededRemoteDataPayload,
|
|
} from '../../../core/shared/operators';
|
|
import {
|
|
hasNoValue,
|
|
hasValue,
|
|
} from '../../../shared/empty.util';
|
|
import { LiveRegionService } from '../../../shared/live-region/live-region.service';
|
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
|
import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes';
|
|
import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes';
|
|
|
|
export const MOVE_KEY = 'item.edit.bitstreams.notifications.move';
|
|
|
|
/**
|
|
* Interface storing all the information necessary to create a row in the bitstream edit table
|
|
*/
|
|
export interface BitstreamTableEntry {
|
|
/**
|
|
* The bitstream
|
|
*/
|
|
bitstream: Bitstream,
|
|
/**
|
|
* The uuid of the Bitstream
|
|
*/
|
|
id: string,
|
|
/**
|
|
* The name of the Bitstream
|
|
*/
|
|
name: string,
|
|
/**
|
|
* The name of the Bitstream with all whitespace removed
|
|
*/
|
|
nameStripped: string,
|
|
/**
|
|
* The description of the Bitstream
|
|
*/
|
|
description: string,
|
|
/**
|
|
* Observable emitting the Format of the Bitstream
|
|
*/
|
|
format: Observable<BitstreamFormat>,
|
|
/**
|
|
* The download url of the Bitstream
|
|
*/
|
|
downloadUrl: string,
|
|
}
|
|
|
|
/**
|
|
* Interface storing information necessary to highlight and reorder the selected bitstream entry
|
|
*/
|
|
export interface SelectedBitstreamTableEntry {
|
|
/**
|
|
* The selected entry
|
|
*/
|
|
bitstream: BitstreamTableEntry,
|
|
/**
|
|
* The bundle the bitstream belongs to
|
|
*/
|
|
bundle: Bundle,
|
|
/**
|
|
* The total number of bitstreams in the bundle
|
|
*/
|
|
bundleSize: number,
|
|
/**
|
|
* The original position of the bitstream within the bundle.
|
|
*/
|
|
originalPosition: number,
|
|
/**
|
|
* The current position of the bitstream within the bundle.
|
|
*/
|
|
currentPosition: number,
|
|
}
|
|
|
|
/**
|
|
* Interface storing data regarding a change in selected bitstream
|
|
*/
|
|
export interface SelectionAction {
|
|
/**
|
|
* The different types of actions:
|
|
* - Selected: Bitstream was selected
|
|
* - Moved: Bitstream was moved
|
|
* - Cleared: Selection was cleared, bitstream remains at its current position
|
|
* - Cancelled: Selection was cancelled, bitstream returns to its original position
|
|
*/
|
|
action: 'Selected' | 'Moved' | 'Cleared' | 'Cancelled'
|
|
/**
|
|
* The table entry to which the selection action applies
|
|
*/
|
|
selectedEntry: SelectedBitstreamTableEntry,
|
|
}
|
|
|
|
/**
|
|
* This service handles the selection and updating of the bitstreams and their order on the
|
|
* 'Edit Item' -> 'Bitstreams' page.
|
|
*/
|
|
@Injectable(
|
|
{ providedIn: 'root' },
|
|
)
|
|
export class ItemBitstreamsService {
|
|
|
|
/**
|
|
* BehaviorSubject which emits every time the selected bitstream changes.
|
|
*/
|
|
protected selectionAction$: BehaviorSubject<SelectionAction> = new BehaviorSubject(null);
|
|
|
|
protected isPerformingMoveRequest: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
|
|
|
constructor(
|
|
protected notificationsService: NotificationsService,
|
|
protected translateService: TranslateService,
|
|
protected objectUpdatesService: ObjectUpdatesService,
|
|
protected bitstreamService: BitstreamDataService,
|
|
protected bundleService: BundleDataService,
|
|
protected dsoNameService: DSONameService,
|
|
protected requestService: RequestService,
|
|
protected liveRegionService: LiveRegionService,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Returns the observable emitting the selection actions
|
|
*/
|
|
getSelectionAction$(): Observable<SelectionAction> {
|
|
return this.selectionAction$;
|
|
}
|
|
|
|
/**
|
|
* Returns the latest selection action
|
|
*/
|
|
getSelectionAction(): SelectionAction {
|
|
const action = this.selectionAction$.value;
|
|
|
|
if (hasNoValue(action)) {
|
|
return null;
|
|
}
|
|
|
|
return Object.assign({}, action);
|
|
}
|
|
|
|
/**
|
|
* Returns true if there currently is a selected bitstream
|
|
*/
|
|
hasSelectedBitstream(): boolean {
|
|
const selectionAction = this.getSelectionAction();
|
|
|
|
if (hasNoValue(selectionAction)) {
|
|
return false;
|
|
}
|
|
|
|
const action = selectionAction.action;
|
|
|
|
return action === 'Selected' || action === 'Moved';
|
|
}
|
|
|
|
/**
|
|
* Returns a copy of the currently selected bitstream
|
|
*/
|
|
getSelectedBitstream(): SelectedBitstreamTableEntry {
|
|
if (!this.hasSelectedBitstream()) {
|
|
return null;
|
|
}
|
|
|
|
const selectionAction = this.getSelectionAction();
|
|
return Object.assign({}, selectionAction.selectedEntry);
|
|
}
|
|
|
|
/**
|
|
* Select the provided entry
|
|
*/
|
|
selectBitstreamEntry(entry: SelectedBitstreamTableEntry) {
|
|
if (hasValue(entry) && entry.bitstream !== this.getSelectedBitstream()?.bitstream) {
|
|
this.announceSelect(entry.bitstream.name);
|
|
this.updateSelectionAction({ action: 'Selected', selectedEntry: entry });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Makes the {@link selectionAction$} observable emit the provided {@link SelectedBitstreamTableEntry}.
|
|
* @protected
|
|
*/
|
|
protected updateSelectionAction(action: SelectionAction) {
|
|
this.selectionAction$.next(action);
|
|
}
|
|
|
|
/**
|
|
* Unselects the selected bitstream. Does nothing if no bitstream is selected.
|
|
*/
|
|
clearSelection() {
|
|
const selected = this.getSelectedBitstream();
|
|
|
|
if (hasValue(selected)) {
|
|
this.updateSelectionAction({ action: 'Cleared', selectedEntry: selected });
|
|
this.announceClear(selected.bitstream.name);
|
|
|
|
if (selected.currentPosition !== selected.originalPosition) {
|
|
this.displaySuccessNotification(MOVE_KEY);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the currently selected bitstream to its original position and unselects the bitstream.
|
|
* Does nothing if no bitstream is selected.
|
|
*/
|
|
cancelSelection() {
|
|
const selected = this.getSelectedBitstream();
|
|
|
|
if (hasNoValue(selected) || this.getPerformingMoveRequest()) {
|
|
return;
|
|
}
|
|
|
|
|
|
const originalPosition = selected.originalPosition;
|
|
const currentPosition = selected.currentPosition;
|
|
|
|
// If the selected bitstream has not moved, there is no need to return it to its original position
|
|
if (currentPosition === originalPosition) {
|
|
this.announceClear(selected.bitstream.name);
|
|
this.updateSelectionAction({ action: 'Cleared', selectedEntry: selected });
|
|
} else {
|
|
this.announceCancel(selected.bitstream.name, originalPosition);
|
|
this.performBitstreamMoveRequest(selected.bundle, currentPosition, originalPosition);
|
|
this.updateSelectionAction({ action: 'Cancelled', selectedEntry: selected });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves the selected bitstream one position up in the bundle. Does nothing if no bitstream is selected or the
|
|
* selected bitstream already is at the beginning of the bundle.
|
|
*/
|
|
moveSelectedBitstreamUp() {
|
|
const selected = this.getSelectedBitstream();
|
|
|
|
if (hasNoValue(selected) || this.getPerformingMoveRequest()) {
|
|
return;
|
|
}
|
|
|
|
const originalPosition = selected.currentPosition;
|
|
if (originalPosition > 0) {
|
|
const newPosition = originalPosition - 1;
|
|
selected.currentPosition = newPosition;
|
|
|
|
const onRequestCompleted = () => {
|
|
this.announceMove(selected.bitstream.name, newPosition);
|
|
};
|
|
|
|
this.performBitstreamMoveRequest(selected.bundle, originalPosition, newPosition, onRequestCompleted);
|
|
this.updateSelectionAction({ action: 'Moved', selectedEntry: selected });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves the selected bitstream one position down in the bundle. Does nothing if no bitstream is selected or the
|
|
* selected bitstream already is at the end of the bundle.
|
|
*/
|
|
moveSelectedBitstreamDown() {
|
|
const selected = this.getSelectedBitstream();
|
|
|
|
if (hasNoValue(selected) || this.getPerformingMoveRequest()) {
|
|
return;
|
|
}
|
|
|
|
const originalPosition = selected.currentPosition;
|
|
if (originalPosition < selected.bundleSize - 1) {
|
|
const newPosition = originalPosition + 1;
|
|
selected.currentPosition = newPosition;
|
|
|
|
const onRequestCompleted = () => {
|
|
this.announceMove(selected.bitstream.name, newPosition);
|
|
};
|
|
|
|
this.performBitstreamMoveRequest(selected.bundle, originalPosition, newPosition, onRequestCompleted);
|
|
this.updateSelectionAction({ action: 'Moved', selectedEntry: selected });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends out a Move Patch request to the REST API, display notifications,
|
|
* refresh the bundle's cache (so the lists can properly reload)
|
|
* @param bundle The bundle to send patch requests to
|
|
* @param fromIndex The index to move from
|
|
* @param toIndex The index to move to
|
|
* @param finish Optional: Function to execute once the response has been received
|
|
*/
|
|
performBitstreamMoveRequest(bundle: Bundle, fromIndex: number, toIndex: number, finish?: () => void) {
|
|
if (this.getPerformingMoveRequest()) {
|
|
console.warn('Attempted to perform move request while previous request has not completed yet');
|
|
return;
|
|
}
|
|
|
|
const moveOperation: MoveOperation = {
|
|
op: 'move',
|
|
from: `/_links/bitstreams/${fromIndex}/href`,
|
|
path: `/_links/bitstreams/${toIndex}/href`,
|
|
};
|
|
|
|
this.announceLoading();
|
|
this.isPerformingMoveRequest.next(true);
|
|
this.bundleService.patch(bundle, [moveOperation]).pipe(
|
|
getFirstCompletedRemoteData(),
|
|
tap((response: RemoteData<Bundle>) => this.displayFailedResponseNotifications(MOVE_KEY, [response])),
|
|
switchMap(() => this.requestService.setStaleByHrefSubstring(bundle.self)),
|
|
take(1),
|
|
).subscribe(() => {
|
|
this.isPerformingMoveRequest.next(false);
|
|
finish?.();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether the service currently is processing a 'move' request
|
|
*/
|
|
getPerformingMoveRequest(): boolean {
|
|
return this.isPerformingMoveRequest.value;
|
|
}
|
|
|
|
/**
|
|
* Returns an observable which emits when the service starts, or ends, processing a 'move' request
|
|
*/
|
|
getPerformingMoveRequest$(): Observable<boolean> {
|
|
return this.isPerformingMoveRequest;
|
|
}
|
|
|
|
/**
|
|
* Returns the pagination options to use when fetching the bundles
|
|
*/
|
|
getInitialBundlesPaginationOptions(): PaginationComponentOptions {
|
|
return Object.assign(new PaginationComponentOptions(), {
|
|
id: 'bundles-pagination-options',
|
|
currentPage: 1,
|
|
pageSize: 9999,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the initial pagination options to use when fetching the bitstreams
|
|
* @param bundleName The name of the bundle, will be as pagination id.
|
|
*/
|
|
getInitialBitstreamsPaginationOptions(bundleName: string): PaginationComponentOptions {
|
|
return Object.assign(new PaginationComponentOptions(),{
|
|
id: bundleName, // This might behave unexpectedly if the item contains two bundles with the same name
|
|
currentPage: 1,
|
|
pageSize: 10,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the {@link ResponsiveTableSizes} for use in the columns of the edit bitstreams table
|
|
*/
|
|
getColumnSizes(): ResponsiveTableSizes {
|
|
return new ResponsiveTableSizes([
|
|
// Name column
|
|
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
|
|
// Description column
|
|
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
|
|
// Format column
|
|
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
|
|
// Actions column
|
|
new ResponsiveColumnSizes(6, 5, 4, 3, 3),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Display notifications
|
|
* - Error notification for each failed response with their message
|
|
* - Success notification in case there's at least one successful response
|
|
* @param key The i18n key for the notification messages
|
|
* @param responses The returned responses to display notifications for
|
|
*/
|
|
displayNotifications(key: string, responses: RemoteData<any>[]) {
|
|
this.displayFailedResponseNotifications(key, responses);
|
|
this.displaySuccessFulResponseNotifications(key, responses);
|
|
}
|
|
|
|
/**
|
|
* Display an error notification for each failed response with their message
|
|
* @param key The i18n key for the notification messages
|
|
* @param responses The returned responses to display notifications for
|
|
*/
|
|
displayFailedResponseNotifications(key: string, responses: RemoteData<any>[]) {
|
|
const failedResponses = responses.filter((response: RemoteData<Bundle>) => hasValue(response) && response.hasFailed);
|
|
failedResponses.forEach((response: RemoteData<Bundle>) => {
|
|
this.displayErrorNotification(key, response.errorMessage);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Display an error notification with the provided key and message
|
|
* @param key The i18n key for the notification messages
|
|
* @param errorMessage The error message to display
|
|
*/
|
|
displayErrorNotification(key: string, errorMessage: string) {
|
|
this.notificationsService.error(this.translateService.instant(`${key}.failed.title`), errorMessage);
|
|
}
|
|
|
|
/**
|
|
* Display a success notification in case there's at least one successful response
|
|
* @param key The i18n key for the notification messages
|
|
* @param responses The returned responses to display notifications for
|
|
*/
|
|
displaySuccessFulResponseNotifications(key: string, responses: RemoteData<any>[]) {
|
|
const successfulResponses = responses.filter((response: RemoteData<Bundle>) => hasValue(response) && response.hasSucceeded);
|
|
if (successfulResponses.length > 0) {
|
|
this.displaySuccessNotification(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display a success notification with the provided key
|
|
* @param key The i18n key for the notification messages
|
|
*/
|
|
displaySuccessNotification(key: string) {
|
|
this.notificationsService.success(this.translateService.instant(`${key}.saved.title`), this.translateService.instant(`${key}.saved.content`));
|
|
}
|
|
|
|
/**
|
|
* Removes the bitstreams marked for deletion from the Bundles emitted by the provided observable.
|
|
* @param bundles$
|
|
*/
|
|
removeMarkedBitstreams(bundles$: Observable<Bundle[]>): Observable<RemoteData<NoContent>> {
|
|
const bundlesOnce$ = bundles$.pipe(take(1));
|
|
|
|
// Fetch all removed bitstreams from the object update service
|
|
const removedBitstreams$ = bundlesOnce$.pipe(
|
|
switchMap((bundles: Bundle[]) => observableZip(
|
|
...bundles.map((bundle: Bundle) => this.objectUpdatesService.getFieldUpdates(bundle.self, [], true)),
|
|
)),
|
|
map((fieldUpdates: FieldUpdates[]) => ([] as FieldUpdate[]).concat(
|
|
...fieldUpdates.map((updates: FieldUpdates) => Object.values(updates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)),
|
|
)),
|
|
map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field)),
|
|
);
|
|
|
|
// Send out delete requests for all deleted bitstreams
|
|
return removedBitstreams$.pipe(
|
|
take(1),
|
|
switchMap((removedBitstreams: Bitstream[]) => {
|
|
return this.bitstreamService.removeMultiple(removedBitstreams);
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates an array of {@link BitstreamTableEntry}s from an array of {@link Bitstream}s
|
|
* @param bitstreams The bitstreams array to map to table entries
|
|
*/
|
|
mapBitstreamsToTableEntries(bitstreams: Bitstream[]): BitstreamTableEntry[] {
|
|
return bitstreams.map((bitstream) => {
|
|
const name = this.dsoNameService.getName(bitstream);
|
|
|
|
return {
|
|
bitstream: bitstream,
|
|
id: bitstream.uuid,
|
|
name: name,
|
|
nameStripped: this.nameToHeader(name),
|
|
description: bitstream.firstMetadataValue('dc.description'),
|
|
format: bitstream.format.pipe(getFirstSucceededRemoteDataPayload()),
|
|
downloadUrl: getBitstreamDownloadRoute(bitstream),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a string appropriate to be used as header ID
|
|
* @param name
|
|
*/
|
|
nameToHeader(name: string): string {
|
|
// Whitespace is stripped from the Bitstream names for accessibility reasons.
|
|
// To make it clear which headers are relevant for a specific field in the table, the 'headers' attribute is used to
|
|
// refer to specific headers. The Bitstream's name is used as header ID for the row containing information regarding
|
|
// that bitstream. As the 'headers' attribute contains a space-separated string of header IDs, the Bitstream's header
|
|
// ID can not contain spaces itself.
|
|
return this.stripWhiteSpace(name);
|
|
}
|
|
|
|
/**
|
|
* Returns a string equal to the input string with all whitespace removed.
|
|
* @param str
|
|
*/
|
|
stripWhiteSpace(str: string): string {
|
|
// '/\s+/g' matches all occurrences of any amount of whitespace characters
|
|
return str.replace(/\s+/g, '');
|
|
}
|
|
|
|
/**
|
|
* Adds a message to the live region mentioning that the bitstream with the provided name was selected.
|
|
* @param bitstreamName The name of the bitstream that was selected.
|
|
*/
|
|
announceSelect(bitstreamName: string) {
|
|
const message = this.translateService.instant('item.edit.bitstreams.edit.live.select',
|
|
{ bitstream: bitstreamName });
|
|
this.liveRegionService.addMessage(message);
|
|
}
|
|
|
|
/**
|
|
* Adds a message to the live region mentioning that the bitstream with the provided name was moved to the provided
|
|
* position.
|
|
* @param bitstreamName The name of the bitstream that moved.
|
|
* @param toPosition The zero-indexed position that the bitstream moved to.
|
|
*/
|
|
announceMove(bitstreamName: string, toPosition: number) {
|
|
const message = this.translateService.instant('item.edit.bitstreams.edit.live.move',
|
|
{ bitstream: bitstreamName, toIndex: toPosition + 1 });
|
|
this.liveRegionService.addMessage(message);
|
|
}
|
|
|
|
/**
|
|
* Adds a message to the live region mentioning that the bitstream with the provided name is no longer selected and
|
|
* was returned to the provided position.
|
|
* @param bitstreamName The name of the bitstream that is no longer selected
|
|
* @param toPosition The zero-indexed position the bitstream returned to.
|
|
*/
|
|
announceCancel(bitstreamName: string, toPosition: number) {
|
|
const message = this.translateService.instant('item.edit.bitstreams.edit.live.cancel',
|
|
{ bitstream: bitstreamName, toIndex: toPosition + 1 });
|
|
this.liveRegionService.addMessage(message);
|
|
}
|
|
|
|
/**
|
|
* Adds a message to the live region mentioning that the bitstream with the provided name is no longer selected.
|
|
* @param bitstreamName The name of the bitstream that is no longer selected.
|
|
*/
|
|
announceClear(bitstreamName: string) {
|
|
const message = this.translateService.instant('item.edit.bitstreams.edit.live.clear',
|
|
{ bitstream: bitstreamName });
|
|
this.liveRegionService.addMessage(message);
|
|
}
|
|
|
|
/**
|
|
* Adds a message to the live region mentioning that the
|
|
*/
|
|
announceLoading() {
|
|
const message = this.translateService.instant('item.edit.bitstreams.edit.live.loading');
|
|
this.liveRegionService.addMessage(message);
|
|
}
|
|
}
|