diff --git a/src/RealtimeServer/scriptureforge/models/translate-config.ts b/src/RealtimeServer/scriptureforge/models/translate-config.ts index 05d258976c..6f38eee5c9 100644 --- a/src/RealtimeServer/scriptureforge/models/translate-config.ts +++ b/src/RealtimeServer/scriptureforge/models/translate-config.ts @@ -27,6 +27,14 @@ export interface BaseProject { shortName: string; } +/** + * A per-project scripture range. + */ +export interface ProjectScriptureRange { + projectId: string; + scriptureRange: string; +} + export interface DraftConfig { additionalTrainingData: boolean; additionalTrainingSourceEnabled: boolean; @@ -38,6 +46,7 @@ export interface DraftConfig { lastSelectedTrainingBooks: number[]; lastSelectedTrainingDataFiles: string[]; lastSelectedTrainingScriptureRange?: string; + lastSelectedTrainingScriptureRanges?: ProjectScriptureRange[]; lastSelectedTranslationBooks: number[]; lastSelectedTranslationScriptureRange?: string; servalConfig?: string; diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts index d7fa48da96..7d785b1af6 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts @@ -570,6 +570,28 @@ describe('SFProjectMigrations', () => { expect(projectDoc.data.translateConfig.shareEnabled).not.toBeDefined(); }); }); + + describe('version 22', () => { + it('copies selected training and translation books to scripture ranges', async () => { + const env = new TestEnvironment(21); + const conn = env.server.connect(); + + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', { + translateConfig: { draftConfig: { lastSelectedTrainingBooks: [1, 2, 3], lastSelectedTranslationBooks: [4, 5] } } + }); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.lastSelectedTrainingBooks).toEqual([1, 2, 3]); + expect(projectDoc.data.translateConfig.draftConfig.lastSelectedTranslationBooks).toEqual([4, 5]); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.lastSelectedTrainingBooks).toEqual([1, 2, 3]); + expect(projectDoc.data.translateConfig.draftConfig.lastSelectedTranslationBooks).toEqual([4, 5]); + expect(projectDoc.data.translateConfig.draftConfig.lastSelectedTrainingScriptureRange).toEqual('GEN;EXO;LEV'); + expect(projectDoc.data.translateConfig.draftConfig.lastSelectedTranslationScriptureRange).toEqual('NUM;DEU'); + }); + }); }); class TestEnvironment { diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts index 3c01e7f705..1d604cc2b8 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts @@ -1,3 +1,4 @@ +import { Canon } from '@sillsdev/scripture'; import { Doc, Op } from 'sharedb/lib/client'; import { DocMigration, MigrationConstructor, monotonicallyIncreasingMigrationList } from '../../common/migration'; import { Operation } from '../../common/models/project-rights'; @@ -387,6 +388,38 @@ class SFProjectMigration21 extends DocMigration { } } +class SFProjectMigration22 extends DocMigration { + static readonly VERSION = 22; + + async migrateDoc(doc: Doc): Promise { + const ops: Op[] = []; + if (doc.data.translateConfig.draftConfig.lastSelectedTrainingScriptureRange == null) { + const trainingRangeFromBooks: string[] = doc.data.translateConfig.draftConfig.lastSelectedTrainingBooks.map( + (b: number) => Canon.bookNumberToId(b) + ); + if (trainingRangeFromBooks.length > 0) { + ops.push({ + p: ['translateConfig', 'draftConfig', 'lastSelectedTrainingScriptureRange'], + oi: trainingRangeFromBooks.join(';') + }); + } + } + if (doc.data.translateConfig.draftConfig.lastSelectedTranslationScriptureRange == null) { + const translationRangeFromBooks: string[] = doc.data.translateConfig.draftConfig.lastSelectedTranslationBooks.map( + (b: number) => Canon.bookNumberToId(b) + ); + if (translationRangeFromBooks.length > 0) { + ops.push({ + p: ['translateConfig', 'draftConfig', 'lastSelectedTranslationScriptureRange'], + oi: translationRangeFromBooks.join(';') + }); + } + } + + await submitMigrationOp(SFProjectMigration22.VERSION, doc, ops); + } +} + export const SF_PROJECT_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([ SFProjectMigration1, SFProjectMigration2, @@ -408,5 +441,6 @@ export const SF_PROJECT_MIGRATIONS: MigrationConstructor[] = monotonicallyIncrea SFProjectMigration18, SFProjectMigration19, SFProjectMigration20, - SFProjectMigration21 + SFProjectMigration21, + SFProjectMigration22 ]); diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts index f9e5b1a413..bfed1182cd 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts @@ -266,6 +266,21 @@ export class SFProjectService extends ProjectService { lastSelectedTrainingScriptureRange: { bsonType: 'string' }, + lastSelectedTrainingScriptureRanges: { + bsonType: 'array', + items: { + bsonType: 'object', + properties: { + projectId: { + bsonType: 'string' + }, + scriptureRange: { + bsonType: 'string' + } + }, + additionalProperties: false + } + }, lastSelectedTranslationBooks: { bsonType: 'array', items: { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts index 37d026bacf..2756fd2f28 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts @@ -222,7 +222,9 @@ describe('ServalProjectComponent', () => { shortName: 'P4' }, lastSelectedTrainingBooks: preTranslate ? [1, 2] : [], - lastSelectedTranslationBooks: preTranslate ? [3, 4] : [] + lastSelectedTranslationBooks: preTranslate ? [3, 4] : [], + lastSelectedTrainingScriptureRange: preTranslate ? 'GEN;EXO' : undefined, + lastSelectedTranslationScriptureRange: preTranslate ? 'LEV;NUM' : undefined }, preTranslate, source: { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts index cebe298943..38757cdc15 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts @@ -17,7 +17,7 @@ import { BuildDto } from '../machine-api/build-dto'; import { MobileNotSupportedComponent } from '../shared/mobile-not-supported/mobile-not-supported.component'; import { NoticeComponent } from '../shared/notice/notice.component'; import { SharedModule } from '../shared/shared.module'; -import { projectLabel } from '../shared/utils'; +import { booksFromScriptureRange, projectLabel } from '../shared/utils'; import { DraftZipProgress } from '../translate/draft-generation/draft-generation'; import { DraftGenerationService } from '../translate/draft-generation/draft-generation.service'; import { DraftInformationComponent } from '../translate/draft-generation/draft-information/draft-information.component'; @@ -150,13 +150,13 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn this.rows = rows; // Setup the books - this.trainingBooks = project.translateConfig.draftConfig.lastSelectedTrainingBooks.map(bookNum => - Canon.bookNumberToEnglishName(bookNum) - ); + this.trainingBooks = booksFromScriptureRange( + project.translateConfig.draftConfig.lastSelectedTrainingScriptureRange ?? '' + ).map(bookNum => Canon.bookNumberToEnglishName(bookNum)); this.trainingFiles = project.translateConfig.draftConfig.lastSelectedTrainingDataFiles; - this.translationBooks = project.translateConfig.draftConfig.lastSelectedTranslationBooks.map(bookNum => - Canon.bookNumberToEnglishName(bookNum) - ); + this.translationBooks = booksFromScriptureRange( + project.translateConfig.draftConfig.lastSelectedTranslationScriptureRange ?? '' + ).map(bookNum => Canon.bookNumberToEnglishName(bookNum)); this.draftConfig = project.translateConfig.draftConfig; this.draftJob$ = SFProjectService.hasDraft(project) ? this.getDraftJob(projectDoc.id) : of(undefined); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html index 170a0b905f..56c42c2bb7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html @@ -1,6 +1,9 @@ - @if (availableBooks.length > 0 && !readonly) { -
+ @if (availableBooks.length > 0 && !readonly && !basicMode) { +
+ @if (projectName != null) { + {{ projectName }} + } - - {{ "canon.book_names." + book.bookId | transloco }} -
-
+ @if (!basicMode) { + + {{ "canon.book_names." + book.bookId | transloco }} +
+
+ } @else { + + {{ "canon.book_names." + book.bookId | transloco }} + + } }
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss index 278c40ce8d..e5f3d3782b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss @@ -1,5 +1,15 @@ @use 'src/variables'; +.scope-selection { + display: flex; + align-items: center; + column-gap: 8px; + + .project-name { + font-weight: 500; + } +} + .book-multi-select { .mat-mdc-standard-chip { position: relative; @@ -24,29 +34,6 @@ } } -.bulk-select { - width: fit-content; - margin-top: 2px; - margin-block-end: 12px; - font-weight: 300; - div { - display: flex; - align-items: center; - column-gap: 12px; - button { - padding-inline: 12px; - font-weight: 300; - font-size: 16px; - } - mat-button-toggle { - width: 50px; - } - .mat-button-toggle-checked { - background-color: unset; - } - } -} - .loading-message { display: flex; gap: 0.5em; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts index 199503f991..7559af25c2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts @@ -50,9 +50,16 @@ describe('BookMultiSelectComponent', () => { fixture.detectChanges(); }); - it('should initialize book options on ngOnChanges', async () => { + it('supports providing project name', async () => { await component.ngOnChanges(); + expect(fixture.nativeElement.querySelector('.project-name')).toBeNull(); + component.projectName = 'Test Project'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.project-name')).not.toBeNull(); + }); + it('should initialize book options on ngOnChanges', async () => { + await component.ngOnChanges(); expect(component.bookOptions).toEqual([ { bookNum: 1, bookId: 'GEN', selected: true, progressPercentage: 0 }, { bookNum: 2, bookId: 'EXO', selected: false, progressPercentage: 15 }, @@ -137,4 +144,15 @@ describe('BookMultiSelectComponent', () => { expect(component.partialOT).toBe(false); expect(component.partialDC).toBe(true); }); + + it('can hide checkboxes and progress in basic mode', async () => { + await component.ngOnChanges(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.book-multi-select .border-fill')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.scope-selection')).not.toBeNull(); + component.basicMode = true; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.book-multi-select .border-fill')).toBeNull(); + expect(fixture.nativeElement.querySelector('.scope-selection')).toBeNull(); + }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts index 3b16bdd403..b15021de9f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts @@ -28,6 +28,8 @@ export class BookMultiSelectComponent extends SubscriptionDisposable implements @Input() availableBooks: number[] = []; @Input() selectedBooks: number[] = []; @Input() readonly: boolean = false; + @Input() projectName?: string; + @Input() basicMode: boolean = false; @Output() bookSelect = new EventEmitter(); protected loaded = false; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts index e497431fb8..b35bb01433 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts @@ -9,6 +9,7 @@ import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc'; import { roleCanAccessCommunityChecking, roleCanAccessTranslate } from '../core/models/sf-project-role-info'; import { SFProjectUserConfigDoc } from '../core/models/sf-project-user-config-doc'; import { SelectableProject } from '../core/paratext.service'; +import { DraftSource } from '../translate/draft-generation/draft-sources.service'; // Regular expression for getting the verse from a segment ref // Some projects will have the right to left marker in the segment attribute which we need to account for @@ -190,7 +191,7 @@ export function checkAppAccess( } } -export function projectLabel(project: SelectableProject | undefined): string { +export function projectLabel(project: SelectableProject | DraftSource | undefined): string { if (project == null || (!project.shortName && !project.name)) { return ''; } @@ -265,6 +266,11 @@ export function getUnsupportedTags(deltaOp: DeltaOperation): string[] { return [...invalidTags]; } +export function booksFromScriptureRange(scriptureRange: string): number[] { + if (scriptureRange === '') return []; + return scriptureRange.split(';').map(book => Canon.bookIdToNumber(book)); +} + export class XmlUtils { /** Encode text to be valid xml text node. Escape reserved xml characters such as & and < >. */ static encodeForXml(text: string): string { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html index 8ee595074e..13a544c8bf 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html @@ -75,14 +75,16 @@

- + {{ t("choose_books_for_training_label") }}

{{ t("choose_books_for_training") }}

+

{{ t("translated_books") }}

@@ -115,10 +117,34 @@

} - @if (unusableTranslateTargetBooks.length) { - - - +

{{ t("reference_books") }}

+

{{ trainingSourceProjectName }}

+ @if (selectableSourceTrainingBooks.length === 0) { + {{ + t("training_books_will_appear") + }} + } @else { + + } + @if (trainingAdditionalSourceProjectName?.length > 0) { +

{{ trainingAdditionalSourceProjectName }}

+ @if (selectableAdditionalSourceTrainingBooks.length === 0) { + {{ + t("training_books_will_appear") + }} + } @else { + + } } @if (showBookSelectionError) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.scss index ba7bf302d8..c24213a312 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.scss @@ -18,6 +18,10 @@ h1 { margin: 12px 0; } +h2 { + font-weight: 500; +} + // Prevent font increase when selecting a step .mat-stepper-horizontal { --mat-stepper-header-selected-state-label-text-size: var(--mat-stepper-header-label-text-size); @@ -97,6 +101,10 @@ app-notice { } } +.reference-project-label { + font-weight: 500; +} + .loading { display: flex; margin: 1em; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts index 3d9bb62f32..1388af59b6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts @@ -59,6 +59,7 @@ describe('DraftGenerationStepsComponent', () => { const mockSourceNonNllbProjectDoc = { data: createTestProjectProfile({ + paratextId: 'sourcePt1', texts: [{ bookNum: 1 }, { bookNum: 2 }, { bookNum: 3 }, { bookNum: 4 }, { bookNum: 5 }, { bookNum: 100 }], writingSystem: { tag: 'xyz' } }) @@ -73,6 +74,15 @@ describe('DraftGenerationStepsComponent', () => { const mockAlternateTrainingSourceProjectDoc = { data: createTestProjectProfile({ + paratextId: 'sourcePtAlt1', + texts: [{ bookNum: 2 }, { bookNum: 3 }, { bookNum: 4 }, { bookNum: 5 }, { bookNum: 8 }, { bookNum: 100 }], + writingSystem: { tag: 'xyz' } + }) + } as SFProjectProfileDoc; + + const mockAdditionalTrainingSourceProjectDoc = { + data: createTestProjectProfile({ + paratextId: 'sourcePt2', texts: [{ bookNum: 2 }, { bookNum: 3 }, { bookNum: 4 }, { bookNum: 5 }, { bookNum: 8 }, { bookNum: 100 }], writingSystem: { tag: 'xyz' } }) @@ -80,7 +90,9 @@ describe('DraftGenerationStepsComponent', () => { const mockUserDoc = { data: createTestUser({ - sites: { [environment.siteId]: { projects: ['alternateTrainingProject', 'sourceProject', 'test'] } } + sites: { + [environment.siteId]: { projects: ['alternateTrainingProject', 'sourceProject', 'test', 'sourceProject2'] } + } }) } as UserDoc; @@ -185,6 +197,7 @@ describe('DraftGenerationStepsComponent', () => { component.tryAdvanceStep(); fixture.detectChanges(); component.userSelectedTranslateBooks = [1]; + component.userSelectedTrainingBooks = [2, 3]; fixture.detectChanges(); // Go to training books component.tryAdvanceStep(); @@ -192,7 +205,6 @@ describe('DraftGenerationStepsComponent', () => { fixture.detectChanges(); verify(mockNoticeService.show(anything())).never(); expect(component.stepper.selectedIndex).toBe(2); - component.userSelectedTrainingBooks = [2, 3]; tick(); fixture.detectChanges(); // Attempt to generate draft @@ -201,6 +213,56 @@ describe('DraftGenerationStepsComponent', () => { verify(mockNoticeService.show(anything())).once(); expect(component.stepper.selectedIndex).toBe(2); })); + + it('should allow selecting books from the alternate training source project', () => { + const trainingBooks = [3]; + const trainingDataFiles: string[] = []; + const translationBooks = [2]; + + component.userSelectedTrainingBooks = trainingBooks; + component.userSelectedTranslateBooks = translationBooks; + component.selectedTrainingDataIds = trainingDataFiles; + component['draftSourceProjectIds'] = { + draftingSourceId: 'sourceProject', + trainingSourceId: 'sourceProject', + trainingAlternateSourceId: 'alternateTrainingProject' + }; + component.onStepChange(); + fixture.detectChanges(); + expect(component.availableTrainingBooks).toEqual(trainingBooks); + expect(component.selectableSourceTrainingBooks).toEqual(trainingBooks); + expect(component.userSelectedSourceTrainingBooks).toEqual(trainingBooks); + expect(fixture.nativeElement.querySelector('.books-appear-notice')).toBeNull(); + + component.onSourceTrainingBookSelect([]); + fixture.detectChanges(); + expect(component.selectableSourceTrainingBooks).toEqual(trainingBooks); + expect(component.userSelectedSourceTrainingBooks).toEqual([]); + expect(fixture.nativeElement.querySelector('.books-appear-notice')).toBeNull(); + }); + + it('does not allow selecting not selectable source training books', () => { + const trainingBooks = [3]; + const trainingDataFiles: string[] = []; + const translationBooks = [2]; + + component.userSelectedTrainingBooks = trainingBooks; + component.userSelectedTranslateBooks = translationBooks; + component.selectedTrainingDataIds = trainingDataFiles; + component['draftSourceProjectIds'] = { + draftingSourceId: 'sourceProject', + trainingSourceId: 'sourceProject', + trainingAlternateSourceId: 'alternateTrainingProject' + }; + component.onStepChange(); + expect(component.availableTrainingBooks).toEqual(trainingBooks); + expect(component.selectableSourceTrainingBooks).toEqual(trainingBooks); + expect(component.userSelectedSourceTrainingBooks).toEqual(trainingBooks); + + component.onSourceTrainingBookSelect([2, 3]); + fixture.detectChanges(); + expect(component.userSelectedSourceTrainingBooks).toEqual(trainingBooks); + }); }); describe('NO alternate training source project', () => { @@ -259,6 +321,8 @@ describe('DraftGenerationStepsComponent', () => { it('should select no books initially', () => { expect(component.initialSelectedTrainingBooks).toEqual([]); expect(component.userSelectedTrainingBooks).toEqual([]); + expect(component.userSelectedSourceTrainingBooks).toEqual([]); + expect(component.userSelectedAdditionalSourceTrainingBooks).toEqual([]); expect(component.initialSelectedTranslateBooks).toEqual([]); expect(component.userSelectedTranslateBooks).toEqual([]); }); @@ -271,6 +335,8 @@ describe('DraftGenerationStepsComponent', () => { component.userSelectedTrainingBooks = trainingBooks; component.userSelectedTranslateBooks = translationBooks; component.selectedTrainingDataIds = trainingDataFiles; + component.userSelectedSourceTrainingBooks = trainingBooks; + component['draftSourceProjectIds'] = { draftingSourceId: 'sourceProject', trainingSourceId: 'sourceProject' }; spyOn(component.done, 'emit'); expect(component.isStepsCompleted).toBe(false); @@ -287,11 +353,9 @@ describe('DraftGenerationStepsComponent', () => { fixture.detectChanges(); expect(component.done.emit).toHaveBeenCalledWith({ - trainingBooks: trainingBooks.filter(book => !translationBooks.includes(book)), trainingDataFiles, - trainingScriptureRanges: [], - translationBooks, - translationScriptureRanges: [], + trainingScriptureRanges: [{ projectId: 'sourceProject', scriptureRange: 'LEV' }], + translationScriptureRange: 'GEN;EXO', fastTraining: false } as DraftGenerationStepsResult); expect(component.isStepsCompleted).toBe(true); @@ -327,7 +391,31 @@ describe('DraftGenerationStepsComponent', () => { when(mockProjectService.getProfile(anything())).thenResolve(mockSourceNllbProjectDoc); targetProjectDoc$.next(mockTargetProjectDoc); // Trigger re-init on project changes tick(); + fixture.detectChanges(); expect(component.isTrainingOptional).toBe(true); + const translateBooks = [1, 2]; + const trainingBooks = []; + const trainingDataFiles = []; + spyOn(component.done, 'emit'); + + component.userSelectedTranslateBooks = translateBooks; + component.userSelectedTrainingBooks = trainingBooks; + component['draftSourceProjectIds'] = { draftingSourceId: 'sourceProject', trainingSourceId: 'sourceProject' }; + clickConfirmLanguages(fixture); + fixture.detectChanges(); + component.tryAdvanceStep(); + fixture.detectChanges(); + component.tryAdvanceStep(); + fixture.detectChanges(); + component.tryAdvanceStep(); + fixture.detectChanges(); + expect(component.isStepsCompleted).toBe(true); + expect(component.done.emit).toHaveBeenCalledWith({ + trainingDataFiles, + trainingScriptureRanges: [], + translationScriptureRange: 'GEN;EXO', + fastTraining: false + } as DraftGenerationStepsResult); })); it('should update training books when a step changes', fakeAsync(() => { @@ -358,6 +446,192 @@ describe('DraftGenerationStepsComponent', () => { })); }); + describe('additional training source project', () => { + beforeEach(fakeAsync(() => { + const mockTargetProjectDoc = { + data: createTestProjectProfile({ + texts: [{ bookNum: 1 }, { bookNum: 2 }, { bookNum: 3 }, { bookNum: 6 }, { bookNum: 7 }], + translateConfig: { + source: { projectRef: 'sourceProject', writingSystem: { tag: 'xyz' }, paratextId: 'sourcePT1' }, + draftConfig: { + additionalTrainingSourceEnabled: true, + additionalTrainingSource: { + projectRef: 'sourceProject2', + writingSystem: { tag: 'xyz' }, + paratextId: 'sourcePT2' + } + } + } + }) + } as SFProjectProfileDoc; + when(mockActivatedProjectService.projectDoc).thenReturn(mockTargetProjectDoc); + const targetProjectDoc$ = new BehaviorSubject(mockTargetProjectDoc); + when(mockActivatedProjectService.projectDoc$).thenReturn(targetProjectDoc$); + when(mockUserService.getCurrentUser()).thenResolve(mockUserDoc); + when(mockFeatureFlagService.allowFastTraining).thenReturn(createTestFeatureFlag(false)); + when(mockProjectService.getProfile('sourceProject')).thenResolve(mockSourceNonNllbProjectDoc); + when(mockProjectService.getProfile('sourceProject2')).thenResolve(mockAdditionalTrainingSourceProjectDoc); + when(mockNllbLanguageService.isNllbLanguageAsync(anything())).thenResolve(true); + when(mockNllbLanguageService.isNllbLanguageAsync('xyz')).thenResolve(false); + when(mockTrainingDataService.queryTrainingDataAsync(anything())).thenResolve(instance(mockTrainingDataQuery)); + when(mockTrainingDataQuery.docs).thenReturn([]); + + fixture = TestBed.createComponent(DraftGenerationStepsComponent); + component = fixture.componentInstance; + tick(); + fixture.detectChanges(); + })); + + it('should show and hide selectable training source books when training books selected', () => { + const trainingBooks = [3]; + const trainingDataFiles: string[] = []; + const translationBooks = [1, 2]; + + component.userSelectedTrainingBooks = []; + component.userSelectedTranslateBooks = translationBooks; + component.selectedTrainingDataIds = trainingDataFiles; + component.userSelectedSourceTrainingBooks = []; + component.userSelectedAdditionalSourceTrainingBooks = []; + component['availableAdditionalTrainingBooks'] = trainingBooks; + component['draftSourceProjectIds'] = { + draftingSourceId: 'sourceProject', + trainingSourceId: 'sourceProject', + trainingAdditionalSourceId: 'sourceProject2' + }; + component.onStepChange(); + fixture.detectChanges(); + expect(component.availableTrainingBooks).toEqual(trainingBooks); + expect(component.selectableSourceTrainingBooks).toEqual([]); + expect(component.selectableAdditionalSourceTrainingBooks).toEqual([]); + expect(fixture.nativeElement.querySelector('.books-appear-notice')).not.toBeNull(); + + // select a training book + component.onTrainingBookSelect(trainingBooks); + fixture.detectChanges(); + expect(component.selectableSourceTrainingBooks).toEqual(trainingBooks); + expect(component.selectableAdditionalSourceTrainingBooks).toEqual(trainingBooks); + expect(component.userSelectedSourceTrainingBooks).toEqual(trainingBooks); + expect(component.userSelectedAdditionalSourceTrainingBooks).toEqual(trainingBooks); + expect(fixture.nativeElement.querySelector('.books-appear-notice')).toBeNull(); + + // deselect all training books + component.onTrainingBookSelect([]); + fixture.detectChanges(); + expect(component.selectableSourceTrainingBooks).toEqual([]); + expect(component.selectableAdditionalSourceTrainingBooks).toEqual([]); + expect(component.userSelectedSourceTrainingBooks).toEqual([]); + expect(component.userSelectedAdditionalSourceTrainingBooks).toEqual([]); + expect(fixture.nativeElement.querySelector('.books-appear-notice')).not.toBeNull(); + }); + + it('should correctly emit the selected books when done', fakeAsync(() => { + const trainingBooks = [3]; + const trainingDataFiles: string[] = []; + const translationBooks = [1, 2]; + + component.userSelectedTrainingBooks = trainingBooks; + component.userSelectedTranslateBooks = translationBooks; + component.selectedTrainingDataIds = trainingDataFiles; + component.userSelectedSourceTrainingBooks = trainingBooks; + component.userSelectedAdditionalSourceTrainingBooks = trainingBooks; + component['draftSourceProjectIds'] = { + draftingSourceId: 'sourceProject', + trainingSourceId: 'sourceProject', + trainingAdditionalSourceId: 'sourceProject2' + }; + + spyOn(component.done, 'emit'); + fixture.detectChanges(); + clickConfirmLanguages(fixture); + expect(component.isStepsCompleted).toBe(false); + // Advance to the next step when at last step should emit books result + fixture.detectChanges(); + component.tryAdvanceStep(); + fixture.detectChanges(); + component.tryAdvanceStep(); + fixture.detectChanges(); + component.tryAdvanceStep(); + fixture.detectChanges(); + + expect(component.done.emit).toHaveBeenCalledWith({ + trainingDataFiles, + trainingScriptureRanges: [ + { projectId: 'sourceProject', scriptureRange: 'LEV' }, + { projectId: 'sourceProject2', scriptureRange: 'LEV' } + ], + translationScriptureRange: 'GEN;EXO', + fastTraining: false + } as DraftGenerationStepsResult); + expect(component.isStepsCompleted).toBe(true); + })); + + it('does not allow selecting not selectable additional source training books', () => { + const trainingBooks = [3]; + const trainingDataFiles: string[] = []; + const translationBooks = [1, 2]; + + component.userSelectedTrainingBooks = trainingBooks; + component.userSelectedTranslateBooks = translationBooks; + component.selectedTrainingDataIds = trainingDataFiles; + component['draftSourceProjectIds'] = { + draftingSourceId: 'sourceProject', + trainingSourceId: 'sourceProject', + trainingAdditionalSourceId: 'sourceProject2' + }; + component.onStepChange(); + expect(component.availableTrainingBooks).toEqual(trainingBooks); + expect(component.selectableSourceTrainingBooks).toEqual(trainingBooks); + expect(component.userSelectedSourceTrainingBooks).toEqual(trainingBooks); + expect(component.selectableAdditionalSourceTrainingBooks).toEqual(trainingBooks); + expect(component.userSelectedAdditionalSourceTrainingBooks).toEqual(trainingBooks); + + component.onAdditionalSourceTrainingBookSelect([2, 3]); + fixture.detectChanges(); + expect(component.userSelectedAdditionalSourceTrainingBooks).toEqual(trainingBooks); + }); + + it('should allow advancing if one source has no books selected', () => { + const trainingBooks = [3]; + const trainingDataFiles: string[] = []; + const translationBooks = [1, 2]; + + component.userSelectedTrainingBooks = trainingBooks; + component.userSelectedTranslateBooks = translationBooks; + component.selectedTrainingDataIds = trainingDataFiles; + component.userSelectedSourceTrainingBooks = trainingBooks; + component.userSelectedAdditionalSourceTrainingBooks = trainingBooks; + component['draftSourceProjectIds'] = { + draftingSourceId: 'sourceProject', + trainingSourceId: 'sourceProject', + trainingAdditionalSourceId: 'sourceProject2' + }; + + spyOn(component.done, 'emit'); + fixture.detectChanges(); + clickConfirmLanguages(fixture); + expect(component.isStepsCompleted).toBe(false); + // Advance to the next step when at last step should emit books result + fixture.detectChanges(); + component.tryAdvanceStep(); + fixture.detectChanges(); + component.tryAdvanceStep(); + fixture.detectChanges(); + + component.onSourceTrainingBookSelect([]); + fixture.detectChanges(); + component.tryAdvanceStep(); + fixture.detectChanges(); + + expect(component.done.emit).toHaveBeenCalledWith({ + trainingDataFiles, + trainingScriptureRanges: [{ projectId: 'sourceProject2', scriptureRange: 'LEV' }], + translationScriptureRange: 'GEN;EXO', + fastTraining: false + } as DraftGenerationStepsResult); + expect(component.isStepsCompleted).toBe(true); + }); + }); + describe('allow fast training feature flag is enabled', () => { beforeEach(fakeAsync(() => { when(mockActivatedProjectService.projectDoc).thenReturn(mockTargetProjectDoc); @@ -381,6 +655,8 @@ describe('DraftGenerationStepsComponent', () => { component.userSelectedTrainingBooks = trainingBooks; component.userSelectedTranslateBooks = translationBooks; component.selectedTrainingDataIds = trainingDataFiles; + component.userSelectedSourceTrainingBooks = trainingBooks; + component['draftSourceProjectIds'] = { draftingSourceId: 'sourceProject', trainingSourceId: 'sourceProject' }; spyOn(component.done, 'emit'); @@ -402,11 +678,9 @@ describe('DraftGenerationStepsComponent', () => { fixture.detectChanges(); expect(component.done.emit).toHaveBeenCalledWith({ - trainingBooks, trainingDataFiles, - trainingScriptureRanges: [], - translationBooks, - translationScriptureRanges: [], + trainingScriptureRanges: [{ projectId: 'sourceProject', scriptureRange: 'GEN;EXO' }], + translationScriptureRange: 'LEV;NUM', fastTraining: true } as DraftGenerationStepsResult); expect(generateDraftButton['disabled']).toBe(true); @@ -420,9 +694,9 @@ describe('DraftGenerationStepsComponent', () => { translateConfig: { source: { projectRef: 'test' }, draftConfig: { - lastSelectedTrainingBooks: [2, 3, 4], lastSelectedTrainingDataFiles: [], - lastSelectedTranslationBooks: [2, 3, 4] + lastSelectedTranslationScriptureRange: 'GEN;EXO', + lastSelectedTrainingScriptureRanges: [{ projectId: 'test', scriptureRange: 'LEV' }] } } }) @@ -441,9 +715,9 @@ describe('DraftGenerationStepsComponent', () => { tick(); })); - it('should restore previously selected books', () => { - expect(component.initialSelectedTrainingBooks).toEqual([2, 3]); - expect(component.initialSelectedTranslateBooks).toEqual([2, 3]); + it('should restore previously selected ranges', () => { + expect(component.initialSelectedTrainingBooks).toEqual([3]); + expect(component.initialSelectedTranslateBooks).toEqual([1, 2]); }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts index f549871f50..8dd14cdde9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.ts @@ -4,6 +4,7 @@ import { translate, TranslocoModule } from '@ngneat/transloco'; import { Canon } from '@sillsdev/scripture'; import { TranslocoMarkupModule } from 'ngx-transloco-markup'; import { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; +import { ProjectScriptureRange } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; import { merge, Subscription } from 'rxjs'; import { filter, tap } from 'rxjs/operators'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; @@ -18,22 +19,20 @@ import { filterNullish } from 'xforge-common/util/rxjs-util'; import { TrainingDataDoc } from '../../../core/models/training-data-doc'; import { BookMultiSelectComponent } from '../../../shared/book-multi-select/book-multi-select.component'; import { SharedModule } from '../../../shared/shared.module'; +import { booksFromScriptureRange, projectLabel } from '../../../shared/utils'; import { NllbLanguageService } from '../../nllb-language.service'; import { ConfirmSourcesComponent } from '../confirm-sources/confirm-sources.component'; -import { ProjectScriptureRange } from '../draft-generation'; -import { DraftSource, DraftSourcesService } from '../draft-sources.service'; +import { DraftSource, DraftSourceIds, DraftSourcesService } from '../draft-sources.service'; import { TrainingDataMultiSelectComponent } from '../training-data/training-data-multi-select.component'; import { TrainingDataUploadDialogComponent } from '../training-data/training-data-upload-dialog.component'; import { TrainingDataService } from '../training-data/training-data.service'; export interface DraftGenerationStepsResult { - trainingBooks: number[]; trainingDataFiles: string[]; trainingScriptureRange?: string; trainingScriptureRanges: ProjectScriptureRange[]; - translationBooks: number[]; translationScriptureRange?: string; - translationScriptureRanges: ProjectScriptureRange[]; + translationScriptureRanges?: ProjectScriptureRange[]; fastTraining: boolean; } @@ -60,6 +59,8 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem availableTranslateBooks?: number[] = undefined; availableTrainingBooks: number[] = []; + selectableSourceTrainingBooks: number[] = []; + selectableAdditionalSourceTrainingBooks: number[] = []; availableTrainingData: Readonly[] = []; // Unusable books do not exist in the target or corresponding drafting/training source project @@ -72,14 +73,14 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem initialSelectedTranslateBooks: number[] = []; userSelectedTrainingBooks: number[] = []; userSelectedTranslateBooks: number[] = []; + userSelectedSourceTrainingBooks: number[] = []; + userSelectedAdditionalSourceTrainingBooks: number[] = []; selectedTrainingDataIds: string[] = []; - // When translate books are selected, they will be filtered out from this list - initialAvailableTrainingBooks: number[] = []; - draftingSourceProjectName?: string; trainingSourceProjectName?: string; + trainingAdditionalSourceProjectName?: string; targetProjectName?: string; showBookSelectionError = false; @@ -95,6 +96,10 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem protected languagesVerified = false; protected nextClickedOnLanguageVerification = false; + // When translate books are selected, they will be filtered out from this list + private initialAvailableTrainingBooks: number[] = []; + private availableAdditionalTrainingBooks: number[] = []; + private draftSourceProjectIds?: DraftSourceIds; private trainingDataQuery?: RealtimeQuery; private trainingDataSub?: Subscription; @@ -111,16 +116,32 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem super(); } + get trainingSourceBooksSelected(): boolean { + return this.userSelectedSourceTrainingBooks.length > 0 || this.userSelectedAdditionalSourceTrainingBooks.length > 0; + } + ngOnInit(): void { this.subscribe( this.draftSourcesService.getDraftProjectSources().pipe( - filter(({ target, source, alternateSource, alternateTrainingSource }) => { - this.setProjectDisplayNames(target, alternateSource ?? source, alternateTrainingSource); + filter(({ target, source, alternateSource, alternateTrainingSource, additionalTrainingSource }) => { + this.setProjectDisplayNames( + target, + alternateSource ?? source, + alternateTrainingSource, + additionalTrainingSource + ); return target != null && source != null; }) ), // Build book lists - async ({ target, source, alternateSource, alternateTrainingSource }) => { + async ({ + target, + source, + alternateSource, + alternateTrainingSource, + additionalTrainingSource, + draftSourceIds + }) => { // The null values will have been filtered above target = target!; // Use the alternate source if specified, otherwise use the source @@ -132,21 +153,20 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem (await this.nllbLanguageService.isNllbLanguageAsync(draftingSource.writingSystem.tag)); const draftingSourceBooks = new Set(); - let trainingSourceBooks = new Set(); - for (const text of draftingSource.texts) { draftingSourceBooks.add(text.bookNum); } - if (alternateTrainingSource != null) { - for (const text of alternateTrainingSource.texts) { - trainingSourceBooks.add(text.bookNum); - } - } else { - // If no training source project, use drafting source project books - trainingSourceBooks = draftingSourceBooks; - } + let trainingSourceBooks: Set = + alternateTrainingSource != null + ? new Set(alternateTrainingSource.texts.map(t => t.bookNum)) + : draftingSourceBooks; + let additionalTrainingSourceBooks: Set | undefined = + additionalTrainingSource != null + ? new Set(additionalTrainingSource?.texts.map(t => t.bookNum)) + : undefined; + this.draftSourceProjectIds = draftSourceIds; this.availableTranslateBooks = []; // If book exists in both target and source, add to available books. @@ -175,6 +195,9 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem } else { this.unusableTrainingSourceBooks.push(bookNum); } + if (additionalTrainingSourceBooks != null && additionalTrainingSourceBooks.has(bookNum)) { + this.availableAdditionalTrainingBooks.push(bookNum); + } } // Store initially available training books that will be filtered to remove user selected translate books @@ -201,7 +224,6 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem // Query for all training data files in the project this.trainingDataQuery?.dispose(); this.trainingDataQuery = await this.trainingDataService.queryTrainingDataAsync(projectDoc.id); - let projectChanged: boolean = true; // Subscribe to this query, and show these @@ -232,7 +254,27 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem } onTrainingBookSelect(selectedBooks: number[]): void { - this.userSelectedTrainingBooks = selectedBooks; + const newBookSelections: number[] = selectedBooks.filter(b => !this.userSelectedTrainingBooks.includes(b)); + this.userSelectedTrainingBooks = [...selectedBooks]; + this.selectableSourceTrainingBooks = [...selectedBooks]; + this.selectableAdditionalSourceTrainingBooks = this.availableAdditionalTrainingBooks.filter(b => + selectedBooks.includes(b) + ); + + // remove selected books that are no longer selectable + this.userSelectedSourceTrainingBooks = this.userSelectedSourceTrainingBooks.filter(b => selectedBooks.includes(b)); + this.userSelectedAdditionalSourceTrainingBooks = this.userSelectedAdditionalSourceTrainingBooks.filter(b => + selectedBooks.includes(b) + ); + + // automatically select books that are newly selected as training books + for (const bookNum of newBookSelections) { + this.userSelectedSourceTrainingBooks.push(bookNum); + if (this.selectableAdditionalSourceTrainingBooks.includes(bookNum)) { + this.userSelectedAdditionalSourceTrainingBooks.push(bookNum); + } + } + this.clearErrorMessage(); } @@ -241,6 +283,18 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem this.clearErrorMessage(); } + onSourceTrainingBookSelect(selectedBooks: number[]): void { + this.userSelectedSourceTrainingBooks = this.selectableSourceTrainingBooks.filter(b => selectedBooks.includes(b)); + this.clearErrorMessage(); + } + + onAdditionalSourceTrainingBookSelect(selectedBooks: number[]): void { + this.userSelectedAdditionalSourceTrainingBooks = this.selectableAdditionalSourceTrainingBooks.filter(b => + selectedBooks.includes(b) + ); + this.clearErrorMessage(); + } + onTranslateBookSelect(selectedBooks: number[]): void { this.userSelectedTranslateBooks = selectedBooks; this.clearErrorMessage(); @@ -264,12 +318,32 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem return; } this.isStepsCompleted = true; + const trainingScriptureRange: ProjectScriptureRange | undefined = + this.userSelectedSourceTrainingBooks.length > 0 + ? this.convertToScriptureRange( + this.draftSourceProjectIds!.trainingAlternateSourceId ?? this.draftSourceProjectIds!.trainingSourceId, + this.userSelectedSourceTrainingBooks + ) + : undefined; + + const trainingScriptureRanges: ProjectScriptureRange[] = []; + if (trainingScriptureRange != null) { + trainingScriptureRanges.push(trainingScriptureRange); + } + // Use the additional training range if selected + const useAdditionalTranslateRange: boolean = this.userSelectedAdditionalSourceTrainingBooks.length > 0; + if (useAdditionalTranslateRange) { + trainingScriptureRanges.push( + this.convertToScriptureRange( + this.draftSourceProjectIds!.trainingAdditionalSourceId, + this.userSelectedAdditionalSourceTrainingBooks + ) + ); + } this.done.emit({ - trainingBooks: this.userSelectedTrainingBooks, - trainingScriptureRanges: [], + trainingScriptureRanges, trainingDataFiles: this.selectedTrainingDataIds, - translationBooks: this.userSelectedTranslateBooks, - translationScriptureRanges: [], + translationScriptureRange: this.userSelectedTranslateBooks.map(b => Canon.bookNumberToId(b)).join(';'), fastTraining: this.fastTraining }); } @@ -277,7 +351,7 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem /** * Filter selected translate books from available/selected training books. - * Currently, training books cannot in the set of translate books, + * Currently, training books cannot be in the set of translate books, * but this requirement may be removed in the future. */ updateTrainingBooks(): void { @@ -292,13 +366,25 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem ); this.initialSelectedTrainingBooks = newSelectedTrainingBooks; - this.userSelectedTrainingBooks = newSelectedTrainingBooks; + this.userSelectedTrainingBooks = [...newSelectedTrainingBooks]; + this.selectableSourceTrainingBooks = [...newSelectedTrainingBooks]; + this.userSelectedSourceTrainingBooks = [...newSelectedTrainingBooks]; + this.selectableAdditionalSourceTrainingBooks = this.availableAdditionalTrainingBooks.filter(b => + newSelectedTrainingBooks.includes(b) + ); + this.userSelectedAdditionalSourceTrainingBooks = this.selectableAdditionalSourceTrainingBooks.filter(b => + newSelectedTrainingBooks.includes(b) + ); } bookNames(books: number[]): string { return this.i18n.enumerateList(books.map(bookNum => this.i18n.localizeBook(bookNum))); } + private convertToScriptureRange(projectId: string, books: number[]): ProjectScriptureRange { + return { projectId: projectId, scriptureRange: books.map(b => Canon.bookNumberToId(b)).join(';') }; + } + private validateCurrentStep(): boolean { const isValid = this.stepper.selected?.completed!; this.showBookSelectionError = !isValid; @@ -311,30 +397,43 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem private setInitialTranslateBooks(availableBooks: number[]): void { // Get the previously selected translation books from the target project - const previousBooks: Set = new Set( - this.activatedProject.projectDoc?.data?.translateConfig.draftConfig.lastSelectedTranslationBooks ?? [] - ); + const previousTranslationRange: string = + this.activatedProject.projectDoc?.data?.translateConfig.draftConfig.lastSelectedTranslationScriptureRange ?? ''; + const previousBooks: Set = new Set(booksFromScriptureRange(previousTranslationRange)); // The intersection is all of the available books in the source project that match the target's previous books - const intersection = availableBooks.filter(bookNum => previousBooks.has(bookNum)); + const intersection: number[] = availableBooks.filter(bookNum => previousBooks.has(bookNum)); // Set the selected books to the intersection, or if the intersection is empty, do not select any this.initialSelectedTranslateBooks = intersection.length > 0 ? intersection : []; - this.userSelectedTranslateBooks = this.initialSelectedTranslateBooks; + this.userSelectedTranslateBooks = [...this.initialSelectedTranslateBooks]; } private setInitialTrainingBooks(availableBooks: number[]): void { // Get the previously selected training books from the target project - const previousBooks: Set = new Set( - this.activatedProject.projectDoc?.data?.translateConfig.draftConfig.lastSelectedTrainingBooks ?? [] - ); + const trainingSourceId = + this.draftSourceProjectIds?.trainingAlternateSourceId ?? this.draftSourceProjectIds?.trainingSourceId; + let previousTrainingRange: string = + this.activatedProject.projectDoc?.data?.translateConfig.draftConfig.lastSelectedTrainingScriptureRanges?.find( + r => r.projectId === trainingSourceId + )?.scriptureRange ?? ''; + const trainingScriptureRange: string | undefined = + this.activatedProject.projectDoc?.data?.translateConfig.draftConfig.lastSelectedTrainingScriptureRange; + if (previousTrainingRange === '' && trainingScriptureRange != null) { + previousTrainingRange = trainingScriptureRange; + } + const previousBooks: Set = new Set(booksFromScriptureRange(previousTrainingRange)); // The intersection is all of the available books in the source project that match the target's previous books - const intersection = availableBooks.filter(bookNum => previousBooks.has(bookNum)); + const intersection: number[] = availableBooks.filter(bookNum => previousBooks.has(bookNum)); // Set the selected books to the intersection, or if the intersection is empty, do not select any this.initialSelectedTrainingBooks = intersection.length > 0 ? intersection : []; - this.userSelectedTrainingBooks = this.initialSelectedTrainingBooks; + this.userSelectedTrainingBooks = [...this.initialSelectedTrainingBooks]; + this.userSelectedSourceTrainingBooks = [...this.initialSelectedTrainingBooks]; + this.userSelectedAdditionalSourceTrainingBooks = this.availableAdditionalTrainingBooks.filter(b => + this.initialSelectedTrainingBooks.includes(b) + ); } private setInitialTrainingDataFiles(availableDataFiles: string[]): void { @@ -355,12 +454,14 @@ export class DraftGenerationStepsComponent extends SubscriptionDisposable implem private setProjectDisplayNames( target: DraftSource | undefined, draftingSource: DraftSource | undefined, - trainingSource: DraftSource | undefined + trainingSource: DraftSource | undefined, + additionalTrainingSource: DraftSource | undefined ): void { - this.targetProjectName = target != null ? `${target.shortName} - ${target.name}` : ''; - this.draftingSourceProjectName = - draftingSource != null ? `${draftingSource.shortName} - ${draftingSource.name}` : ''; + this.targetProjectName = target != null ? projectLabel(target) : ''; + this.draftingSourceProjectName = draftingSource != null ? projectLabel(draftingSource) : ''; this.trainingSourceProjectName = - trainingSource != null ? `${trainingSource.shortName} - ${trainingSource.name}` : this.draftingSourceProjectName; + trainingSource != null ? projectLabel(trainingSource) : this.draftingSourceProjectName; + this.trainingAdditionalSourceProjectName = + additionalTrainingSource != null ? projectLabel(additionalTrainingSource) : ''; } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.spec.ts index c5b1e1efc4..0c89087bd0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.spec.ts @@ -185,7 +185,9 @@ describe('DraftGenerationComponent', () => { }, draftConfig: { lastSelectedTrainingBooks: preTranslate ? [1] : [], - lastSelectedTranslationBooks: preTranslate ? [2] : [] + lastSelectedTranslationBooks: preTranslate ? [2] : [], + lastSelectedTrainingScriptureRange: preTranslate ? 'GEN' : undefined, + lastSelectedTranslationScriptureRange: preTranslate ? 'EXO' : undefined } }, texts: [ @@ -1974,10 +1976,8 @@ describe('DraftGenerationComponent', () => { env.component.currentPage = 'steps'; env.component.startBuild({ - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false, projectId: projectId @@ -1986,10 +1986,8 @@ describe('DraftGenerationComponent', () => { expect(env.component.currentPage).toBe('steps'); expect(mockDraftGenerationService.startBuildOrGetActiveBuild).toHaveBeenCalledWith({ projectId: projectId, - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false }); @@ -2005,10 +2003,8 @@ describe('DraftGenerationComponent', () => { env.component.cancelDialogRef = instance(mockDialogRef); env.component.startBuild({ - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false, projectId: projectId @@ -2016,10 +2012,8 @@ describe('DraftGenerationComponent', () => { env.startedOrActiveBuild$.next({ ...buildDto, state: BuildStates.Queued }); expect(mockDraftGenerationService.startBuildOrGetActiveBuild).toHaveBeenCalledWith({ projectId: projectId, - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false }); @@ -2034,10 +2028,8 @@ describe('DraftGenerationComponent', () => { env.component.cancelDialogRef = instance(mockDialogRef); env.component.startBuild({ - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false, projectId: projectId @@ -2045,10 +2037,8 @@ describe('DraftGenerationComponent', () => { env.startedOrActiveBuild$.next({ ...buildDto, state: BuildStates.Pending }); expect(mockDraftGenerationService.startBuildOrGetActiveBuild).toHaveBeenCalledWith({ projectId: projectId, - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false }); @@ -2063,10 +2053,8 @@ describe('DraftGenerationComponent', () => { env.component.cancelDialogRef = instance(mockDialogRef); env.component.startBuild({ - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false, projectId: projectId @@ -2074,10 +2062,8 @@ describe('DraftGenerationComponent', () => { env.startedOrActiveBuild$.next({ ...buildDto, state: BuildStates.Active }); expect(mockDraftGenerationService.startBuildOrGetActiveBuild).toHaveBeenCalledWith({ projectId: projectId, - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false }); @@ -2093,10 +2079,8 @@ describe('DraftGenerationComponent', () => { env.component.cancelDialogRef = instance(mockDialogRef); env.component.startBuild({ - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false, projectId: projectId @@ -2104,10 +2088,8 @@ describe('DraftGenerationComponent', () => { env.startedOrActiveBuild$.next({ ...buildDto, state: BuildStates.Canceled }); expect(mockDraftGenerationService.startBuildOrGetActiveBuild).toHaveBeenCalledWith({ projectId: projectId, - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false }); @@ -2122,10 +2104,8 @@ describe('DraftGenerationComponent', () => { }); env.component.startBuild({ - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false, projectId: projectId @@ -2134,10 +2114,8 @@ describe('DraftGenerationComponent', () => { expect(mockDraftGenerationService.startBuildOrGetActiveBuild).toHaveBeenCalledWith({ projectId: projectId, - trainingBooks: [], trainingDataFiles: [], trainingScriptureRanges: [], - translationBooks: [], translationScriptureRanges: [], fastTraining: false }); @@ -2391,7 +2369,7 @@ describe('DraftGenerationComponent', () => { // Update the has draft flag for the project projectDoc.data!.texts[0].chapters[0].hasDraft = true; - projectDoc.data!.translateConfig.draftConfig.lastSelectedTranslationBooks = [1]; + projectDoc.data!.translateConfig.draftConfig.lastSelectedTranslationScriptureRange = 'GEN'; projectSubject.next(projectDoc); buildSubject.next({ ...buildDto, state: BuildStates.Completed }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.ts index ac43d299db..0ee7c57ae0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.ts @@ -452,13 +452,11 @@ export class DraftGenerationComponent extends DataLoadingComponent implements On onPreGenerationStepsComplete(result: DraftGenerationStepsResult): void { this.startBuild({ projectId: this.activatedProject.projectId!, - trainingBooks: result.trainingBooks, trainingDataFiles: result.trainingDataFiles, trainingScriptureRange: result.trainingScriptureRange, trainingScriptureRanges: result.trainingScriptureRanges, - translationBooks: result.translationBooks, translationScriptureRange: result.translationScriptureRange, - translationScriptureRanges: result.trainingScriptureRanges, + translationScriptureRanges: result.translationScriptureRanges, fastTraining: result.fastTraining }); } @@ -580,7 +578,7 @@ export class DraftGenerationComponent extends DataLoadingComponent implements On private hasStartedBuild(projectDoc: SFProjectProfileDoc): boolean { return ( projectDoc.data?.translateConfig.preTranslate === true && - projectDoc.data?.translateConfig.draftConfig.lastSelectedTranslationBooks.length > 0 + projectDoc.data?.translateConfig.draftConfig.lastSelectedTranslationScriptureRange != null ); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts index 1599ff8542..40ecd6e6f7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts @@ -41,10 +41,8 @@ describe('DraftGenerationService', () => { const projectId = 'testProjectId'; const buildConfig: BuildConfig = { projectId, - trainingBooks: [], trainingDataFiles: [], translationScriptureRanges: [], - translationBooks: [], trainingScriptureRanges: [], fastTraining: false }; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.ts index 8e08feafdb..2d501c20b9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.ts @@ -1,4 +1,5 @@ import { InjectionToken } from '@angular/core'; +import { ProjectScriptureRange } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; import { BuildStates } from '../../machine-api/build-states'; /** @@ -6,24 +7,14 @@ import { BuildStates } from '../../machine-api/build-states'; */ export interface BuildConfig { projectId: string; - trainingBooks: number[]; trainingDataFiles: string[]; trainingScriptureRange?: string; trainingScriptureRanges: ProjectScriptureRange[]; - translationBooks: number[]; translationScriptureRange?: string; translationScriptureRanges: ProjectScriptureRange[]; fastTraining: boolean; } -/** - * A per-project scripture range. - */ -export interface ProjectScriptureRange { - projectId: string; - scriptureRange: string; -} - /** * Dictionary of 'segmentRef -> segment text'. */ diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts index ae0b6ee85e..154c595739 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts @@ -11,7 +11,7 @@ import { UserService } from 'xforge-common/user.service'; import { environment } from '../../../environments/environment'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SFProjectService } from '../../core/sf-project.service'; -import { DraftSources, DraftSourcesService } from './draft-sources.service'; +import { DraftSource, DraftSources, DraftSourcesService } from './draft-sources.service'; describe('DraftSourcesService', () => { let service: DraftSourcesService; @@ -45,7 +45,14 @@ describe('DraftSourcesService', () => { source: undefined, alternateSource: undefined, alternateTrainingSource: undefined, - additionalTrainingSource: undefined + additionalTrainingSource: undefined, + draftSourceIds: { + draftingSourceId: undefined, + draftingAlternateSourceId: undefined, + trainingSourceId: undefined, + trainingAlternateSourceId: undefined, + trainingAdditionalSourceId: undefined + } } as DraftSources); done(); }); @@ -141,6 +148,13 @@ describe('DraftSourcesService', () => { tag: 'en_UK' }, noAccess: true + }, + draftSourceIds: { + draftingSourceId: 'source_project', + draftingAlternateSourceId: 'alternate_source_project', + trainingSourceId: 'source_project', + trainingAlternateSourceId: 'alternate_training_source_project', + trainingAdditionalSourceId: 'additional_training_source_project' } } as DraftSources); done(); @@ -242,7 +256,14 @@ describe('DraftSourcesService', () => { source: sourceProject, alternateSource: alternateSourceProject, alternateTrainingSource: alternateTrainingSourceProject, - additionalTrainingSource: additionalTrainingSourceProject + additionalTrainingSource: additionalTrainingSourceProject, + draftSourceIds: { + draftingSourceId: 'source_project', + draftingAlternateSourceId: 'alternate_source_project', + trainingSourceId: 'source_project', + trainingAlternateSourceId: 'alternate_training_source_project', + trainingAdditionalSourceId: 'additional_training_source_project' + } } as DraftSources); done(); }); @@ -271,13 +292,7 @@ describe('DraftSourcesService', () => { ); service.getDraftProjectSources().subscribe(result => { - expect(result).toEqual({ - target: targetProject, - source: undefined, - alternateSource: undefined, - alternateTrainingSource: undefined, - additionalTrainingSource: undefined - } as DraftSources); + expectTargetOnly(targetProject, result); done(); }); }); @@ -286,7 +301,7 @@ describe('DraftSourcesService', () => { const targetProject = createTestProjectProfile({ translateConfig: { draftConfig: { - alternateSourceEnabled: false + alternateSourceEnabled: true } } }); @@ -297,13 +312,7 @@ describe('DraftSourcesService', () => { ); service.getDraftProjectSources().subscribe(result => { - expect(result).toEqual({ - target: targetProject, - source: undefined, - alternateSource: undefined, - alternateTrainingSource: undefined, - additionalTrainingSource: undefined - } as DraftSources); + expectTargetOnly(targetProject, result); done(); }); }); @@ -331,13 +340,7 @@ describe('DraftSourcesService', () => { ); service.getDraftProjectSources().subscribe(result => { - expect(result).toEqual({ - target: targetProject, - source: undefined, - alternateSource: undefined, - alternateTrainingSource: undefined, - additionalTrainingSource: undefined - } as DraftSources); + expectTargetOnly(targetProject, result); done(); }); }); @@ -346,7 +349,7 @@ describe('DraftSourcesService', () => { const targetProject = createTestProjectProfile({ translateConfig: { draftConfig: { - alternateTrainingSourceEnabled: false + alternateTrainingSourceEnabled: true } } }); @@ -357,13 +360,7 @@ describe('DraftSourcesService', () => { ); service.getDraftProjectSources().subscribe(result => { - expect(result).toEqual({ - target: targetProject, - source: undefined, - alternateSource: undefined, - alternateTrainingSource: undefined, - additionalTrainingSource: undefined - } as DraftSources); + expectTargetOnly(targetProject, result); done(); }); }); @@ -391,13 +388,7 @@ describe('DraftSourcesService', () => { ); service.getDraftProjectSources().subscribe(result => { - expect(result).toEqual({ - target: targetProject, - source: undefined, - alternateSource: undefined, - alternateTrainingSource: undefined, - additionalTrainingSource: undefined - } as DraftSources); + expectTargetOnly(targetProject, result); done(); }); }); @@ -406,7 +397,7 @@ describe('DraftSourcesService', () => { const targetProject = createTestProjectProfile({ translateConfig: { draftConfig: { - additionalTrainingSourceEnabled: false + additionalTrainingSourceEnabled: true } } }); @@ -417,15 +408,26 @@ describe('DraftSourcesService', () => { ); service.getDraftProjectSources().subscribe(result => { - expect(result).toEqual({ - target: targetProject, - source: undefined, - alternateSource: undefined, - alternateTrainingSource: undefined, - additionalTrainingSource: undefined - } as DraftSources); + expectTargetOnly(targetProject, result); done(); }); }); }); + + function expectTargetOnly(targetProject: DraftSource, result: DraftSources): void { + expect(result).toEqual({ + target: targetProject, + source: undefined, + alternateSource: undefined, + alternateTrainingSource: undefined, + additionalTrainingSource: undefined, + draftSourceIds: { + draftingSourceId: undefined, + draftingAlternateSourceId: undefined, + trainingSourceId: undefined, + trainingAlternateSourceId: undefined, + trainingAdditionalSourceId: undefined + } + } as DraftSources); + } }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts index a208295acf..f99ecf2762 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts @@ -23,12 +23,22 @@ export interface DraftSource { interface DraftSourceDoc { data: DraftSource; } + +export interface DraftSourceIds { + trainingSourceId?: string; + trainingAlternateSourceId?: string; + trainingAdditionalSourceId?: string; + draftingSourceId?: string; + draftingAlternateSourceId?: string; +} + export interface DraftSources { target?: Readonly; source?: Readonly; alternateSource?: Readonly; alternateTrainingSource?: Readonly; additionalTrainingSource?: Readonly; + draftSourceIds?: DraftSourceIds; } @Injectable({ @@ -117,7 +127,14 @@ export class DraftSourcesService { source: sourceDoc?.data, alternateSource: alternateSourceDoc?.data, alternateTrainingSource: alternateTrainingSourceDoc?.data, - additionalTrainingSource: additionalTrainingSourceProjectDoc?.data + additionalTrainingSource: additionalTrainingSourceProjectDoc?.data, + draftSourceIds: { + trainingSourceId: sourceProjectId, + trainingAlternateSourceId: alternateTrainingSourceProjectId, + trainingAdditionalSourceId: additionalTrainingSourceProjectId, + draftingSourceId: sourceProjectId, + draftingAlternateSourceId: alternateSourceProjectId + } }; }) ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-menu.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-menu.service.spec.ts index 63b2347977..49afc448e9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-menu.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-menu.service.spec.ts @@ -266,7 +266,7 @@ class TestEnvironment { ], translateConfig: { preTranslate: true, - draftConfig: { lastSelectedTranslationBooks: [40], lastSelectedTrainingBooks: [41] } + draftConfig: { lastSelectedTranslationScriptureRange: 'MAT', lastSelectedTrainingScriptureRange: 'MRK' } }, userRoles: TestEnvironment.rolesByUser, biblicalTermsConfig: { biblicalTermsEnabled: true } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json index a802f9a6d4..a10446ef46 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json +++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json @@ -251,8 +251,11 @@ "next": "Next", "no_available_books": "You have no books available for drafting.", "overview": "Overview", + "reference_books": "Reference books", "these_source_books_cannot_be_used_for_training": "The following books cannot be used for training as they are not in the training source text ({{ trainingSourceProjectName }}).", "these_source_books_cannot_be_used_for_translating": "The following books cannot be translated as they are not in the drafting source text ({{ draftingSourceProjectName }}).", + "training_books_will_appear": "Training books will appear as you select books under translated books", + "translated_books": "Translated books", "unusable_target_books": "Can't find the book you're looking for? Be sure the book is created in Paratext, then sync your project." }, "draft_preview_books": {