From 0fd8dc7619cc106c2c641aed7a75276ceb3aa094 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Thu, 13 Nov 2025 08:40:54 +1300 Subject: [PATCH 1/4] SF-3638 Add property to the realtime server --- .../scriptureforge/models/translate-config.ts | 2 + .../services/sf-project-migrations.spec.ts | 117 ++++++++++++++++++ .../services/sf-project-migrations.ts | 35 +++++- .../services/sf-project-service.ts | 6 + 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/RealtimeServer/scriptureforge/models/translate-config.ts b/src/RealtimeServer/scriptureforge/models/translate-config.ts index b318812356..adeefea31e 100644 --- a/src/RealtimeServer/scriptureforge/models/translate-config.ts +++ b/src/RealtimeServer/scriptureforge/models/translate-config.ts @@ -69,6 +69,8 @@ export interface DraftConfig { servalConfig?: string; usfmConfig?: DraftUsfmConfig; sendEmailOnBuildFinished?: boolean; + currentScriptureRange?: string; + draftedScriptureRange?: string; } export interface TranslateConfig { diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts index f3c8c6728c..0f80925905 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.spec.ts @@ -1052,6 +1052,123 @@ describe('SFProjectMigrations', () => { expect(projectDoc.data.translateConfig.draftConfig.additionalTrainingSourceEnabled).toBeUndefined(); }); }); + + describe('version 28', () => { + it('adds currentScriptureRange to draftConfig', async () => { + const env = new TestEnvironment(27); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', { + translateConfig: { draftConfig: {} }, + texts: [ + { bookNum: 1, chapters: [{ hasDraft: true }] }, + { bookNum: 2, chapters: [{ hasDraft: false }] }, + { bookNum: 3, chapters: [{ hasDraft: true }] }, + { bookNum: 0, chapters: [{ hasDraft: true }] } + ] + }); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).not.toBeDefined(); + expect(projectDoc.data.translateConfig.draftConfig.draftedScriptureRange).not.toBeDefined(); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).toBe('GEN;LEV'); + expect(projectDoc.data.translateConfig.draftConfig.draftedScriptureRange).toBe('GEN;LEV'); + }); + + it('does not add currentScriptureRange to draftConfig if it exists', async () => { + const env = new TestEnvironment(27); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', { + translateConfig: { draftConfig: { currentScriptureRange: 'NUM;DEU' } }, + texts: [ + { bookNum: 1, chapters: [{ hasDraft: true }] }, + { bookNum: 2, chapters: [{ hasDraft: false }] }, + { bookNum: 3, chapters: [{ hasDraft: true }] }, + { bookNum: 0, chapters: [{ hasDraft: true }] } + ] + }); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).toBe('NUM;DEU'); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).toBe('NUM;DEU'); + }); + + it('does not add draftedScriptureRange to draftConfig if it exists', async () => { + const env = new TestEnvironment(27); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', { + translateConfig: { draftConfig: { draftedScriptureRange: 'NUM;DEU' } }, + texts: [ + { bookNum: 1, chapters: [{ hasDraft: true }] }, + { bookNum: 2, chapters: [{ hasDraft: false }] }, + { bookNum: 3, chapters: [{ hasDraft: true }] }, + { bookNum: 0, chapters: [{ hasDraft: true }] } + ] + }); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).not.toBeDefined(); + expect(projectDoc.data.translateConfig.draftConfig.draftedScriptureRange).toBeDefined(); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).toBe('GEN;LEV'); + expect(projectDoc.data.translateConfig.draftConfig.draftedScriptureRange).toBe('NUM;DEU'); + }); + + it('does not add currentScriptureRange to draftConfig if no drafted chapters', async () => { + const env = new TestEnvironment(27); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', { + translateConfig: { draftConfig: {} }, + texts: [ + { bookNum: 1, chapters: [{ hasDraft: false }] }, + { bookNum: 2, chapters: [{ hasDraft: false }] } + ] + }); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).not.toBeDefined(); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).not.toBeDefined(); + }); + + it('does not add currentScriptureRange to draftConfig if there are no texts', async () => { + const env = new TestEnvironment(27); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', { + translateConfig: { draftConfig: {} }, + texts: [] + }); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).not.toBeDefined(); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data.translateConfig.draftConfig.currentScriptureRange).not.toBeDefined(); + }); + + it('does not add currentScriptureRange to draftConfig if the project is null', async () => { + const env = new TestEnvironment(27); + const conn = env.server.connect(); + await createDoc(conn, SF_PROJECTS_COLLECTION, 'project01', null); + let projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data?.translateConfig?.draftConfig?.currentScriptureRange).not.toBeDefined(); + + await env.server.migrateIfNecessary(); + + projectDoc = await fetchDoc(conn, SF_PROJECTS_COLLECTION, 'project01'); + expect(projectDoc.data?.translateConfig?.draftConfig?.currentScriptureRange).not.toBeDefined(); + }); + }); }); class TestEnvironment { diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts index 68d543ca2e..ee4baaa683 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts @@ -6,6 +6,7 @@ import { submitMigrationOp } from '../../common/realtime-server'; import { NoteTag } from '../models/note-tag'; import { SF_PROJECT_RIGHTS, SFProjectDomain } from '../models/sf-project-rights'; import { SFProjectRole } from '../models/sf-project-role'; +import { TextInfo } from '../models/text-info'; import { TextInfoPermission } from '../models/text-info-permission'; import { TranslateShareLevel, TranslateSource } from '../models/translate-config'; @@ -630,6 +631,37 @@ class SFProjectMigration27 extends DocMigration { } } +class SFProjectMigration28 extends DocMigration { + static readonly VERSION = 28; + + async migrateDoc(doc: Doc): Promise { + const ops: Op[] = []; + if (doc.data?.texts != null && doc.data?.translateConfig?.draftConfig?.currentScriptureRange == null) { + const currentScriptureRange = doc.data.texts + .filter((t: TextInfo) => t.chapters.some(c => c.hasDraft)) + .map((t: TextInfo) => Canon.bookNumberToId(t.bookNum, '')) + .filter((id: string) => id !== '') + .join(';'); + if (currentScriptureRange !== '' && currentScriptureRange != null) { + ops.push({ + p: ['translateConfig', 'draftConfig', 'currentScriptureRange'], + oi: currentScriptureRange + }); + if (doc.data.translateConfig?.draftConfig?.draftedScriptureRange == null) { + ops.push({ + p: ['translateConfig', 'draftConfig', 'draftedScriptureRange'], + oi: currentScriptureRange + }); + } + } + } + + if (ops.length > 0) { + await submitMigrationOp(SFProjectMigration28.VERSION, doc, ops); + } + } +} + export const SF_PROJECT_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([ SFProjectMigration1, SFProjectMigration2, @@ -657,5 +689,6 @@ export const SF_PROJECT_MIGRATIONS: MigrationConstructor[] = monotonicallyIncrea SFProjectMigration24, SFProjectMigration25, SFProjectMigration26, - SFProjectMigration27 + SFProjectMigration27, + SFProjectMigration28 ]); diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts index cd2731daa6..dd0b935a25 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts @@ -269,6 +269,12 @@ export class SFProjectService extends ProjectService { }, sendEmailOnBuildFinished: { bsonType: 'bool' + }, + currentScriptureRange: { + bsonType: 'string' + }, + draftedScriptureRange: { + bsonType: 'string' } }, additionalProperties: false From 9802abe534493e08adfab5590f9cddab9c5acd45 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 11 Nov 2025 15:00:33 +1300 Subject: [PATCH 2/4] SF-3638 Remove the hasDraft property from the chapter object --- .../src/app/core/sf-project.service.spec.ts | 75 +++++++++++++++++++ .../src/app/core/sf-project.service.ts | 25 ++++++- .../serval-project.component.spec.ts | 9 ++- .../serval-project.component.ts | 4 +- .../ClientApp/src/app/shared/utils.spec.ts | 16 ++++ .../ClientApp/src/app/shared/utils.ts | 9 ++- .../draft-generation.component.spec.ts | 14 ++-- .../draft-generation.component.ts | 3 +- .../draft-generation.service.spec.ts | 4 +- .../draft-generation.service.ts | 2 + .../draft-preview-books.component.spec.ts | 17 +---- .../draft-preview-books.component.ts | 8 +- .../draft-usfm-format.component.spec.ts | 16 ++-- .../draft-usfm-format.component.ts | 8 +- .../editor-draft.component.spec.ts | 15 ++-- .../editor-draft/editor-draft.component.ts | 5 +- .../translate/editor/editor.component.spec.ts | 16 ++-- .../app/translate/editor/editor.component.ts | 2 +- .../tabs/editor-tab-menu.service.spec.ts | 18 +++-- .../editor/tabs/editor-tab-menu.service.ts | 5 +- 20 files changed, 201 insertions(+), 70 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts index 843ac57546..26f7ade5f6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts @@ -1,6 +1,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { fakeAsync, TestBed } from '@angular/core/testing'; +import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { anything, mock, verify, when } from 'ts-mockito'; import { CommandService } from 'xforge-common/command.service'; import { RealtimeService } from 'xforge-common/realtime.service'; @@ -20,6 +21,80 @@ describe('SFProjectService', () => { ] })); + describe('hasDraft', () => { + it('should return true if the book is in the drafted scripture range', fakeAsync(() => { + const env = new TestEnvironment(); + const project = { + translateConfig: { draftConfig: { draftedScriptureRange: 'GEN;EXO;LEV', currentScriptureRange: 'MAT;MRK' } } + } as SFProjectProfile; + const actual = env.service.hasDraft(project, 2); + expect(actual).toBe(true); + })); + + it('should return true if the book is in the current scripture range when current build is true', fakeAsync(() => { + const env = new TestEnvironment(); + const project = { + translateConfig: { draftConfig: { draftedScriptureRange: 'MAT;MRK', currentScriptureRange: 'GEN;EXO;LEV' } } + } as SFProjectProfile; + const actual = env.service.hasDraft(project, 2, true); + expect(actual).toBe(true); + })); + + it('should return true if the drafted scripture range has books', fakeAsync(() => { + const env = new TestEnvironment(); + const project = { + translateConfig: { draftConfig: { draftedScriptureRange: 'GEN;EXO;LEV' } } + } as SFProjectProfile; + const actual = env.service.hasDraft(project); + expect(actual).toBe(true); + })); + + it('should return true if the current scripture range has books', fakeAsync(() => { + const env = new TestEnvironment(); + const project = { + translateConfig: { draftConfig: { currentScriptureRange: 'GEN;EXO;LEV' } } + } as SFProjectProfile; + const actual = env.service.hasDraft(project, undefined, true); + expect(actual).toBe(true); + })); + + it('should return false if the book is not in the drafted scripture range', fakeAsync(() => { + const env = new TestEnvironment(); + const project = { + translateConfig: { draftConfig: { draftedScriptureRange: 'MAT;MRK', currentScriptureRange: 'GEN;EXO;LEV' } } + } as SFProjectProfile; + const actual = env.service.hasDraft(project, 2); + expect(actual).toBe(false); + })); + + it('should return false if the book is not in the current scripture range when current build is true', fakeAsync(() => { + const env = new TestEnvironment(); + const project = { + translateConfig: { draftConfig: { draftedScriptureRange: 'GEN;EXO;LEV', currentScriptureRange: 'MAT;MRK' } } + } as SFProjectProfile; + const actual = env.service.hasDraft(project, 2, true); + expect(actual).toBe(false); + })); + + it('should return false if the drafted scripture range does not have books', fakeAsync(() => { + const env = new TestEnvironment(); + const project = { + translateConfig: { draftConfig: { currentScriptureRange: 'GEN;EXO;LEV' } } + } as SFProjectProfile; + const actual = env.service.hasDraft(project); + expect(actual).toBe(false); + })); + + it('should return false if the current scripture range does not have books', fakeAsync(() => { + const env = new TestEnvironment(); + const project = { + translateConfig: { draftConfig: { draftedScriptureRange: 'GEN;EXO;LEV' } } + } as SFProjectProfile; + const actual = env.service.hasDraft(project, undefined, true); + expect(actual).toBe(false); + })); + }); + describe('onlineSetRoleProjectPermissions', () => { it('should invoke the command service', fakeAsync(async () => { const env = new TestEnvironment(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts index 21f165e97b..2ae8f2b3bf 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts @@ -23,6 +23,7 @@ import { QueryParameters, QueryResults } from 'xforge-common/query-parameters'; import { RealtimeService } from 'xforge-common/realtime.service'; import { RetryingRequest, RetryingRequestService } from 'xforge-common/retrying-request.service'; import { EventMetric } from '../event-metrics/event-metric'; +import { booksFromScriptureRange } from '../shared/utils'; import { BiblicalTermDoc } from './models/biblical-term-doc'; import { InviteeStatus } from './models/invitee-status'; import { NoteThreadDoc } from './models/note-thread-doc'; @@ -53,8 +54,28 @@ export class SFProjectService extends ProjectService { super(realtimeService, commandService, retryingRequestService, SF_PROJECT_ROLES); } - static hasDraft(project: SFProjectProfile): boolean { - return project.texts.some(text => text.chapters.some(chapter => chapter.hasDraft)); + /** + * Determines if there is a draft in the project for the specified scripture range or book number. + * @param project The project. + * @param scriptureRange The scripture range or book number. + * @param currentBuild If true, only return true if the current build on serval contains the scripture range. + * @returns true if the project contains a draft for the specified scripture range or book number. + */ + hasDraft( + project: SFProjectProfile | undefined, + bookNum: number | undefined = undefined, + currentBuild: boolean = false + ): boolean { + const books: number[] = booksFromScriptureRange( + currentBuild + ? project?.translateConfig.draftConfig.currentScriptureRange + : project?.translateConfig.draftConfig.draftedScriptureRange + ); + if (bookNum == null) { + return books.length > 0; + } else { + return books.includes(bookNum); + } } async onlineCreate(settings: SFProjectCreateSettings): Promise { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts index 076218a892..bc4b584ed8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts @@ -293,10 +293,10 @@ describe('ServalProjectComponent', () => { name: 'Project 01', shortName: 'P1', texts: [ - { bookNum: 1, chapters: [{ number: 1, hasDraft: false }] }, - { bookNum: 2, chapters: [{ number: 1, hasDraft: false }] }, - { bookNum: 3, chapters: [{ number: 1, hasDraft: args.preTranslate }] }, - { bookNum: 4, chapters: [{ number: 1, hasDraft: args.preTranslate }] } + { bookNum: 1, chapters: [{ number: 1 }] }, + { bookNum: 2, chapters: [{ number: 1 }] }, + { bookNum: 3, chapters: [{ number: 1 }] }, + { bookNum: 4, chapters: [{ number: 1 }] } ], translateConfig: { draftConfig: { @@ -358,6 +358,7 @@ describe('ServalProjectComponent', () => { when(mockServalAdministrationService.downloadProject(anything())).thenReturn(of(new Blob())); when(mockAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]); when(mockDraftGenerationService.getBuildProgress(anything())).thenReturn(of({ additionalInfo: {} } as BuildDto)); + when(mockSFProjectService.hasDraft(anything())).thenReturn(args.preTranslate); when(mockSFProjectService.onlineSetServalConfig(this.mockProjectId, anything())).thenResolve(); spyOn(saveAs, 'saveAs').and.stub(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts index 3442c39f28..ba94a62307 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts @@ -234,13 +234,13 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn } this.draftConfig = draftConfig; - this.draftJob$ = SFProjectService.hasDraft(project) ? this.getDraftJob(projectDoc.id) : of(undefined); + this.draftJob$ = this.projectService.hasDraft(project) ? this.getDraftJob(projectDoc.id) : of(undefined); // Setup the serval config value this.servalConfig.setValue(project.translateConfig.draftConfig.servalConfig); // Get the last completed build - if (this.isOnline && SFProjectService.hasDraft(project)) { + if (this.isOnline && this.projectService.hasDraft(project)) { return this.draftGenerationService.getLastCompletedBuild(projectDoc.id); } else { return of(undefined); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.spec.ts index d4f5dd9991..304e160784 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.spec.ts @@ -3,6 +3,7 @@ import { SFProject } from 'realtime-server/lib/esm/scriptureforge/models/sf-proj import { DeltaOperation } from 'rich-text'; import { SelectableProject } from '../core/models/selectable-project'; import { + booksFromScriptureRange, compareProjectsForSorting, getBookFileNameDigits, getUnsupportedTags, @@ -95,6 +96,21 @@ describe('shared utils', () => { expect(projects.map(project => project.shortName)).toEqual(['AAA', 'bbb', 'CCC']); }); + describe('booksFromScriptureRange', () => { + it('should return an empty array for non-scripture book values', () => { + expect(booksFromScriptureRange(undefined)).toEqual([]); + expect(booksFromScriptureRange('')).toEqual([]); + expect(booksFromScriptureRange(' ')).toEqual([]); + expect(booksFromScriptureRange('NOT_A_BOOK')).toEqual([]); + }); + + it('should return numbers for valid scripture book values', () => { + expect(booksFromScriptureRange('GEN')).toEqual([1]); + expect(booksFromScriptureRange('GEN;EXO')).toEqual([1, 2]); + expect(booksFromScriptureRange('GEN;NOT_A_BOOK;EXO')).toEqual([1, 2]); + }); + }); + describe('Xml Utils', () => { it('should convert plain text to xml', () => { expect(XmlUtils.encodeForXml('')).toEqual(''); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts index 7e1ac0c05d..f92902871b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts @@ -169,9 +169,12 @@ export function getUnsupportedTags(deltaOp: DeltaOperation): string[] { return [...invalidTags]; } -export function booksFromScriptureRange(scriptureRange: string): number[] { - if (scriptureRange === '') return []; - return scriptureRange.split(';').map(book => Canon.bookIdToNumber(book)); +export function booksFromScriptureRange(scriptureRange: string | undefined): number[] { + if (scriptureRange == null) return []; + return scriptureRange + .split(';') + .map(book => Canon.bookIdToNumber(book)) + .filter(bookId => bookId > 0); } export class XmlUtils { 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 326fd9f28a..356a0c0353 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 @@ -48,6 +48,7 @@ describe('DraftGenerationComponent', () => { let mockNllbLanguageService: jasmine.SpyObj; let mockTrainingDataService: jasmine.SpyObj; let mockFeatureFlagService: jasmine.SpyObj; + let mockSFProjectService: jasmine.SpyObj; const buildDto: BuildDto = { id: 'testId', @@ -88,7 +89,7 @@ describe('DraftGenerationComponent', () => { { provide: DraftSourcesService, useValue: mockDraftSourcesService }, { provide: DraftHandlingService, useValue: undefined }, { provide: ActivatedProjectService, useValue: mockActivatedProjectService }, - { provide: SFProjectService, useValue: undefined }, + { provide: SFProjectService, useValue: mockSFProjectService }, { provide: UserService, useValue: mockUserService }, { provide: TextDocService, useValue: undefined }, { provide: DialogService, useValue: mockDialogService }, @@ -157,6 +158,8 @@ describe('DraftGenerationComponent', () => { newDraftHistory: createTestFeatureFlag(false), usfmFormat: createTestFeatureFlag(false) }); + mockSFProjectService = jasmine.createSpyObj(['hasDraft']); + mockSFProjectService.hasDraft.and.returnValue(true); } static initProject(currentUserId: string, preTranslate: boolean = true): void { @@ -184,7 +187,7 @@ describe('DraftGenerationComponent', () => { { bookNum: 1, chapters: [{ number: 1 }], permissions: { user01: TextInfoPermission.Write } }, { bookNum: 2, - chapters: [{ number: 1, hasDraft: preTranslate }], + chapters: [{ number: 1 }], permissions: { user01: TextInfoPermission.Write } } ], @@ -1399,7 +1402,7 @@ describe('DraftGenerationComponent', () => { expect(env.downloadButton).toBeNull(); }); - it('button should display if the project updates the hasDraft field', fakeAsync(() => { + it('button should display if the project has a draft complete', fakeAsync(() => { // Setup the project and subject const projectDoc: SFProjectProfileDoc = { data: createTestProjectProfile({ @@ -1416,7 +1419,7 @@ describe('DraftGenerationComponent', () => { texts: [ { bookNum: 1, - chapters: [{ number: 1, hasDraft: false }], + chapters: [{ number: 1 }], permissions: { user01: TextInfoPermission.Write } } ] @@ -1439,6 +1442,7 @@ describe('DraftGenerationComponent', () => { mockDraftGenerationService.getBuildProgress.and.returnValue(buildObservable); mockDraftGenerationService.pollBuildProgress.and.returnValue(buildObservable); mockDraftGenerationService.getLastCompletedBuild.and.returnValue(buildObservable); + mockSFProjectService.hasDraft.and.returnValue(false); }); tick(500); env.fixture.detectChanges(); @@ -1447,7 +1451,7 @@ describe('DraftGenerationComponent', () => { expect(env.downloadButton).toBeNull(); // Update the has draft flag for the project - projectDoc.data!.texts[0].chapters[0].hasDraft = true; + mockSFProjectService.hasDraft.and.returnValue(true); projectDoc.data!.translateConfig.draftConfig.lastSelectedTranslationScriptureRanges = [ { projectId: 'testSourceProjectId', scriptureRange: 'GEN' } ]; 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 22ef53098a..957cdc7de8 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 @@ -168,6 +168,7 @@ export class DraftGenerationComponent extends DataLoadingComponent implements On protected readonly noticeService: NoticeService, protected readonly urlService: ExternalUrlService, protected readonly featureFlags: FeatureFlagService, + private readonly projectService: SFProjectService, private destroyRef: DestroyRef ) { super(noticeService); @@ -245,7 +246,7 @@ export class DraftGenerationComponent extends DataLoadingComponent implements On this.isBackTranslation = translateConfig?.projectType === ProjectType.BackTranslation; this.targetLanguage = projectDoc.data?.writingSystem.tag; this.isPreTranslationApproved = translateConfig?.preTranslate ?? false; - this.hasDraftBooksAvailable = projectDoc.data != null && SFProjectService.hasDraft(projectDoc.data); + this.hasDraftBooksAvailable = projectDoc.data != null && this.projectService.hasDraft(projectDoc.data); }) ), this.draftSourcesService.getDraftProjectSources().pipe( 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 bcff44dc36..24404bda9d 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 @@ -910,12 +910,12 @@ describe('DraftGenerationService', () => { { bookNum: 62, chapters: [ - { number: 1, hasDraft: false }, + { number: 1, hasDraft: true }, { number: 2, hasDraft: true } ] }, { bookNum: 63, chapters: [{ number: 1, hasDraft: true }] }, - { bookNum: 64, chapters: [{ number: 1, hasDraft: false }] } + { bookNum: 64, chapters: [{ number: 1, hasDraft: true }] } ] }) } as SFProjectProfileDoc; 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 a472dfe15f..0756923eac 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 @@ -388,6 +388,8 @@ export class DraftGenerationService { // If no books were found in the build, use the project document if (books.size === 0) { books = new Set( + // Legacy calculation for very old drafts + // eslint-disable-next-line @typescript-eslint/no-deprecated projectDoc.data.texts.filter(text => text.chapters.some(c => c.hasDraft)).map(text => text.bookNum) ); } 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 0439a90009..9956e283c5 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 @@ -411,29 +411,19 @@ class TestEnvironment { { bookNum: 1, hasSource: true, - chapters: [ - { number: 1, hasDraft: true }, - { number: 2, hasDraft: true }, - { number: 3, hasDraft: true } - ], + chapters: [{ number: 1 }, { number: 2 }, { number: 3 }], permissions: { user01: TextInfoPermission.Write } }, { bookNum: 2, hasSource: true, - chapters: [ - { number: 1, hasDraft: true }, - { number: 2, hasDraft: false } - ], + chapters: [{ number: 1 }, { number: 2 }], permissions: { user01: TextInfoPermission.Write } }, { bookNum: 3, hasSource: true, - chapters: [ - { number: 1, hasDraft: true }, - { number: 2, hasDraft: true } - ], + chapters: [{ number: 1 }, { number: 2 }], permissions: { user01: TextInfoPermission.Read } } ], @@ -458,6 +448,7 @@ class TestEnvironment { ).thenResolve(); when(mockedActivatedProjectService.projectId).thenReturn('project01'); when(mockedUserService.currentUserId).thenReturn('user01'); + when(mockedProjectService.hasDraft(anything(), anything())).thenReturn(true); when(mockedProjectService.getProfile(anything())).thenResolve(this.mockProjectDoc); this.fixture = TestBed.createComponent(DraftPreviewBooksComponent); this.component = this.fixture.componentInstance; 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 39d5fcce0f..52d40a74da 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 @@ -73,8 +73,10 @@ 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), - draftApplied: text.chapters.filter(chapter => chapter.hasDraft).every(chapter => chapter.draftApplied) + chaptersWithDrafts: this.projectService.hasDraft(projectDoc.data, text.bookNum) + ? text.chapters.map(chapter => chapter.number) + : [], + draftApplied: text.chapters.every(chapter => chapter.draftApplied) })) .sort((a, b) => a.bookNumber - b.bookNumber) .filter(book => book.chaptersWithDrafts.length > 0) as BookWithDraft[]; @@ -89,7 +91,7 @@ export class DraftPreviewBooksComponent { bookId: Canon.bookNumberToId(bookNum), canEdit: text?.permissions?.[this.userService.currentUserId] === TextInfoPermission.Write, chaptersWithDrafts: text?.chapters?.map(ch => ch.number) ?? [], - draftApplied: text?.chapters?.filter(ch => ch.hasDraft).every(ch => ch.draftApplied) ?? false + draftApplied: text?.chapters?.every(ch => ch.draftApplied) ?? false }; }) // Do not filter chapters with drafts, as the book or chapters may have been removed. diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.spec.ts index 38a103d0b6..0d2f5cf7cf 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.spec.ts @@ -135,9 +135,9 @@ describe('DraftUsfmFormatComponent', () => { { bookNum: 1, chapters: [ - { number: 1, lastVerse: 15, isValid: true, permissions: {}, hasDraft: false }, - { number: 2, lastVerse: 20, isValid: true, permissions: {}, hasDraft: true }, - { number: 3, lastVerse: 18, isValid: true, permissions: {}, hasDraft: true } + { number: 1, lastVerse: 0, isValid: true, permissions: {} }, + { number: 2, lastVerse: 20, isValid: true, permissions: {} }, + { number: 3, lastVerse: 18, isValid: true, permissions: {} } ], hasSource: true, permissions: {} @@ -145,6 +145,7 @@ describe('DraftUsfmFormatComponent', () => { ] } }); + when(mockedProjectService.hasDraft(anything(), anything(), anything())).thenReturn(false); tick(EDITOR_READY_TIMEOUT); env.fixture.detectChanges(); tick(EDITOR_READY_TIMEOUT); @@ -168,7 +169,7 @@ describe('DraftUsfmFormatComponent', () => { verify(mockedDraftHandlingService.getDraft(anything(), anything())).once(); expect(env.component.chaptersWithDrafts.length).toEqual(1); - expect(env.component.booksWithDrafts.length).toEqual(2); + expect(env.component.booksWithDrafts.length).toEqual(3); env.component.bookChanged(2); tick(); @@ -309,6 +310,7 @@ class TestEnvironment { when(mockedNoticeService.show(anything())).thenResolve(); when(mockedDialogService.confirm(anything(), anything(), anything())).thenResolve(true); when(mockedServalAdministration.onlineRetrievePreTranslationStatus(anything())).thenResolve(); + when(mockedProjectService.hasDraft(anything(), anything(), anything())).thenReturn(true); this.setupProject(args.project); this.fixture = TestBed.createComponent(DraftUsfmFormatComponent); this.component = this.fixture.componentInstance; @@ -338,15 +340,15 @@ class TestEnvironment { const texts: TextInfo[] = [ { bookNum: 1, - chapters: [{ number: 1, lastVerse: 20, isValid: true, permissions: {}, hasDraft: true }], + chapters: [{ number: 1, lastVerse: 20, isValid: true, permissions: {} }], hasSource: true, permissions: {} }, { bookNum: 2, chapters: [ - { number: 1, lastVerse: 20, isValid: true, permissions: {}, hasDraft: true }, - { number: 2, lastVerse: 20, isValid: true, permissions: {}, hasDraft: true } + { number: 1, lastVerse: 20, isValid: true, permissions: {} }, + { number: 2, lastVerse: 20, isValid: true, permissions: {} } ], hasSource: true, permissions: {} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts index 940331d290..586a23f75e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts @@ -144,7 +144,9 @@ export class DraftUsfmFormatComponent extends DataLoadingComponent implements Af if (projectDoc?.data == null) return; this.setUsfmConfig(projectDoc.data.translateConfig.draftConfig.usfmConfig); const texts: TextInfo[] = projectDoc.data.texts; - this.booksWithDrafts = texts.filter(t => t.chapters.some(c => c.hasDraft)).map(t => t.bookNum); + this.booksWithDrafts = texts + .filter(t => this.projectService.hasDraft(projectDoc.data, t.bookNum, true)) + .map(t => t.bookNum); if (this.booksWithDrafts.length === 0) return; this.loadingStarted(); @@ -258,8 +260,8 @@ export class DraftUsfmFormatComponent extends DataLoadingComponent implements Af private getChaptersWithDrafts(bookNum: number, project: SFProjectProfile): number[] { return ( project.texts - .find(t => t.bookNum === bookNum) - ?.chapters.filter(c => !!c.hasDraft && c.lastVerse > 0) + .find(t => t.bookNum === bookNum && this.projectService.hasDraft(project, t.bookNum, true)) + ?.chapters.filter(c => c.lastVerse > 0) .map(c => c.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 3f7bbf0da0..b47b4bd7ab 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 @@ -28,6 +28,7 @@ import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/te import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { SF_TYPE_REGISTRY } from '../../../core/models/sf-type-registry'; import { Revision } from '../../../core/paratext.service'; +import { SFProjectService } from '../../../core/sf-project.service'; import { BuildDto } from '../../../machine-api/build-dto'; import { BuildStates } from '../../../machine-api/build-states'; import { provideQuillRegistrations } from '../../../shared/text/quill-editor-registration/quill-providers'; @@ -46,6 +47,7 @@ const mockDialogService = mock(DialogService); const mockNoticeService = mock(NoticeService); const mockErrorReportingService = mock(ErrorReportingService); const mockFeatureFlagService = mock(FeatureFlagService); +const mockSFProjectService = mock(SFProjectService); describe('EditorDraftComponent', () => { let fixture: ComponentFixture; @@ -77,6 +79,7 @@ describe('EditorDraftComponent', () => { { provide: NoticeService, useMock: mockNoticeService }, { provide: ErrorReportingService, useMock: mockErrorReportingService }, { provide: FeatureFlagService, useMock: mockFeatureFlagService }, + { provide: SFProjectService, useMock: mockSFProjectService }, provideNoopAnimations() ] })); @@ -99,6 +102,7 @@ describe('EditorDraftComponent', () => { of({ state: BuildStates.Completed } as BuildDto) ); when(mockDraftHandlingService.opsHaveContent(anything())).thenReturn(true); + when(mockSFProjectService.hasDraft(anything(), anything())).thenReturn(true); fixture = TestBed.createComponent(EditorDraftComponent); component = fixture.componentInstance; @@ -383,7 +387,7 @@ describe('EditorDraftComponent', () => { texts: [ { bookNum: 1, - chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator }, hasDraft: true }] + chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator } }] } ], translateConfig: { @@ -552,7 +556,7 @@ describe('EditorDraftComponent', () => { texts: [ { bookNum: 1, - chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator }, hasDraft: true }] + chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator } }] } ] }) @@ -578,7 +582,7 @@ describe('EditorDraftComponent', () => { texts: [ { bookNum: 1, - chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator }, hasDraft: true }] + chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator } }] } ] }) @@ -610,7 +614,7 @@ describe('EditorDraftComponent', () => { texts: [ { bookNum: 1, - chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator }, hasDraft: false }] + chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator } }] } ] }) @@ -618,6 +622,7 @@ describe('EditorDraftComponent', () => { when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(false)); when(mockActivatedProjectService.projectDoc$).thenReturn(of(testProjectDoc)); when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc)); + when(mockSFProjectService.hasDraft(anything(), anything())).thenReturn(false); when(mockDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn( of({ state: BuildStates.Completed } as BuildDto) @@ -636,7 +641,7 @@ describe('EditorDraftComponent', () => { texts: [ { bookNum: 1, - chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator }, hasDraft: true }] + chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator } }] } ] }) 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 95d00627dd..88f4d54388 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 @@ -158,10 +158,7 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { } get doesLatestCompletedHaveDraft(): boolean { - return ( - this.targetProject?.texts.find(t => t.bookNum === this.bookNum)?.chapters.find(c => c.number === this.chapter) - ?.hasDraft ?? false - ); + return this.projectService.hasDraft(this.targetProject, this.bookNum); } get isLatestBuildCompleted(): boolean { 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 50246dbd33..1ab0d378b2 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 @@ -4142,6 +4142,7 @@ describe('EditorComponent', () => { Object.defineProperty(env.component, 'showSource', { get: () => true }); }); when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + when(mockedSFProjectService.hasDraft(anything(), anything())).thenReturn(true); env.wait(); env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); env.wait(); @@ -4160,6 +4161,7 @@ describe('EditorComponent', () => { Object.defineProperty(env.component, 'showSource', { get: () => false }); }); when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + when(mockedSFProjectService.hasDraft(anything(), anything())).thenReturn(true); env.wait(); env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); env.wait(); @@ -4198,6 +4200,7 @@ describe('EditorComponent', () => { Object.defineProperty(env.component, 'showSource', { get: () => true }); }); when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + when(mockedSFProjectService.hasDraft(anything(), anything())).thenReturn(true); env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); env.wait(); @@ -4205,6 +4208,7 @@ describe('EditorComponent', () => { expect(sourceTabGroup?.tabs[1].type).toEqual('draft'); expect(env.component.chapter).toBe(1); + when(mockedSFProjectService.hasDraft(anything(), anything())).thenReturn(false); env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); env.wait(); @@ -4234,6 +4238,7 @@ describe('EditorComponent', () => { Object.defineProperty(env.component, 'showSource', { get: () => false }); }); when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + when(mockedSFProjectService.hasDraft(anything(), anything())).thenReturn(true); env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); env.wait(); @@ -4241,6 +4246,7 @@ describe('EditorComponent', () => { expect(targetTabGroup?.tabs[1].type).toEqual('draft'); expect(env.component.chapter).toBe(1); + when(mockedSFProjectService.hasDraft(anything(), anything())).thenReturn(false); env.routeWithParams({ projectId: 'project01', bookId: 'MAT', chapter: '2' }); env.wait(); @@ -4283,6 +4289,7 @@ describe('EditorComponent', () => { it('should not select the draft tab if url query param is not set', fakeAsync(() => { const env = new TestEnvironment(); when(mockedActivatedRoute.snapshot).thenReturn({ queryParams: {} } as any); + when(mockedSFProjectService.hasDraft(anything(), anything())).thenReturn(true); when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); env.wait(); env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' }); @@ -4780,8 +4787,7 @@ class TestEnvironment { number: 1, lastVerse: 3, isValid: true, - permissions: this.textInfoPermissions, - hasDraft: true + permissions: this.textInfoPermissions }, { number: 2, @@ -4791,15 +4797,13 @@ class TestEnvironment { user01: TextInfoPermission.Write, user02: TextInfoPermission.None, user03: TextInfoPermission.Write - }, - hasDraft: false + } }, { number: 3, lastVerse: 3, isValid: true, - permissions: this.textInfoPermissions, - hasDraft: false + permissions: this.textInfoPermissions } ], hasSource: false, 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 e62fbed57d..23a86d1180 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 @@ -1516,7 +1516,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, private async updateDraftTabVisibility(): Promise { const chapter: Chapter | undefined = this.text?.chapters.find(c => c.number === this.chapter); - const hasDraft: boolean = chapter?.hasDraft ?? false; + const hasDraft: boolean = this.projectService.hasDraft(this.projectDoc?.data, this.bookNum); const draftApplied: boolean = chapter?.draftApplied ?? false; const existingDraftTab: { groupId: EditorTabGroupType; index: number } | undefined = this.tabState.getFirstTabOfTypeIndex('draft'); 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 8937541d41..9a9bd29d31 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 @@ -15,6 +15,7 @@ import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { SF_TYPE_REGISTRY } from '../../../core/models/sf-type-registry'; import { PermissionsService } from '../../../core/permissions.service'; +import { SFProjectService } from '../../../core/sf-project.service'; import { TabStateService } from '../../../shared/sf-tab-group'; import { DraftGenerationService } from '../../draft-generation/draft-generation.service'; import { DraftOptionsService } from '../../draft-generation/draft-options.service'; @@ -26,6 +27,7 @@ const mockActivatedProject = mock(ActivatedProjectService); const mockTabState: TabStateService = mock(TabStateService); const mockUserService = mock(UserService); const mockPermissionsService = mock(PermissionsService); +const mockSFProjectService = mock(SFProjectService); const mockDraftOptionsService = mock(DraftOptionsService); const mockDraftGenerationService = mock(DraftGenerationService); @@ -40,6 +42,7 @@ describe('EditorTabMenuService', () => { { provide: TabStateService, useMock: mockTabState }, { provide: UserService, useMock: mockUserService }, { provide: PermissionsService, useMock: mockPermissionsService }, + { provide: SFProjectService, useMock: mockSFProjectService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: DraftOptionsService, useMock: mockDraftOptionsService }, { provide: DraftGenerationService, useMock: mockDraftGenerationService } @@ -47,7 +50,7 @@ describe('EditorTabMenuService', () => { })); it('should get "history", "draft", and "project-resource" menu items', async () => { - const env = new TestEnvironment(); + const env = new TestEnvironment(undefined, { hasCompletedDraftBuild: true }); env.setExistingTabs([{ id: uuid(), type: 'history', headerText$: of('History'), closeable: true, movable: true }]); service['canShowHistory'] = () => true; service['canShowResource'] = () => true; @@ -84,7 +87,7 @@ describe('EditorTabMenuService', () => { }); it('should get "draft", "project-resource", and not "history" menu items', async () => { - const env = new TestEnvironment(); + const env = new TestEnvironment(undefined, { hasCompletedDraftBuild: true }); env.setExistingTabs([]); service['canShowHistory'] = () => false; service['canShowResource'] = () => true; @@ -160,7 +163,7 @@ describe('EditorTabMenuService', () => { }); it('should handle offline', async () => { - const env = new TestEnvironment(); + const env = new TestEnvironment(undefined, { hasCompletedDraftBuild: true }); env.setExistingTabs([]); service['canShowHistory'] = () => true; service['canShowResource'] = () => true; @@ -241,8 +244,8 @@ class TestEnvironment { id: 'project-no-draft', data: createTestProjectProfile({ texts: [ - { bookNum: 40, chapters: [{ number: 1, hasDraft: false }] }, - { bookNum: 41, chapters: [{ number: 1, hasDraft: false }] } + { bookNum: 40, chapters: [{ number: 1 }] }, + { bookNum: 41, chapters: [{ number: 1 }] } ] }) } as SFProjectProfileDoc; @@ -254,8 +257,8 @@ class TestEnvironment { id: 'project1', data: createTestProjectProfile({ texts: [ - { bookNum: 40, chapters: [{ number: 1, hasDraft: false }] }, - { bookNum: 41, chapters: [{ number: 1, hasDraft: true }] } + { bookNum: 40, chapters: [{ number: 1 }] }, + { bookNum: 41, chapters: [{ number: 1 }] } ], translateConfig: { preTranslate: true @@ -283,6 +286,7 @@ class TestEnvironment { ); when(mockUserService.currentUserId).thenReturn('user01'); when(mockPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true); + when(mockSFProjectService.hasDraft(anything())).thenReturn(options?.hasCompletedDraftBuild ?? false); service = TestBed.inject(EditorTabMenuService); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-menu.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-menu.service.ts index d466030089..31fcdf115e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-menu.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/tabs/editor-tab-menu.service.ts @@ -27,7 +27,7 @@ export class EditorTabMenuService implements TabMenuService map( projectDoc => projectDoc?.data != null && - SFProjectService.hasDraft(projectDoc.data) && + this.projectService.hasDraft(projectDoc.data) && this.permissionsService.canAccessDrafts(projectDoc, this.userService.currentUserId) ), distinctUntilChanged() @@ -57,7 +57,8 @@ export class EditorTabMenuService implements TabMenuService private readonly permissionsService: PermissionsService, private readonly i18n: I18nService, private readonly draftOptionsService: DraftOptionsService, - private readonly draftGenerationService: DraftGenerationService + private readonly draftGenerationService: DraftGenerationService, + private readonly projectService: SFProjectService ) {} getMenuItems(): Observable { From aa46b5b891a31d10aedb141f5afbb2412ce6cc9a Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Wed, 12 Nov 2025 14:25:28 +1300 Subject: [PATCH 3/4] Migrate HasDraft to CurrentScriptureRange and DraftedScriptureRange --- src/SIL.XForge.Scripture/Models/Chapter.cs | 3 + .../Models/DraftConfig.cs | 10 + .../Services/IPreTranslationService.cs | 2 - .../Services/MachineApiService.cs | 89 ++++++--- .../Services/MachineProjectService.cs | 3 + .../Services/ParatextService.cs | 4 +- .../Services/ParatextSyncRunner.cs | 1 - .../Services/PreTranslationService.cs | 116 ----------- .../Services/MachineApiServiceTests.cs | 117 +++++++++-- .../Services/ParatextServiceTests.cs | 2 +- .../Services/ParatextSyncRunnerTests.cs | 2 - .../Services/PreTranslationServiceTests.cs | 188 +----------------- 12 files changed, 176 insertions(+), 361 deletions(-) diff --git a/src/SIL.XForge.Scripture/Models/Chapter.cs b/src/SIL.XForge.Scripture/Models/Chapter.cs index 527b9c2038..b65bb9ff87 100644 --- a/src/SIL.XForge.Scripture/Models/Chapter.cs +++ b/src/SIL.XForge.Scripture/Models/Chapter.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace SIL.XForge.Scripture.Models; @@ -22,6 +23,8 @@ public class Chapter public bool IsValid { get; set; } public Dictionary Permissions { get; set; } = []; public bool? HasAudio { get; set; } + + [Obsolete("Use DraftedScriptureRange instead.")] public bool? HasDraft { get; set; } public bool? DraftApplied { get; set; } } diff --git a/src/SIL.XForge.Scripture/Models/DraftConfig.cs b/src/SIL.XForge.Scripture/Models/DraftConfig.cs index c2244c5089..3578a1d3ca 100644 --- a/src/SIL.XForge.Scripture/Models/DraftConfig.cs +++ b/src/SIL.XForge.Scripture/Models/DraftConfig.cs @@ -14,4 +14,14 @@ public class DraftConfig public string? ServalConfig { get; set; } public DraftUsfmConfig? UsfmConfig { get; set; } public bool? SendEmailOnBuildFinished { get; set; } + + /// + /// A scripture range containing the books that are in the current draft on Serval. + /// + public string? CurrentScriptureRange { get; set; } + + /// + /// A scripture range containing the books that have been drafted and are available in Scripture Forge. + /// + public string? DraftedScriptureRange { get; set; } } diff --git a/src/SIL.XForge.Scripture/Services/IPreTranslationService.cs b/src/SIL.XForge.Scripture/Services/IPreTranslationService.cs index 0d5a1eca64..1bd20c9283 100644 --- a/src/SIL.XForge.Scripture/Services/IPreTranslationService.cs +++ b/src/SIL.XForge.Scripture/Services/IPreTranslationService.cs @@ -20,6 +20,4 @@ Task GetPreTranslationUsfmAsync( DraftUsfmConfig config, CancellationToken cancellationToken ); - - Task UpdatePreTranslationStatusAsync(string sfProjectId, CancellationToken cancellationToken); } diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index 40f08ce722..cfe74c7280 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -1091,10 +1091,6 @@ await translationEnginesClient.GetAllBuildsAsync(translationEngineId, cancellati ?? VerseRef.defaultVersification; ScriptureRangeParser scriptureRangeParser = new ScriptureRangeParser(versification); - - // Create the dictionary of scripture range bookIds and bookNums to check against the project texts - Dictionary scriptureRangeBooksWithDraft = []; - foreach (PretranslateCorpus ptc in pretranslateCorpus) { // We are using the TranslationBuild.Pretranslate.SourceFilters.ScriptureRange to find the @@ -1111,7 +1107,6 @@ await translationEnginesClient.GetAllBuildsAsync(translationEngineId, cancellati ) { int bookNum = Canon.BookIdToNumber(book); - scriptureRangeBooksWithDraft.Add(book, bookNum); // Ensure that if chapters is blank, it contains every chapter in the book List chapters = bookChapters; if (chapters.Count == 0) @@ -1139,23 +1134,12 @@ await translationEnginesClient.GetAllBuildsAsync(translationEngineId, cancellati } } - // check if any chapters from the scripture range are marked as HasDraft = false or null - bool hasDraftIsFalseOrNullInScriptureRange = - scriptureRangeBooksWithDraft.Count > 0 - && scriptureRangeBooksWithDraft.All(kvp => - { - return project.Texts.Any(text => - text.BookNum == kvp.Value - && text.Chapters.Where(chapter => - scriptureRangesWithDrafts[kvp.Key].Contains(chapter.Number) - ) - .Any(c => !(c.HasDraft ?? false)) - ); - }); - - if (hasDraftIsFalseOrNullInScriptureRange) + // See if the current scripture range has changed + if ( + project.TranslateConfig.DraftConfig.CurrentScriptureRange + != string.Join(';', scriptureRangesWithDrafts.Keys) + ) { - // Chapters HasDraft is missing or false but should be true, retrieve the pre-translation status to update them. backgroundJobClient.Enqueue(r => r.RetrievePreTranslationStatusAsync(sfProjectId, CancellationToken.None) ); @@ -1884,11 +1868,11 @@ out SFProjectSecret projectSecret { // Get the last completed build string translationEngineId = GetTranslationEngineId(projectSecret, preTranslate: true); - TranslationBuild? translationBuild = ( - await translationEnginesClient.GetAllBuildsAsync(translationEngineId, cancellationToken) - ) - .Where(b => b.State == JobState.Completed) - .MaxBy(b => b.DateFinished); + TranslationBuild translationBuild = + (await translationEnginesClient.GetAllBuildsAsync(translationEngineId, cancellationToken)) + .Where(b => b.State == JobState.Completed) + .MaxBy(b => b.DateFinished) + ?? throw new DataNotFoundException("The build does not exist."); // Set the retrieved flag as in progress await projectSecrets.UpdateAsync( @@ -1897,12 +1881,49 @@ await projectSecrets.UpdateAsync( cancellationToken: cancellationToken ); - // Get the pre-translations - await preTranslationService.UpdatePreTranslationStatusAsync(sfProjectId, cancellationToken); + // Connect to the realtime server to get the project + await using IConnection conn = await realtimeService.ConnectAsync(); + IDocument projectDoc = await conn.FetchAsync(sfProjectId); + if (!projectDoc.IsLoaded) + { + throw new DataNotFoundException("The project does not exist."); + } + + // Store the CurrentScriptureRange based on the books we asked Serval to draft + string currentScriptureRange = string.Join( + ';', + translationBuild + .Pretranslate?.SelectMany(p => p.SourceFilters) + .Where(f => f.ScriptureRange != null) + .Select(f => f.ScriptureRange) ?? [] + ); + if (string.IsNullOrWhiteSpace(currentScriptureRange)) + { + throw new DataNotFoundException( + "The latest completed build does not have a valid scripture range." + ); + } + + // Store the current scripture range + await projectDoc.SubmitJson0OpAsync(u => + u.Set(p => p.TranslateConfig.DraftConfig.CurrentScriptureRange, currentScriptureRange) + ); // Update the pre-translation text documents await UpdatePreTranslationTextDocumentsAsync(sfProjectId, cancellationToken); + // Update the drafted scripture range to include the current scripture range + string draftedScriptureRange = + projectDoc.Data.TranslateConfig.DraftConfig.DraftedScriptureRange ?? string.Empty; + ScriptureRangeParser scriptureRangeParser = new ScriptureRangeParser(); + List currentBooks = [.. scriptureRangeParser.GetChapters(currentScriptureRange).Keys]; + List draftedBooks = [.. scriptureRangeParser.GetChapters(draftedScriptureRange).Keys]; + List allBooks = [.. currentBooks, .. draftedBooks]; + draftedScriptureRange = string.Join(';', allBooks.Distinct()); + await projectDoc.SubmitJson0OpAsync(u => + u.Set(p => p.TranslateConfig.DraftConfig.DraftedScriptureRange, draftedScriptureRange) + ); + // Set the retrieved flag as complete await projectSecrets.UpdateAsync( sfProjectId, @@ -1915,13 +1936,13 @@ await hubContext.NotifyBuildProgress( sfProjectId, new ServalBuildState { - BuildId = translationBuild?.Id, + BuildId = translationBuild.Id, State = nameof(ServalData.PreTranslationsRetrieved), } ); // Return the build id - return translationBuild?.Id; + return translationBuild.Id; } } catch (TaskCanceledException e) when (e.InnerException is not TimeoutException) @@ -2261,11 +2282,15 @@ out SFProjectSecret projectSecret } // For every text we have a draft applied to, get the pre-translation - foreach (TextInfo textInfo in projectDoc.Data.Texts.Where(t => t.Chapters.Any(c => c.HasDraft == true))) + foreach ( + string bookId in ScriptureRangeParser + .GetChapters(projectDoc.Data.TranslateConfig.DraftConfig.CurrentScriptureRange) + .Keys + ) { // Set up variables string paratextId = projectDoc.Data.ParatextId; - int bookNum = textInfo.BookNum; + int bookNum = Canon.BookIdToNumber(bookId); int chapterNum = 0; // Get the USFM diff --git a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs index c4c089f9a1..2bcbdf7060 100644 --- a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs @@ -637,6 +637,9 @@ await RecreateOrUpdateTranslationEngineIfRequiredAsync( cancellationToken ); + // Clear the current scripture range, as the corpora will be replaced, clearing the draft on Serval + await projectDoc.SubmitJson0OpAsync(u => u.Unset(p => p.TranslateConfig.DraftConfig.CurrentScriptureRange)); + // Perform the file and corpora sync with Serval IList corporaSyncInfo = await SyncProjectCorporaAsync( curUserId, diff --git a/src/SIL.XForge.Scripture/Services/ParatextService.cs b/src/SIL.XForge.Scripture/Services/ParatextService.cs index b70cf53b35..a98d6dd72a 100644 --- a/src/SIL.XForge.Scripture/Services/ParatextService.cs +++ b/src/SIL.XForge.Scripture/Services/ParatextService.cs @@ -2552,7 +2552,9 @@ IEnumerable projectsMetadata // Determine if there is a draft bool hasDraft = isDraftingEnabled - && correspondingSfProject?.Texts.Any(t => t.Chapters.Any(c => c.HasDraft == true)) == true; + && !string.IsNullOrWhiteSpace( + correspondingSfProject?.TranslateConfig.DraftConfig.DraftedScriptureRange + ); // Determine if the project has an update pending bool hasUpdate = diff --git a/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs b/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs index 971288e49f..29735d225a 100644 --- a/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs +++ b/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs @@ -1241,7 +1241,6 @@ private static List UpdateNewChapters(SFProject project, int bookNum, L if (oldChapter is not null) { newChapter.HasAudio = oldChapter.HasAudio; - newChapter.HasDraft = oldChapter.HasDraft; newChapter.DraftApplied = oldChapter.DraftApplied; } } diff --git a/src/SIL.XForge.Scripture/Services/PreTranslationService.cs b/src/SIL.XForge.Scripture/Services/PreTranslationService.cs index 5d38f0407e..849f9d1b0e 100644 --- a/src/SIL.XForge.Scripture/Services/PreTranslationService.cs +++ b/src/SIL.XForge.Scripture/Services/PreTranslationService.cs @@ -8,8 +8,6 @@ using Serval.Client; using SIL.Scripture; using SIL.XForge.DataAccess; -using SIL.XForge.Realtime; -using SIL.XForge.Realtime.Json0; using SIL.XForge.Scripture.Models; using SIL.XForge.Services; @@ -17,7 +15,6 @@ namespace SIL.XForge.Scripture.Services; public class PreTranslationService( IRepository projectSecrets, - IRealtimeService realtimeService, ITranslationEnginesClient translationEnginesClient ) : IPreTranslationService { @@ -275,119 +272,6 @@ CancellationToken cancellationToken return string.Empty; } - public async Task UpdatePreTranslationStatusAsync(string sfProjectId, CancellationToken cancellationToken) - { - // Load the project from the realtime service - await using IConnection conn = await realtimeService.ConnectAsync(); - IDocument projectDoc = await conn.FetchAsync(sfProjectId); - if (!projectDoc.IsLoaded) - { - throw new DataNotFoundException("The project does not exist."); - } - - // Ensure we have the parameters to retrieve the pre-translation - (string? translationEngineId, string corpusId, string parallelCorpusId, bool useParatextVerseRef) = - await GetPreTranslationParametersAsync(sfProjectId); - - // Get all the pre-translations and update the chapters - IList preTranslations; - if (parallelCorpusId is not null) - { - preTranslations = await translationEnginesClient.GetAllPretranslationsAsync( - translationEngineId, - parallelCorpusId, - textId: null, - cancellationToken - ); - } - else - { - // Retrieve the pre-translations from a legacy corpus -#pragma warning disable CS0612 // Type or member is obsolete - preTranslations = await translationEnginesClient.GetAllCorpusPretranslationsAsync( - translationEngineId, - corpusId, - textId: null, - cancellationToken - ); -#pragma warning restore CS0612 // Type or member is obsolete - } - Dictionary> bookChapters = []; - foreach (Pretranslation preTranslation in preTranslations) - { - // Get the book and chapter number - int bookNum; - int chapterNum; - if (useParatextVerseRef) - { - // The file format is FileFormat.Paratext - // We need to get the chapter number from the reference, as the textId is the book code - // A reference will be in the format: MAT 1:2 or MAT 1:2/1:p - string reference = preTranslation.Refs.FirstOrDefault() ?? string.Empty; - - // If there is a forward slash, in the reference, the first half is the verse reference - if (reference.Contains('/', StringComparison.OrdinalIgnoreCase)) - { - reference = reference.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); - } - - // Ensure we have a valid verse reference and it is for this chapter - if (string.IsNullOrWhiteSpace(reference) || !VerseRef.TryParse(reference, out VerseRef verseRef)) - { - continue; - } - - bookNum = verseRef.BookNum; - chapterNum = verseRef.ChapterNum; - } - else - { - // The textId will be in the format bookNum_chapterNum - string[] textIdParts = preTranslation.TextId.Split('_', StringSplitOptions.RemoveEmptyEntries); - if ( - textIdParts.Length != 2 - || !int.TryParse(textIdParts[0], out bookNum) - || !int.TryParse(textIdParts[1], out chapterNum) - ) - { - continue; - } - } - - // Store the book number and chapter number - if (bookChapters.TryGetValue(bookNum, out HashSet value)) - { - // The HashSet stops duplicate chapter numbers for this book - value.Add(chapterNum); - } - else - { - bookChapters.Add(bookNum, [chapterNum]); - } - } - - // Update the project chapters - await projectDoc.SubmitJson0OpAsync(op => - { - for (int i = 0; i < projectDoc.Data.Texts.Count; i++) - { - for (int j = 0; j < projectDoc.Data.Texts[i].Chapters.Count; j++) - { - // As we will use these in a closure, instantiate to stop out of scope modification - int textIndex = i; - int chapterIndex = j; - bool hasDraft = - bookChapters.TryGetValue(projectDoc.Data.Texts[i].BookNum, out HashSet chapters) - && chapters.Contains(projectDoc.Data.Texts[i].Chapters[chapterIndex].Number); - - // Update the has draft value for the chapter - op.Set(p => p.Texts[textIndex].Chapters[chapterIndex].HasDraft, hasDraft); - op.Unset(p => p.Texts[textIndex].Chapters[chapterIndex].DraftApplied); - } - } - }); - } - /// /// Gets the required parameters from the project secret to retrieve the pre-translations. /// diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index b646c9af59..22d7545ea6 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -1914,8 +1914,7 @@ public async Task GetLastCompletedPreTranslationBuildAsync_RetrievePreTranslatio ] ) ); - SFProject project = env.Projects.Get(Project01); - project.Texts[0].Chapters[1].HasDraft = true; + _ = env.Projects.Get(Project01); // SUT ServalBuildDto? actual = await env.Service.GetLastCompletedPreTranslationBuildAsync( @@ -3556,9 +3555,20 @@ await env.QueueBuildAsync( [Test] public async Task RetrievePreTranslationStatusAsync_DoesNotRecordTaskCancellation() { - // Set up test environment + // Set up test environment with a completed build var env = new TestEnvironment(); - env.PreTranslationService.UpdatePreTranslationStatusAsync(Project01, CancellationToken.None) + env.ConfigureTranslationBuild( + new TranslationBuild + { + State = JobState.Completed, + Pretranslate = + [ + new PretranslateCorpus { SourceFilters = [new ParallelCorpusFilter { ScriptureRange = "MAT" }] }, + ], + } + ); + env.Service.Configure() + .UpdatePreTranslationTextDocumentsAsync(Project01, CancellationToken.None) .Throws(new TaskCanceledException()); // SUT @@ -3571,25 +3581,46 @@ public async Task RetrievePreTranslationStatusAsync_DoesNotRecordTaskCancellatio [Test] public async Task RetrievePreTranslationStatusAsync_DoesNotUpdateIfAlreadyRunning() { - // Set up test environment + // Set up test environment with a completed build var env = new TestEnvironment(); + env.ConfigureTranslationBuild( + new TranslationBuild + { + State = JobState.Completed, + Pretranslate = + [ + new PretranslateCorpus { SourceFilters = [new ParallelCorpusFilter { ScriptureRange = "MAT" }] }, + ], + } + ); + env.Service.Configure() + .UpdatePreTranslationTextDocumentsAsync(Project01, CancellationToken.None) + .Returns(Task.CompletedTask); await env.ProjectSecrets.UpdateAsync(Project01, u => u.Set(p => p.ServalData.PreTranslationsRetrieved, false)); // SUT await env.Service.RetrievePreTranslationStatusAsync(Project01, CancellationToken.None); - await env - .PreTranslationService.DidNotReceive() - .UpdatePreTranslationStatusAsync(Project01, CancellationToken.None); + await env.Service.DidNotReceive().UpdatePreTranslationTextDocumentsAsync(Project01, CancellationToken.None); } [Test] public void RetrievePreTranslationStatusAsync_ReportsErrors() { - // Set up test environment + // Set up test environment with a completed build var env = new TestEnvironment(); + env.ConfigureTranslationBuild( + new TranslationBuild + { + State = JobState.Completed, + Pretranslate = + [ + new PretranslateCorpus { SourceFilters = [new ParallelCorpusFilter { ScriptureRange = "MAT" }] }, + ], + } + ); ServalApiException ex = ServalApiExceptions.Forbidden; - env.PreTranslationService.UpdatePreTranslationStatusAsync(Project01, CancellationToken.None).Throws(ex); + env.Service.Configure().UpdatePreTranslationTextDocumentsAsync(Project01, CancellationToken.None).Throws(ex); // SUT Assert.ThrowsAsync(() => @@ -3606,19 +3637,53 @@ public async Task RetrievePreTranslationStatusAsync_ReportsErrorWhenProjectDoesN { // Set up test environment var env = new TestEnvironment(); + await env.Projects.DeleteAllAsync(_ => true); + env.ConfigureTranslationBuild( + new TranslationBuild + { + State = JobState.Completed, + Pretranslate = + [ + new PretranslateCorpus { SourceFilters = [new ParallelCorpusFilter { ScriptureRange = "MAT" }] }, + ], + } + ); // SUT - await env.Service.RetrievePreTranslationStatusAsync("invalid_project_id", CancellationToken.None); + await env.Service.RetrievePreTranslationStatusAsync(Project01, CancellationToken.None); env.ExceptionHandler.Received().ReportException(Arg.Any()); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationsRetrieved); } [Test] - public async Task RetrievePreTranslationStatusAsync_UpdatesPreTranslationStatusAndTextDocuments() + public async Task RetrievePreTranslationStatusAsync_ReportsErrorWhenProjectSecretDoesNotExist() { // Set up test environment var env = new TestEnvironment(); + await env.ProjectSecrets.DeleteAllAsync(_ => true); + + // SUT + await env.Service.RetrievePreTranslationStatusAsync(Project01, CancellationToken.None); + + env.ExceptionHandler.Received().ReportException(Arg.Any()); + } + + [Test] + public async Task RetrievePreTranslationStatusAsync_UpdatesPreTranslationStatusAndTextDocuments() + { + // Set up test environment with a completed build + var env = new TestEnvironment(); + env.ConfigureTranslationBuild( + new TranslationBuild + { + State = JobState.Completed, + Pretranslate = + [ + new PretranslateCorpus { SourceFilters = [new ParallelCorpusFilter { ScriptureRange = "MAT" }] }, + ], + } + ); env.Service.Configure() .UpdatePreTranslationTextDocumentsAsync(Project01, CancellationToken.None) .Returns(Task.CompletedTask); @@ -3626,21 +3691,33 @@ public async Task RetrievePreTranslationStatusAsync_UpdatesPreTranslationStatusA // SUT await env.Service.RetrievePreTranslationStatusAsync(Project01, CancellationToken.None); - await env.PreTranslationService.Received().UpdatePreTranslationStatusAsync(Project01, CancellationToken.None); await env.Service.Received().UpdatePreTranslationTextDocumentsAsync(Project01, CancellationToken.None); } [Test] public async Task RetrievePreTranslationStatusAsync_UpdatesPreTranslationStatusIfPreviouslyRun() { - // Set up test environment + // Set up test environment with a completed build var env = new TestEnvironment(); + env.ConfigureTranslationBuild( + new TranslationBuild + { + State = JobState.Completed, + Pretranslate = + [ + new PretranslateCorpus { SourceFilters = [new ParallelCorpusFilter { ScriptureRange = "MAT" }] }, + ], + } + ); + env.Service.Configure() + .UpdatePreTranslationTextDocumentsAsync(Project01, CancellationToken.None) + .Returns(Task.CompletedTask); await env.ProjectSecrets.UpdateAsync(Project01, u => u.Set(p => p.ServalData.PreTranslationsRetrieved, true)); // SUT await env.Service.RetrievePreTranslationStatusAsync(Project01, CancellationToken.None); - await env.PreTranslationService.Received().UpdatePreTranslationStatusAsync(Project01, CancellationToken.None); + await env.Service.Received().UpdatePreTranslationTextDocumentsAsync(Project01, CancellationToken.None); } [Test] @@ -4621,11 +4698,7 @@ public TestEnvironment() new TextInfo { BookNum = 1, - Chapters = - [ - new Chapter { Number = 1, HasDraft = true }, - new Chapter { Number = 2, HasDraft = false }, - ], + Chapters = [new Chapter { Number = 1 }, new Chapter { Number = 2 }], }, ], UserRoles = new Dictionary { { User01, SFProjectRole.Administrator } }, @@ -5121,6 +5194,10 @@ await ProjectSecrets.UpdateAsync( /// public void SetupTextDocument(string textDocumentId, int bookNum, bool alreadyExists) { + Projects.UpdateAsync( + Project01, + u => u.Set(p => p.TranslateConfig.DraftConfig.CurrentScriptureRange, Canon.BookNumberToId(bookNum)) + ); PreTranslationService .GetPreTranslationUsfmAsync(Project01, bookNum, 0, Arg.Any(), CancellationToken.None) .Returns(Task.FromResult(TestUsfm)); diff --git a/test/SIL.XForge.Scripture.Tests/Services/ParatextServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/ParatextServiceTests.cs index 6b514ffd4b..7be4b72aee 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/ParatextServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/ParatextServiceTests.cs @@ -312,7 +312,7 @@ public async Task GetProjectsAsync_HasDraft() // 1: Pre-translation enabled and a draft is present SFProject project1 = env.NewSFProject(env.Project01); project1.TranslateConfig.PreTranslate = true; - project1.Texts[0].Chapters[0].HasDraft = true; + project1.TranslateConfig.DraftConfig.DraftedScriptureRange = "MAT"; // 2: Pre-translation enabled and no draft is present SFProject project2 = env.NewSFProject(env.Project02); diff --git a/test/SIL.XForge.Scripture.Tests/Services/ParatextSyncRunnerTests.cs b/test/SIL.XForge.Scripture.Tests/Services/ParatextSyncRunnerTests.cs index 01092c039c..23dd5c2c05 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/ParatextSyncRunnerTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/ParatextSyncRunnerTests.cs @@ -499,7 +499,6 @@ await env { u.Set(p => p.Texts[0].Chapters[0].LastVerse, 9); u.Set(p => p.Texts[0].Chapters[0].HasAudio, true); - u.Set(p => p.Texts[0].Chapters[0].HasDraft, true); u.Set(p => p.Texts[0].Chapters[0].DraftApplied, true); } ); @@ -541,7 +540,6 @@ await env SFProject project = env.GetProject(); Assert.That(project.Texts[0].Chapters[0].LastVerse, Is.EqualTo(10)); Assert.That(project.Texts[0].Chapters[0].HasAudio, Is.True); - Assert.That(project.Texts[0].Chapters[0].HasDraft, Is.True); Assert.That(project.Texts[0].Chapters[0].DraftApplied, Is.True); } diff --git a/test/SIL.XForge.Scripture.Tests/Services/PreTranslationServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/PreTranslationServiceTests.cs index 5d6c533ab5..5bce6bcac8 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/PreTranslationServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/PreTranslationServiceTests.cs @@ -8,9 +8,7 @@ using NUnit.Framework; using Serval.Client; using SIL.XForge.DataAccess; -using SIL.XForge.Realtime; using SIL.XForge.Scripture.Models; -using SIL.XForge.Scripture.Realtime; using SIL.XForge.Services; namespace SIL.XForge.Scripture.Services; @@ -708,150 +706,6 @@ public async Task GetPreTranslationUsfmAsync_ReturnsEmptyStringForMissingChapter Assert.IsEmpty(usfm); } - [Test] - public void UpdatePreTranslationStatusAsync_ThrowsExceptionWhenProjectMissing() - { - // Set up test environment - var env = new TestEnvironment(); - - // SUT - Assert.ThrowsAsync(() => - env.Service.UpdatePreTranslationStatusAsync("invalid_project_id", CancellationToken.None) - ); - } - - [Test] - public async Task UpdatePreTranslationStatusAsync_NoDrafts() - { - // Set up test environment - var env = new TestEnvironment(new TestEnvironmentOptions { MockPreTranslationParameters = true }); - - env.TranslationEnginesClient.GetAllPretranslationsAsync( - TranslationEngine01, - Corpus01, - textId: null, - CancellationToken.None - ) - .Returns(Task.FromResult>([])); - - // SUT - await env.Service.UpdatePreTranslationStatusAsync(Project01, CancellationToken.None); - var project = env.RealtimeService.GetRepository().Get(Project01); - - // Validate HasDraft status for Matthew - Assert.AreEqual(40, project.Texts[0].BookNum); - Assert.AreEqual(1, project.Texts[0].Chapters[0].Number); - Assert.IsFalse(project.Texts[0].Chapters[0].HasDraft); - Assert.AreEqual(2, project.Texts[0].Chapters[1].Number); - Assert.IsFalse(project.Texts[0].Chapters[1].HasDraft); - Assert.AreEqual(3, project.Texts[0].Chapters[2].Number); - Assert.IsFalse(project.Texts[0].Chapters[2].HasDraft); - - // Validate HasDraft status for Mark - Assert.AreEqual(41, project.Texts[1].BookNum); - Assert.AreEqual(1, project.Texts[1].Chapters[0].Number); - Assert.IsFalse(project.Texts[1].Chapters[0].HasDraft); - Assert.AreEqual(2, project.Texts[1].Chapters[1].Number); - Assert.IsFalse(project.Texts[1].Chapters[1].HasDraft); - Assert.AreEqual(3, project.Texts[1].Chapters[2].Number); - Assert.IsFalse(project.Texts[1].Chapters[2].HasDraft); - } - - [Test] - public async Task UpdatePreTranslationStatusAsync_Paratext() - { - // Set up test environment - var env = new TestEnvironment( - new TestEnvironmentOptions { MockPreTranslationParameters = true, UseParatextZipFile = true } - ); - - env.TranslationEnginesClient.GetAllPretranslationsAsync( - TranslationEngine01, - ParallelCorpus01, - textId: null, - CancellationToken.None - ) - .Returns( - Task.FromResult>( - [ - new Pretranslation { TextId = "MAT", Refs = ["MAT 1:1"] }, - new Pretranslation { TextId = "MRK", Refs = ["MRK 1:1"] }, - new Pretranslation { TextId = "MRK", Refs = ["MRK 1:2"] }, - new Pretranslation { TextId = "MRK", Refs = ["MRK 2:1/3:h"] }, - ] - ) - ); - - // SUT - await env.Service.UpdatePreTranslationStatusAsync(Project01, CancellationToken.None); - var project = env.RealtimeService.GetRepository().Get(Project01); - - // Validate HasDraft status for Matthew - Assert.AreEqual(40, project.Texts[0].BookNum); - Assert.AreEqual(1, project.Texts[0].Chapters[0].Number); - Assert.IsTrue(project.Texts[0].Chapters[0].HasDraft); - Assert.AreEqual(2, project.Texts[0].Chapters[1].Number); - Assert.IsFalse(project.Texts[0].Chapters[1].HasDraft); - Assert.AreEqual(3, project.Texts[0].Chapters[2].Number); - Assert.IsFalse(project.Texts[0].Chapters[2].HasDraft); - - // Validate HasDraft status for Mark - Assert.AreEqual(41, project.Texts[1].BookNum); - Assert.AreEqual(1, project.Texts[1].Chapters[0].Number); - Assert.IsTrue(project.Texts[1].Chapters[0].HasDraft); - Assert.AreEqual(2, project.Texts[1].Chapters[1].Number); - Assert.IsTrue(project.Texts[1].Chapters[1].HasDraft); - Assert.AreEqual(3, project.Texts[1].Chapters[2].Number); - Assert.IsFalse(project.Texts[1].Chapters[2].HasDraft); - } - - [Test] - [Obsolete("Tests legacy corpus")] - public async Task UpdatePreTranslationStatusAsync_Text() - { - // Set up test environment - var env = new TestEnvironment(new TestEnvironmentOptions { MockLegacyPreTranslationParameters = true }); - - env.TranslationEnginesClient.GetAllCorpusPretranslationsAsync( - TranslationEngine01, - Corpus01, - textId: null, - CancellationToken.None - ) - .Returns( - Task.FromResult>( - [ - new Pretranslation { TextId = "40_1" }, - new Pretranslation { TextId = "41_1" }, - new Pretranslation { TextId = "41_1" }, - new Pretranslation { TextId = "41_2" }, - ] - ) - ); - - // SUT - await env.Service.UpdatePreTranslationStatusAsync(Project01, CancellationToken.None); - var project = env.RealtimeService.GetRepository().Get(Project01); - - // Validate HasDraft status for Matthew - Assert.AreEqual(40, project.Texts[0].BookNum); - Assert.AreEqual(1, project.Texts[0].Chapters[0].Number); - Assert.IsTrue(project.Texts[0].Chapters[0].HasDraft); - Assert.AreEqual(2, project.Texts[0].Chapters[1].Number); - Assert.IsFalse(project.Texts[0].Chapters[1].HasDraft); - Assert.AreEqual(3, project.Texts[0].Chapters[2].Number); - Assert.IsFalse(project.Texts[0].Chapters[2].HasDraft); - - // Validate HasDraft status for Mark - Assert.AreEqual(41, project.Texts[1].BookNum); - Assert.AreEqual(1, project.Texts[1].Chapters[0].Number); - Assert.IsTrue(project.Texts[1].Chapters[0].HasDraft); - Assert.AreEqual(2, project.Texts[1].Chapters[1].Number); - Assert.IsTrue(project.Texts[1].Chapters[1].HasDraft); - Assert.AreEqual(3, project.Texts[1].Chapters[2].Number); - Assert.IsFalse(project.Texts[1].Chapters[2].HasDraft); - } - private class TestEnvironmentOptions { public bool MockLegacyPreTranslationParameters { get; init; } @@ -870,39 +724,6 @@ public TestEnvironment(TestEnvironmentOptions? options = null) { options ??= new TestEnvironmentOptions(); ProjectSecrets = new MemoryRepository([new SFProjectSecret { Id = Project01 }]); - - RealtimeService = new SFMemoryRealtimeService(); - SFProject[] sfProjects = - [ - new SFProject - { - Id = Project01, - Texts = - [ - new TextInfo - { - BookNum = 40, - Chapters = - [ - new Chapter { Number = 1, HasDraft = true }, - new Chapter { Number = 2, HasDraft = false }, - new Chapter { Number = 3, HasDraft = true }, - ], - }, - new TextInfo - { - BookNum = 41, - Chapters = - [ - new Chapter { Number = 1, HasDraft = false }, - new Chapter { Number = 2, HasDraft = null }, - new Chapter { Number = 3, HasDraft = null }, - ], - }, - ], - }, - ]; - RealtimeService.AddRepository("sf_projects", OTType.Json0, new MemoryRepository(sfProjects)); TranslationEnginesClient = Substitute.For(); TranslationEnginesClient .GetPretranslatedUsfmAsync( @@ -918,12 +739,8 @@ public TestEnvironment(TestEnvironmentOptions? options = null) cancellationToken: CancellationToken.None ) .Returns(MatthewBookUsfm); - Service = Substitute.ForPartsOf( - ProjectSecrets, - RealtimeService, - TranslationEnginesClient - ); - if (options.MockLegacyPreTranslationParameters) + Service = Substitute.ForPartsOf(ProjectSecrets, TranslationEnginesClient); + if (options.MockPreTranslationParameters) { Service .Configure() @@ -948,7 +765,6 @@ public TestEnvironment(TestEnvironmentOptions? options = null) } private MemoryRepository ProjectSecrets { get; } - public SFMemoryRealtimeService RealtimeService { get; } public PreTranslationService Service { get; } public ITranslationEnginesClient TranslationEnginesClient { get; } From ea4f271d57437154fd2457b8910c7952b0364a17 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 24 Nov 2025 12:18:24 +1300 Subject: [PATCH 4/4] Code review fixes --- .../scriptureforge/models/text-info.ts | 3 + .../services/sf-project-migrations.ts | 1 + .../draft-generation.component.spec.ts | 10 +- .../draft-generation.service.spec.ts | 21 ++-- .../draft-generation.service.ts | 3 +- .../draft-preview-books.component.spec.ts | 19 +--- .../Models/DraftConfig.cs | 3 + .../Services/MachineApiService.cs | 105 ++++++------------ .../Services/MachineApiServiceTests.cs | 2 +- .../Services/PreTranslationServiceTests.cs | 2 +- 10 files changed, 66 insertions(+), 103 deletions(-) diff --git a/src/RealtimeServer/scriptureforge/models/text-info.ts b/src/RealtimeServer/scriptureforge/models/text-info.ts index f7b2bbbc24..12d7c271d4 100644 --- a/src/RealtimeServer/scriptureforge/models/text-info.ts +++ b/src/RealtimeServer/scriptureforge/models/text-info.ts @@ -4,6 +4,9 @@ export interface Chapter { isValid: boolean; permissions: { [userRef: string]: string }; hasAudio?: boolean; + /** + * @deprecated Use SFProjectService.hasDraft() instead + */ hasDraft?: boolean; draftApplied?: boolean; } diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts index ee4baaa683..8587978b5c 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-migrations.ts @@ -638,6 +638,7 @@ class SFProjectMigration28 extends DocMigration { const ops: Op[] = []; if (doc.data?.texts != null && doc.data?.translateConfig?.draftConfig?.currentScriptureRange == null) { const currentScriptureRange = doc.data.texts + // eslint-disable-next-line @typescript-eslint/no-deprecated .filter((t: TextInfo) => t.chapters.some(c => c.hasDraft)) .map((t: TextInfo) => Canon.bookNumberToId(t.bookNum, '')) .filter((id: string) => id !== '') 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 356a0c0353..6903b5592e 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 @@ -4,6 +4,7 @@ import { MatDialogRef, MatDialogState } from '@angular/material/dialog'; import { provideRouter } from '@angular/router'; import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data'; +import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; @@ -158,8 +159,6 @@ describe('DraftGenerationComponent', () => { newDraftHistory: createTestFeatureFlag(false), usfmFormat: createTestFeatureFlag(false) }); - mockSFProjectService = jasmine.createSpyObj(['hasDraft']); - mockSFProjectService.hasDraft.and.returnValue(true); } static initProject(currentUserId: string, preTranslate: boolean = true): void { @@ -211,6 +210,13 @@ describe('DraftGenerationComponent', () => { projectDoc$: of(projectDoc), changes$: of(projectDoc) }); + const matchThisProject = { + asymmetricMatch: (proj: SFProjectProfile | undefined) => + proj != null && proj.paratextId === projectDoc.data?.paratextId + }; + mockSFProjectService = jasmine.createSpyObj(['hasDraft']); + mockSFProjectService.hasDraft.withArgs(matchThisProject).and.returnValue(preTranslate); + mockSFProjectService.hasDraft.withArgs(matchThisProject, jasmine.anything()).and.returnValue(preTranslate); } get configureDraftButton(): HTMLElement | 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 24404bda9d..3bd1ace53a 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 @@ -906,16 +906,18 @@ describe('DraftGenerationService', () => { const projectDoc: SFProjectProfileDoc = { id: projectId, data: createTestProjectProfile({ + translateConfig: { + draftConfig: { + currentScriptureRange: '1JN;2JN;3JN' + } + }, texts: [ { bookNum: 62, - chapters: [ - { number: 1, hasDraft: true }, - { number: 2, hasDraft: true } - ] + chapters: [{ number: 1 }, { number: 2 }] }, - { bookNum: 63, chapters: [{ number: 1, hasDraft: true }] }, - { bookNum: 64, chapters: [{ number: 1, hasDraft: true }] } + { bookNum: 63, chapters: [{ number: 1 }] }, + { bookNum: 64, chapters: [{ number: 1 }] } ] }) } as SFProjectProfileDoc; @@ -951,7 +953,12 @@ describe('DraftGenerationService', () => { const projectDoc: SFProjectProfileDoc = { id: projectId, data: createTestProjectProfile({ - texts: [{ bookNum: 62, chapters: [{ number: 1, hasDraft: true }] }] + translateConfig: { + draftConfig: { + currentScriptureRange: '1JN' + } + }, + texts: [{ bookNum: 62, chapters: [{ number: 1 }] }] }) } as SFProjectProfileDoc; const lastCompletedBuild: BuildDto = { 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 0756923eac..94bdb0bb9d 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 @@ -389,8 +389,7 @@ export class DraftGenerationService { if (books.size === 0) { books = new Set( // Legacy calculation for very old drafts - // eslint-disable-next-line @typescript-eslint/no-deprecated - projectDoc.data.texts.filter(text => text.chapters.some(c => c.hasDraft)).map(text => text.bookNum) + booksFromScriptureRange(projectDoc.data.translateConfig.draftConfig.currentScriptureRange) ); } 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 9956e283c5..af2997e812 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 @@ -142,21 +142,6 @@ describe('DraftPreviewBooks', () => { verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times(3); })); - it('can apply chapters with drafts and skips chapters without drafts', fakeAsync(() => { - env = new TestEnvironment(); - const bookWithDraft: BookWithDraft = env.booksWithDrafts[1]; - setupDialog('project01'); - when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).thenResolve( - undefined - ); - expect(env.getBookButtonAtIndex(1).querySelector('.book-more')).toBeTruthy(); - env.component.chooseProjectToAddDraft(bookWithDraft); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times(1); - })); - it('can apply a historic draft', fakeAsync(() => { env = new TestEnvironment({ additionalInfo: { @@ -174,7 +159,7 @@ 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(2); })); it('can open dialog with the current project', fakeAsync(() => { @@ -436,7 +421,7 @@ class TestEnvironment { 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: 2, bookId: 'EXO', canEdit: true, chaptersWithDrafts: [1, 2], draftApplied: false }, { bookNumber: 3, bookId: 'LEV', canEdit: false, chaptersWithDrafts: [1, 2], draftApplied: false } ]; diff --git a/src/SIL.XForge.Scripture/Models/DraftConfig.cs b/src/SIL.XForge.Scripture/Models/DraftConfig.cs index 3578a1d3ca..ff6f4bc863 100644 --- a/src/SIL.XForge.Scripture/Models/DraftConfig.cs +++ b/src/SIL.XForge.Scripture/Models/DraftConfig.cs @@ -23,5 +23,8 @@ public class DraftConfig /// /// A scripture range containing the books that have been drafted and are available in Scripture Forge. /// + /// + /// This is a combination of the scripture ranges of previous drafts. + /// public string? DraftedScriptureRange { get; set; } } diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index cfe74c7280..07881c97c8 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -1072,73 +1072,9 @@ await translationEnginesClient.GetAllBuildsAsync(translationEngineId, cancellati .MaxBy(b => b.DateFinished); if (translationBuild is not null) { - // Verify that each book/chapter from the translationBuild is marked HasDraft = true - // If the projects texts chapters are not all marked as having a draft, then the webhook likely failed - // and we want to retrieve the pre-translation status to update the chapters as having a draft - Dictionary> scriptureRangesWithDrafts = []; - - IList pretranslateCorpus = translationBuild.Pretranslate ?? []; - - // Retrieve the user secret - Attempt attempt = await userSecrets.TryGetAsync(curUserId, cancellationToken); - if (!attempt.TryResult(out UserSecret userSecret)) - { - throw new DataNotFoundException("The user does not exist."); - } - - ScrVers versification = - paratextService.GetParatextSettings(userSecret, project.ParatextId)?.Versification - ?? VerseRef.defaultVersification; - - ScriptureRangeParser scriptureRangeParser = new ScriptureRangeParser(versification); - foreach (PretranslateCorpus ptc in pretranslateCorpus) - { - // We are using the TranslationBuild.Pretranslate.SourceFilters.ScriptureRange to find the - // books selected for drafting. Some projects may have used the now obsolete field - // TranslationBuild.Pretranslate.ScriptureRange and will not get checked for webhook failures. - foreach ( - ParallelCorpusFilter source in ptc.SourceFilters?.Where(s => s.ScriptureRange is not null) ?? [] - ) - { - foreach ( - (string book, List bookChapters) in scriptureRangeParser.GetChapters( - source.ScriptureRange - ) - ) - { - int bookNum = Canon.BookIdToNumber(book); - // Ensure that if chapters is blank, it contains every chapter in the book - List chapters = bookChapters; - if (chapters.Count == 0) - { - chapters = [.. Enumerable.Range(1, versification.GetLastChapter(bookNum))]; - } - - // Set or merge the list of chapters - if (!scriptureRangesWithDrafts.TryGetValue(book, out List existingChapters)) - { - scriptureRangesWithDrafts[book] = chapters; - } - else - { - // Merge new chapters into existing list, avoiding duplicates - foreach (int chapter in chapters.Where(chapter => !existingChapters.Contains(chapter))) - { - existingChapters.Add(chapter); - } - - // Add existing chapters to the books chapter list - scriptureRangesWithDrafts[book].AddRange(existingChapters); - } - } - } - } - // See if the current scripture range has changed - if ( - project.TranslateConfig.DraftConfig.CurrentScriptureRange - != string.Join(';', scriptureRangesWithDrafts.Keys) - ) + string currentScriptureRange = GetDraftedScriptureRange(translationBuild); + if (project.TranslateConfig.DraftConfig.CurrentScriptureRange != currentScriptureRange) { backgroundJobClient.Enqueue(r => r.RetrievePreTranslationStatusAsync(sfProjectId, CancellationToken.None) @@ -1890,13 +1826,7 @@ await projectSecrets.UpdateAsync( } // Store the CurrentScriptureRange based on the books we asked Serval to draft - string currentScriptureRange = string.Join( - ';', - translationBuild - .Pretranslate?.SelectMany(p => p.SourceFilters) - .Where(f => f.ScriptureRange != null) - .Select(f => f.ScriptureRange) ?? [] - ); + string currentScriptureRange = GetDraftedScriptureRange(translationBuild); if (string.IsNullOrWhiteSpace(currentScriptureRange)) { throw new DataNotFoundException( @@ -2392,6 +2322,35 @@ private static ServalEngineDto CreateDto(TranslationEngine translationEngine) => TargetLanguageTag = translationEngine.TargetLanguage, }; + /// + /// Gets the drafted scripture range for a translation build. + /// + /// The translation build. + /// The scripture range that was drafted. + private static string GetDraftedScriptureRange(TranslationBuild translationBuild) + { + List booksWithDrafts = []; + ScriptureRangeParser scriptureRangeParser = new ScriptureRangeParser(); + foreach (PretranslateCorpus ptc in translationBuild.Pretranslate ?? []) + { + // We are using the TranslationBuild.Pretranslate.SourceFilters.ScriptureRange to find the + // books selected for drafting. Some projects may have used the now obsolete field + // TranslationBuild.Pretranslate.ScriptureRange and will not get checked for webhook failures. + foreach (ParallelCorpusFilter source in ptc.SourceFilters?.Where(s => s.ScriptureRange is not null) ?? []) + { + foreach ((string book, List _) in scriptureRangeParser.GetChapters(source.ScriptureRange)) + { + if (!booksWithDrafts.Contains(book)) + { + booksWithDrafts.Add(book); + } + } + } + } + + return string.Join(';', booksWithDrafts); + } + /// /// Gets the highest ranked user id on a project. /// diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 22d7545ea6..286f57a65d 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -3670,7 +3670,7 @@ public async Task RetrievePreTranslationStatusAsync_ReportsErrorWhenProjectSecre } [Test] - public async Task RetrievePreTranslationStatusAsync_UpdatesPreTranslationStatusAndTextDocuments() + public async Task RetrievePreTranslationStatusAsync_UpdatesPreTranslationTextDocuments() { // Set up test environment with a completed build var env = new TestEnvironment(); diff --git a/test/SIL.XForge.Scripture.Tests/Services/PreTranslationServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/PreTranslationServiceTests.cs index 5bce6bcac8..b397e8fd04 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/PreTranslationServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/PreTranslationServiceTests.cs @@ -740,7 +740,7 @@ public TestEnvironment(TestEnvironmentOptions? options = null) ) .Returns(MatthewBookUsfm); Service = Substitute.ForPartsOf(ProjectSecrets, TranslationEnginesClient); - if (options.MockPreTranslationParameters) + if (options.MockLegacyPreTranslationParameters) { Service .Configure()