Skip to content

Commit a71dda3

Browse files
milanmajchrakclaude
andcommitted
Port X-02b to dtq-dev-9-base: restore the CLARIN bitstream-checksum column on the edit-bitstreams table
Source: no single commit - the fork delta is measured against dspace-7.6.5, and the v9 upgrade took both payload files wholesale from vanilla 9.3, so the fork hunks were never applied (guard X1 sweep 2026-09-10, card X-02b). - bitstream.model.ts: SYNCHRONIZED_STORES_NUMBER = 77, @autoserialize storeNumber, the checksum HALLink and the @link(BITSTREAM_CHECKSUM) follow-link. - item-bitstreams.service.ts: fifth ResponsiveColumnSizes and the rebalanced widths (the MAX_PAGE_SIZE half of the same fork hunk had already landed). - item-edit-bitstream-bundle.component.{ts,html}: the Sync | Checksum column, the four popover templates, and BitstreamChecksumDataService wired in directly (it is providedIn:'root' on this base; core.module.ts no longer exists). v9 adaptations, each deliberate: - computeChecksum(entry) and isBitstreamSynchronized(entry) take the table row instead of itemBitstreamsService.getSelectedBitstream(), which returns null on v9 when no row is selected - the fork form throws a TypeError from any unselected row's button. - computeChecksum() returns early when the row has no checksum link. - _links.checksum is optional: the fork's required key breaks the strict object literal in the vanilla thumbnail.component.spec.ts, and the link is genuinely CLARIN-only. - @if / @for control flow, Bootstrap 5 utilities (ps-1, ps-2, fw-bold), NgbPopover and VarDirective added to the standalone imports[]. - [ngTemplateOutletContext] and [popoverContext] pass the row down; the fork sends none, so its let-entry is always undefined. - The five column ids/headers are suffixed with sanitizedBundleName, as the card requires - 9-base emits duplicate DOM ids when an item has more than one bundle. Five new karma tests, each proven load-bearing by a production-code negative control. en.json5 is untouched: all nine item.edit.bitstreams.checksum.* keys were already there with no consumer (finding X-03). Closes card X-02b. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0300412 commit a71dda3

6 files changed

Lines changed: 332 additions & 18 deletions

File tree

src/app/core/shared/bitstream.model.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
} from '../cache/builders/build-decorators';
1414
import { RemoteData } from '../data/remote-data';
1515
import { BITSTREAM } from './bitstream.resource-type';
16+
import { BitstreamChecksum } from './bitstream-checksum.model';
17+
import { BITSTREAM_CHECKSUM } from './bitstream-checksum.resource';
1618
import { BitstreamFormat } from './bitstream-format.model';
1719
import { BITSTREAM_FORMAT } from './bitstream-format.resource-type';
1820
import { Bundle } from './bundle.model';
@@ -21,6 +23,9 @@ import { ChildHALResource } from './child-hal-resource.model';
2123
import { DSpaceObject } from './dspace-object.model';
2224
import { HALLink } from './hal-link.model';
2325

26+
// Store number if the bitstream is stored in the both stores (S3 and local)
27+
export const SYNCHRONIZED_STORES_NUMBER = 77;
28+
2429
@typedObject
2530
@inheritSerialization(DSpaceObject)
2631
export class Bitstream extends DSpaceObject implements ChildHALResource {
@@ -44,6 +49,12 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
4449
@autoserialize
4550
bundleName: string;
4651

52+
/**
53+
* The number of the store where the bitstream is store, it could be S3, local or both.
54+
*/
55+
@autoserialize
56+
storeNumber: number;
57+
4758
/**
4859
* The {@link HALLink}s for this Bitstream
4960
*/
@@ -54,6 +65,10 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
5465
format: HALLink;
5566
content: HALLink;
5667
thumbnail: HALLink;
68+
/**
69+
* Optional: only the CLARIN backend exposes this link.
70+
*/
71+
checksum?: HALLink;
5772
accessStatus: HALLink;
5873
};
5974

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

