From 988b90cb33e011d9db5cd38db0ef62ca21541f4e Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Mon, 8 Jun 2026 09:07:22 -0600 Subject: [PATCH 1/3] SF-3747 Determine available chapters based on generated draft --- .../draft-handling.service.ts | 5 ++ .../editor-draft.component.spec.ts | 84 +++++++++++++++++-- .../editor-draft/editor-draft.component.ts | 14 +++- .../translate/editor/editor.component.html | 3 +- .../translate/editor/editor.component.spec.ts | 49 ++++++++++- .../app/translate/editor/editor.component.ts | 21 +++-- 6 files changed, 157 insertions(+), 19 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts index 203e93ea3d3..cd12f5b7e99 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts @@ -69,6 +69,11 @@ export class DraftHandlingService { return chapterDrafts; } + async chaptersWithDraft(textDocId: TextDocId, timestamp: Date): Promise { + const drafts: Map = await this.getBookDraft(textDocId, { timestamp }); + return Array.from(drafts.keys()).map(chapterNumStr => +chapterNumStr); + } + canApplyDraft( targetProject: SFProjectProfile, bookNum: number, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts index 1debd09f7bc..3c0c63eeef9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts @@ -1,3 +1,4 @@ +import { Component, ViewChild } from '@angular/core'; import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { MatIcon } from '@angular/material/icon'; import { MatProgressBar } from '@angular/material/progress-bar'; @@ -48,16 +49,46 @@ const mockErrorReportingService = mock(ErrorReportingService); const mockSFProjectService = mock(SFProjectService); const mockProjectNotificationService = mock(ProjectNotificationService); -describe('EditorDraftComponent', () => { - let fixture: ComponentFixture; +@Component({ + standalone: true, + imports: [EditorDraftComponent], + template: ` + + ` +}) +class HostEditorDraftComponent { + @ViewChild('editorDraft') editorDraftComponent!: EditorDraftComponent; + projectId = 'targetProjectId'; + bookNum = 1; + chapter = 1; + isRightToLeft = false; + timestamp?: Date; + chaptersInDraft: number[] = []; + + onChaptersInDraft(chaptersInDraft: number[]): void { + this.chaptersInDraft = chaptersInDraft; + } +} + +fdescribe('EditorDraftComponent', () => { + let fixture: ComponentFixture; let component: EditorDraftComponent; + let hostComponent: HostEditorDraftComponent; let testOnlineStatus: TestOnlineStatusService; const buildProgress$ = new BehaviorSubject(undefined); configureTestingModule(() => ({ imports: [ HistoryRevisionFormatPipe, - EditorDraftComponent, + HostEditorDraftComponent, MatProgressBar, MatSelect, MatIcon, @@ -102,20 +133,27 @@ describe('EditorDraftComponent', () => { when(mockDraftHandlingService.getBookDraft(anything(), anything())).thenResolve(bookDraftByChapters); when(mockDraftHandlingService.opsHaveContent(anything())).thenReturn(true); when(mockSFProjectService.hasDraft(anything(), anything(), anything(), anything())).thenReturn(true); + when(mockSFProjectService.getText(anything())).thenResolve({ isLoaded: false } as any); - fixture = TestBed.createComponent(EditorDraftComponent); - component = fixture.componentInstance; + fixture = TestBed.createComponent(HostEditorDraftComponent); + hostComponent = fixture.componentInstance; testOnlineStatus = TestBed.inject(OnlineStatusService) as TestOnlineStatusService; + }); + + function initializeComponent(host: HostEditorDraftComponent): void { + fixture.detectChanges(); + component = host.editorDraftComponent; component.projectId = 'targetProjectId'; component.bookNum = 1; component.chapter = 1; component.isRightToLeft = false; component.ngOnChanges(); - }); + } it('should handle offline when component created', fakeAsync(() => { + initializeComponent(hostComponent); testOnlineStatus.setIsOnline(false); fixture.detectChanges(); expect(component.draftCheckState).toEqual('draft-unknown'); @@ -132,6 +170,7 @@ describe('EditorDraftComponent', () => { of(draftHistory) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); fixture.detectChanges(); @@ -160,6 +199,7 @@ describe('EditorDraftComponent', () => { when(mockDraftGenerationService.getGeneratedDraftHistory(anything(), anything(), anything())).thenReturn( of(draftHistory) ); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); testOnlineStatus.setIsOnline(false); @@ -195,6 +235,7 @@ describe('EditorDraftComponent', () => { of(draftHistory) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); fixture.detectChanges(); @@ -214,12 +255,14 @@ describe('EditorDraftComponent', () => { of(draftHistory) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); // Initial load fetches draft for current selection. fixture.detectChanges(); tick(EDITOR_READY_TIMEOUT); expect(component.bookNum).toBe(1); + expect(hostComponent.chaptersInDraft).toEqual([1, 2]); verify(mockDraftHandlingService.getBookDraft(anything(), anything())).once(); // Changing chapter triggers another draft retrieval call. @@ -227,14 +270,17 @@ describe('EditorDraftComponent', () => { component.ngOnChanges(); fixture.detectChanges(); tick(EDITOR_READY_TIMEOUT); + expect(hostComponent.chaptersInDraft).toEqual([1, 2]); verify(mockDraftHandlingService.getBookDraft(anything(), anything())).twice(); // Changing book triggers one more draft retrieval call. + when(mockDraftHandlingService.getBookDraft(anything(), anything())).thenResolve(emptyBookDraftByChapters); component.bookNum = 2; component.chapter = 1; component.ngOnChanges(); fixture.detectChanges(); tick(EDITOR_READY_TIMEOUT); + expect(hostComponent.chaptersInDraft).toEqual([1]); verify(mockDraftHandlingService.getBookDraft(anything(), anything())).thrice(); flush(); })); @@ -247,6 +293,7 @@ describe('EditorDraftComponent', () => { of(draftHistory) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); // Set the date to a time before the earliest draft @@ -270,6 +317,7 @@ describe('EditorDraftComponent', () => { of(draftHistory) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); // Set the date to a time just before the earliest draft @@ -296,6 +344,7 @@ describe('EditorDraftComponent', () => { of(draftHistory) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); // SUT @@ -316,9 +365,10 @@ describe('EditorDraftComponent', () => { of(draftHistory) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); - spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); when(mockDraftHandlingService.getBookDraft(anything(), anything())).thenResolve(emptyBookDraftByChapters); when(mockDraftHandlingService.opsHaveContent(anything())).thenReturn(false); + initializeComponent(hostComponent); + spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); // SUT fixture.detectChanges(); @@ -340,8 +390,10 @@ describe('EditorDraftComponent', () => { of(draftHistory.slice(0, 1)) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); - spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); when(mockDraftHandlingService.opsHaveContent(anything())).thenReturn(false); + when(mockDraftHandlingService.opsHaveContent(anything())).thenReturn(false); + initializeComponent(hostComponent); + spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); // SUT fixture.detectChanges(); @@ -361,6 +413,7 @@ describe('EditorDraftComponent', () => { } as SFProjectProfileDoc; when(mockDraftGenerationService.getGeneratedDraftHistory(anything(), anything(), anything())).thenReturn(of([])); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); fixture.detectChanges(); @@ -379,6 +432,7 @@ describe('EditorDraftComponent', () => { of(draftHistory) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops!)); fixture.detectChanges(); @@ -419,6 +473,7 @@ describe('EditorDraftComponent', () => { when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); when(mockDialogService.confirm(anything(), anything())).thenResolve(true); when(mockDraftHandlingService.canApplyDraft(anything(), anything(), anything(), anything())).thenReturn(true); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops)); fixture.detectChanges(); @@ -437,6 +492,7 @@ describe('EditorDraftComponent', () => { ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); when(mockDialogService.confirm(anything(), anything())).thenResolve(true); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops)); fixture.detectChanges(); @@ -460,6 +516,7 @@ describe('EditorDraftComponent', () => { of(draftHistory) ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of([])); fixture.detectChanges(); @@ -476,6 +533,8 @@ describe('EditorDraftComponent', () => { })); it('should throw error if there is no draft', fakeAsync(() => { + when(mockDraftGenerationService.getGeneratedDraftHistory(anything(), anything(), anything())).thenReturn(of([])); + initializeComponent(hostComponent); component.applyDraft().catch(e => { expect(e).toEqual(new Error('No draft ops to apply.')); }); @@ -491,6 +550,7 @@ describe('EditorDraftComponent', () => { ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); when(mockDialogService.confirm(anything(), anything())).thenResolve(true); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops)); fixture.detectChanges(); @@ -516,6 +576,7 @@ describe('EditorDraftComponent', () => { ); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); when(mockDialogService.confirm(anything(), anything())).thenResolve(true); + initializeComponent(hostComponent); spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops)); fixture.detectChanges(); tick(EDITOR_READY_TIMEOUT); @@ -550,10 +611,10 @@ describe('EditorDraftComponent', () => { when(mockDraftGenerationService.getGeneratedDraftHistory(anything(), anything(), anything())).thenReturn( of(draftHistory) ); - spyOn(component, 'getTargetOps').and.returnValue(of(targetDelta.ops)); }); it('should be true when latest build has draft and selected revision is latest', fakeAsync(() => { + initializeComponent(hostComponent); const testProjectDoc: SFProjectProfileDoc = { data: createTestProjectProfile({ texts: [ @@ -579,6 +640,7 @@ describe('EditorDraftComponent', () => { })); it('should be false when selected revision is not the latest', fakeAsync(() => { + initializeComponent(hostComponent); const testProjectDoc: SFProjectProfileDoc = { data: createTestProjectProfile({ texts: [ @@ -610,6 +672,7 @@ describe('EditorDraftComponent', () => { })); it('should be false when latest build does not have a draft', fakeAsync(() => { + initializeComponent(hostComponent); const testProjectDoc: SFProjectProfileDoc = { data: createTestProjectProfile({ texts: [ @@ -637,6 +700,7 @@ describe('EditorDraftComponent', () => { })); it('should be false when latest build is canceled even if draft exists and selected revision is latest', fakeAsync(() => { + initializeComponent(hostComponent); const testProjectDoc: SFProjectProfileDoc = { data: createTestProjectProfile({ texts: [ @@ -665,6 +729,7 @@ describe('EditorDraftComponent', () => { describe('getLocalizedBookChapter', () => { it('should return an empty string if bookNum or chapter is undefined', () => { + initializeComponent(hostComponent); component.bookNum = undefined; component.chapter = 1; expect(component['getLocalizedBookChapter']()).toEqual(''); @@ -675,6 +740,7 @@ describe('EditorDraftComponent', () => { }); it('should return a localized book and chapter if both are not null', () => { + initializeComponent(hostComponent); when(mockI18nService.localizeBookChapter(1, 1)).thenReturn('Localized Book 1'); component.bookNum = 1; component.chapter = 1; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts index 7f03d6e51cd..e0fe907e2eb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts @@ -1,5 +1,5 @@ import { AsyncPipe, NgClass } from '@angular/common'; -import { AfterViewInit, Component, DestroyRef, Input, OnChanges, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, DestroyRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'; import { MatOption } from '@angular/material/autocomplete'; import { MatButton } from '@angular/material/button'; import { MatFormField } from '@angular/material/form-field'; @@ -88,6 +88,7 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { @Input() isRightToLeft!: boolean; @Input() fontSize?: string; @Input() timestamp?: Date; + @Output() readonly chaptersUpdated = new EventEmitter(); @ViewChild(TextComponent) draftText!: TextComponent; @@ -289,7 +290,7 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { ) ) .subscribe(async ({ targetOps, textDocId, timestamp }) => { - const draftOps: DeltaOperation[] = await this.getChapterDraftOps(textDocId, timestamp); + const draftOps: DeltaOperation[] = await this.getAndRefreshChapters(textDocId, timestamp); // The user may have navigated to another chapter while this draft was being fetched, in which case the result // must not be kept as the current chapter's draft. The current chapter's own fetch is either in flight or // complete, so nothing is lost by discarding this one. @@ -447,13 +448,20 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { ); } - private async getChapterDraftOps(textDocId: TextDocId, timestamp: string): Promise { + /** + * Gets the chapters with drafts and emits the chapter numbers. + * Returns the draft for the specified text and timestamp. + */ + private async getAndRefreshChapters(textDocId: TextDocId, timestamp: string): Promise { const chapterNum: string = textDocId.chapterNum.toString(); const timestampAsDate = new Date(timestamp); const chapterDrafts: Map = await this.draftHandlingService.getBookDraft(textDocId, { timestamp: timestampAsDate }); + console.log(chapterDrafts); + this.chaptersUpdated.emit(Array.from(chapterDrafts.keys()).map(chapterNumStr => +chapterNumStr)); + // this.chaptersUpdated.emit([1, 2]); return chapterDrafts.get(chapterNum) ?? []; } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html index 7960511aac5..757b302accc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html @@ -10,7 +10,7 @@ (bookChange)="setBook($event)" [books]="books" [(chapter)]="chapter" - [chapters]="chapters" + [chapters]="availableChapters" > @if (canShowSourceTab) {
 
@@ -282,6 +282,7 @@ [isRightToLeft]="isTargetRightToLeft" [fontSize]="fontSize" [timestamp]="draftTimestamp" + (chaptersUpdated)="onDraftChaptersUpdated($event)" >
} 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 0973269873a..1b7ce5616b4 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 @@ -379,7 +379,54 @@ describe('EditorComponent', () => { env.routeWithParams({ projectId: 'project01', bookId: 'GEN' }); env.wait(); expect(env.bookName).toEqual('Genesis'); - expect(env.component.chapters.length).toEqual(50); + expect(env.component.availableChapters.length).toEqual(50); + })); + + it('allows navigating to extra chapters in the draft', fakeAsync(() => { + const env = new TestEnvironment(); + env.setupProject({ + texts: [ + { bookNum: 40, chapters: [{ number: 1 }] }, + { bookNum: 41, chapters: [{ number: 1 }] } + ], + translateConfig: { + draftConfig: { + trainingSources: [ + { + paratextId: 'source01', + projectRef: 's01', + name: 'Source Project', + shortName: 'SRC', + writingSystem: { tag: 'en' } + } + ], + draftingSources: [ + { + paratextId: 'source01', + projectRef: 's01', + name: 'Source Project', + shortName: 'SRC', + writingSystem: { tag: 'en' } + } + ], + draftedScriptureRange: 'JON;MAT;MRK' + } + } + }); + env.wait(); + // Jonah is included since it is a book with a draft + expect(env.component.books).toEqual([32, ...env.testProjectProfile.texts.map(t => t.bookNum)]); + + env.routeWithParams({ projectId: 'project01', bookId: 'JON' }); + env.wait(); + expect(env.bookName).toEqual('Jonah'); + // Jonah has 4 chapters + expect(env.component.availableChapters).toEqual([1, 2, 3, 4]); + + env.component.onDraftChaptersUpdated([1, 2, 3, 4, 5]); + tick(); + env.fixture.detectChanges(); + expect(env.component.availableChapters).toEqual([1, 2, 3, 4, 5]); })); describe('Show editor tabs in single pane setting', () => { 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 babd602f3d5..8ffb2163f7f 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 @@ -287,7 +287,6 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, suggestions: Suggestion[] = []; showSuggestions: boolean = false; books: number[] = []; - chapters: number[] = []; text?: TextInfo; isProjectAdmin: boolean = false; metricsSession?: TranslateMetricsSession; @@ -330,6 +329,8 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, private targetLoaded: boolean = false; private _targetFocused: boolean = false; private chapter$ = new BehaviorSubject(undefined); + private bookChapters: number[] = []; + private chaptersUniqueInDraft: number[] = []; private _verse: string = '0'; private lastShownSuggestions: Suggestion[] = []; private readonly segmentUpdated$: Subject; @@ -472,6 +473,10 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, } } + get availableChapters(): number[] { + return [...this.bookChapters, ...this.chaptersUniqueInDraft]; + } + setBook(book: number): void { void this.router.navigate(['projects', this.projectId, 'translate', Canon.bookNumberToId(book)]); } @@ -840,7 +845,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, this.text?.chapters[this.text.chapters.length - 1]?.number ?? 1, expectedBookChapters(Canon.bookNumberToId(bookNum)) ); - this.chapters = Array.from({ length: allChapters }, (_, i) => i + 1); + this.bookChapters = Array.from({ length: allChapters }, (_, i) => i + 1); this.updateVerseNumber(); @@ -1339,6 +1344,12 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, this.changeDetector.detectChanges(); } + // Determines the chapters in the draft that are not part of the existing chapters + onDraftChaptersUpdated(draftChapters: number[]): void { + // We may want to expand this range of chapters to not have any gaps + this.chaptersUniqueInDraft = Array.from(new Set(draftChapters).difference(new Set(this.bookChapters))); + } + /** * Initializes the tab state from persisted tabs plus non-persisted tabs (source and target projects), * then listens for tab state changes to update the persisted tabs or add the blank tab. @@ -2158,16 +2169,16 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, if (this.text != null && this.projectUserConfigDoc.data.selectedBookNum === this.text.bookNum) { if ( this.projectUserConfigDoc.data.selectedChapterNum != null && - this.chapters.includes(this.projectUserConfigDoc.data.selectedChapterNum) + this.availableChapters.includes(this.projectUserConfigDoc.data.selectedChapterNum) ) { chapter = this.projectUserConfigDoc.data.selectedChapterNum; } } } - if (!this.chapters.includes(chapter)) { + if (!this.availableChapters.includes(chapter)) { this.loadingFinished(); - this.chapter = this.chapters[0] ?? 1; + this.chapter = this.availableChapters[0] ?? 1; return; } this.toggleNoteThreadVerses(false); From 1e12f7ef9ef900afaf0f43dddcfd33eb5f6efb0d Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Thu, 6 Aug 2026 16:58:11 -0600 Subject: [PATCH 2/3] Update tests to implement builds --- .../Services/MachineApiService.cs | 35 +++-- .../Services/MachineApiServiceTests.cs | 142 +++++++++++------- 2 files changed, 108 insertions(+), 69 deletions(-) diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index 6713708c3b5..7d0f05b5dc4 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -2749,7 +2749,7 @@ CancellationToken cancellationToken await using IConnection connection = await realtimeService.ConnectAsync(userId); string id = TextDocument.GetDocId(sfProjectId, bookNum, chapterNum, TextDocument.Draft); - DateTime latestTimestampForRevision = await LatestTimestampForRevisionAsync( + (DateTime latestTimestampForRevision, ServalBuildDto build) = await GetCorrespondingTimestampAndBuildAsync( curUserId, sfProjectId, bookNum, @@ -2770,10 +2770,11 @@ CancellationToken cancellationToken ScrVers versification = paratextService.GetParatextSettings(userSecret, project.ParatextId)?.Versification ?? VerseRef.defaultVersification; - // Just in case the versification is incorrect, use the last chapter in Mongo if it is larger - int lastChapterInMongo = - project.Texts.SingleOrDefault(t => t.BookNum == bookNum)?.Chapters.Max(c => c.Number) ?? 0; - int lastChapter = Math.Max(versification.GetLastChapter(bookNum), lastChapterInMongo); + // Find the last chapter of the drafted book based on the build + Dictionary> draftedChapters = GetDraftedChaptersForBuild(build, versification); + draftedChapters.TryGetValue(Canon.BookNumberToId(bookNum), out SortedSet chapters); + int lastChapter = Math.Max(versification.GetLastChapter(bookNum), chapters?.Max ?? 0); + List content = []; for (int chapter = 1; chapter <= lastChapter; chapter++) { @@ -3958,7 +3959,7 @@ private static string GetTranslationEngineId( /// The cancellation token. /// The timestamp of the draft that immediate follows the intended draft revision. /// This function is internal so it can be unit tests. - internal async Task LatestTimestampForRevisionAsync( + internal async Task<(DateTime, ServalBuildDto)> GetCorrespondingTimestampAndBuildAsync( string curUserId, string sfProjectId, int bookNum, @@ -3986,17 +3987,23 @@ CancellationToken cancellationToken builds = await FilterBuildsByBookAndChapterAsync(project, builds, bookNum, chapterNum, cancellationToken); // See if there is a build that was requested after the timestamp - DateTimeOffset? time = builds - .FirstOrDefault(b => b.AdditionalInfo?.DateRequested?.UtcDateTime > timestamp) - ?.AdditionalInfo?.DateRequested; + ServalBuildDto? build = builds.FirstOrDefault(b => b.AdditionalInfo?.DateRequested?.UtcDateTime > timestamp); + DateTimeOffset? time = build?.AdditionalInfo?.DateRequested; + + if (time is null) + { + // If not, search for a build that comes before the timestamp and use the current time if the build exists + build = builds.LastOrDefault(b => b.AdditionalInfo?.DateRequested?.UtcDateTime < timestamp); + time = build is not null ? DateTime.UtcNow : null; + } - // If not, search for a build that comes before the timestamp and use the current time if the build exists - time ??= builds.LastOrDefault(b => b.AdditionalInfo?.DateRequested?.UtcDateTime < timestamp) is not null - ? DateTime.UtcNow - : null; + if (build is null) + { + throw new DataNotFoundException("No draft builds were found for the specified book and chapter."); + } // Return the latest time to access a draft, or the original timestamp is none is found - return time?.UtcDateTime ?? timestamp; + return (time?.UtcDateTime ?? timestamp, build); } /// diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index fa2b1c125a7..1aeb0d330f4 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -3078,6 +3078,8 @@ public void GetPreTranslationDeltaAsync_CorpusDoesNotSupportUsfm() ) .Throws(ServalApiExceptions.InvalidCorpus); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); + // SUT Assert.ThrowsAsync(() => env.Service.GetPreTranslationDeltaAsync( @@ -3101,6 +3103,7 @@ public async Task GetPreTranslationDeltaAsync_ServalAdminDoesNotNeedPermission() JToken token = JToken.Parse("{\"insert\": { \"chapter\": { \"number\": \"1\", \"style\": \"c\" } } }"); Delta expected = new Delta([token]); env.DeltaUsxMapper.ToChapterDeltas(Arg.Any()).Returns([new ChapterDelta(1, 1, true, expected)]); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); // SUT Dictionary> actual = await env.Service.GetPreTranslationDeltaAsync( @@ -3125,6 +3128,7 @@ public async Task GetPreTranslationDeltaAsync_Success() JToken token = JToken.Parse("{\"insert\": { \"chapter\": { \"number\": \"1\", \"style\": \"c\" } } }"); Delta expected = new Delta([token]); env.DeltaUsxMapper.ToChapterDeltas(Arg.Any()).Returns([new ChapterDelta(1, 1, true, expected)]); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); // SUT Dictionary> actual = await env.Service.GetPreTranslationDeltaAsync( @@ -3149,6 +3153,7 @@ public async Task GetPreTranslationDeltaAsync_SuccessSpecificConfig() JToken token = JToken.Parse("{\"insert\": { \"chapter\": { \"number\": \"1\", \"style\": \"c\" } } }"); Delta expected = new Delta([token]); env.DeltaUsxMapper.ToChapterDeltas(Arg.Any()).Returns([new ChapterDelta(1, 1, true, expected)]); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); DraftUsfmConfig config = new DraftUsfmConfig { ParagraphFormat = ParagraphBreakFormatOptions.Remove }; // SUT @@ -3187,6 +3192,7 @@ public async Task GetPreTranslationDeltaAsync_CancelEnumeration() JToken token = JToken.Parse("{\"insert\": { \"chapter\": { \"number\": \"1\", \"style\": \"c\" } } }"); Delta expected = new Delta([token]); env.DeltaUsxMapper.ToChapterDeltas(Arg.Any()).Returns([new ChapterDelta(1, 1, true, expected)]); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); DraftUsfmConfig config = new DraftUsfmConfig { ParagraphFormat = ParagraphBreakFormatOptions.Remove }; // SUT @@ -3406,6 +3412,8 @@ public void GetPreTranslationUsfmAsync_CorpusDoesNotSupportUsfm() ) .Throws(ServalApiExceptions.InvalidCorpus); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); + // SUT Assert.ThrowsAsync(() => env.Service.GetPreTranslationUsfmAsync( @@ -3452,6 +3460,8 @@ public async Task GetPreTranslationUsfmAsync_ServalAdminDoesNotNeedPermission() env.ParatextService.ConvertUsxToUsfm(Arg.Any(), Arg.Any(), 40, Arg.Any()) .Returns(expected); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); + // SUT string usfm = await env.Service.GetPreTranslationUsfmAsync( User02, @@ -3475,6 +3485,8 @@ public async Task GetPreTranslationUsfmAsync_Success() env.ParatextService.ConvertUsxToUsfm(Arg.Any(), Arg.Any(), 40, Arg.Any()) .Returns(expected); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); + // SUT string usfm = await env.Service.GetPreTranslationUsfmAsync( User01, @@ -3503,6 +3515,7 @@ public async Task GetPreTranslationUsfmAsync_SuccessSpecificConfig() ParagraphFormat = ParagraphBreakFormatOptions.Remove, QuoteFormat = QuoteStyleOptions.Normalized, }; + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); // SUT string usfm = await env.Service.GetPreTranslationUsfmAsync( @@ -3559,6 +3572,7 @@ public async Task GetPreTranslationUsjAsync_ChapterZeroLoadsMultipleChaptersFrom env.TextDocuments.Add(new TextDocument(id1, TestUsj)); string id2 = TextDocument.GetDocId(Project01, 40, 2, TextDocument.Draft); env.TextDocuments.Add(new TextDocument(id2, testUsjChapterTwo)); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); // The chapters must be configured in the project document await env.Projects.UpdateAsync( @@ -3607,6 +3621,8 @@ public void GetPreTranslationUsjAsync_CorpusDoesNotSupportUsfm() ) .Throws(ServalApiExceptions.InvalidCorpus); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); + // SUT Assert.ThrowsAsync(() => env.Service.GetPreTranslationUsjAsync( @@ -3636,6 +3652,7 @@ public async Task GetPreTranslationUsjAsync_DoesNotSaveIfChapterZero() ) .Returns(Task.FromResult(TestUsfm)); env.ParatextService.GetBookText(Arg.Any(), Arg.Any(), 40, TestUsfm).Returns(TestUsx); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); // SUT IUsj actual = await env.Service.GetPreTranslationUsjAsync( @@ -3670,6 +3687,7 @@ public async Task GetPreTranslationUsjAsync_DoesNotSaveIfSpecificConfig() ) .Returns(Task.FromResult(TestUsfm)); env.ParatextService.GetBookText(Arg.Any(), Arg.Any(), 40, TestUsfm).Returns(TestUsx); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); // SUT IUsj actual = await env.Service.GetPreTranslationUsjAsync( @@ -3750,6 +3768,7 @@ public async Task GetPreTranslationUsjAsync_RetrievesFromServalIfNoLocalCopy() ) .Returns(Task.FromResult(TestUsfm)); env.ParatextService.GetBookText(Arg.Any(), Arg.Any(), 40, TestUsfm).Returns(TestUsx); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); // SUT IUsj actual = await env.Service.GetPreTranslationUsjAsync( @@ -3783,6 +3802,7 @@ public async Task GetPreTranslationUsjAsync_ServalAdminDoesNotNeedPermission() ) .Returns(Task.FromResult(TestUsfm)); env.ParatextService.GetBookText(Arg.Any(), Arg.Any(), 40, TestUsfm).Returns(TestUsx); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); // SUT IUsj actual = await env.Service.GetPreTranslationUsjAsync( @@ -3805,6 +3825,7 @@ public async Task GetPreTranslationUsjAsync_Success() var env = new TestEnvironment(); string id = TextDocument.GetDocId(Project01, 40, 1, TextDocument.Draft); env.TextDocuments.Add(new TextDocument(id, TestUsj)); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); // SUT IUsj actual = await env.Service.GetPreTranslationUsjAsync( @@ -3866,6 +3887,7 @@ public async Task GetPreTranslationUsjAsync_SuccessSpecificConfig() }; // Add a default document snapshot env.TextDocuments.Add(new TextDocument(id, usj)); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); var config = new DraftUsfmConfig { ParagraphFormat = ParagraphBreakFormatOptions.Remove }; // SUT @@ -3907,6 +3929,8 @@ public void GetPreTranslationUsxAsync_CorpusDoesNotSupportUsfm() ) .Throws(ServalApiExceptions.InvalidCorpus); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); + // SUT Assert.ThrowsAsync(() => env.Service.GetPreTranslationUsxAsync( @@ -3962,6 +3986,7 @@ public async Task GetPreTranslationUsxAsync_ServalAdminDoesNotNeedPermission() ) .Returns(Task.FromResult(usfm)); env.ParatextService.GetBookText(Arg.Any(), Arg.Any(), 40, usfm).Returns(usx); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); string expected = UsjToUsx.UsjToUsxString(TestUsj); // SUT @@ -3996,6 +4021,7 @@ public async Task GetPreTranslationUsxAsync_Success() ) .Returns(Task.FromResult(usfm)); env.ParatextService.GetBookText(Arg.Any(), Arg.Any(), 40, usfm).Returns(usx); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); string expected = UsjToUsx.UsjToUsxString(TestUsj); // SUT @@ -4030,6 +4056,7 @@ public async Task GetPreTranslationUsxAsync_SuccessSpecificConfig() ) .Returns(Task.FromResult(usfm)); env.ParatextService.GetBookText(Arg.Any(), Arg.Any(), 40, usfm).Returns(usx); + env.SetupPreTranslationBuilds((ServalBuildId01, "MAT", DateTime.UtcNow.AddHours(-1), JobState.Completed)); string expected = UsjToUsx.UsjToUsxString(TestUsj); var config = new DraftUsfmConfig { ParagraphFormat = ParagraphBreakFormatOptions.Remove }; @@ -4774,7 +4801,7 @@ public async Task LatestTimestampForRevisionAsync_BuildAfterTimestamp() .Returns(Task.FromResult>([build])); // SUT - DateTime actual = await env.Service.LatestTimestampForRevisionAsync( + var (actualTimestamp, correspondingBuild) = await env.Service.GetCorrespondingTimestampAndBuildAsync( User01, Project01, bookNum: 2, @@ -4783,7 +4810,8 @@ public async Task LatestTimestampForRevisionAsync_BuildAfterTimestamp() timestamp, CancellationToken.None ); - Assert.AreEqual(actual, buildRequested); + Assert.AreEqual(actualTimestamp, buildRequested); + Assert.AreEqual(correspondingBuild, build); } [Test] @@ -4804,7 +4832,7 @@ public async Task LatestTimestampForRevisionAsync_BuildBeforeTimestamp() .Returns(Task.FromResult>([build])); // SUT - DateTime actual = await env.Service.LatestTimestampForRevisionAsync( + var (actualTimestamp, correspondingBuild) = await env.Service.GetCorrespondingTimestampAndBuildAsync( User01, Project01, bookNum: 2, @@ -4813,37 +4841,30 @@ public async Task LatestTimestampForRevisionAsync_BuildBeforeTimestamp() timestamp, CancellationToken.None ); - Assert.GreaterOrEqual(actual, timestamp); + Assert.GreaterOrEqual(actualTimestamp, timestamp); + Assert.AreEqual(correspondingBuild, build); } [Test] - public async Task LatestTimestampForRevisionAsync_BuildFinishing() + public async Task LatestTimestampForRevisionAsync_BuildActive() { var env = new TestEnvironment(); DateTime timestamp = DateTime.UtcNow; DateTime buildRequested = timestamp.AddHours(-1); - var build = new ServalBuildDto - { - State = MachineApiService.BuildStateFinishing, - AdditionalInfo = new ServalBuildAdditionalInfo(), - }; - build.AdditionalInfo.DateRequested = new DateTimeOffset(buildRequested); - build.AdditionalInfo.TranslationScriptureRanges.Add(new ProjectScriptureRange { ScriptureRange = "GEN-DEU" }); - env.Service.Configure() - .GetBuildsAsync(User01, Project01, preTranslate: true, isServalAdmin: true, CancellationToken.None) - .Returns(Task.FromResult>([build])); + env.SetupPreTranslationBuilds((ServalBuildId01, "GEN-DEU", buildRequested, JobState.Active)); // SUT - DateTime actual = await env.Service.LatestTimestampForRevisionAsync( - User01, - Project01, - bookNum: 2, - chapterNum: 1, - isServalAdmin: true, - timestamp, - CancellationToken.None + Assert.ThrowsAsync(async () => + await env.Service.GetCorrespondingTimestampAndBuildAsync( + User01, + Project01, + bookNum: 2, + chapterNum: 1, + isServalAdmin: true, + timestamp, + CancellationToken.None + ) ); - Assert.AreEqual(actual, timestamp); } [Test] @@ -4852,28 +4873,20 @@ public async Task LatestTimestampForRevisionAsync_BuildsDoNotContainBook() var env = new TestEnvironment(); DateTime timestamp = DateTime.UtcNow; DateTime buildRequested = timestamp.AddHours(-1); - var build = new ServalBuildDto - { - State = MachineApiService.BuildStateFinishing, - AdditionalInfo = new ServalBuildAdditionalInfo(), - }; - build.AdditionalInfo.DateRequested = new DateTimeOffset(buildRequested); - build.AdditionalInfo.TranslationScriptureRanges.Add(new ProjectScriptureRange { ScriptureRange = "LEV-DEU" }); - env.Service.Configure() - .GetBuildsAsync(User01, Project01, preTranslate: true, isServalAdmin: true, CancellationToken.None) - .Returns(Task.FromResult>([build])); + env.SetupPreTranslationBuilds((ServalBuildId01, "LEV-DEU", buildRequested, JobState.Completed)); // SUT - DateTime actual = await env.Service.LatestTimestampForRevisionAsync( - User01, - Project01, - bookNum: 2, - chapterNum: 1, - isServalAdmin: true, - timestamp, - CancellationToken.None + Assert.ThrowsAsync(async () => + await env.Service.GetCorrespondingTimestampAndBuildAsync( + User01, + Project01, + bookNum: 2, + chapterNum: 1, + isServalAdmin: true, + timestamp, + CancellationToken.None + ) ); - Assert.AreEqual(actual, timestamp); } [Test] @@ -4881,21 +4894,20 @@ public async Task LatestTimestampForRevisionAsync_NoBuilds() { var env = new TestEnvironment(); DateTime timestamp = DateTime.UtcNow; - env.Service.Configure() - .GetBuildsAsync(User01, Project01, preTranslate: true, isServalAdmin: true, CancellationToken.None) - .Returns(Task.FromResult>([])); + env.SetupPreTranslationBuilds(); // SUT - DateTime actual = await env.Service.LatestTimestampForRevisionAsync( - User01, - Project01, - bookNum: 2, - chapterNum: 1, - isServalAdmin: true, - timestamp, - CancellationToken.None + Assert.ThrowsAsync(async () => + await env.Service.GetCorrespondingTimestampAndBuildAsync( + User01, + Project01, + bookNum: 2, + chapterNum: 1, + isServalAdmin: true, + timestamp, + CancellationToken.None + ) ); - Assert.AreEqual(actual, timestamp); } [Test] @@ -6896,6 +6908,26 @@ public void SetupPreTranslationBuilds( Revision = 43, State = b.State, DateFinished = b.DateRequested.AddMinutes(30), + Pretranslate = b.ScriptureRange is not null + ? + [ + new PretranslateCorpus + { + SourceFilters = + [ + new ParallelCorpusFilter + { + Corpus = new ResourceLink + { + Id = "corpusId", + Url = "https://example.com", + }, + ScriptureRange = "MAT", + }, + ], + }, + ] + : null, }), ]) ); From 570facb5dee823c3f0a95547e3420b8d3c29eaa1 Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Wed, 19 Aug 2026 14:11:02 -0600 Subject: [PATCH 3/3] Show all draft and text chapters --- .../editor-draft.component.spec.ts | 2 +- .../editor-draft/editor-draft.component.ts | 2 -- .../app/translate/editor/editor.component.ts | 18 ++++++++---------- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts index 3c0c63eeef9..ab018857e18 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts @@ -78,7 +78,7 @@ class HostEditorDraftComponent { } } -fdescribe('EditorDraftComponent', () => { +describe('EditorDraftComponent', () => { let fixture: ComponentFixture; let component: EditorDraftComponent; let hostComponent: HostEditorDraftComponent; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts index e0fe907e2eb..109cb9df1e7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts @@ -459,9 +459,7 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { const chapterDrafts: Map = await this.draftHandlingService.getBookDraft(textDocId, { timestamp: timestampAsDate }); - console.log(chapterDrafts); this.chaptersUpdated.emit(Array.from(chapterDrafts.keys()).map(chapterNumStr => +chapterNumStr)); - // this.chaptersUpdated.emit([1, 2]); return chapterDrafts.get(chapterNum) ?? []; } } 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 8ffb2163f7f..6587760fce9 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 @@ -287,6 +287,8 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, suggestions: Suggestion[] = []; showSuggestions: boolean = false; books: number[] = []; + /** Includes all chapters in the texts, resources, and drafts that a user can navigate to for a book. */ + availableChapters: number[] = []; text?: TextInfo; isProjectAdmin: boolean = false; metricsSession?: TranslateMetricsSession; @@ -329,8 +331,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, private targetLoaded: boolean = false; private _targetFocused: boolean = false; private chapter$ = new BehaviorSubject(undefined); - private bookChapters: number[] = []; - private chaptersUniqueInDraft: number[] = []; + private chaptersInTexts: number[] = []; private _verse: string = '0'; private lastShownSuggestions: Suggestion[] = []; private readonly segmentUpdated$: Subject; @@ -473,10 +474,6 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, } } - get availableChapters(): number[] { - return [...this.bookChapters, ...this.chaptersUniqueInDraft]; - } - setBook(book: number): void { void this.router.navigate(['projects', this.projectId, 'translate', Canon.bookNumberToId(book)]); } @@ -845,7 +842,8 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, this.text?.chapters[this.text.chapters.length - 1]?.number ?? 1, expectedBookChapters(Canon.bookNumberToId(bookNum)) ); - this.bookChapters = Array.from({ length: allChapters }, (_, i) => i + 1); + this.chaptersInTexts = Array.from({ length: allChapters }, (_, i) => i + 1); + this.availableChapters = [...this.chaptersInTexts]; this.updateVerseNumber(); @@ -1344,10 +1342,10 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, this.changeDetector.detectChanges(); } - // Determines the chapters in the draft that are not part of the existing chapters + // Respond to updates to the chapters available in the draft tab onDraftChaptersUpdated(draftChapters: number[]): void { - // We may want to expand this range of chapters to not have any gaps - this.chaptersUniqueInDraft = Array.from(new Set(draftChapters).difference(new Set(this.bookChapters))); + const highestChapter: number = Math.max(...this.chaptersInTexts, ...draftChapters); + this.availableChapters = Array.from({ length: highestChapter }, (_, i) => i + 1); } /**