Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/app/core/shared/bitstream.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
} from '../cache/builders/build-decorators';
import { RemoteData } from '../data/remote-data';
import { BITSTREAM } from './bitstream.resource-type';
import { BitstreamChecksum } from './bitstream-checksum.model';
import { BITSTREAM_CHECKSUM } from './bitstream-checksum.resource';
import { BitstreamFormat } from './bitstream-format.model';
import { BITSTREAM_FORMAT } from './bitstream-format.resource-type';
import { Bundle } from './bundle.model';
Expand All @@ -21,6 +23,9 @@ import { ChildHALResource } from './child-hal-resource.model';
import { DSpaceObject } from './dspace-object.model';
import { HALLink } from './hal-link.model';

// Store number if the bitstream is stored in the both stores (S3 and local)
export const SYNCHRONIZED_STORES_NUMBER = 77;

@typedObject
@inheritSerialization(DSpaceObject)
export class Bitstream extends DSpaceObject implements ChildHALResource {
Expand All @@ -44,6 +49,12 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
@autoserialize
bundleName: string;

/**
* The number of the store where the bitstream is store, it could be S3, local or both.
*/
@autoserialize
storeNumber: number;

/**
* The {@link HALLink}s for this Bitstream
*/
Expand All @@ -54,6 +65,10 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
format: HALLink;
content: HALLink;
thumbnail: HALLink;
/**
* Optional: only the CLARIN backend exposes this link.
*/
checksum?: HALLink;
accessStatus: HALLink;
};

Expand All @@ -78,6 +93,12 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
@link(BUNDLE)
bundle?: Observable<RemoteData<Bundle>>;

/**
* The checksum values fetched from the DB, local and S3 store.
*/
@link(BITSTREAM_CHECKSUM)
checksum?: Observable<RemoteData<BitstreamChecksum>>;

