From 9fa9db860726379961e676092437d002e9412462 Mon Sep 17 00:00:00 2001 From: Nateowami Date: Tue, 17 Dec 2024 15:28:46 -0500 Subject: [PATCH 1/9] Verify migrations have monotonically increasing version numbers (#2916) --- src/RealtimeServer/common/migration.ts | 18 ++++++++++++++++++ .../common/services/user-migrations.ts | 4 ++-- .../services/biblical-term-migrations.ts | 4 ++-- .../services/note-thread-migrations.ts | 6 +++--- .../services/question-migrations.ts | 4 ++-- .../services/sf-project-migrations.ts | 6 +++--- .../sf-project-user-config-migrations.ts | 6 +++--- .../services/text-audio-migrations.ts | 4 ++-- .../scriptureforge/services/text-migrations.ts | 4 ++-- .../services/training-data-migrations.ts | 4 ++-- 10 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/RealtimeServer/common/migration.ts b/src/RealtimeServer/common/migration.ts index 69c2be69ac..ae12c3f703 100644 --- a/src/RealtimeServer/common/migration.ts +++ b/src/RealtimeServer/common/migration.ts @@ -33,3 +33,21 @@ export abstract class DocMigration implements Migration { // do nothing } } + +/** + * Verifies that the specified migrations are have version numbers monotonically increasing by 1 and that the class + * names include the version number. Throws an error if any of the migrations violate this rule. Otherwise, returns the + * migrations. + */ +export function monotonicallyIncreasingMigrationList(migrations: MigrationConstructor[]): MigrationConstructor[] { + for (const [index, migration] of migrations.entries()) { + const expectedVersion = index + 1; + if (migration.VERSION !== expectedVersion) { + throw new Error(`Migration version mismatch: expected ${expectedVersion}, got ${migration.VERSION}`); + } + if (!migration.name.includes(migration.VERSION.toString())) { + throw new Error(`Migration class name must include the version number: ${migration.name}`); + } + } + return migrations; +} diff --git a/src/RealtimeServer/common/services/user-migrations.ts b/src/RealtimeServer/common/services/user-migrations.ts index 75f3be4fb2..d7945d9d6a 100644 --- a/src/RealtimeServer/common/services/user-migrations.ts +++ b/src/RealtimeServer/common/services/user-migrations.ts @@ -1,6 +1,6 @@ import { Doc, Op } from 'sharedb/lib/client'; import { submitMigrationOp } from '../../common/realtime-server'; -import { DocMigration, MigrationConstructor } from '../migration'; +import { DocMigration, MigrationConstructor, monotonicallyIncreasingMigrationList } from '../migration'; class UserMigration1 extends DocMigration { static readonly VERSION = 1; @@ -21,4 +21,4 @@ class UserMigration1 extends DocMigration { } } -export const USER_MIGRATIONS: MigrationConstructor[] = [UserMigration1]; +export const USER_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([UserMigration1]); diff --git a/src/RealtimeServer/scriptureforge/services/biblical-term-migrations.ts b/src/RealtimeServer/scriptureforge/services/biblical-term-migrations.ts index 08502e6074..253b1f395a 100644 --- a/src/RealtimeServer/scriptureforge/services/biblical-term-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/biblical-term-migrations.ts @@ -1,3 +1,3 @@ -import { MigrationConstructor } from '../../common/migration'; +import { MigrationConstructor, monotonicallyIncreasingMigrationList } from '../../common/migration'; -export const BIBLICAL_TERM_MIGRATIONS: MigrationConstructor[] = []; +export const BIBLICAL_TERM_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([]); diff --git a/src/RealtimeServer/scriptureforge/services/note-thread-migrations.ts b/src/RealtimeServer/scriptureforge/services/note-thread-migrations.ts index 11dc57bce9..2ec78c98f0 100644 --- a/src/RealtimeServer/scriptureforge/services/note-thread-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/note-thread-migrations.ts @@ -1,5 +1,5 @@ import { Doc, Op } from 'sharedb/lib/client'; -import { DocMigration, MigrationConstructor } from '../../common/migration'; +import { DocMigration, MigrationConstructor, monotonicallyIncreasingMigrationList } from '../../common/migration'; import { submitMigrationOp } from '../../common/realtime-server'; class NoteThreadMigration1 extends DocMigration { @@ -69,9 +69,9 @@ class NoteThreadMigration4 extends DocMigration { } } -export const NOTE_THREAD_MIGRATIONS: MigrationConstructor[] = [ +export const NOTE_THREAD_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([ NoteThreadMigration1, NoteThreadMigration2, NoteThreadMigration3, NoteThreadMigration4 -]; +]); diff --git a/src/RealtimeServer/scriptureforge/services/question-migrations.ts b/src/RealtimeServer/scriptureforge/services/question-migrations.ts index a25524f631..38d8835556 100644 --- a/src/RealtimeServer/scriptureforge/services/question-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/question-migrations.ts @@ -1,5 +1,5 @@ import { Doc, Op } from 'sharedb/lib/client'; -import { DocMigration, MigrationConstructor } from '../../common/migration'; +import { DocMigration, MigrationConstructor, monotonicallyIncreasingMigrationList } from '../../common/migration'; import { submitMigrationOp } from '../../common/realtime-server'; class QuestionMigration1 extends DocMigration { @@ -24,4 +24,4 @@ class QuestionMigration1 extends DocMigration { } } -export const QUESTION_MIGRATIONS: MigrationConstructor[] = [QuestionMigration1]; +export const QUESTION_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([QuestionMigration1]); diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts index 5754fa3ff6..3c01e7f705 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts @@ -1,5 +1,5 @@ import { Doc, Op } from 'sharedb/lib/client'; -import { DocMigration, MigrationConstructor } from '../../common/migration'; +import { DocMigration, MigrationConstructor, monotonicallyIncreasingMigrationList } from '../../common/migration'; import { Operation } from '../../common/models/project-rights'; import { submitMigrationOp } from '../../common/realtime-server'; import { NoteTag } from '../models/note-tag'; @@ -387,7 +387,7 @@ class SFProjectMigration21 extends DocMigration { } } -export const SF_PROJECT_MIGRATIONS: MigrationConstructor[] = [ +export const SF_PROJECT_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([ SFProjectMigration1, SFProjectMigration2, SFProjectMigration3, @@ -409,4 +409,4 @@ export const SF_PROJECT_MIGRATIONS: MigrationConstructor[] = [ SFProjectMigration19, SFProjectMigration20, SFProjectMigration21 -]; +]); diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-user-config-migrations.ts b/src/RealtimeServer/scriptureforge/services/sf-project-user-config-migrations.ts index ea7c5a67e4..9bf30fa447 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-user-config-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-user-config-migrations.ts @@ -1,5 +1,5 @@ import { Doc, ObjectDeleteOp, ObjectInsertOp } from 'sharedb/lib/client'; -import { DocMigration, MigrationConstructor } from '../../common/migration'; +import { DocMigration, MigrationConstructor, monotonicallyIncreasingMigrationList } from '../../common/migration'; import { submitMigrationOp } from '../../common/realtime-server'; class SFProjectUserConfigMigration1 extends DocMigration { @@ -85,7 +85,7 @@ class SFProjectUserConfigMigration7 extends DocMigration { } } -export const SF_PROJECT_USER_CONFIG_MIGRATIONS: MigrationConstructor[] = [ +export const SF_PROJECT_USER_CONFIG_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([ SFProjectUserConfigMigration1, SFProjectUserConfigMigration2, SFProjectUserConfigMigration3, @@ -93,4 +93,4 @@ export const SF_PROJECT_USER_CONFIG_MIGRATIONS: MigrationConstructor[] = [ SFProjectUserConfigMigration5, SFProjectUserConfigMigration6, SFProjectUserConfigMigration7 -]; +]); diff --git a/src/RealtimeServer/scriptureforge/services/text-audio-migrations.ts b/src/RealtimeServer/scriptureforge/services/text-audio-migrations.ts index 8ca62d192a..5d5a9af8ce 100644 --- a/src/RealtimeServer/scriptureforge/services/text-audio-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/text-audio-migrations.ts @@ -1,3 +1,3 @@ -import { MigrationConstructor } from '../../common/migration'; +import { MigrationConstructor, monotonicallyIncreasingMigrationList } from '../../common/migration'; -export const TEXT_AUDIO_MIGRATIONS: MigrationConstructor[] = []; +export const TEXT_AUDIO_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([]); diff --git a/src/RealtimeServer/scriptureforge/services/text-migrations.ts b/src/RealtimeServer/scriptureforge/services/text-migrations.ts index 68c665afa1..797d5122e4 100644 --- a/src/RealtimeServer/scriptureforge/services/text-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/text-migrations.ts @@ -1,3 +1,3 @@ -import { MigrationConstructor } from '../../common/migration'; +import { MigrationConstructor, monotonicallyIncreasingMigrationList } from '../../common/migration'; -export const TEXT_MIGRATIONS: MigrationConstructor[] = []; +export const TEXT_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([]); diff --git a/src/RealtimeServer/scriptureforge/services/training-data-migrations.ts b/src/RealtimeServer/scriptureforge/services/training-data-migrations.ts index 29fc0da510..2dcc2cf13d 100644 --- a/src/RealtimeServer/scriptureforge/services/training-data-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/training-data-migrations.ts @@ -1,3 +1,3 @@ -import { MigrationConstructor } from '../../common/migration'; +import { MigrationConstructor, monotonicallyIncreasingMigrationList } from '../../common/migration'; -export const TRAINING_DATA_MIGRATIONS: MigrationConstructor[] = []; +export const TRAINING_DATA_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([]); From 8046571b48cd0f7f9189629c117d9ae6aa1c04a6 Mon Sep 17 00:00:00 2001 From: josephmyers <43733878+josephmyers@users.noreply.github.com> Date: Thu, 19 Dec 2024 01:13:23 +0700 Subject: [PATCH 2/9] SF-3121 Upgrade old subscribe calls to use observers (#2920) --- .../remote-translation-engine.spec.ts | 52 +++++++++---------- .../xforge-common/subscription-disposable.ts | 2 +- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.spec.ts index 37bce66e94..fcef93337e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.spec.ts @@ -112,16 +112,16 @@ describe('RemoteTranslationEngine', () => { env.addBuildProgress(); let expectedStep = -1; - env.client.train().subscribe( - progress => { + env.client.train().subscribe({ + next: progress => { expectedStep++; expect(progress.percentCompleted).toEqual(expectedStep / 10); }, - () => {}, - () => { + error: () => {}, + complete: () => { expect(expectedStep).toEqual(10); } - ); + }); }); it('train with error while starting build', () => { @@ -130,10 +130,10 @@ describe('RemoteTranslationEngine', () => { throwError(() => new Error('Error while creating build.')) ); - env.client.train().subscribe( - () => {}, - err => expect(err.message).toEqual('Error while creating build.') - ); + env.client.train().subscribe({ + next: () => {}, + error: err => expect(err.message).toEqual('Error while creating build.') + }); }); it('train with 404 error during build', () => { @@ -143,10 +143,10 @@ describe('RemoteTranslationEngine', () => { throwError(() => new HttpErrorResponse({ status: 404, statusText: 'Not Found' })) ); - env.client.train().subscribe( - progress => expect(progress.percentCompleted).toEqual(0), - err => expect(err.message).toContain('404 Not Found') - ); + env.client.train().subscribe({ + next: progress => expect(progress.percentCompleted).toEqual(0), + error: err => expect(err.message).toContain('404 Not Found') + }); }); it('train with error during build', () => { @@ -168,10 +168,10 @@ describe('RemoteTranslationEngine', () => { }) ); - env.client.train().subscribe( - progress => expect(progress.percentCompleted).toEqual(0), - err => expect(err.message).toEqual('Error occurred during build: broken') - ); + env.client.train().subscribe({ + next: progress => expect(progress.percentCompleted).toEqual(0), + error: err => expect(err.message).toEqual('Error occurred during build: broken') + }); }); it('train segment executes successfully', async () => { @@ -345,10 +345,10 @@ describe('RemoteTranslationEngine', () => { throwError(() => new HttpErrorResponse({ status: 404 })); }); - env.client.listenForTrainingStatus().subscribe( - progress => throwError(() => new Error(`This should not be called. Progress: ${progress}`)), - err => throwError(() => err) - ); + env.client.listenForTrainingStatus().subscribe({ + next: progress => throwError(() => new Error(`This should not be called. Progress: ${progress}`)), + error: err => throwError(() => err) + }); expect(errorThrown).toBe(true); }); @@ -372,16 +372,16 @@ describe('RemoteTranslationEngine', () => { env.addBuildProgress(); let expectedStep = -1; - env.client.listenForTrainingStatus().subscribe( - progress => { + env.client.listenForTrainingStatus().subscribe({ + next: progress => { expectedStep++; expect(progress.percentCompleted).toEqual(expectedStep / 10); }, - () => {}, - () => { + error: () => {}, + complete: () => { expect(expectedStep).toEqual(10); } - ); + }); }); it('sends notice when getWordGraph has error', async function () { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/subscription-disposable.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/subscription-disposable.ts index 40fbfbdaa2..9b9ee2771b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/subscription-disposable.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/subscription-disposable.ts @@ -28,6 +28,6 @@ export abstract class SubscriptionDisposable implements OnDestroy { error?: (error: any) => void, complete?: () => void ): Subscription { - return observable.pipe(takeUntil(this.ngUnsubscribe)).subscribe(next, error, complete); + return observable.pipe(takeUntil(this.ngUnsubscribe)).subscribe({ next, error, complete }); } } From e3e072a2e0f3fa3932f4eb9e77c1947220a00985 Mon Sep 17 00:00:00 2001 From: josephmyers <43733878+josephmyers@users.noreply.github.com> Date: Thu, 19 Dec 2024 01:26:55 +0700 Subject: [PATCH 3/9] SF-3122 Use lambda in all throwError calls (#2919) --- .../src/app/machine-api/remote-translation-engine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.ts index 71e1cbed28..c1d3df66b1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.ts @@ -132,7 +132,7 @@ export class RemoteTranslationEngine implements InteractiveTranslationEngine { if (err.status === 404) { return of(undefined); } else { - return throwError(err); + return throwError(() => err); } }) ) @@ -156,7 +156,7 @@ export class RemoteTranslationEngine implements InteractiveTranslationEngine { if (err.status === 404) { return of({ confidence: 0.0, trainedSegmentCount: 0 }); } else { - return throwError(err); + return throwError(() => err); } }) ) From a13bb9491b1bbdfd28bc0baae6ae4989a921eb4e Mon Sep 17 00:00:00 2001 From: josephmyers <43733878+josephmyers@users.noreply.github.com> Date: Thu, 19 Dec 2024 02:45:05 +0700 Subject: [PATCH 4/9] SF-3114 Chapter audio only hides bottom bar if scripture text is shown (#2905) --- .../checking/checking/checking.component.html | 2 +- .../checking/checking/checking.component.ts | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.html index 8e9f118cc4..7250f2bac2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.html @@ -199,7 +199,7 @@

{{ t("filter_questions") }}

@if (projectDoc && totalVisibleQuestions() > 0) { -
+
@if (!isQuestionListPermanent) {
- + {{ 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": { From 0ac32bec5da9cac9d290a011475979678d045b2d Mon Sep 17 00:00:00 2001 From: josephmyers <43733878+josephmyers@users.noreply.github.com> Date: Thu, 19 Dec 2024 22:35:18 +0700 Subject: [PATCH 7/9] SF-3026 Force consistent date format for Nepali across platforms (#2901) By default, Chrome on Windows defaults to standard English formatting for Nepali dates, whereas Chrome on Android uses Nepali script and formatting (to an extent). This change supplies a custom date format that all platforms will use. It gets as close to the appropriate date/time format as possible with what's built into the standard API. The one difference/shortcoming of which I'm aware is that Nepal uses the Vikram Samvat calendar, which is not supported with the 'calendar' option. We could use the 'indian' calendar, but given that Android defaults to Nepali script and Gregorian calendar, it should be acceptable to do so here, as well. --- .../src/xforge-common/i18n.service.spec.ts | 3 +++ .../src/xforge-common/i18n.service.ts | 27 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts index a651db8570..30e98aa8c0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts @@ -119,6 +119,9 @@ describe('I18nService', () => { service.setLocale('az'); expect(service.formatDate(date)).toEqual('25.11.1991 17:28'); + + service.setLocale('npi'); + expect(service.formatDate(date)).toEqual('१९९१-११-२५, १७:२८'); }); it('should support including the timezone in the date', () => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts index b4f05f05c3..e0e096de45 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts @@ -70,7 +70,7 @@ const defaultLocale = locales.find(locale => locale.tags.some(canonicalTag => ca export class I18nService { static readonly locales: Locale[] = locales; - static dateFormats: { [key: string]: DateFormat } = { + static customDateFormats: { [key: string]: DateFormat } = { en: { month: 'short' }, 'en-GB': { month: 'short', hour12: true }, // Chrome formats az dates as en-US. This manual override is the format Firefox uses for az @@ -81,6 +81,29 @@ export class I18nService { } return s; }, + npi: (d: Date, options) => { + const o = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + numberingSystem: 'deva', + ...options + } as const; + + const parts = new Intl.DateTimeFormat('npi', o).formatToParts(d); + + // Build custom YYYY-MM-DD, HH:MM format + const year = parts.find(p => p.type === 'year')?.value; + const month = parts.find(p => p.type === 'month')?.value; + const day = parts.find(p => p.type === 'day')?.value; + const hour = parts.find(p => p.type === 'hour')?.value; + const minute = parts.find(p => p.type === 'minute')?.value; + + return `${year}-${month}-${day}, ${hour}:${minute}`; + }, [PseudoLocalization.locale.canonicalTag]: PseudoLocalization.dateFormat }; @@ -263,7 +286,7 @@ export class I18nService { formatDate(date: Date, options: { showTimeZone?: boolean } = {}): string { // fall back to en in the event the language code isn't valid - const format = I18nService.dateFormats[this.localeCode] || {}; + const format = I18nService.customDateFormats[this.localeCode] || {}; return typeof format === 'function' ? format(date, options) : date.toLocaleString( From c2d99189a3953df3a57409d3c17ff5c616005ae4 Mon Sep 17 00:00:00 2001 From: josephmyers <43733878+josephmyers@users.noreply.github.com> Date: Fri, 20 Dec 2024 00:52:17 +0700 Subject: [PATCH 8/9] SF-3120 Replace toPromise with lastValueFrom (#2918) --- .../import-questions-dialog.component.ts | 3 +- .../question-dialog.service.ts | 5 ++-- .../machine-api/remote-translation-engine.ts | 12 ++++---- .../src/app/project/project.component.ts | 4 +-- .../page-not-found.component.ts | 4 +-- .../translate/editor/editor.component.spec.ts | 30 ++++++++++++------- .../app/translate/editor/editor.component.ts | 27 +++++++++-------- .../src/xforge-common/dialog.service.ts | 4 +-- .../xforge-common/retrying-request.service.ts | 11 ++----- .../testing-retrying-request.service.ts | 4 +-- .../src/xforge-common/user.service.ts | 4 +-- 11 files changed, 58 insertions(+), 50 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts index a119a5053f..a2762d7df0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts @@ -6,6 +6,7 @@ import { TranslocoService } from '@ngneat/transloco'; import { Canon, VerseRef } from '@sillsdev/scripture'; import { Question } from 'realtime-server/lib/esm/scriptureforge/models/question'; import { fromVerseRef, toVerseRef } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; +import { lastValueFrom } from 'rxjs'; import { CsvService } from 'xforge-common/csv-service.service'; import { DialogService } from 'xforge-common/dialog.service'; import { ExternalUrlService } from 'xforge-common/external-url.service'; @@ -475,7 +476,7 @@ export class ImportQuestionsDialogComponent extends SubscriptionDisposable imple ImportQuestionsConfirmationDialogComponent, data ) as MatDialogRef; - (await dialogRef.afterClosed().toPromise())!.forEach( + (await lastValueFrom(dialogRef.afterClosed())).forEach( (checked, index) => (changesToConfirm[index].checked = checked) ); this.updateSelectAllCheckbox(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.ts index 1ad7eaa4eb..15e116e77d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.service.ts @@ -3,8 +3,9 @@ import { MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; import { TranslocoService } from '@ngneat/transloco'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; import { Question } from 'realtime-server/lib/esm/scriptureforge/models/question'; -import { SFProjectDomain, SF_PROJECT_RIGHTS } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; +import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; import { fromVerseRef } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; +import { lastValueFrom } from 'rxjs'; import { DialogService } from 'xforge-common/dialog.service'; import { FileType } from 'xforge-common/models/file-offline-data'; import { NoticeService } from 'xforge-common/notice.service'; @@ -38,7 +39,7 @@ export class QuestionDialogService { >; // ENHANCE: Put the audio upload logic into QuestionDialogComponent so we can detect if the upload // fails and notify the user without discarding the question. For example, see chapter-audio-dialog.component.ts. - const result: QuestionDialogResult | 'close' | undefined = await dialogRef.afterClosed().toPromise(); + const result: QuestionDialogResult | 'close' | undefined = await lastValueFrom(dialogRef.afterClosed()); if (result == null || result === 'close') { return questionDoc; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.ts index c1d3df66b1..9cc4666409 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/machine-api/remote-translation-engine.ts @@ -113,9 +113,9 @@ export class RemoteTranslationEngine implements InteractiveTranslationEngine { async trainSegment(sourceSegment: string, targetSegment: string, sentenceStart: boolean = true): Promise { const pairDto: SegmentPairDto = { sourceSegment, targetSegment, sentenceStart }; - await this.httpClient - .post(`translation/engines/project:${this.projectId}/actions/trainSegment`, pairDto) - .toPromise(); + await lastValueFrom( + this.httpClient.post(`translation/engines/project:${this.projectId}/actions/trainSegment`, pairDto) + ); } train(): Observable { @@ -126,8 +126,8 @@ export class RemoteTranslationEngine implements InteractiveTranslationEngine { } async startTraining(): Promise { - return this.createBuild(this.projectId) - .pipe( + return lastValueFrom( + this.createBuild(this.projectId).pipe( catchError(err => { if (err.status === 404) { return of(undefined); @@ -136,7 +136,7 @@ export class RemoteTranslationEngine implements InteractiveTranslationEngine { } }) ) - .toPromise(); + ); } listenForTrainingStatus(): Observable { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.ts index fd3488b5de..cb78ccbac8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/project/project.component.ts @@ -3,7 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Canon } from '@sillsdev/scripture'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { SFProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config'; -import { Observable } from 'rxjs'; +import { lastValueFrom, Observable } from 'rxjs'; import { distinctUntilChanged, filter, first, map } from 'rxjs/operators'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; @@ -138,7 +138,7 @@ export class ProjectComponent extends DataLoadingComponent implements OnInit { private async navigateToChecking(projectId: string, task: TaskType = 'checking'): Promise { const defaultCheckingLink: string[] = ['/projects', projectId, task]; - const link = await this.resumeCheckingService.checkingLink$.pipe(first()).toPromise(); + const link = await lastValueFrom(this.resumeCheckingService.checkingLink$.pipe(first())); this.router.navigate(link ?? defaultCheckingLink, { replaceUrl: true }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/page-not-found/page-not-found.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/page-not-found/page-not-found.component.ts index af85dcd38d..221a4d682e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/page-not-found/page-not-found.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/page-not-found/page-not-found.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { Router } from '@angular/router'; -import { timer } from 'rxjs'; +import { lastValueFrom, timer } from 'rxjs'; import { map, takeWhile } from 'rxjs/operators'; // All times in milliseconds @@ -20,7 +20,7 @@ export class PageNotFoundComponent { ); constructor(readonly router: Router) { - this.progress.toPromise().then(() => { + lastValueFrom(this.progress).then(() => { this.router.navigateByUrl('/projects'); }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts index 108cbabeeb..f5d9b8324f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts @@ -4,13 +4,14 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { Location } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { DebugElement, NgZone } from '@angular/core'; -import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, flush, tick } from '@angular/core/testing'; +import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; import { MatTooltipHarness } from '@angular/material/tooltip/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute, Params, Route, Router, RouterModule } from '@angular/router'; import { + createRange, InteractiveTranslatorFactory, LatinWordDetokenizer, LatinWordTokenizer, @@ -18,8 +19,7 @@ import { TranslationSources, WordAlignmentMatrix, WordGraph, - WordGraphArc, - createRange + WordGraphArc } from '@sillsdev/machine'; import { Canon, VerseRef } from '@sillsdev/scripture'; import { merge } from 'lodash-es'; @@ -38,29 +38,29 @@ import { Note, REATTACH_SEPARATOR } from 'realtime-server/lib/esm/scriptureforge import { NoteTag, SF_TAG_ICON } from 'realtime-server/lib/esm/scriptureforge/models/note-tag'; import { AssignedUsers, + getNoteThreadDocId, NoteConflictType, NoteStatus, NoteThread, - NoteType, - getNoteThreadDocId + NoteType } from 'realtime-server/lib/esm/scriptureforge/models/note-thread'; import { ParatextUserProfile } from 'realtime-server/lib/esm/scriptureforge/models/paratext-user-profile'; import { SFProject, SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; -import { SFProjectRole, isParatextRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; +import { isParatextRole, SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { createTestProject, createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { - SFProjectUserConfig, - getSFProjectUserConfigDocId + getSFProjectUserConfigDocId, + SFProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config'; import { TextAnchor } from 'realtime-server/lib/esm/scriptureforge/models/text-anchor'; import { TextType } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; import { fromVerseRef } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; import * as RichText from 'rich-text'; -import { BehaviorSubject, Observable, Subject, defer, firstValueFrom, of, take } from 'rxjs'; +import { BehaviorSubject, defer, firstValueFrom, Observable, of, Subject, take } from 'rxjs'; import { anything, capture, deepEqual, instance, mock, resetCalls, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { AuthService } from 'xforge-common/auth.service'; @@ -75,7 +75,7 @@ import { TestOnlineStatusModule } from 'xforge-common/test-online-status.module' import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; import { TestRealtimeModule } from 'xforge-common/test-realtime.module'; import { TestRealtimeService } from 'xforge-common/test-realtime.service'; -import { TestTranslocoModule, configureTestingModule } from 'xforge-common/test-utils'; +import { configureTestingModule, TestTranslocoModule } from 'xforge-common/test-utils'; import { UICommonModule } from 'xforge-common/ui-common.module'; import { UserService } from 'xforge-common/user.service'; import { isBlink } from 'xforge-common/utils'; @@ -204,6 +204,8 @@ describe('EditorComponent', () => { env.wait(); const dialogMessage = spyOn((env.component as any).dialogService, 'message').and.callThrough(); + env.setupDialogRef(); + const textDocId = new TextDocId('project02', 40, 1, 'target'); env.deleteText(textDocId.toString()); expect(dialogMessage).toHaveBeenCalledTimes(1); @@ -3666,6 +3668,8 @@ describe('EditorComponent', () => { // SUT const dialogMessage = spyOn((env.component as any).dialogService, 'openGenericDialog').and.callThrough(); + env.setupDialogRef(); + expect(env.copyrightBanner).not.toBeNull(); env.copyrightMoreInfo.nativeElement.click(); tick(); @@ -4736,6 +4740,12 @@ class TestEnvironment { this.addProjectUserConfig(user5Config as SFProjectUserConfig); } + setupDialogRef(): void { + const mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['afterClosed']); + mockDialogRef.afterClosed.and.returnValue(of(undefined)); + spyOn((this.component as any).dialogService.matDialog, 'open').and.returnValue(mockDialogRef); + } + getProjectUserConfigDoc(userId: string = 'user01'): SFProjectUserConfigDoc { return this.realtimeService.get( SFProjectUserConfigDoc.COLLECTION, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts index ed8276c727..3d940979b1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts @@ -39,14 +39,14 @@ import { EditorTabPersistData } from 'realtime-server/lib/esm/scriptureforge/mod import { Note } from 'realtime-server/lib/esm/scriptureforge/models/note'; import { BIBLICAL_TERM_TAG_ICON, NoteTag } from 'realtime-server/lib/esm/scriptureforge/models/note-tag'; import { + getNoteThreadDocId, NoteConflictType, NoteStatus, NoteThread, - NoteType, - getNoteThreadDocId + NoteType } from 'realtime-server/lib/esm/scriptureforge/models/note-thread'; import { ParatextUserProfile } from 'realtime-server/lib/esm/scriptureforge/models/paratext-user-profile'; -import { SFProjectDomain, SF_PROJECT_RIGHTS } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; +import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { TextAnchor } from 'realtime-server/lib/esm/scriptureforge/models/text-anchor'; import { TextType } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; @@ -56,16 +56,17 @@ import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/t import { fromVerseRef } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; import { DeltaOperation } from 'rich-text'; import { - BehaviorSubject, - Observable, - Subject, - Subscription, asyncScheduler, + BehaviorSubject, combineLatest, firstValueFrom, fromEvent, + lastValueFrom, merge, + Observable, of, + Subject, + Subscription, timer } from 'rxjs'; import { debounceTime, filter, first, map, repeat, retry, switchMap, take, tap, throttleTime } from 'rxjs/operators'; @@ -87,7 +88,7 @@ import { filterNullish } from 'xforge-common/util/rxjs-util'; import { browserLinks, getLinkHTML, isBlink, issuesEmailTemplate, objectId } from 'xforge-common/utils'; import { XFValidators } from 'xforge-common/xfvalidators'; import { environment } from '../../../environments/environment'; -import { NoteThreadDoc, NoteThreadIcon, defaultNoteThreadIcon } from '../../core/models/note-thread-doc'; +import { defaultNoteThreadIcon, NoteThreadDoc, NoteThreadIcon } from '../../core/models/note-thread-doc'; import { SFProjectDoc } from '../../core/models/sf-project-doc'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SF_DEFAULT_TRANSLATE_SHARE_ROLE } from '../../core/models/sf-project-role-info'; @@ -111,15 +112,15 @@ import { TextComponent } from '../../shared/text/text.component'; import { - RIGHT_TO_LEFT_MARK, - VERSE_REGEX, - XmlUtils, canInsertNote, formatFontSizeToRems, getUnsupportedTags, getVerseRefFromSegmentRef, + RIGHT_TO_LEFT_MARK, threadIdFromMouseEvent, - verseRefFromMouseEvent + VERSE_REGEX, + verseRefFromMouseEvent, + XmlUtils } from '../../shared/utils'; import { DraftGenerationService } from '../draft-generation/draft-generation.service'; import { EditorHistoryService } from './editor-history/editor-history.service'; @@ -1551,7 +1552,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, const currentVerseRef: VerseRef | undefined = this.commenterSelectedVerseRef; this.setNoteFabVisibility('hidden'); - const result: NoteDialogResult | undefined = await dialogRef.afterClosed().toPromise(); + const result: NoteDialogResult | undefined = await lastValueFrom(dialogRef.afterClosed()); if (result != null) { if (result.noteContent != null || result.status != null) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/dialog.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/dialog.service.ts index 0f9d9d0034..51ca855116 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/dialog.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/dialog.service.ts @@ -2,7 +2,7 @@ import { OverlayRef } from '@angular/cdk/overlay'; import { ComponentType } from '@angular/cdk/portal'; import { Injectable } from '@angular/core'; import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; -import { Observable } from 'rxjs'; +import { lastValueFrom, Observable } from 'rxjs'; import { hasObjectProp } from '../type-utils'; import { GenericDialogComponent, @@ -43,7 +43,7 @@ export class DialogService { return { dialogRef, - result: dialogRef.afterClosed().toPromise() + result: lastValueFrom(dialogRef.afterClosed()) }; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/retrying-request.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/retrying-request.service.ts index 253d4973f7..b33bec9499 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/retrying-request.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/retrying-request.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@angular/core'; -import { Observable, Subject } from 'rxjs'; +import { firstValueFrom, Observable, Subject } from 'rxjs'; import { filter, take } from 'rxjs/operators'; import { CONSOLE, ConsoleInterface } from './browser-globals'; import { CommandErrorCode, CommandService } from './command.service'; @@ -96,7 +96,7 @@ export class RetryingRequest { private async invoke(options: FetchOptions): Promise { while (!this.canceled && this.status !== 'complete') { - const online = await this.online$.pipe(take(1)).toPromise(); + const online = await firstValueFrom(this.online$); if (online !== true) { this.status = 'offline'; await this.uponOnline(); @@ -124,12 +124,7 @@ export class RetryingRequest { } private async uponOnline(): Promise { - await this.online$ - .pipe( - filter(isOnline => isOnline), - take(1) - ) - .toPromise(); + await firstValueFrom(this.online$.pipe(filter(isOnline => isOnline))); } private isNetworkError(error: unknown): boolean { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/testing-retrying-request.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/testing-retrying-request.service.ts index 7d48a6227a..d5b7d642a5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/testing-retrying-request.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/testing-retrying-request.service.ts @@ -1,4 +1,4 @@ -import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { BehaviorSubject, lastValueFrom, Observable, Subject } from 'rxjs'; import { ConsoleInterface } from './browser-globals'; import { FetchOptions, JsonRpcInvocable, RetryingRequest } from './retrying-request.service'; @@ -12,7 +12,7 @@ export class TestingRetryingRequestService { cancel$ = new Subject() ): RetryingRequest { const invocable = { - onlineInvoke: (_url: string, _method: string, _params: string) => invoke.toPromise() + onlineInvoke: (_url: string, _method: string, _params: string) => lastValueFrom(invoke) } as JsonRpcInvocable; const mockConsole = { log: () => {}, error: () => {} } as ConsoleInterface; return new RetryingRequest(invocable, online, cancel$, {} as FetchOptions, mockConsole); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user.service.ts index 1983607442..cffc8a2858 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/user.service.ts @@ -5,7 +5,7 @@ import { escapeRegExp } from 'lodash-es'; import merge from 'lodash-es/merge'; import { User } from 'realtime-server/lib/esm/common/models/user'; import { obj } from 'realtime-server/lib/esm/common/utils/obj-path'; -import { combineLatest, from, Observable } from 'rxjs'; +import { combineLatest, from, lastValueFrom, Observable } from 'rxjs'; import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; import { environment } from '../environments/environment'; import { AuthService } from './auth.service'; @@ -116,7 +116,7 @@ export class UserService { disableClose: isConfirmation, width: '280px' }) as MatDialogRef; - const result = await dialogRef.afterClosed().toPromise(); + const result = await lastValueFrom(dialogRef.afterClosed()); if (result != null && result !== 'close') { await currentUserDoc.submitJson0Op(op => { op.set(u => u.displayName, result.displayName); From 52020709da92ad20a1fe29d0abd364cff5570138 Mon Sep 17 00:00:00 2001 From: josephmyers <43733878+josephmyers@users.noreply.github.com> Date: Fri, 20 Dec 2024 01:02:33 +0700 Subject: [PATCH 9/9] SF-3070 Refresh the selected verse if the states are wrong (#2911) --- .../src/app/shared/text/text.component.ts | 19 ++++++++++++++++--- .../app/translate/editor/editor.component.ts | 7 +++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts index 00d76a33b8..11c7f09cc7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts @@ -18,7 +18,7 @@ import QuillCursors from 'quill-cursors'; import { AuthType, getAuthType } from 'realtime-server/lib/esm/common/models/user'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { TextAnchor } from 'realtime-server/lib/esm/scriptureforge/models/text-anchor'; -import { Subject, Subscription, fromEvent, timer } from 'rxjs'; +import { fromEvent, Subject, Subscription, timer } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { LocalPresence, Presence } from 'sharedb/lib/sharedb'; import tinyColor from 'tinycolor2'; @@ -36,11 +36,11 @@ import { SFProjectService } from '../../core/sf-project.service'; import { TextDocService } from '../../core/text-doc.service'; import { MultiCursorViewer } from '../../translate/editor/multi-viewer/multi-viewer.component'; import { - VERSE_REGEX, attributeFromMouseEvent, getBaseVerse, getVerseRefFromSegmentRef, - getVerseStrFromSegmentRef + getVerseStrFromSegmentRef, + VERSE_REGEX } from '../utils'; import { getAttributesAtPosition, registerScripture } from './quill-scripture'; import { Segment } from './segment'; @@ -738,6 +738,19 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } } + get commenterSelection(): RangeStatic[] { + const ret = []; + for (const segment of this.viewModel.segments) { + const range = segment[1]; + const formats = getAttributesAtPosition(this.editor, range.index); + if (formats['commenter-selection'] === true) { + ret.push(range); + } + } + + return ret; + } + toggleVerseSelection(verseRef: VerseRef): boolean { if (this.editor == null) return false; const verseSegments: string[] = this.filterSegments(this.getCompatibleSegments(verseRef)); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts index 3d940979b1..7ba518f6e1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts @@ -924,6 +924,13 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, this.syncScrollRequested$.next(); } + if (this.commenterSelectedVerseRef != null && this.target.commenterSelection.length === 0) { + // if we're here, the state hasn't been updated, and we need to re-toggle the selected verse + const correctVerseRef = this.commenterSelectedVerseRef; + this.commenterSelectedVerseRef = undefined; + this.toggleVerseRefElement(correctVerseRef); + } + if (delta != null && this.shouldNoteThreadsRespondToEdits) { // wait 20 ms so that note thread docs have time to receive the updated note positions setTimeout(() => {