From 21c6bc10204c00aea4e3e6c72481e71003d6df24 Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Mon, 8 Sep 2025 10:22:31 -0600 Subject: [PATCH 1/3] SF-3554 Create missing chapters when adding draft to a project --- .../Controllers/MachineApiController.cs | 42 +++++++++++++++++++ src/SIL.XForge.Scripture/Models/MachineApi.cs | 2 + .../Services/IMachineApiService.cs | 6 +++ .../Services/MachineApiService.cs | 22 ++++++++++ .../Services/MachineApiServiceTests.cs | 6 +++ 5 files changed, 78 insertions(+) diff --git a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs index 8a8cbc09b9..4201f2d48c 100644 --- a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs +++ b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs @@ -932,6 +932,48 @@ CancellationToken cancellationToken } } + /// + /// Gets the count of chapters that have TextDocument objects. + /// + /// The Scripture Forge project identifier. + /// The book number. + /// The cancellation token. + /// The chapter count was retrieved successfully. + /// You do not have permission to access this project. + /// The project does not exist. + /// The ML server is temporarily unavailable or unresponsive. + [HttpGet(MachineApi.GetChaptersForBook)] + public async Task> GetPretranslationChapterCountAsync( + string sfProjectId, + int bookNum, + CancellationToken cancellationToken + ) + { + try + { + int count = await _machineApiService.GetPretranslationChapterCountAsync( + _userAccessor.UserId, + sfProjectId, + bookNum, + cancellationToken + ); + return Ok(count); + } + catch (BrokenCircuitException e) + { + _exceptionHandler.ReportException(e); + return StatusCode(StatusCodes.Status503ServiceUnavailable, MachineApiUnavailable); + } + catch (DataNotFoundException) + { + return NotFound(); + } + catch (ForbiddenException) + { + return Forbid(); + } + } + /// /// Translates a segment of text into the top N results. /// diff --git a/src/SIL.XForge.Scripture/Models/MachineApi.cs b/src/SIL.XForge.Scripture/Models/MachineApi.cs index b1920afdf3..61d6493872 100644 --- a/src/SIL.XForge.Scripture/Models/MachineApi.cs +++ b/src/SIL.XForge.Scripture/Models/MachineApi.cs @@ -33,6 +33,8 @@ public static class MachineApi "translation/engines/project:{sfProjectId}/actions/preTranslate/{bookNum}_{chapterNum}/usx"; public const string GetLastCompletedPreTranslationBuild = "translation/engines/project:{sfProjectId}/actions/getLastCompletedPreTranslationBuild"; + public const string GetChaptersForBook = + "translation/engines/project:{sfProjectId}/actions/preTranslate/{bookNum}/chapters"; public static string GetBuildHref(string sfProjectId, string buildId) { diff --git a/src/SIL.XForge.Scripture/Services/IMachineApiService.cs b/src/SIL.XForge.Scripture/Services/IMachineApiService.cs index 951c4e2eda..61baf1b887 100644 --- a/src/SIL.XForge.Scripture/Services/IMachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/IMachineApiService.cs @@ -49,6 +49,12 @@ CancellationToken cancellationToken CancellationToken cancellationToken ); Task GetEngineAsync(string curUserId, string sfProjectId, CancellationToken cancellationToken); + Task GetPretranslationChapterCountAsync( + string curUserId, + string sfProjectId, + int bookNum, + CancellationToken cancellationToken + ); Task GetLastCompletedPreTranslationBuildAsync( string curUserId, string sfProjectId, diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index 6582c99108..f94e4f4301 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -1167,6 +1167,28 @@ CancellationToken cancellationToken } } + public async Task GetPretranslationChapterCountAsync( + string curUserId, + string sfProjectId, + int bookNum, + CancellationToken cancellationToken + ) + { + // Ensure that the user has permission + SFProject project = await EnsureProjectPermissionAsync( + curUserId, + sfProjectId, + isServalAdmin: false, + cancellationToken + ); + + IList textDocuments = await realtimeService + .QuerySnapshots() + .Where(t => t.Id.StartsWith($"{sfProjectId}:{Canon.BookNumberToId(bookNum)}")) + .ToListAsync(cancellationToken); + return textDocuments.Count; + } + public async Task IsLanguageSupportedAsync(string languageCode, CancellationToken cancellationToken) { LanguageInfo languageInfo = await translationEngineTypesClient.GetLanguageInfoAsync( diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index c9eaa94453..00ac7650cf 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -2708,6 +2708,12 @@ await env ); } + [Test] + public void GetPretranslationChapterCountAsync_Success() + { + // TODO: Gets the number of documents + } + [Test] public void GetWordGraphAsync_NoPermission() { From cd6d08a1058adaa841e776d0bb140e3bb49e8521 Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Tue, 9 Sep 2025 15:48:28 -0600 Subject: [PATCH 2/3] SF-3554 Create chapters when adding draft to project --- .../draft-apply-dialog.component.spec.ts | 10 +++- .../draft-apply-dialog.component.ts | 34 ++++++++--- .../draft-generation.service.ts | 17 ++++++ .../draft-preview-books.component.html | 2 +- .../draft-preview-books.component.spec.ts | 55 +++++++++++------- .../draft-preview-books.component.ts | 31 +++++----- .../Controllers/MachineApiController.cs | 17 +----- .../Models/TextDocument.cs | 6 ++ .../Services/IMachineApiService.cs | 2 +- .../Services/MachineApiService.cs | 6 +- .../Controllers/MachineApiControllerTests.cs | 43 ++++++++++++++ .../Services/MachineApiServiceTests.cs | 57 ++++++++++++++++++- 12 files changed, 215 insertions(+), 65 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts index cc50834085..995359320d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts @@ -11,6 +11,7 @@ import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; import { of } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { TestOnlineStatusModule } from 'xforge-common/test-online-status.module'; import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; @@ -22,11 +23,14 @@ import { TextDoc } from '../../../core/models/text-doc'; import { SFProjectService } from '../../../core/sf-project.service'; import { TextDocService } from '../../../core/text-doc.service'; import { CustomValidatorState } from '../../../shared/sfvalidators'; +import { DraftGenerationService } from '../draft-generation.service'; import { DraftApplyDialogComponent } from './draft-apply-dialog.component'; +const mockedActivatedProjectService = mock(ActivatedProjectService); const mockedUserProjectsService = mock(SFUserProjectsService); const mockedProjectService = mock(SFProjectService); const mockedUserService = mock(UserService); +const mockedDraftGenerationService = mock(DraftGenerationService); const mockedDialogRef = mock(MatDialogRef); const mockedTextDocService = mock(TextDocService); @@ -39,7 +43,7 @@ const ROUTES: Route[] = [{ path: 'projects', component: MockComponent }]; let env: TestEnvironment; -describe('DraftApplyDialogComponent', () => { +fdescribe('DraftApplyDialogComponent', () => { configureTestingModule(() => ({ imports: [ TestTranslocoModule, @@ -48,9 +52,11 @@ describe('DraftApplyDialogComponent', () => { TestOnlineStatusModule.forRoot() ], providers: [ + { provide: ActivatedProjectService, useMock: mockedActivatedProjectService }, { provide: SFUserProjectsService, useMock: mockedUserProjectsService }, { provide: SFProjectService, useMock: mockedProjectService }, { provide: UserService, useMock: mockedUserService }, + { provide: DraftGenerationService, useMock: mockedDraftGenerationService }, { provide: TextDocService, useMock: mockedTextDocService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: MatDialogRef, useMock: mockedDialogRef }, @@ -343,7 +349,9 @@ class TestEnvironment { const mockedTextDoc = { getNonEmptyVerses: (): string[] => ['verse_1_1', 'verse_1_2', 'verse_1_3'] } as TextDoc; + when(mockedActivatedProjectService.projectId$).thenReturn(of('project01')); when(mockedProjectService.getText(anything())).thenResolve(mockedTextDoc); when(mockedTextDocService.userHasGeneralEditRight(anything())).thenReturn(true); + when(mockedDraftGenerationService.getDraftChaptersForBook(anything(), anything())).thenReturn(of([1, 2])); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts index 940edb5c99..e984fbff03 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts @@ -1,18 +1,19 @@ import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { Component, DestroyRef, Inject, OnInit, ViewChild } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { TranslocoModule } from '@ngneat/transloco'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; -import { BehaviorSubject, map } from 'rxjs'; +import { BehaviorSubject, map, switchMap } from 'rxjs'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { I18nService } from 'xforge-common/i18n.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UICommonModule } from 'xforge-common/ui-common.module'; import { SFUserProjectsService } from 'xforge-common/user-projects.service'; import { UserService } from 'xforge-common/user.service'; -import { filterNullish } from 'xforge-common/util/rxjs-util'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { XForgeCommonModule } from 'xforge-common/xforge-common.module'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { TextDoc, TextDocId } from '../../../core/models/text-doc'; @@ -23,15 +24,16 @@ import { ProjectSelectComponent } from '../../../project-select/project-select.c import { CustomValidatorState as CustomErrorState, SFValidators } from '../../../shared/sfvalidators'; import { SharedModule } from '../../../shared/shared.module'; import { compareProjectsForSorting } from '../../../shared/utils'; +import { DraftGenerationService } from '../draft-generation.service'; export interface DraftApplyDialogResult { projectId: string; + chapters: number[]; } export interface DraftApplyDialogConfig { initialParatextId?: string; bookNum: number; - chapters: number[]; } @Component({ @@ -69,21 +71,25 @@ export class DraftApplyDialogComponent implements OnInit { bookName: this.bookName }) }; + isValid: boolean = false; // the project id to add the draft to private targetProjectId?: string; private paratextIdToProjectId: Map = new Map(); - isValid: boolean = false; + private chaptersWithDrafts: number[] = []; constructor( @Inject(MAT_DIALOG_DATA) private data: DraftApplyDialogConfig, @Inject(MatDialogRef) private dialogRef: MatDialogRef, private readonly userProjectsService: SFUserProjectsService, private readonly projectService: SFProjectService, + private readonly activatedProjectService: ActivatedProjectService, + private readonly draftGenerationService: DraftGenerationService, private readonly textDocService: TextDocService, readonly i18n: I18nService, private readonly userService: UserService, - private readonly onlineStatusService: OnlineStatusService + private readonly onlineStatusService: OnlineStatusService, + private readonly destroyRef: DestroyRef ) { this.targetProject$.pipe(filterNullish()).subscribe(async project => { const chapters: number = await this.chaptersWithTextAsync(project); @@ -149,6 +155,18 @@ export class DraftApplyDialogComponent implements OnInit { this._projects = projects; this.isLoading = false; }); + + this.activatedProjectService.projectId$ + .pipe( + quietTakeUntilDestroyed(this.destroyRef), + filterNullish(), + switchMap(projectId => { + return this.draftGenerationService.getDraftChaptersForBook(projectId, this.data.bookNum); + }) + ) + .subscribe(draftChapters => { + this.chaptersWithDrafts = draftChapters ?? []; + }); } addToProject(): void { @@ -157,7 +175,7 @@ export class DraftApplyDialogComponent implements OnInit { if (!this.isAppOnline || !this.isFormValid || this.targetProjectId == null || !this.canEditProject) { return; } - this.dialogRef.close({ projectId: this.targetProjectId }); + this.dialogRef.close({ projectId: this.targetProjectId, chapters: this.chaptersWithDrafts }); } projectSelected(paratextId: string): void { @@ -185,7 +203,7 @@ export class DraftApplyDialogComponent implements OnInit { const bookIsEmpty: boolean = targetBook?.chapters.length === 1 && targetBook?.chapters[0].lastVerse < 1; const targetBookChapters: number[] = targetBook?.chapters.map(c => c.number) ?? []; this.projectHasMissingChapters = - bookIsEmpty || this.data.chapters.filter(c => !targetBookChapters.includes(c)).length > 0; + bookIsEmpty || this.chaptersWithDrafts.filter(c => !targetBookChapters.includes(c)).length > 0; if (this.projectHasMissingChapters) { this.createChaptersControl.addValidators(Validators.requiredTrue); this.createChaptersControl.updateValueAndValidity(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts index 5b6a049268..cd5ada2d73 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts @@ -401,6 +401,23 @@ export class DraftGenerationService { return this.getGeneratedDraft(projectId, book, chapter).pipe(map(draft => Object.keys(draft).length > 0)); } + /** + * Gets the number of draft chapters for a specific book. + * @param projectId The SF project id for the target translation. + * @param bookNum The book number. + * @returns An observable containing the number of draft chapters or undefined if not found. + */ + getDraftChaptersForBook(projectId: string, bookNum: number): Observable { + return this.httpClient + .get(`translation/engines/project:${projectId}/actions/pretranslate/${bookNum}/chapters`) + .pipe( + map(res => res.data), + catchError(_ => { + return of(undefined); + }) + ); + } + /** * Calls the machine api to start a pre-translation build job. * This should only be called if no build is currently active. diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.html index 195cce4d8d..abb66a6de9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.html @@ -2,7 +2,7 @@ @for (book of booksWithDrafts$ | async; track book.bookId) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts index d94394f518..cb2acdb705 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts @@ -21,6 +21,7 @@ import { TextDocService } from '../../../core/text-doc.service'; import { BuildDto } from '../../../machine-api/build-dto'; import { DraftApplyDialogComponent } from '../draft-apply-dialog/draft-apply-dialog.component'; import { DraftApplyProgress } from '../draft-apply-progress-dialog/draft-apply-progress-dialog.component'; +import { DraftGenerationService } from '../draft-generation.service'; import { DraftHandlingService } from '../draft-handling.service'; import { BookWithDraft, DraftPreviewBooksComponent } from './draft-preview-books.component'; @@ -28,6 +29,7 @@ const mockedActivatedProjectService = mock(ActivatedProjectService); const mockedProjectService = mock(SFProjectService); const mockedUserService = mock(UserService); const mockedDraftHandlingService = mock(DraftHandlingService); +const mockedDraftGenerationService = mock(DraftGenerationService); const mockedDialogService = mock(DialogService); const mockedTextService = mock(TextDocService); const mockedErrorReportingService = mock(ErrorReportingService); @@ -43,6 +45,7 @@ describe('DraftPreviewBooks', () => { { provide: SFProjectService, useMock: mockedProjectService }, { provide: UserService, useMock: mockedUserService }, { provide: DraftHandlingService, useMock: mockedDraftHandlingService }, + { provide: DraftGenerationService, useMock: mockedDraftGenerationService }, { provide: DialogService, useMock: mockedDialogService }, { provide: TextDocService, useMock: mockedTextService }, { provide: ErrorReportingService, useMock: mockedErrorReportingService }, @@ -108,7 +111,7 @@ describe('DraftPreviewBooks', () => { it('notifies user if applying a draft failed due to an error', fakeAsync(() => { env = new TestEnvironment(); const bookWithDraft: BookWithDraft = env.booksWithDrafts[0]; - setupDialog('project01'); + setupDialog('project01', [1, 2, 3]); when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())) .thenReject(new Error('Draft error')) .thenResolve(undefined) @@ -127,7 +130,7 @@ describe('DraftPreviewBooks', () => { it('can apply all chapters of a draft to a book', fakeAsync(() => { env = new TestEnvironment(); const bookWithDraft: BookWithDraft = env.booksWithDrafts[0]; - setupDialog('project01'); + setupDialog('project01', [1, 2, 3]); when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).thenResolve( undefined ); @@ -144,7 +147,8 @@ describe('DraftPreviewBooks', () => { it('can apply chapters with drafts and skips chapters without drafts', fakeAsync(() => { env = new TestEnvironment(); const bookWithDraft: BookWithDraft = env.booksWithDrafts[1]; - setupDialog('project01'); + const chaptersWithDrafts = [1, 3]; + setupDialog('project01', chaptersWithDrafts); when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).thenResolve( undefined ); @@ -153,7 +157,9 @@ describe('DraftPreviewBooks', () => { tick(); env.fixture.detectChanges(); verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times(1); + verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times( + chaptersWithDrafts.length + ); })); it('can apply a historic draft', fakeAsync(() => { @@ -164,7 +170,7 @@ describe('DraftPreviewBooks', () => { } } as BuildDto); const bookWithDraft: BookWithDraft = env.booksWithDrafts[1]; - setupDialog('project01'); + setupDialog('project01', [1]); when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).thenResolve( undefined ); @@ -180,7 +186,8 @@ describe('DraftPreviewBooks', () => { env = new TestEnvironment(); expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy(); const mockedDialogRef: MatDialogRef = mock(MatDialogRef); - when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: 'project01' })); + const chaptersWithDrafts = [1, 2, 3]; + when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: 'project01', chapters: chaptersWithDrafts })); when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn( instance(mockedDialogRef) ); @@ -190,7 +197,7 @@ describe('DraftPreviewBooks', () => { env.fixture.detectChanges(); verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times( - env.booksWithDrafts[0].chaptersWithDrafts.length + chaptersWithDrafts.length ); verify(mockedProjectService.onlineAddChapters('project01', anything(), anything())).never(); })); @@ -199,7 +206,8 @@ describe('DraftPreviewBooks', () => { env = new TestEnvironment(); expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy(); const mockedDialogRef: MatDialogRef = mock(MatDialogRef); - when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: 'otherProject' })); + const chaptersWithDrafts = [1, 2, 3]; + when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: 'otherProject', chapters: chaptersWithDrafts })); when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn( instance(mockedDialogRef) ); @@ -208,7 +216,7 @@ describe('DraftPreviewBooks', () => { env.fixture.detectChanges(); verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times( - env.booksWithDrafts[0].chaptersWithDrafts.length + chaptersWithDrafts.length ); verify(mockedProjectService.onlineAddChapters('otherProject', anything(), anything())).never(); })); @@ -244,7 +252,8 @@ describe('DraftPreviewBooks', () => { expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy(); const projectEmptyBook = 'projectEmptyBook'; const mockedDialogRef: MatDialogRef = mock(MatDialogRef); - when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: projectEmptyBook })); + const chaptersWithDrafts = [1, 2, 3]; + when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: projectEmptyBook, chapters: chaptersWithDrafts })); when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn( instance(mockedDialogRef) ); @@ -267,14 +276,15 @@ describe('DraftPreviewBooks', () => { // needs to create 2 texts verify(mockedTextService.createTextDoc(anything())).twice(); verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times( - env.booksWithDrafts[0].chaptersWithDrafts.length + chaptersWithDrafts.length ); })); it('shows message to generate a new draft if legacy USFM draft', fakeAsync(() => { env = new TestEnvironment(); const bookWithDraft: BookWithDraft = env.booksWithDrafts[0]; - setupDialog('project01'); + const chaptersWithDrafts = [1, 2, 3]; + setupDialog('project01', chaptersWithDrafts); when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).thenResolve( 'error: legacy format' ); @@ -283,13 +293,16 @@ describe('DraftPreviewBooks', () => { tick(); env.fixture.detectChanges(); verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times(3); + verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times( + chaptersWithDrafts.length + ); })); it('can track progress of chapters applied', fakeAsync(() => { env = new TestEnvironment(); const bookWithDraft: BookWithDraft = env.booksWithDrafts[0]; - setupDialog('project01'); + const chaptersWithDrafts = [1, 2, 3]; + setupDialog('project01', chaptersWithDrafts); const resolveSubject$: BehaviorSubject = new BehaviorSubject(false); const promise: Promise = new Promise(resolve => { resolveSubject$.pipe(filter(value => value)).subscribe(() => resolve(undefined)); @@ -302,7 +315,9 @@ describe('DraftPreviewBooks', () => { tick(); env.fixture.detectChanges(); verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times(3); + verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times( + chaptersWithDrafts.length + ); expect(env.component.numChaptersApplied).toEqual(1); resolveSubject$.next(true); resolveSubject$.complete(); @@ -387,9 +402,9 @@ describe('DraftPreviewBooks', () => { }); }); - function setupDialog(projectId?: string): void { + function setupDialog(projectId?: string, chapters?: number[]): void { const mockedDialogRef: MatDialogRef = mock(MatDialogRef); - when(mockedDialogRef.afterClosed()).thenReturn(of(projectId ? { projectId } : undefined)); + when(mockedDialogRef.afterClosed()).thenReturn(of(projectId ? { projectId, chapters } : undefined)); when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn( instance(mockedDialogRef) ); @@ -444,9 +459,9 @@ class TestEnvironment { } as SFProjectProfileDoc; booksWithDrafts: BookWithDraft[] = [ - { bookNumber: 1, bookId: 'GEN', canEdit: true, chaptersWithDrafts: [1, 2, 3], draftApplied: false }, - { bookNumber: 2, bookId: 'EXO', canEdit: true, chaptersWithDrafts: [1], draftApplied: false }, - { bookNumber: 3, bookId: 'LEV', canEdit: false, chaptersWithDrafts: [1, 2], draftApplied: false } + { bookNumber: 1, bookId: 'GEN', canEdit: true, existingChapters: [1, 2, 3], draftApplied: false }, + { bookNumber: 2, bookId: 'EXO', canEdit: true, existingChapters: [1], draftApplied: false }, + { bookNumber: 3, bookId: 'LEV', canEdit: false, existingChapters: [1, 2], draftApplied: false } ]; constructor(build: BuildDto | undefined = undefined) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts index 0e8962f9e6..56ebb20bd7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts @@ -35,7 +35,7 @@ export interface BookWithDraft { bookNumber: number; bookId: string; canEdit: boolean; - chaptersWithDrafts: number[]; + existingChapters: number[]; draftApplied: boolean; } @@ -63,11 +63,11 @@ export class DraftPreviewBooksComponent { bookNumber: text.bookNum, bookId: Canon.bookNumberToId(text.bookNum), canEdit: text.permissions[this.userService.currentUserId] === TextInfoPermission.Write, - chaptersWithDrafts: text.chapters.filter(chapter => chapter.hasDraft).map(chapter => chapter.number), + existingChapters: text.chapters.filter(chapter => chapter.hasDraft).map(chapter => chapter.number), draftApplied: text.chapters.filter(chapter => chapter.hasDraft).every(chapter => chapter.draftApplied) })) .sort((a, b) => a.bookNumber - b.bookNumber) - .filter(book => book.chaptersWithDrafts.length > 0) as BookWithDraft[]; + .filter(book => book.existingChapters.length > 0) as BookWithDraft[]; } else { // TODO: Support books from multiple translation projects draftBooks = this.build.additionalInfo?.translationScriptureRanges @@ -78,7 +78,7 @@ export class DraftPreviewBooksComponent { bookNumber: bookNum, bookId: Canon.bookNumberToId(bookNum), canEdit: text?.permissions?.[this.userService.currentUserId] === TextInfoPermission.Write, - chaptersWithDrafts: text?.chapters?.map(ch => ch.number) ?? [], + existingChapters: text?.chapters?.map(ch => ch.number) ?? [], draftApplied: text?.chapters?.filter(ch => ch.hasDraft).every(ch => ch.draftApplied) ?? false }; }) @@ -123,15 +123,14 @@ export class DraftPreviewBooksComponent { async chooseProjectToAddDraft(bookWithDraft: BookWithDraft, paratextId?: string): Promise { const dialogData: DraftApplyDialogData = { initialParatextId: paratextId, - bookNum: bookWithDraft.bookNumber, - chapters: bookWithDraft.chaptersWithDrafts + bookNum: bookWithDraft.bookNumber }; const dialogRef: MatDialogRef = this.dialogService.openMatDialog( DraftApplyDialogComponent, { data: dialogData, width: '600px' } ); const result: DraftApplyDialogResult | undefined = await firstValueFrom(dialogRef.afterClosed()); - if (result == null || result.projectId == null) { + if (result == null || result.projectId == null || result.chapters == null) { return; } @@ -141,7 +140,7 @@ export class DraftPreviewBooksComponent { )!; const projectChapters: number[] = projectTextInfo.chapters.map(c => c.number); - const missingChapters: number[] = bookWithDraft.chaptersWithDrafts.filter(c => !projectChapters.includes(c)); + const missingChapters: number[] = result.chapters.filter(c => !projectChapters.includes(c)); if (missingChapters.length > 0) { await this.projectService.onlineAddChapters(result.projectId, bookWithDraft.bookNumber, missingChapters); for (const chapter of missingChapters) { @@ -149,21 +148,21 @@ export class DraftPreviewBooksComponent { await this.textDocService.createTextDoc(textDocId); } } - await this.applyBookDraftAsync(bookWithDraft, result.projectId); + await this.applyBookDraftAsync(bookWithDraft.bookNumber, result.chapters, result.projectId); } - private async applyBookDraftAsync(bookWithDraft: BookWithDraft, targetProjectId: string): Promise { - this.applyChapters = bookWithDraft.chaptersWithDrafts; - this.draftApplyBookNum = bookWithDraft.bookNumber; + private async applyBookDraftAsync(bookNum: number, chapters: number[], targetProjectId: string): Promise { + this.applyChapters = chapters; + this.draftApplyBookNum = bookNum; this.chaptersApplied = []; this.errorMessages = []; this.updateProgress(); const promises: Promise[] = []; const targetProject = (await this.projectService.getProfile(targetProjectId)).data!; - for (const chapter of bookWithDraft.chaptersWithDrafts) { - const draftTextDocId = new TextDocId(this.activatedProjectService.projectId!, bookWithDraft.bookNumber, chapter); - const targetTextDocId = new TextDocId(targetProjectId, bookWithDraft.bookNumber, chapter); + for (const chapter of chapters) { + const draftTextDocId = new TextDocId(this.activatedProjectService.projectId!, bookNum, chapter); + const targetTextDocId = new TextDocId(targetProjectId, bookNum, chapter); promises.push(this.applyAndReportChapter(targetProject, draftTextDocId, targetTextDocId)); } @@ -186,7 +185,7 @@ export class DraftPreviewBooksComponent { } navigate(book: BookWithDraft): void { - this.router.navigate(this.linkForBookAndChapter(book.bookId, book.chaptersWithDrafts[0]), { + this.router.navigate(this.linkForBookAndChapter(book.bookId, book.existingChapters[0]), { queryParams: { 'draft-active': true, 'draft-timestamp': this.build?.additionalInfo?.dateGenerated } }); } diff --git a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs index 4201f2d48c..ffd722ecbb 100644 --- a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs +++ b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs @@ -940,10 +940,8 @@ CancellationToken cancellationToken /// The cancellation token. /// The chapter count was retrieved successfully. /// You do not have permission to access this project. - /// The project does not exist. - /// The ML server is temporarily unavailable or unresponsive. [HttpGet(MachineApi.GetChaptersForBook)] - public async Task> GetPretranslationChapterCountAsync( + public async Task> GetPretranslationChapterCountAsync( string sfProjectId, int bookNum, CancellationToken cancellationToken @@ -951,22 +949,13 @@ CancellationToken cancellationToken { try { - int count = await _machineApiService.GetPretranslationChapterCountAsync( + int[] chapters = await _machineApiService.GetPretranslationChapterCountAsync( _userAccessor.UserId, sfProjectId, bookNum, cancellationToken ); - return Ok(count); - } - catch (BrokenCircuitException e) - { - _exceptionHandler.ReportException(e); - return StatusCode(StatusCodes.Status503ServiceUnavailable, MachineApiUnavailable); - } - catch (DataNotFoundException) - { - return NotFound(); + return Ok(chapters); } catch (ForbiddenException) { diff --git a/src/SIL.XForge.Scripture/Models/TextDocument.cs b/src/SIL.XForge.Scripture/Models/TextDocument.cs index f9f3110110..8805d16af2 100644 --- a/src/SIL.XForge.Scripture/Models/TextDocument.cs +++ b/src/SIL.XForge.Scripture/Models/TextDocument.cs @@ -55,4 +55,10 @@ public static string GetDocId(string projectId, int book, int chapter, string te /// The USJ spec version. /// public string Version { get; set; } = Usj.UsjVersion; + + public int GetChapterNumber() + { + string[] parts = Id.Split(':'); + return parts.Length >= 3 && int.TryParse(parts[2], out int chapter) ? chapter : 0; + } } diff --git a/src/SIL.XForge.Scripture/Services/IMachineApiService.cs b/src/SIL.XForge.Scripture/Services/IMachineApiService.cs index 61baf1b887..de8003563b 100644 --- a/src/SIL.XForge.Scripture/Services/IMachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/IMachineApiService.cs @@ -49,7 +49,7 @@ CancellationToken cancellationToken CancellationToken cancellationToken ); Task GetEngineAsync(string curUserId, string sfProjectId, CancellationToken cancellationToken); - Task GetPretranslationChapterCountAsync( + Task GetPretranslationChapterCountAsync( string curUserId, string sfProjectId, int bookNum, diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index f94e4f4301..1e6ba04a8a 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -1167,7 +1167,7 @@ CancellationToken cancellationToken } } - public async Task GetPretranslationChapterCountAsync( + public async Task GetPretranslationChapterCountAsync( string curUserId, string sfProjectId, int bookNum, @@ -1186,7 +1186,9 @@ CancellationToken cancellationToken .QuerySnapshots() .Where(t => t.Id.StartsWith($"{sfProjectId}:{Canon.BookNumberToId(bookNum)}")) .ToListAsync(cancellationToken); - return textDocuments.Count; + List chapters = [.. textDocuments.Select(t => t.GetChapterNumber()).Where(c => c != 0)]; + chapters.Sort(); + return [.. chapters]; } public async Task IsLanguageSupportedAsync(string languageCode, CancellationToken cancellationToken) diff --git a/test/SIL.XForge.Scripture.Tests/Controllers/MachineApiControllerTests.cs b/test/SIL.XForge.Scripture.Tests/Controllers/MachineApiControllerTests.cs index 2e992192dd..cee04bd206 100644 --- a/test/SIL.XForge.Scripture.Tests/Controllers/MachineApiControllerTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Controllers/MachineApiControllerTests.cs @@ -2344,6 +2344,49 @@ public async Task TranslateNAsync_Success() Assert.IsInstanceOf(actual.Result); } + [Test] + public async Task GetPretranslationChapterCountAsync_Success() + { + // Set up test environment + int[] chapterCount = [1, 2, 3, 4, 5, 6]; + int bookNum = 40; + var env = new TestEnvironment(); + env.MachineApiService.GetPretranslationChapterCountAsync(User01, Project01, bookNum, CancellationToken.None) + .Returns(Task.FromResult(chapterCount)); + + // SUT + ActionResult actual = await env.Controller.GetPretranslationChapterCountAsync( + Project01, + bookNum, + CancellationToken.None + ); + + Assert.IsInstanceOf(actual.Result); + Assert.AreEqual(chapterCount, (actual.Result as OkObjectResult)?.Value); + await env + .MachineApiService.Received(1) + .GetPretranslationChapterCountAsync(User01, Project01, bookNum, CancellationToken.None); + } + + [Test] + public async Task GetPretranslationChapterCountAsync_NoPermission() + { + // Set up test environment + const int bookNum = 40; + var env = new TestEnvironment(); + env.MachineApiService.GetPretranslationChapterCountAsync(User01, Project01, bookNum, CancellationToken.None) + .Throws(new ForbiddenException()); + + // SUT + ActionResult actual = await env.Controller.GetPretranslationChapterCountAsync( + Project01, + bookNum, + CancellationToken.None + ); + + Assert.IsInstanceOf(actual.Result); + } + private class TestEnvironment { private static readonly DateTime Timestamp = DateTime.UtcNow; diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 00ac7650cf..0a91f15e8d 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -2709,9 +2709,62 @@ await env } [Test] - public void GetPretranslationChapterCountAsync_Success() + public void GetPretranslationChapterCountAsync_NoPermissions() { - // TODO: Gets the number of documents + var env = new TestEnvironment(); + env.ProjectRights.HasRight(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(new ForbiddenException()); + Assert.ThrowsAsync(() => + env.Service.GetPretranslationChapterCountAsync(User01, Project01, 1, CancellationToken.None) + ); + } + + [Test] + public async Task GetPretranslationChapterCountAsync_NoChapters_Success() + { + var env = new TestEnvironment(); + int[] chapters = await env.Service.GetPretranslationChapterCountAsync( + User01, + Project01, + 1, + CancellationToken.None + ); + Assert.That(chapters.Length, Is.EqualTo(0)); + } + + [Test] + public async Task GetPretranslationChapterCountAsync_Success() + { + var env = new TestEnvironment(); + int[] chaptersWithDrafts = [1, 2, 3]; + int bookWithDrafts = 1; + int bookWithoutDrafts = 2; + foreach (int i in chaptersWithDrafts) + { + env.SetupTextDocument( + TextDocument.GetDocId(Project01, bookWithDrafts, i, TextDocument.Draft), + bookWithDrafts, + alreadyExists: true + ); + } + + // SUT + int[] chapters = await env.Service.GetPretranslationChapterCountAsync( + User01, + Project01, + bookWithDrafts, + CancellationToken.None + ); + Assert.That(chapters, Is.EqualTo(chaptersWithDrafts)); + + // SUT 2 + int[] chaptersDifferentBook = await env.Service.GetPretranslationChapterCountAsync( + User01, + Project01, + bookWithoutDrafts, + CancellationToken.None + ); + Assert.That(chaptersDifferentBook.Length, Is.EqualTo(0)); } [Test] From 8f77130a8834325b8fa5e7da99a4e9df47051eab Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Mon, 15 Sep 2025 13:21:35 -0600 Subject: [PATCH 3/3] Test chapter endpoint is called --- .../draft-apply-dialog.component.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts index 995359320d..36be1fac74 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts @@ -43,7 +43,7 @@ const ROUTES: Route[] = [{ path: 'projects', component: MockComponent }]; let env: TestEnvironment; -fdescribe('DraftApplyDialogComponent', () => { +describe('DraftApplyDialogComponent', () => { configureTestingModule(() => ({ imports: [ TestTranslocoModule, @@ -60,7 +60,7 @@ fdescribe('DraftApplyDialogComponent', () => { { provide: TextDocService, useMock: mockedTextDocService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: MatDialogRef, useMock: mockedDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { bookNum: 1, chapters: [1, 2] } } + { provide: MAT_DIALOG_DATA, useValue: { bookNum: 1 } } ] })); @@ -159,6 +159,7 @@ fdescribe('DraftApplyDialogComponent', () => { }) } as SFProjectProfileDoc; env = new TestEnvironment({ projectDoc }); + verify(mockedDraftGenerationService.getDraftChaptersForBook(projectDoc.id, 1)).once(); env.selectParatextProject('paratextId3'); expect(env.component['targetProjectId']).toBe('project03'); tick(); @@ -349,7 +350,7 @@ class TestEnvironment { const mockedTextDoc = { getNonEmptyVerses: (): string[] => ['verse_1_1', 'verse_1_2', 'verse_1_3'] } as TextDoc; - when(mockedActivatedProjectService.projectId$).thenReturn(of('project01')); + when(mockedActivatedProjectService.projectId$).thenReturn(of(projectDoc?.id)); when(mockedProjectService.getText(anything())).thenResolve(mockedTextDoc); when(mockedTextDocService.userHasGeneralEditRight(anything())).thenReturn(true); when(mockedDraftGenerationService.getDraftChaptersForBook(anything(), anything())).thenReturn(of([1, 2]));