/**
* The access status for this Bitstream
* Will be undefined unless the access status {@link HALLink} has been resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ export class ItemBitstreamsServiceStub {
getColumnSizes = jasmine.createSpy('getColumnSizes').and
.returnValue(
new ResponsiveTableSizes([
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
new ResponsiveColumnSizes(6, 5, 4, 3, 3),
]),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,13 +383,15 @@ export class ItemBitstreamsService {
getColumnSizes(): ResponsiveTableSizes {
return new ResponsiveTableSizes([
// Name column
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
// Description column
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
// Format column
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
// Actions column
new ResponsiveColumnSizes(6, 5, 4, 3, 3),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
// Store synchronization column
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@
[attr.aria-label]="'item.edit.bitstreams.bundle.table.aria-label' | translate: { bundle: bundleName } ">
<thead [class.visually-hidden]="!isFirstTable">
<tr class="header-row fw-bold">
<th id="name" scope="col" class="{{ columnSizes.columns[0].buildClasses() }}">
<th id="name-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[0].buildClasses() }}">
{{'item.edit.bitstreams.headers.name' | translate}}
</th>
<th id="description" scope="col" class="{{ columnSizes.columns[1].buildClasses() }}">
<th id="description-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[1].buildClasses() }}">
{{'item.edit.bitstreams.headers.description' | translate}}
</th>
<th id="format" scope="col" class="{{ columnSizes.columns[2].buildClasses() }}">
<th id="format-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[2].buildClasses() }}">
{{'item.edit.bitstreams.headers.format' | translate}}
</th>
<th id="actions" scope="col" class="{{ columnSizes.columns[3].buildClasses() }} text-center">
<th id="actions-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[3].buildClasses() }} text-center">
{{'item.edit.bitstreams.headers.actions' | translate}}
</th>
<th id="synchronized-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[4].buildClasses() }} text-center">
{{'item.edit.bitstreams.headers.synchronized' | translate}}
</th>
</tr>
</thead>
<tbody cdkDropList (cdkDropListDropped)="drop($event)">
Expand Down Expand Up @@ -72,29 +75,30 @@
</div>
</div>
</td>
<td class="row-element {{ columnSizes.columns[4].buildClasses() }}"></td>
</tr>
@for (entry of (tableEntries$ | async); track entry) {
@if (updates[entry.id]; as update) {
<tr [ngClass]="getRowClass(update, entry)" class="bitstream-row" cdkDrag
(cdkDragStarted)="dragStart()" (cdkDragEnded)="dragEnd()">
<th class="bitstream-name row-element {{ columnSizes.columns[0].buildClasses() }}"
scope="row" id="{{ entry.nameStripped }}" headers="{{ bundleName }} name">
scope="row" id="{{ entry.nameStripped }}" headers="{{ bundleName }} name-{{ sanitizedBundleName }}">
<div class="drag-handle text-muted float-start p-1 me-2 d-inline" tabindex="0" cdkDragHandle
(keydown.enter)="select($event, entry)" (keydown.space)="select($event, entry)" (click)="select($event, entry)">
<i class="drag-icon" [title]="'item.edit.bitstreams.edit.buttons.drag' | translate"></i>
</div>
<span class="dont-break-out">{{ entry.name }}</span>
</th>
<td class="row-element {{ columnSizes.columns[1].buildClasses() }}"
headers="{{ entry.nameStripped }} {{ bundleName }} description">
headers="{{ entry.nameStripped }} {{ bundleName }} description-{{ sanitizedBundleName }}">
{{ entry.description }}
</td>
<td class="row-element {{ columnSizes.columns[2].buildClasses() }}"
headers="{{ entry.nameStripped }} {{ bundleName }} format">
headers="{{ entry.nameStripped }} {{ bundleName }} format-{{ sanitizedBundleName }}">
{{ (entry.format | async)?.shortDescription }}
</td>
<td class="row-element {{ columnSizes.columns[3].buildClasses() }}"
headers="{{ entry.nameStripped }} {{ bundleName }} actions">
headers="{{ entry.nameStripped }} {{ bundleName }} actions-{{ sanitizedBundleName }}">
<div class="text-center w-100">
<div class="btn-group relationship-action-buttons">
<a [href]="entry.downloadUrl"
Expand Down Expand Up @@ -126,6 +130,17 @@
</div>
</div>
</td>
<td class="row-element {{ columnSizes.columns[4].buildClasses() }}"
headers="{{ entry.nameStripped }} {{ bundleName }} synchronized-{{ sanitizedBundleName }}">
<div class="d-flex align-items-center justify-content-center">
<span class="text-center">
<i [class]="isBitstreamSynchronized(entry) ? 'fas fa-check' : 'fas fa-times'"
aria-hidden="true"></i>
</span>
<span class="ps-1">|</span>
<div class="ps-1" [ngTemplateOutlet]="checksum" [ngTemplateOutletContext]="{ entry: entry }"></div>
</div>
</td>
</tr>
}
}
Expand All @@ -136,3 +151,89 @@
}

</ng-template>

<ng-template #checksum let-entry="entry">
<div class="d-flex align-items-center"
(mouseenter)="showChecksumValues = true"
(mouseleave)="showChecksumValues = false"
*ngVar="(checkSum$ | async) as bitstreamChecksum">
@if (!computedChecksum) {
@if (loading) {
<i class="fas fa-spinner fa-spin"></i>
}
@if (!loading) {
<i class="fas fa-question"
triggers="mouseenter:mouseleave"
[ngbPopover]="checksumWarning"></i>
}
<a class="btn" (click)="computeChecksum(entry)"
triggers="mouseenter:mouseleave"
[ngbPopover]="computeChecksumInfo">
<i class="fas fa-download"></i>
</a>
}
@if (computedChecksum) {
<i [class]="checksumsAreEqual(bitstreamChecksum, entry) ? 'fas fa-check' : 'fas fa-times'"></i>
<i class="ps-2 fas fa-info-circle"
triggers="mouseenter:mouseleave"
[ngbPopover]="checksumPopover"
[popoverContext]="{ entry: entry }"
popoverTitle="Checksums"></i>
}
</div>
</ng-template>

<ng-template #checksumWarning>
<div class="fw-bold text-decoration-underline">
{{ 'item.edit.bitstreams.checksum.popover.warning.header' | translate }}
</div>
<div>
{{ 'item.edit.bitstreams.checksum.popover.warning.body' | translate }}
</div>
</ng-template>

<ng-template #computeChecksumInfo>
<div>
{{ 'item.edit.bitstreams.checksum.popover.info.body' | translate }}
</div>
</ng-template>

<ng-template #checksumPopover let-entry="entry">
<div *ngVar="(checkSum$ | async) as bitstreamChecksum">
<div>
<div class="fw-bold text-decoration-underline">
{{'item.edit.bitstreams.checksum.database' | translate}}
</div>
<div>
{{'item.edit.bitstreams.checksum.algorithm' | translate}} {{bitstreamChecksum?.databaseChecksum?.checkSumAlgorithm}}
</div>
<div>
{{'item.edit.bitstreams.checksum.value' | translate}} {{ bitstreamChecksum?.databaseChecksum?.value }}
</div>
</div>
<div>
<div class="fw-bold text-decoration-underline">
{{'item.edit.bitstreams.checksum.active-store' | translate}}
</div>
<div>
{{'item.edit.bitstreams.checksum.algorithm' | translate}} {{bitstreamChecksum?.activeStore?.checkSumAlgorithm}}
</div>
<div>
{{'item.edit.bitstreams.checksum.value' | translate}} {{ bitstreamChecksum?.activeStore?.value }}
</div>
</div>
@if (isBitstreamSynchronized(entry)) {
<div>
<div class="fw-bold text-decoration-underline">
{{'item.edit.bitstreams.checksum.sync-store' | translate}}
</div>
<div>
{{'item.edit.bitstreams.checksum.algorithm' | translate}} {{bitstreamChecksum?.synchronizedStore?.checkSumAlgorithm}}
</div>
<div>
{{'item.edit.bitstreams.checksum.value' | translate}} {{ bitstreamChecksum?.synchronizedStore?.value }}
</div>
</div>
}
</div>
</ng-template>
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@ import {
Subject,
} from 'rxjs';

import { BitstreamChecksumDataService } from '../../../../core/bitstream-checksum-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 { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { RequestService } from '../../../../core/data/request.service';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import {
Bitstream,
SYNCHRONIZED_STORES_NUMBER,
} from '../../../../core/shared/bitstream.model';
import { BitstreamChecksum } from '../../../../core/shared/bitstream-checksum.model';
import { Bundle } from '../../../../core/shared/bundle.model';
import { Item } from '../../../../core/shared/item.model';
import { getMockRequestService } from '../../../../shared/mocks/request.service.mock';
Expand All @@ -45,12 +51,35 @@ describe('ItemEditBitstreamBundleComponent', () => {
let viewContainerRef: ViewContainerRef;

const columnSizes = new ResponsiveTableSizes([
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
new ResponsiveColumnSizes(6, 5, 4, 3, 3),
]);


const md5 = (value: string) => ({ checkSumAlgorithm: 'MD5', value });
const checksumAllEqual = Object.assign(new BitstreamChecksum(), {
databaseChecksum: md5('abc'),
activeStore: md5('abc'),
synchronizedStore: md5('abc'),
});

const tableEntry = (storeNumber: number, checksumHref?: string): BitstreamTableEntry => ({
bitstream: Object.assign(new Bitstream(), {
uuid: 'bitstream-1',
storeNumber,
_links: checksumHref ? { checksum: { href: checksumHref } } : {},
}),
id: 'bitstream-1',
name: 'file.txt',
nameStripped: 'file.txt',
description: '',
format: of(null),
downloadUrl: 'download-url',
} as any);

const item = Object.assign(new Item(), {
id: 'item-1',
uuid: 'item-1',
Expand All @@ -71,6 +100,7 @@ describe('ItemEditBitstreamBundleComponent', () => {

let objectUpdatesService: any;
let itemBitstreamsService: ItemBitstreamsServiceStub;
let bitstreamChecksumService: jasmine.SpyObj<BitstreamChecksumDataService>;

beforeEach(waitForAsync(() => {
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', {
Expand All @@ -79,6 +109,8 @@ describe('ItemEditBitstreamBundleComponent', () => {
});

itemBitstreamsService = getItemBitstreamsServiceStub();
bitstreamChecksumService = jasmine.createSpyObj('bitstreamChecksumService', ['findByHref']);
bitstreamChecksumService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(checksumAllEqual));

TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), ItemEditBitstreamBundleComponent],
Expand All @@ -88,6 +120,7 @@ describe('ItemEditBitstreamBundleComponent', () => {
{ provide: PaginationService, useValue: new PaginationServiceStub() },
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: ItemBitstreamsService, useValue: itemBitstreamsService },
{ provide: BitstreamChecksumDataService, useValue: bitstreamChecksumService },
],
schemas: [
NO_ERRORS_SCHEMA,
Expand Down Expand Up @@ -356,4 +389,53 @@ describe('ItemEditBitstreamBundleComponent', () => {
expect(itemBitstreamsService.cancelSelection).not.toHaveBeenCalled();
});
});

describe('bitstream checksum column', () => {

it('should report a bitstream stored in both stores as synchronized', () => {
expect(comp.isBitstreamSynchronized(tableEntry(SYNCHRONIZED_STORES_NUMBER))).toBeTrue();
expect(comp.isBitstreamSynchronized(tableEntry(0))).toBeFalse();
});

it('should treat checksums with the same value but a different algorithm as unequal', () => {
expect(comp.compareChecksums(md5('abc'), md5('abc'))).toBeTrue();
expect(comp.compareChecksums(md5('abc'), { checkSumAlgorithm: 'SHA-256', value: 'abc' })).toBeFalse();
});

it('should compare the synchronized store as well, but only for a synchronized bitstream', () => {
const syncStoreDiffers = Object.assign(new BitstreamChecksum(), {
databaseChecksum: md5('abc'),
activeStore: md5('abc'),
synchronizedStore: md5('zzz'),
});

expect(comp.checksumsAreEqual(syncStoreDiffers, tableEntry(SYNCHRONIZED_STORES_NUMBER))).toBeFalse();
expect(comp.checksumsAreEqual(syncStoreDiffers, tableEntry(0))).toBeTrue();
});

it('should request the checksum of the row it was given, not of the selected row', () => {
const entry = tableEntry(SYNCHRONIZED_STORES_NUMBER, 'https://rest/api/core/bitstreams/bitstream-1/checksum');

comp.computeChecksum(entry);

expect(bitstreamChecksumService.findByHref)
.toHaveBeenCalledWith('https://rest/api/core/bitstreams/bitstream-1/checksum');
expect(itemBitstreamsService.getSelectedBitstream).not.toHaveBeenCalled();

let emitted: BitstreamChecksum = null;
comp.checkSum$.subscribe((value) => emitted = value);

expect(emitted).toBe(checksumAllEqual);
expect(comp.computedChecksum).toBeTrue();
expect(comp.loading).toBeFalse();
});

it('should do nothing when the row has no checksum link', () => {
const entry = tableEntry(0);

expect(() => comp.computeChecksum(entry)).not.toThrow();
expect(bitstreamChecksumService.findByHref).not.toHaveBeenCalled();
expect(comp.loading).toBeFalse();
});
});
});
Loading
Loading