96+
/**
97+
* The checksum values fetched from the DB, local and S3 store.
98+
*/
99+
@link(BITSTREAM_CHECKSUM)
100+
checksum?: Observable<RemoteData<BitstreamChecksum>>;
101+
81102
/**
82103
* The access status for this Bitstream
83104
* Will be undefined unless the access status {@link HALLink} has been resolved.

src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ export class ItemBitstreamsServiceStub {
4444
getColumnSizes = jasmine.createSpy('getColumnSizes').and
4545
.returnValue(
4646
new ResponsiveTableSizes([
47-
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
48-
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
47+
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
48+
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
49+
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
50+
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
4951
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
50-
new ResponsiveColumnSizes(6, 5, 4, 3, 3),
5152
]),
5253
);
5354

src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -383,13 +383,15 @@ export class ItemBitstreamsService {
383383
getColumnSizes(): ResponsiveTableSizes {
384384
return new ResponsiveTableSizes([
385385
// Name column
386-
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
386+
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
387387
// Description column
388-
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
388+
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
389389
// Format column
390390
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
391391
// Actions column
392-
new ResponsiveColumnSizes(6, 5, 4, 3, 3),
392+
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
393+
// Store synchronization column
394+
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
393395
]);
394396
}
395397

src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,21 @@
1515
[attr.aria-label]="'item.edit.bitstreams.bundle.table.aria-label' | translate: { bundle: bundleName } ">
1616
<thead [class.visually-hidden]="!isFirstTable">
1717
<tr class="header-row fw-bold">
18-
<th id="name" scope="col" class="{{ columnSizes.columns[0].buildClasses() }}">
18+
<th id="name-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[0].buildClasses() }}">
1919
{{'item.edit.bitstreams.headers.name' | translate}}
2020
</th>
21-
<th id="description" scope="col" class="{{ columnSizes.columns[1].buildClasses() }}">
21+
<th id="description-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[1].buildClasses() }}">
2222
{{'item.edit.bitstreams.headers.description' | translate}}
2323
</th>
24-
<th id="format" scope="col" class="{{ columnSizes.columns[2].buildClasses() }}">
24+
<th id="format-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[2].buildClasses() }}">
2525
{{'item.edit.bitstreams.headers.format' | translate}}
2626
</th>
27-
<th id="actions" scope="col" class="{{ columnSizes.columns[3].buildClasses() }} text-center">
27+
<th id="actions-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[3].buildClasses() }} text-center">
2828
{{'item.edit.bitstreams.headers.actions' | translate}}
2929
</th>
30+
<th id="synchronized-{{ sanitizedBundleName }}" scope="col" class="{{ columnSizes.columns[4].buildClasses() }} text-center">
31+
{{'item.edit.bitstreams.headers.synchronized' | translate}}
32+
</th>
3033
</tr>
3134
</thead>
3235
<tbody cdkDropList (cdkDropListDropped)="drop($event)">
@@ -72,29 +75,30 @@
7275
</div>
7376
</div>
7477
</td>
78+
<td class="row-element {{ columnSizes.columns[4].buildClasses() }}"></td>
7579
</tr>
7680
@for (entry of (tableEntries$ | async); track entry) {
7781
@if (updates[entry.id]; as update) {
7882
<tr [ngClass]="getRowClass(update, entry)" class="bitstream-row" cdkDrag
7983
(cdkDragStarted)="dragStart()" (cdkDragEnded)="dragEnd()">
8084
<th class="bitstream-name row-element {{ columnSizes.columns[0].buildClasses() }}"
81-
scope="row" id="{{ entry.nameStripped }}" headers="{{ bundleName }} name">
85+
scope="row" id="{{ entry.nameStripped }}" headers="{{ bundleName }} name-{{ sanitizedBundleName }}">
8286
<div class="drag-handle text-muted float-start p-1 me-2 d-inline" tabindex="0" cdkDragHandle
8387
(keydown.enter)="select($event, entry)" (keydown.space)="select($event, entry)" (click)="select($event, entry)">
8488
<i class="drag-icon" [title]="'item.edit.bitstreams.edit.buttons.drag' | translate"></i>
8589
</div>
8690
<span class="dont-break-out">{{ entry.name }}</span>
8791
</th>
8892
<td class="row-element {{ columnSizes.columns[1].buildClasses() }}"
89-
headers="{{ entry.nameStripped }} {{ bundleName }} description">
93+
headers="{{ entry.nameStripped }} {{ bundleName }} description-{{ sanitizedBundleName }}">
9094
{{ entry.description }}
9195
</td>
9296
<td class="row-element {{ columnSizes.columns[2].buildClasses() }}"
93-
headers="{{ entry.nameStripped }} {{ bundleName }} format">
97+
headers="{{ entry.nameStripped }} {{ bundleName }} format-{{ sanitizedBundleName }}">
9498
{{ (entry.format | async)?.shortDescription }}
9599
</td>
96100
<td class="row-element {{ columnSizes.columns[3].buildClasses() }}"
97-
headers="{{ entry.nameStripped }} {{ bundleName }} actions">
101+
headers="{{ entry.nameStripped }} {{ bundleName }} actions-{{ sanitizedBundleName }}">
98102
<div class="text-center w-100">
99103
<div class="btn-group relationship-action-buttons">
100104
<a [href]="entry.downloadUrl"
@@ -126,6 +130,17 @@
126130
</div>
127131
</div>
128132
</td>
133+
<td class="row-element {{ columnSizes.columns[4].buildClasses() }}"
134+
headers="{{ entry.nameStripped }} {{ bundleName }} synchronized-{{ sanitizedBundleName }}">
135+
<div class="d-flex align-items-center justify-content-center">
136+
<span class="text-center">
137+
<i [class]="isBitstreamSynchronized(entry) ? 'fas fa-check' : 'fas fa-times'"
138+
[attr.aria-label]="'item.edit.bitstreams.headers.synchronized' | translate"></i>
139+
</span>
140+
<span class="ps-1">|</span>
141+
<div class="ps-1" [ngTemplateOutlet]="checksum" [ngTemplateOutletContext]="{ entry: entry }"></div>
142+
</div>
143+
</td>
129144
</tr>
130145
}
131146
}
@@ -136,3 +151,89 @@
136151
}
137152

138153
</ng-template>
154+
155+
<ng-template #checksum let-entry="entry">
156+
<div class="d-flex align-items-center"
157+
(mouseenter)="showChecksumValues = true"
158+
(mouseleave)="showChecksumValues = false"
159+
*ngVar="(checkSum$ | async) as bitstreamChecksum">
160+
@if (!computedChecksum) {
161+
@if (loading) {
162+
<i class="fas fa-spinner fa-spin"></i>
163+
}
164+
@if (!loading) {
165+
<i class="fas fa-question"
166+
triggers="mouseenter:mouseleave"
167+
[ngbPopover]="checksumWarning"></i>
168+
}
169+
<a class="btn" (click)="computeChecksum(entry)"
170+
triggers="mouseenter:mouseleave"
171+
[ngbPopover]="computeChecksumInfo">
172+
<i class="fas fa-download"></i>
173+
</a>
174+
}
175+
@if (computedChecksum) {
176+
<i [class]="checksumsAreEqual(bitstreamChecksum, entry) ? 'fas fa-check' : 'fas fa-times'"></i>
177+
<i class="ps-2 fas fa-info-circle"
178+
triggers="mouseenter:mouseleave"
179+
[ngbPopover]="checksumPopover"
180+
[popoverContext]="{ entry: entry }"
181+
popoverTitle="Checksums"></i>
182+
}
183+
</div>
184+
</ng-template>
185+
186+
<ng-template #checksumWarning>
187+
<div class="fw-bold text-decoration-underline">
188+
{{ 'item.edit.bitstreams.checksum.popover.warning.header' | translate }}
189+
</div>
190+
<div>
191+
{{ 'item.edit.bitstreams.checksum.popover.warning.body' | translate }}
192+
</div>
193+
</ng-template>
194+
195+
<ng-template #computeChecksumInfo>
196+
<div>
197+
{{ 'item.edit.bitstreams.checksum.popover.info.body' | translate }}
198+
</div>
199+
</ng-template>
200+
201+
<ng-template #checksumPopover let-entry="entry">
202+
<div *ngVar="(checkSum$ | async) as bitstreamChecksum">
203+
<div>
204+
<div class="fw-bold text-decoration-underline">
205+
{{'item.edit.bitstreams.checksum.database' | translate}}
206+
</div>
207+
<div>
208+
{{'item.edit.bitstreams.checksum.algorithm' | translate}} {{bitstreamChecksum?.databaseChecksum?.checkSumAlgorithm}}
209+
</div>
210+
<div>
211+
{{'item.edit.bitstreams.checksum.value' | translate}} {{ bitstreamChecksum?.databaseChecksum?.value }}
212+
</div>
213+
</div>
214+
<div>
215+
<div class="fw-bold text-decoration-underline">
216+
{{'item.edit.bitstreams.checksum.active-store' | translate}}
217+
</div>
218+
<div>
219+
{{'item.edit.bitstreams.checksum.algorithm' | translate}} {{bitstreamChecksum?.activeStore?.checkSumAlgorithm}}
220+
</div>
221+
<div>
222+
{{'item.edit.bitstreams.checksum.value' | translate}} {{ bitstreamChecksum?.activeStore?.value }}
223+
</div>
224+
</div>
225+
@if (isBitstreamSynchronized(entry)) {
226+
<div>
227+
<div class="fw-bold text-decoration-underline">
228+
{{'item.edit.bitstreams.checksum.sync-store' | translate}}
229+
</div>
230+
<div>
231+
{{'item.edit.bitstreams.checksum.algorithm' | translate}} {{bitstreamChecksum?.synchronizedStore?.checkSumAlgorithm}}
232+
</div>
233+
<div>
234+
{{'item.edit.bitstreams.checksum.value' | translate}} {{ bitstreamChecksum?.synchronizedStore?.value }}
235+
</div>
236+
</div>
237+
}
238+
</div>
239+
</ng-template>

src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,18 @@ import {
1414
Subject,
1515
} from 'rxjs';
1616

17+
import { BitstreamChecksumDataService } from '../../../../core/bitstream-checksum-data.service';
1718
import { BundleDataService } from '../../../../core/data/bundle-data.service';
1819
import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model';
1920
import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model';
2021
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
2122
import { RequestService } from '../../../../core/data/request.service';
2223
import { PaginationService } from '../../../../core/pagination/pagination.service';
24+
import {
25+
Bitstream,
26+
SYNCHRONIZED_STORES_NUMBER,
27+
} from '../../../../core/shared/bitstream.model';
28+
import { BitstreamChecksum } from '../../../../core/shared/bitstream-checksum.model';
2329
import { Bundle } from '../../../../core/shared/bundle.model';
2430
import { Item } from '../../../../core/shared/item.model';
2531
import { getMockRequestService } from '../../../../shared/mocks/request.service.mock';
@@ -45,12 +51,35 @@ describe('ItemEditBitstreamBundleComponent', () => {
4551
let viewContainerRef: ViewContainerRef;
4652

4753
const columnSizes = new ResponsiveTableSizes([
48-
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
49-
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
54+
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
55+
new ResponsiveColumnSizes(3, 3, 3, 3, 3),
56+
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
57+
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
5058
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
51-
new ResponsiveColumnSizes(6, 5, 4, 3, 3),
5259
]);
5360

61+
62+
const md5 = (value: string) => ({ checkSumAlgorithm: 'MD5', value });
63+
const checksumAllEqual = Object.assign(new BitstreamChecksum(), {
64+
databaseChecksum: md5('abc'),
65+
activeStore: md5('abc'),
66+
synchronizedStore: md5('abc'),
67+
});
68+
69+
const tableEntry = (storeNumber: number, checksumHref?: string): BitstreamTableEntry => ({
70+
bitstream: Object.assign(new Bitstream(), {
71+
uuid: 'bitstream-1',
72+
storeNumber,
73+
_links: checksumHref ? { checksum: { href: checksumHref } } : {},
74+
}),
75+
id: 'bitstream-1',
76+
name: 'file.txt',
77+
nameStripped: 'file.txt',
78+
description: '',
79+
format: of(null),
80+
downloadUrl: 'download-url',
81+
} as any);
82+
5483
const item = Object.assign(new Item(), {
5584
id: 'item-1',
5685
uuid: 'item-1',
@@ -71,6 +100,7 @@ describe('ItemEditBitstreamBundleComponent', () => {
71100

72101
let objectUpdatesService: any;
73102
let itemBitstreamsService: ItemBitstreamsServiceStub;
103+
let bitstreamChecksumService: jasmine.SpyObj<BitstreamChecksumDataService>;
74104

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

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

83115
TestBed.configureTestingModule({
84116
imports: [TranslateModule.forRoot(), ItemEditBitstreamBundleComponent],
@@ -88,6 +120,7 @@ describe('ItemEditBitstreamBundleComponent', () => {
88120
{ provide: PaginationService, useValue: new PaginationServiceStub() },
89121
{ provide: RequestService, useValue: getMockRequestService() },
90122
{ provide: ItemBitstreamsService, useValue: itemBitstreamsService },
123+
{ provide: BitstreamChecksumDataService, useValue: bitstreamChecksumService },
91124
],
92125
schemas: [
93126
NO_ERRORS_SCHEMA,
@@ -356,4 +389,53 @@ describe('ItemEditBitstreamBundleComponent', () => {
356389
expect(itemBitstreamsService.cancelSelection).not.toHaveBeenCalled();
357390
});
358391
});
392+
393+
describe('bitstream checksum column', () => {
394+
395+
it('should report a bitstream stored in both stores as synchronized', () => {
396+
expect(comp.isBitstreamSynchronized(tableEntry(SYNCHRONIZED_STORES_NUMBER))).toBeTrue();
397+
expect(comp.isBitstreamSynchronized(tableEntry(0))).toBeFalse();
398+
});
399+
400+
it('should treat checksums with the same value but a different algorithm as unequal', () => {
401+
expect(comp.compareChecksums(md5('abc'), md5('abc'))).toBeTrue();
402+
expect(comp.compareChecksums(md5('abc'), { checkSumAlgorithm: 'SHA-256', value: 'abc' })).toBeFalse();
403+
});
404+
405+
it('should compare the synchronized store as well, but only for a synchronized bitstream', () => {
406+
const syncStoreDiffers = Object.assign(new BitstreamChecksum(), {
407+
databaseChecksum: md5('abc'),
408+
activeStore: md5('abc'),
409+
synchronizedStore: md5('zzz'),
410+
});
411+
412+
expect(comp.checksumsAreEqual(syncStoreDiffers, tableEntry(SYNCHRONIZED_STORES_NUMBER))).toBeFalse();
413+
expect(comp.checksumsAreEqual(syncStoreDiffers, tableEntry(0))).toBeTrue();
414+
});
415+
416+
it('should request the checksum of the row it was given, not of the selected row', () => {
417+
const entry = tableEntry(SYNCHRONIZED_STORES_NUMBER, 'https://rest/api/core/bitstreams/bitstream-1/checksum');
418+
419+
comp.computeChecksum(entry);
420+
421+
expect(bitstreamChecksumService.findByHref)
422+
.toHaveBeenCalledWith('https://rest/api/core/bitstreams/bitstream-1/checksum');
423+
expect(itemBitstreamsService.getSelectedBitstream).not.toHaveBeenCalled();
424+
425+
let emitted: BitstreamChecksum = null;
426+
comp.checkSum$.subscribe((value) => emitted = value);
427+
428+
expect(emitted).toBe(checksumAllEqual);
429+
expect(comp.computedChecksum).toBeTrue();
430+
expect(comp.loading).toBeFalse();
431+
});
432+
433+
it('should do nothing when the row has no checksum link', () => {
434+
const entry = tableEntry(0);
435+
436+
expect(() => comp.computeChecksum(entry)).not.toThrow();
437+
expect(bitstreamChecksumService.findByHref).not.toHaveBeenCalled();
438+
expect(comp.loading).toBeFalse();
439+
});
440+
});
359441
});

0 commit comments

Comments
 (0)