Skip to content

Commit 0816c20

Browse files
committed
SF-3651 Allow serval admins to download training data
1 parent 7e567d4 commit 0816c20

File tree

6 files changed

+174
-20
lines changed

6 files changed

+174
-20
lines changed

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.html

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ <h2>Downloads</h2>
8383
<em>any edits made in Scripture Forge</em> since the last sync will not be included
8484
</app-notice>
8585
<div class="table-container">
86-
<table mat-table [dataSource]="rows">
86+
<table mat-table [dataSource]="rows" class="draft-sources-table">
8787
@for (column of columnsToDisplay | slice: 0 : columnsToDisplay.length - 1; track column) {
8888
<ng-container [matColumnDef]="column">
8989
<th mat-header-cell *matHeaderCellDef>{{ headingsToDisplay[column] }}</th>
@@ -109,6 +109,26 @@ <h2>Downloads</h2>
109109
<tr mat-header-row *matHeaderRowDef="columnsToDisplay"></tr>
110110
<tr mat-row *matRowDef="let myRowData; columns: columnsToDisplay"></tr>
111111
</table>
112+
<h3>Training Files</h3>
113+
<table mat-table [dataSource]="trainingDataFiles" class="training-data-table">
114+
<ng-container matColumnDef="title">
115+
<th mat-header-cell *matHeaderCellDef>File Name</th>
116+
<td mat-cell *matCellDef="let file">{{ file.title }}</td>
117+
</ng-container>
118+
119+
<ng-container matColumnDef="download">
120+
<th mat-header-cell *matHeaderCellDef></th>
121+
<td mat-cell *matCellDef="let file" class="data-table-end">
122+
<button mat-flat-button color="primary" (click)="downloadTrainingData(file.dataId)" [disabled]="!isOnline">
123+
<mat-icon>download</mat-icon>
124+
Download
125+
</button>
126+
</td>
127+
</ng-container>
128+
129+
<tr mat-header-row *matHeaderRowDef="['title', 'download']"></tr>
130+
<tr mat-row *matRowDef="let row; columns: ['title', 'download']"></tr>
131+
</table>
112132
</div>
113133

114134
@if (rawLastCompletedBuild?.executionData?.warnings?.length > 0) {

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@
1818
display: flex;
1919
align-items: center;
2020
}
21+
22+
.data-table-end {
23+
display: flex;
24+
justify-content: flex-end;
25+
align-items: center;
26+
height: inherit;
27+
}

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,28 @@ import { ActivatedRoute } from '@angular/router';
77
import { saveAs } from 'file-saver';
88
import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role';
99
import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data';
10+
import { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data';
1011
import { DraftConfig } from 'realtime-server/lib/esm/scriptureforge/models/translate-config';
1112
import { BehaviorSubject, of, throwError } from 'rxjs';
12-
import { anything, mock, verify, when } from 'ts-mockito';
13+
import { anything, instance, mock, verify, when } from 'ts-mockito';
1314
import { ActivatedProjectService } from 'xforge-common/activated-project.service';
1415
import { AuthService } from 'xforge-common/auth.service';
16+
import { FileService } from 'xforge-common/file.service';
1517
import { I18nService } from 'xforge-common/i18n.service';
1618
import { NoticeService } from 'xforge-common/notice.service';
1719
import { OnlineStatusService } from 'xforge-common/online-status.service';
1820
import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers';
1921
import { TestOnlineStatusService } from 'xforge-common/test-online-status.service';
2022
import { configureTestingModule } from 'xforge-common/test-utils';
23+
import { FileType } from '../../xforge-common/models/file-offline-data';
24+
import { RealtimeQuery } from '../../xforge-common/models/realtime-query';
2125
import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc';
26+
import { TrainingDataDoc } from '../core/models/training-data-doc';
2227
import { SFProjectService } from '../core/sf-project.service';
2328
import { BuildDto } from '../machine-api/build-dto';
2429
import { DraftZipProgress } from '../translate/draft-generation/draft-generation';
2530
import { DraftGenerationService } from '../translate/draft-generation/draft-generation.service';
31+
import { TrainingDataService } from '../translate/draft-generation/training-data/training-data.service';
2632
import { ServalAdministrationService } from './serval-administration.service';
2733
import { ServalProjectComponent } from './serval-project.component';
2834

@@ -36,23 +42,27 @@ const mockActivatedProjectService = mock(ActivatedProjectService);
3642
const mockActivatedRoute = mock(ActivatedRoute);
3743
const mockAuthService = mock(AuthService);
3844
const mockDraftGenerationService = mock(DraftGenerationService);
45+
const mockFileService = mock(FileService);
3946
const mockedI18nService = mock(I18nService);
4047
const mockNoticeService = mock(NoticeService);
4148
const mockSFProjectService = mock(SFProjectService);
4249
const mockServalAdministrationService = mock(ServalAdministrationService);
50+
const mockTrainingDataService = mock(TrainingDataService);
4351

44-
describe('ServalProjectComponent', () => {
52+
fdescribe('ServalProjectComponent', () => {
4553
configureTestingModule(() => ({
4654
providers: [
4755
provideTestOnlineStatus(),
4856
{ provide: ActivatedProjectService, useMock: mockActivatedProjectService },
4957
{ provide: ActivatedRoute, useMock: mockActivatedRoute },
5058
{ provide: AuthService, useMock: mockAuthService },
5159
{ provide: DraftGenerationService, useMock: mockDraftGenerationService },
60+
{ provide: FileService, useMock: mockFileService },
5261
{ provide: I18nService, useMock: mockedI18nService },
5362
{ provide: NoticeService, useMock: mockNoticeService },
5463
{ provide: OnlineStatusService, useClass: TestOnlineStatusService },
5564
{ provide: ServalAdministrationService, useMock: mockServalAdministrationService },
65+
{ provide: TrainingDataService, useMock: mockTrainingDataService },
5666
{ provide: SFProjectService, useMock: mockSFProjectService },
5767
provideNoopAnimations()
5868
]
@@ -116,33 +126,33 @@ describe('ServalProjectComponent', () => {
116126
it('should disable the download button when offline', fakeAsync(() => {
117127
const env = new TestEnvironment();
118128
env.onlineStatus = false;
119-
expect(env.firstDownloadButton.innerText).toContain('Download');
120-
expect(env.firstDownloadButton.disabled).toBe(true);
129+
expect(env.firstSourceDownloadButton.innerText).toContain('Download');
130+
expect(env.firstSourceDownloadButton.disabled).toBe(true);
121131
}));
122132

123133
it('should display a notice if the project cannot be downloaded', fakeAsync(() => {
124134
const env = new TestEnvironment();
125135
when(mockServalAdministrationService.downloadProject(anything())).thenReturn(
126136
throwError(() => new HttpErrorResponse({ status: 404 }))
127137
);
128-
expect(env.firstDownloadButton.innerText).toContain('Download');
129-
expect(env.firstDownloadButton.disabled).toBe(false);
130-
env.clickElement(env.firstDownloadButton);
138+
expect(env.firstSourceDownloadButton.innerText).toContain('Download');
139+
expect(env.firstSourceDownloadButton.disabled).toBe(false);
140+
env.clickElement(env.firstSourceDownloadButton);
131141
verify(mockNoticeService.showError(anything())).once();
132142
}));
133143

134144
it('should have a download button', fakeAsync(() => {
135145
const env = new TestEnvironment();
136-
expect(env.downloadButtons.length).toBe(4);
137-
expect(env.firstDownloadButton.innerText).toContain('Download');
138-
expect(env.firstDownloadButton.disabled).toBe(false);
146+
expect(env.sourceDownloadButtons.length).toBe(4);
147+
expect(env.firstSourceDownloadButton.innerText).toContain('Download');
148+
expect(env.firstSourceDownloadButton.disabled).toBe(false);
139149
}));
140150

141151
it('should allow clicking of the button to download', fakeAsync(() => {
142152
const env = new TestEnvironment();
143-
expect(env.firstDownloadButton.innerText).toContain('Download');
144-
expect(env.firstDownloadButton.disabled).toBe(false);
145-
env.clickElement(env.firstDownloadButton);
153+
expect(env.firstSourceDownloadButton.innerText).toContain('Download');
154+
expect(env.firstSourceDownloadButton.disabled).toBe(false);
155+
env.clickElement(env.firstSourceDownloadButton);
146156
expect(saveAs).toHaveBeenCalled();
147157
}));
148158
});
@@ -186,6 +196,33 @@ describe('ServalProjectComponent', () => {
186196
}));
187197
});
188198

199+
describe('download training data', () => {
200+
it('should show training data saved on a project', fakeAsync(() => {
201+
const env = new TestEnvironment();
202+
tick();
203+
env.fixture.detectChanges();
204+
expect(env.component.trainingDataFiles).toBeDefined();
205+
expect(env.component.trainingDataFiles.length).toBe(1);
206+
expect(env.component.trainingDataFiles[0].dataId).toBe('dataId01');
207+
expect(env.component.trainingDataFiles[0].fileUrl).toBe('file-url');
208+
}));
209+
210+
it('should disable the download button when offline', fakeAsync(() => {
211+
const env = new TestEnvironment();
212+
env.onlineStatus = false;
213+
expect(env.downloadTrainingDataButton.disabled).toBe(true);
214+
}));
215+
216+
it('can download the training data', fakeAsync(() => {
217+
const env = new TestEnvironment();
218+
tick();
219+
env.fixture.detectChanges();
220+
expect(env.downloadTrainingDataButton).not.toBeNull();
221+
env.clickElement(env.downloadTrainingDataButton);
222+
verify(mockFileService.onlineDownloadFile(FileType.TrainingData, 'file-url', 'training-data-01.csv')).once();
223+
}));
224+
});
225+
189226
describe('get last completed build', () => {
190227
it('does not get last completed build if project does not have draft books', fakeAsync(() => {
191228
const env = new TestEnvironment({ preTranslate: false });
@@ -360,6 +397,19 @@ describe('ServalProjectComponent', () => {
360397
when(mockDraftGenerationService.getBuildProgress(anything())).thenReturn(of({ additionalInfo: {} } as BuildDto));
361398
when(mockSFProjectService.hasDraft(anything())).thenReturn(args.preTranslate);
362399
when(mockSFProjectService.onlineSetServalConfig(this.mockProjectId, anything())).thenResolve();
400+
const trainingData: TrainingDataDoc[] = [
401+
{
402+
id: 'training01',
403+
data: {
404+
fileUrl: 'file-url',
405+
dataId: 'dataId01',
406+
title: 'training-data-01.csv'
407+
} as TrainingData
408+
} as TrainingDataDoc
409+
];
410+
const mockQuery: RealtimeQuery<TrainingDataDoc> = mock(RealtimeQuery<TrainingDataDoc>);
411+
when(mockQuery.docs).thenReturn(trainingData);
412+
when(mockTrainingDataService.queryTrainingDataAsync(anything(), anything())).thenResolve(instance(mockQuery));
363413

364414
spyOn(saveAs, 'saveAs').and.stub();
365415

@@ -380,16 +430,20 @@ describe('ServalProjectComponent', () => {
380430
return this.fixture.nativeElement.querySelector('#view-event-log');
381431
}
382432

383-
get firstDownloadButton(): HTMLInputElement {
384-
return this.fixture.nativeElement.querySelector('td button');
433+
get firstSourceDownloadButton(): HTMLInputElement {
434+
return this.fixture.nativeElement.querySelector('.draft-sources-table td button');
385435
}
386436

387-
get downloadButtons(): NodeListOf<HTMLButtonElement> {
388-
return this.fixture.nativeElement.querySelectorAll('td button');
437+
get sourceDownloadButtons(): NodeListOf<HTMLButtonElement> {
438+
return this.fixture.nativeElement.querySelectorAll('.draft-sources-table td button');
389439
}
390440

391441
get downloadDraftButton(): HTMLInputElement {
392-
return this.fixture.nativeElement.querySelector('#download-draft');
442+
return this.fixture.nativeElement.querySelector('#download-draft')!;
443+
}
444+
445+
get downloadTrainingDataButton(): HTMLInputElement {
446+
return this.fixture.nativeElement.querySelector('.training-data-table td button');
393447
}
394448

395449
get saveServalConfigButton(): HTMLInputElement {

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,32 @@ import {
2323
import { Router } from '@angular/router';
2424
import { saveAs } from 'file-saver';
2525
import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project';
26+
import { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data';
2627
import { DraftConfig, TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/translate-config';
27-
import { catchError, firstValueFrom, lastValueFrom, Observable, of, Subscription, switchMap, throwError } from 'rxjs';
28+
import {
29+
catchError,
30+
filter,
31+
firstValueFrom,
32+
lastValueFrom,
33+
Observable,
34+
of,
35+
Subscription,
36+
switchMap,
37+
throwError
38+
} from 'rxjs';
2839
import { ActivatedProjectService } from 'xforge-common/activated-project.service';
2940
import { DataLoadingComponent } from 'xforge-common/data-loading-component';
41+
import { FileService } from 'xforge-common/file.service';
3042
import { I18nService } from 'xforge-common/i18n.service';
3143
import { ElementState } from 'xforge-common/models/element-state';
44+
import { FileType } from 'xforge-common/models/file-offline-data';
45+
import { RealtimeQuery } from 'xforge-common/models/realtime-query';
3246
import { NoticeService } from 'xforge-common/notice.service';
3347
import { OnlineStatusService } from 'xforge-common/online-status.service';
3448
import { RouterLinkDirective } from 'xforge-common/router-link.directive';
3549
import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util';
3650
import { WriteStatusComponent } from 'xforge-common/write-status/write-status.component';
51+
import { TrainingDataDoc } from '../core/models/training-data-doc';
3752
import { ParatextService } from '../core/paratext.service';
3853
import { SFProjectService } from '../core/sf-project.service';
3954
import { BuildDto } from '../machine-api/build-dto';
@@ -46,6 +61,7 @@ import { DraftZipProgress } from '../translate/draft-generation/draft-generation
4661
import { DraftGenerationService } from '../translate/draft-generation/draft-generation.service';
4762
import { DraftInformationComponent } from '../translate/draft-generation/draft-information/draft-information.component';
4863
import { DraftSourcesAsTranslateSourceArrays, projectToDraftSources } from '../translate/draft-generation/draft-utils';
64+
import { TrainingDataService } from '../translate/draft-generation/training-data/training-data.service';
4965
import { ServalAdministrationService } from './serval-administration.service';
5066
interface Row {
5167
id: string;
@@ -133,14 +149,19 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn
133149
lastCompletedBuild: BuildDto | undefined;
134150
rawLastCompletedBuild: any;
135151
zipSubscription: Subscription | undefined;
152+
trainingDataFiles: TrainingData[] = [];
153+
154+
private trainingDataQuery?: RealtimeQuery<TrainingDataDoc>;
136155

137156
constructor(
138157
private readonly activatedProjectService: ActivatedProjectService,
139158
private readonly draftGenerationService: DraftGenerationService,
140159
private readonly i18n: I18nService,
141160
noticeService: NoticeService,
161+
private readonly trainingDataService: TrainingDataService,
142162
private readonly onlineStatusService: OnlineStatusService,
143163
private readonly projectService: SFProjectService,
164+
private readonly fileService: FileService,
144165
private readonly router: Router,
145166
private readonly servalAdministrationService: ServalAdministrationService,
146167
private destroyRef: DestroyRef
@@ -254,6 +275,17 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn
254275
this.rawLastCompletedBuild = await firstValueFrom(this.draftGenerationService.getRawBuild(build.id));
255276
}
256277
});
278+
279+
this.activatedProjectService.projectId$
280+
.pipe(
281+
filter(p => p != null),
282+
quietTakeUntilDestroyed(this.destroyRef)
283+
)
284+
.subscribe(async projectId => {
285+
this.trainingDataQuery?.dispose();
286+
this.trainingDataQuery = await this.trainingDataService.queryTrainingDataAsync(projectId, this.destroyRef);
287+
this.trainingDataFiles = this.trainingDataQuery.docs.map(doc => doc.data).filter(d => d != null);
288+
});
257289
}
258290

259291
async downloadDraft(): Promise<void> {
@@ -299,6 +331,13 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn
299331
this.loadingFinished();
300332
}
301333

334+
downloadTrainingData(dataId: string): void {
335+
const trainingData: TrainingData | undefined = this.trainingDataFiles.find(t => t.dataId === dataId);
336+
if (trainingData == null) return this.noticeService.show('File not found');
337+
338+
this.fileService.onlineDownloadFile(FileType.TrainingData, trainingData.fileUrl, trainingData.title);
339+
}
340+
302341
onUpdatePreTranslate(newValue: boolean): Promise<void> {
303342
return this.projectService.onlineSetPreTranslate(this.activatedProjectService.projectId!, newValue);
304343
}

src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { HttpTestingController, RequestMatch } from '@angular/common/http/testing';
22
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
3+
import { saveAs } from 'file-saver';
34
import { ProjectData } from 'realtime-server/lib/esm/common/models/project-data';
45
import { obj, PathItem } from 'realtime-server/lib/esm/common/utils/obj-path';
56
import { anything, mock, verify, when } from 'ts-mockito';
@@ -225,6 +226,29 @@ describe('FileService', () => {
225226
verify(mockedDialogService.message(anything(), anything())).once();
226227
expect(env.doc!.data!.audioUrl).toBeUndefined();
227228
}));
229+
230+
it('should download the file', fakeAsync(() => {
231+
const env = new TestEnvironment();
232+
const filename: string = 'training-data.csv';
233+
const source: string = '/path/to/training-data.csv';
234+
235+
// Spy on the saveAs function
236+
spyOn(saveAs, 'saveAs').and.stub();
237+
238+
env.service.onlineDownloadFile(FileType.TrainingData, source, filename);
239+
240+
// Verify the HTTP request was made with the correct URL
241+
const expectedUrl: string = formatFileSource(FileType.TrainingData, source);
242+
const req = env.httpMock.expectOne(expectedUrl);
243+
expect(req.request.method).toBe('GET');
244+
245+
// Respond with a blob
246+
const testBlob: Blob = new Blob();
247+
req.flush(testBlob);
248+
tick();
249+
expect(saveAs).toHaveBeenCalled();
250+
env.httpMock.verify();
251+
}));
228252
});
229253

230254
interface ChildData {

src/SIL.XForge.Scripture/ClientApp/src/xforge-common/file.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
22
import { DestroyRef, Injectable } from '@angular/core';
3+
import { saveAs } from 'file-saver';
34
import { lastValueFrom, Observable, Subject } from 'rxjs';
45
import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util';
56
import { environment } from '../environments/environment';
@@ -219,6 +220,15 @@ export class FileService {
219220
}
220221
}
221222

223+
onlineDownloadFile(fileType: FileType, source: string, filename: string): void {
224+
const url: string = formatFileSource(fileType, source);
225+
void this.onlineRequestFile(url).then(blob => {
226+
if (blob != null) {
227+
saveAs(blob, filename);
228+
}
229+
});
230+
}
231+
222232
private convertToPascalCase(input: string): string {
223233
return input
224234
.split('-')

0 commit comments

Comments
 (0)