diff --git a/src/RealtimeServer/common/index.ts b/src/RealtimeServer/common/index.ts index 0960de95d48..55f1bf966f5 100644 --- a/src/RealtimeServer/common/index.ts +++ b/src/RealtimeServer/common/index.ts @@ -210,7 +210,8 @@ export = { collection: string, id: string, data: any, - typeName: OTType + typeName: OTType, + source: string | undefined ): void => { if (server == null) { callback(new Error('Server not started.')); @@ -221,7 +222,17 @@ export = { callback(new Error('Connection not found.')); return; } - doc.create(data, typeName, err => callback(err, createSnapshot(doc))); + const options: any = {}; + doc.submitSource = source != null; + if (source != null) { + options.source = source; + } + doc.create(data, typeName, options, err => { + if (source != null) { + doc.submitSource = false; + } + callback(err, createSnapshot(doc)); + }); }, fetchDoc: (callback: InteropCallback, handle: number, collection: string, id: string): void => { diff --git a/src/RealtimeServer/common/utils/sharedb-utils.ts b/src/RealtimeServer/common/utils/sharedb-utils.ts index 176896d7287..1d41695a10b 100644 --- a/src/RealtimeServer/common/utils/sharedb-utils.ts +++ b/src/RealtimeServer/common/utils/sharedb-utils.ts @@ -13,9 +13,15 @@ export function docFetch(doc: Doc): Promise { }); } -export function docCreate(doc: Doc, data: any, type?: OTType): Promise { +export function docCreate( + doc: Doc, + data: any, + type?: OTType, + source: boolean | any | undefined = undefined +): Promise { + const options: ShareDBSourceOptions = source != null ? { source } : {}; return new Promise((resolve, reject) => { - doc.create(data, type, err => { + doc.create(data, type, options, err => { if (err != null) { reject(err); } else { diff --git a/src/RealtimeServer/common/utils/test-utils.ts b/src/RealtimeServer/common/utils/test-utils.ts index fddf41b3116..34811299ace 100644 --- a/src/RealtimeServer/common/utils/test-utils.ts +++ b/src/RealtimeServer/common/utils/test-utils.ts @@ -20,8 +20,19 @@ export async function hasDoc(conn: Connection, collection: string, id: string): return doc.data != null; } -export function createDoc(conn: Connection, collection: string, id: string, data: T, type?: OTType): Promise { - return docCreate(conn.get(collection, id), data, type); +export function createDoc( + conn: Connection, + collection: string, + id: string, + data: T, + type?: OTType, + source: boolean | any | undefined = undefined +): Promise { + const doc = conn.get(collection, id); + if (source != null) { + doc.submitSource = true; + } + return docCreate(doc, data, type, source); } export async function submitOp( diff --git a/src/RealtimeServer/scriptureforge/services/text-service.spec.ts b/src/RealtimeServer/scriptureforge/services/text-service.spec.ts index 34fb7cd6699..967ecc6fb70 100644 --- a/src/RealtimeServer/scriptureforge/services/text-service.spec.ts +++ b/src/RealtimeServer/scriptureforge/services/text-service.spec.ts @@ -75,6 +75,22 @@ describe('TextService', () => { }); }); }); + + it('writes the op source to the database on create', async () => { + const env = new TestEnvironment(); + await env.createData(); + + const conn = clientConnect(env.server, 'administrator'); + const id: string = getTextDocId('project01', 40, 2); + const source: string = 'history'; + await createDoc(conn, TEXTS_COLLECTION, id, new Delta(), 'rich-text', source); + await new Promise(resolve => { + env.db.getOps(TEXTS_COLLECTION, id, 0, null, { metadata: true }, (_, ops) => { + expect(ops[0].m.source).toBe(source); + resolve(); + }); + }); + }); }); class TestEnvironment { diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/generate-draft.ts b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/generate-draft.ts index f6554dc62da..49bee545fe6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/generate-draft.ts +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/generate-draft.ts @@ -1,6 +1,6 @@ import { expect } from 'npm:@playwright/test'; import { Locator, Page } from 'npm:playwright'; -import { preset, ScreenshotContext } from '../e2e-globals.ts'; +import { E2E_SYNC_DEFAULT_TIMEOUT, preset, ScreenshotContext } from '../e2e-globals.ts'; import { enableDeveloperMode, enableDraftingOnProjectAsServalAdmin, @@ -203,21 +203,27 @@ export async function generateDraft( await user.click(page.getByRole('button', { name: 'Save' })); // Preview and apply chapter 1 - await user.click(page.getByRole('radio', { name: bookToDraft })); + await user.click(page.getByRole('button', { name: bookToDraft, exact: true })); await user.click(page.getByRole('button', { name: 'Add to project' })); await user.click(page.getByRole('button', { name: 'Overwrite chapter' })); await user.click(page.locator('app-tab-header').filter({ hasText: DRAFT_PROJECT_SHORT_NAME })); // Go back to generate draft page and apply all chapters await user.click(page.getByRole('link', { name: 'Generate draft' })); - await user.click(page.locator('app-draft-preview-books mat-button-toggle:last-child button')); - await user.click(page.getByRole('menuitem', { name: 'Add to project' })); - await user.check(page.getByRole('checkbox', { name: /I understand the draft will overwrite .* in .* project/ })); await user.click(page.getByRole('button', { name: 'Add to project' })); - await expect( - page.getByRole('heading', { name: `Successfully applied all chapters to ${bookToDraft}` }) - ).toBeVisible(); - await user.click(page.getByRole('button', { name: 'Close' })); + await user.click(page.getByRole('combobox', { name: 'Choose a project' })); + await user.type(DRAFT_PROJECT_SHORT_NAME); + await user.click(page.getByRole('option', { name: `${DRAFT_PROJECT_SHORT_NAME} -` })); + await user.click(page.getByRole('button', { name: 'Next' })); + await user.check(page.getByRole('checkbox', { name: /I understand that existing content will be overwritten/ })); + await user.click(page.getByRole('button', { name: 'Import' })); + await expect(page.getByText('Import complete', { exact: true })).toBeVisible(); + await user.click(page.getByRole('button', { name: 'Next' })); + await user.click(page.locator('[data-test-id="step-7-sync"]')); + await expect(page.getByText(`The draft has been imported into ${DRAFT_PROJECT_SHORT_NAME}`)).toBeVisible({ + timeout: E2E_SYNC_DEFAULT_TIMEOUT + }); + await user.click(page.getByRole('button', { name: 'Done' })); await screenshot(page, { pageName: 'generate_draft_add_to_project', ...context }); diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/localized-screenshots.ts b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/localized-screenshots.ts index 17e99f2a9d6..8654a78ec6b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/localized-screenshots.ts +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/localized-screenshots.ts @@ -432,11 +432,11 @@ export async function localizedScreenshots( await user.click(page.getByRole('button', { name: 'Save' })); await forEachLocale(async locale => { - await user.hover(page.getByRole('radio').first(), defaultArrowLocation); + await user.hover(page.getByRole('button', { name: 'Ruth', exact: true }), defaultArrowLocation); await screenshot(page, { ...context, pageName: 'draft_complete', locale }); }); - await page.getByRole('radio', { name: 'Ruth' }).first().click(); + await user.click(page.getByRole('button', { name: 'Ruth', exact: true })); await expect(page.getByRole('button', { name: 'Add to project' })).toBeVisible({ timeout: 15_000 }); @@ -461,19 +461,16 @@ export async function localizedScreenshots( await expect(page.getByText('The draft is ready')).toBeVisible(); await forEachLocale(async locale => { - await page.getByRole('radio').nth(1).click(); - await user.hover(page.getByRole('menuitem').last(), defaultArrowLocation); + await user.hover(page.getByRole('button', { name: 'Add to project' }), defaultArrowLocation); await screenshot(page, { ...context, pageName: 'import_book', locale }); - await page.keyboard.press('Escape'); }); await forEachLocale(async locale => { - await page.getByRole('radio').nth(1).click(); - await page.getByRole('menuitem').last().click(); + await user.click(page.getByRole('button', { name: 'Add to project' })); + await page.getByRole('combobox').fill('seedsp2'); await page.getByRole('option', { name: 'seedsp2 - ' }).click(); - await page.getByRole('checkbox').check(); - await user.hover(page.getByRole('button').last(), defaultArrowLocation); + await user.hover(page.getByRole('button', { name: 'next' }), defaultArrowLocation); await screenshotElements( page, [page.locator('mat-dialog-container')], diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/project-notification.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/project-notification.service.ts index eb54a551a7a..7e91da306ab 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/project-notification.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/project-notification.service.ts @@ -1,5 +1,11 @@ import { Injectable } from '@angular/core'; -import { AbortError, HubConnection, HubConnectionBuilder, IHttpConnectionOptions } from '@microsoft/signalr'; +import { + AbortError, + HubConnection, + HubConnectionBuilder, + HubConnectionState, + IHttpConnectionOptions +} from '@microsoft/signalr'; import { AuthService } from 'xforge-common/auth.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -11,6 +17,7 @@ export class ProjectNotificationService { private options: IHttpConnectionOptions = { accessTokenFactory: async () => (await this.authService.getAccessToken()) ?? '' }; + private openConnections: number = 0; constructor( private authService: AuthService, @@ -30,6 +37,10 @@ export class ProjectNotificationService { this.connection.off('notifyBuildProgress', handler); } + removeNotifyDraftApplyProgressHandler(handler: any): void { + this.connection.off('notifyDraftApplyProgress', handler); + } + removeNotifySyncProgressHandler(handler: any): void { this.connection.off('notifySyncProgress', handler); } @@ -38,25 +49,39 @@ export class ProjectNotificationService { this.connection.on('notifyBuildProgress', handler); } + setNotifyDraftApplyProgressHandler(handler: any): void { + this.connection.on('notifyDraftApplyProgress', handler); + } + setNotifySyncProgressHandler(handler: any): void { this.connection.on('notifySyncProgress', handler); } async start(): Promise { - await this.connection.start().catch(err => { - // Suppress AbortErrors, as they are not caused by server error, but the SignalR connection state - // These will be thrown if a user navigates away quickly after - // starting the sync or the app loses internet connection - if (err instanceof AbortError || !this.appOnline) { - return; - } else { - throw err; - } - }); + this.openConnections++; + if ( + this.connection.state !== HubConnectionState.Connected && + this.connection.state !== HubConnectionState.Connecting && + this.connection.state !== HubConnectionState.Reconnecting + ) { + await this.connection.start().catch(err => { + // Suppress AbortErrors, as they are not caused by server error, but the SignalR connection state + // These will be thrown if a user navigates away quickly after + // starting the sync or the app loses internet connection + if (err instanceof AbortError || !this.appOnline) { + return; + } else { + throw err; + } + }); + } } async stop(): Promise { - await this.connection.stop(); + // Only stop the connection if this is the last open connection + if (--this.openConnections <= 0) { + await this.connection.stop(); + } } async subscribeToProject(projectId: string): Promise { 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 fc44c5dc633..f0f15c1ba43 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 @@ -119,15 +119,6 @@ describe('SFProjectService', () => { })); }); - describe('onlineAddChapters', () => { - it('should invoke the command service', fakeAsync(async () => { - const env = new TestEnvironment(); - await env.service.onlineAddChapters('project01', 1, [2, 3]); - verify(mockedCommandService.onlineInvoke(anything(), 'addChapters', anything())).once(); - expect().nothing(); - })); - }); - describe('onlineSetDraftApplied', () => { 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 d1b3c56174d..23589a7d6a5 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 @@ -236,10 +236,6 @@ export class SFProjectService extends ProjectService { }); } - onlineAddChapters(projectId: string, book: number, chapters: number[]): Promise { - return this.onlineInvoke('addChapters', { projectId, book, chapters }); - } - onlineUpdateSettings(id: string, settings: SFProjectSettings): Promise { return this.onlineInvoke('updateSettings', { projectId: id, settings }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts index 72b9819a4ff..8822e79a1c2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts @@ -183,25 +183,6 @@ describe('TextDocService', () => { }); }); - describe('createTextDoc', () => { - it('should throw error if text doc already exists', fakeAsync(() => { - const env = new TestEnvironment(); - expect(() => { - env.textDocService.createTextDoc(env.textDocId, getTextDoc(env.textDocId)); - tick(); - }).toThrowError(); - })); - - it('creates the text doc if it does not already exist', fakeAsync(async () => { - const env = new TestEnvironment(); - const textDocId = new TextDocId('project01', 40, 2); - const textDoc = await env.textDocService.createTextDoc(textDocId, getTextDoc(textDocId)); - tick(); - - expect(textDoc.data).toBeDefined(); - })); - }); - describe('isDataInSync', () => { it('should return true if the project is undefined', () => { const env = new TestEnvironment(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts index 2a56ec96d90..edb30060a10 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts @@ -6,9 +6,7 @@ import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scri import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; -import { type } from 'rich-text'; import { Observable, Subject } from 'rxjs'; -import { RealtimeService } from 'xforge-common/realtime.service'; import { UserService } from 'xforge-common/user.service'; import { TextDoc, TextDocId, TextDocSource } from './models/text-doc'; import { SFProjectService } from './sf-project.service'; @@ -21,8 +19,7 @@ export class TextDocService { constructor( private readonly projectService: SFProjectService, - private readonly userService: UserService, - private readonly realtimeService: RealtimeService + private readonly userService: UserService ) {} /** @@ -81,18 +78,6 @@ export class TextDocService { ); } - async createTextDoc(textDocId: TextDocId, data?: TextData): Promise { - let textDoc: TextDoc = await this.projectService.getText(textDocId); - - if (textDoc?.data != null) { - throw new Error(`Text Doc already exists for ${textDocId}`); - } - - data ??= { ops: [] }; - textDoc = await this.realtimeService.create(TextDoc.COLLECTION, textDocId.toString(), data, type.uri); - return textDoc; - } - /** * Determines if the data is in sync for the project. * diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/project-select/project-select.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/project-select/project-select.component.ts index 9b78165e32a..79c4b08fe72 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/project-select/project-select.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/project-select/project-select.component.ts @@ -49,7 +49,8 @@ const PROJECT_SELECT_VALUE_ACCESSOR: any = { ] }) export class ProjectSelectComponent implements ControlValueAccessor, OnDestroy { - @Output() valueChange: EventEmitter = new EventEmitter(true); + // Firefox will not in a timely way emit this asynchronously, so we must emit the valueChange event synchronously + @Output() valueChange: EventEmitter = new EventEmitter(); @Output() projectSelect = new EventEmitter(); @Input() placeholder = ''; @@ -172,31 +173,35 @@ export class ProjectSelectComponent implements ControlValueAccessor, OnDestroy { @Input() errorMessageMapper?: null | ((errors: ValidationErrors) => string | null) = null; - private externalValidators: ValidatorFn[] = []; + private externalValidators: ValidatorFn[] | 'disabled' = []; @Input() - set validators(value: ValidatorFn[]) { + set validators(value: ValidatorFn[] | 'disabled') { this.externalValidators = value; - const validators = [this.validateProject.bind(this)].concat(value); - for (const validator of validators) + const validators = value === 'disabled' ? null : [this.validateProject.bind(this)].concat(value); + for (const validator of validators ?? []) { if (typeof validator !== 'function') throw new Error(`The validator is not a function: ${validator}`); + } this.paratextIdControl.setValidators(validators); } - get validators(): ValidatorFn[] { + get validators(): ValidatorFn[] | 'disabled' { return this.externalValidators; } get error(): string | null { const errorStates = this.paratextIdControl.errors; - if (errorStates == null) return null; - else if (errorStates.invalidSelection === true) { + if (errorStates == null) { + return null; + } else if (errorStates.invalidSelection === true) { return translate('project_select.please_select_valid_project_or_resource'); - } else if (this.externalValidators.length > 0) { + } else if (this.externalValidators !== 'disabled' && this.externalValidators.length > 0) { const errorMessageMapper = this.errorMessageMapper; if (errorMessageMapper == null) { throw new Error('ProjectSelectComponent requires `errorMessageMapper` when `validators` are provided.'); } return errorMessageMapper(errorStates); - } else return null; + } else { + return null; + } } writeValue(value: any): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html deleted file mode 100644 index 796bc38b0cc..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html +++ /dev/null @@ -1,68 +0,0 @@ -@if (isLoading) { - -} - - -

{{ t("select_alternate_project") }}

-
-
- @if (!isLoading) { - - } - @if (!isAppOnline) { - {{ t("connect_to_the_internet") }} - } - @if (isValid) { -
- @if (targetChapters$ | async; as chapters) { - - {{ - i18n.getPluralRule(chapters) !== "one" - ? t("project_has_text_in_chapters", { bookName, numChapters: chapters, projectName }) - : t("project_has_text_in_one_chapter", { bookName, projectName }) - }} - - } @else { - {{ t("book_is_empty", { bookName, projectName }) }} - } -
- - {{ t("i_understand_overwrite_book", { projectName, bookName }) }} - - @if (addToProjectClicked && !overwriteConfirmed) { - {{ t("confirm_overwrite") }} - } - @if (projectHasMissingChapters()) { - - {{ t("i_understand_missing_chapters_are_created", { projectName, bookName }) }} - - @if (addToProjectClicked && !confirmCreateChapters) { - {{ t("confirm_create_chapters") }} - } - } - } -
-
- -
-
-
- - -
-
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.scss deleted file mode 100644 index 91007167c10..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.scss +++ /dev/null @@ -1,34 +0,0 @@ -@use 'src/variables'; - -.mat-mdc-dialog-actions { - justify-content: flex-end; -} - -.target-project-content { - display: flex; - align-items: center; - column-gap: 12px; - padding: 8px 0; -} - -.unlisted-project-message { - margin-top: 1em; -} - -a { - text-decoration: none; - color: initial; - cursor: initial; - - ::ng-deep u { - cursor: pointer; - &:hover { - color: variables.$theme-secondary; - } - } -} - -.overwrite-content, -.create-chapters { - inset-inline-start: -8px; -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts deleted file mode 100644 index 292a87ebf1f..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { OverlayContainer } from '@angular/cdk/overlay'; -import { HarnessLoader } from '@angular/cdk/testing'; -import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { Component } from '@angular/core'; -import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; -import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing'; -import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { provideNoopAnimations } from '@angular/platform-browser/animations'; -import { provideRouter, Route } from '@angular/router'; -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'; -import { of } from 'rxjs'; -import { anything, mock, verify, when } from 'ts-mockito'; -import { OnlineStatusService } from 'xforge-common/online-status.service'; -import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; -import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; -import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; -import { SFUserProjectsService } from 'xforge-common/user-projects.service'; -import { UserService } from 'xforge-common/user.service'; -import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; -import { TextDoc } from '../../../core/models/text-doc'; -import { SFProjectService } from '../../../core/sf-project.service'; -import { TextDocService } from '../../../core/text-doc.service'; -import { projectLabel } from '../../../shared/utils'; -import { DraftApplyDialogComponent } from './draft-apply-dialog.component'; - -const mockedUserProjectsService = mock(SFUserProjectsService); -const mockedProjectService = mock(SFProjectService); -const mockedUserService = mock(UserService); -const mockedDialogRef = mock(MatDialogRef); -const mockedTextDocService = mock(TextDocService); - -@Component({ - template: `
Mock
` -}) -class MockComponent {} - -const ROUTES: Route[] = [{ path: 'projects', component: MockComponent }]; - -let env: TestEnvironment; - -describe('DraftApplyDialogComponent', () => { - configureTestingModule(() => ({ - imports: [getTestTranslocoModule()], - providers: [ - provideRouter(ROUTES), - provideTestOnlineStatus(), - provideNoopAnimations(), - { provide: SFUserProjectsService, useMock: mockedUserProjectsService }, - { provide: SFProjectService, useMock: mockedProjectService }, - { provide: UserService, useMock: mockedUserService }, - { provide: TextDocService, useMock: mockedTextDocService }, - { provide: OnlineStatusService, useClass: TestOnlineStatusService }, - { provide: MatDialogRef, useMock: mockedDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { bookNum: 1, chapters: [1, 2] } } - ] - })); - - beforeEach(async () => { - env = new TestEnvironment(); - }); - - it('can get projects', fakeAsync(() => { - expect(env.cancelButton).toBeTruthy(); - expect(env.component.projects.map(p => p.paratextId)).toEqual(['paratextId1', 'paratextId2']); - env.cancelButton.click(); - tick(); - env.fixture.detectChanges(); - })); - - it('shows additional information to users', fakeAsync(() => { - expect(env.unlistedProjectMessage).not.toBeNull(); - expect(env.overwriteContentMessage).toBeNull(); - expect(env.targetProjectContent).toBeNull(); - })); - - it('add button does not work until form is valid', fakeAsync(async () => { - expect(env.addButton).toBeTruthy(); - await env.selectParatextProject('paratextId1'); - expect(env.matErrorMessage).toBeNull(); - env.addButton.click(); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogRef.close()).never(); - expect(env.matErrorMessage).toBe('Please confirm you want to overwrite the book.'); - const harness = await env.overwriteCheckboxHarness(); - harness.check(); - tick(); - env.fixture.detectChanges(); - expect(env.matErrorMessage).toBeNull(); - env.addButton.click(); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogRef.close(anything())).once(); - })); - - it('can add draft to project when project selected', fakeAsync(async () => { - await env.selectParatextProject('paratextId1'); - const harness = await env.overwriteCheckboxHarness(); - harness.check(); - tick(); - env.fixture.detectChanges(); - expect(env.addButton.attributes['disabled']).toBeUndefined(); - env.component.addToProject(); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogRef.close(anything())).once(); - })); - - it('checks if the user has edit permissions', fakeAsync(async () => { - await env.selectParatextProject('paratextId1'); - const harness = await env.overwriteCheckboxHarness(); - harness.check(); - tick(); - env.fixture.detectChanges(); - expect(env.targetProjectContent).not.toBeNull(); - expect(env.component['targetProjectId']).toBe('project01'); - verify(mockedTextDocService.userHasGeneralEditRight(anything())).twice(); - tick(); - env.fixture.detectChanges(); - expect(env.component.isValid).toBeTrue(); - expect(env.matErrorMessage).toBeNull(); - })); - - // Skipped - xit('notifies user if no edit permissions', fakeAsync(async () => { - await env.selectParatextProject('paratextId2'); - expect(env.component['targetProjectId']).toBe('project02'); - verify(mockedTextDocService.userHasGeneralEditRight(anything())).twice(); - // Broken - expect(env.component.isValid).toBeFalse(); - expect(env.matErrorMessage).toBe( - "You do not have permission to write to this book on this project. Contact the project's administrator to get permission." - ); - // hides the message when an invalid project is selected - await env.selectParatextProject(''); - tick(); - env.fixture.detectChanges(); - expect(env.matErrorMessage).toBe('Please select a valid project or resource'); - expect(env.component.isValid).toBeFalse(); - })); - - it('user must confirm create chapters if book has missing chapters', fakeAsync(async () => { - const projectDoc = { - id: 'project03', - data: createTestProjectProfile( - { - paratextId: 'paratextId3', - userRoles: { user01: SFProjectRole.ParatextAdministrator }, - texts: [ - { - bookNum: 1, - chapters: [{ number: 1, permissions: { user01: TextInfoPermission.Write }, lastVerse: 31 }], - permissions: { user01: TextInfoPermission.Write } - } - ] - }, - 3 - ) - } as SFProjectProfileDoc; - env = new TestEnvironment({ projectDoc }); - await env.selectParatextProject('paratextId3'); - expect(env.component['targetProjectId']).toBe('project03'); - tick(); - env.fixture.detectChanges(); - expect(env.component.projectHasMissingChapters()).toBe(true); - const overwriteHarness = await env.overwriteCheckboxHarness(); - await overwriteHarness.check(); - const createChapters = await env.createChaptersCheckboxHarnessAsync(); - expect(createChapters).not.toBeNull(); - env.component.addToProject(); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogRef.close(anything())).never(); - - // check the checkbox - await createChapters.check(); - tick(); - env.fixture.detectChanges(); - env.component.addToProject(); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogRef.close(anything())).once(); - })); - - it('user must confirm create chapters if book is empty', fakeAsync(async () => { - const projectDoc = { - id: 'project03', - data: createTestProjectProfile( - { - paratextId: 'paratextId3', - userRoles: { user01: SFProjectRole.ParatextAdministrator }, - texts: [ - { - bookNum: 1, - chapters: [{ number: 1, permissions: { user01: TextInfoPermission.Write }, lastVerse: 0 }], - permissions: { user01: TextInfoPermission.Write } - } - ] - }, - 3 - ) - } as SFProjectProfileDoc; - env = new TestEnvironment({ projectDoc }); - await env.selectParatextProject('paratextId3'); - expect(env.component['targetProjectId']).toBe('project03'); - tick(); - env.fixture.detectChanges(); - expect(env.component.projectHasMissingChapters()).toBe(true); - const overwriteHarness = await env.overwriteCheckboxHarness(); - await overwriteHarness.check(); - const createChapters = await env.createChaptersCheckboxHarnessAsync(); - expect(createChapters).not.toBeNull(); - env.component.addToProject(); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogRef.close(anything())).never(); - - // select a valid project - await env.selectParatextProject('paratextId1'); - expect(env.component['targetProjectId']).toBe('project01'); - tick(); - env.fixture.detectChanges(); - expect(env.component.projectHasMissingChapters()).toBe(false); - env.component.addToProject(); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogRef.close(anything())).once(); - })); - - // Skipped - xit('updates the target project info when updating the project in the selector', fakeAsync(async () => { - await env.selectParatextProject('paratextId1'); - expect(env.targetProjectContent.textContent).toContain('Test project 1'); - // the user does not have permission to edit 'paratextId2' so the info section is hidden - await env.selectParatextProject('paratextId2'); - tick(); - flush(); - env.fixture.detectChanges(); - // Broken - expect(env.targetProjectContent).toBeNull(); - })); - - it('notifies user if offline', fakeAsync(async () => { - await env.selectParatextProject('paratextId1'); - expect(env.offlineWarning).toBeNull(); - const harness = await env.overwriteCheckboxHarness(); - harness.check(); - tick(); - env.fixture.detectChanges(); - env.onlineStatus = false; - tick(); - env.fixture.detectChanges(); - expect(env.offlineWarning).not.toBeNull(); - })); -}); - -// Helper harness that wires the component under test with mocked services and DOM helpers. -class TestEnvironment { - component: DraftApplyDialogComponent; - fixture: ComponentFixture; - loader: HarnessLoader; - - onlineStatusService = TestBed.inject(OnlineStatusService) as TestOnlineStatusService; - private readonly overlayContainer = TestBed.inject(OverlayContainer); - - constructor(args: { projectDoc?: SFProjectProfileDoc } = {}) { - when(mockedUserService.currentUserId).thenReturn('user01'); - this.setupProject(args.projectDoc); - this.fixture = TestBed.createComponent(DraftApplyDialogComponent); - this.loader = TestbedHarnessEnvironment.loader(this.fixture); - this.component = this.fixture.componentInstance; - this.fixture.detectChanges(); - } - - get addButton(): HTMLElement { - return this.fixture.nativeElement.querySelector('.add-button'); - } - - get cancelButton(): HTMLElement { - return this.fixture.nativeElement.querySelector('.cancel-button'); - } - - get targetProjectContent(): HTMLElement { - return this.fixture.nativeElement.querySelector('.target-project-content'); - } - - get overwriteContentMessage(): HTMLElement { - return this.fixture.nativeElement.querySelector('.overwrite-content'); - } - - get unlistedProjectMessage(): HTMLElement { - return this.fixture.nativeElement.querySelector('.unlisted-project-message'); - } - - get offlineWarning(): HTMLElement { - return this.fixture.nativeElement.querySelector('.offline-message'); - } - - get matErrorMessage(): string | null { - const matErrors: HTMLElement[] = Array.from(this.fixture.nativeElement.querySelectorAll('mat-error')); - if (matErrors.length === 0) return null; - expect(matErrors.length).toBe(1); - return matErrors[0].textContent!.trim(); - } - - set onlineStatus(online: boolean) { - this.onlineStatusService.setIsOnline(online); - tick(); - this.fixture.detectChanges(); - } - - async overwriteCheckboxHarness(): Promise { - return await this.loader.getHarness(MatCheckboxHarness.with({ selector: '.overwrite-content' })); - } - - async createChaptersCheckboxHarnessAsync(): Promise { - return await this.loader.getHarness(MatCheckboxHarness.with({ selector: '.create-chapters' })); - } - - async selectParatextProject(paratextId: string): Promise { - const autocomplete = await this.loader.getHarness(MatAutocompleteHarness); - await autocomplete.focus(); - - if (paratextId === '') { - await autocomplete.clear(); - await autocomplete.blur(); - await this.stabilizeFormAsync(); - return; - } - - const project = this.component.projects.find(p => p.paratextId === paratextId); - expect(project).withContext(`Missing project for ${paratextId}`).toBeDefined(); - if (project == null) { - return; - } - - const searchText = project.shortName ?? project.name ?? paratextId; - await autocomplete.clear(); - await autocomplete.enterText(searchText); - await autocomplete.selectOption({ text: projectLabel(project) }); - await autocomplete.blur(); - await this.stabilizeFormAsync(); - } - - private async stabilizeFormAsync(): Promise { - await this.fixture.whenStable(); - tick(); - this.fixture.detectChanges(); - this.clearOverlayContainer(); - } - - private clearOverlayContainer(): void { - const container = this.overlayContainer.getContainerElement(); - if (container.childElementCount > 0) { - container.innerHTML = ''; - } - } - - private setupProject(projectDoc?: SFProjectProfileDoc): void { - const projectPermissions = [ - { id: 'project01', permission: TextInfoPermission.Write }, - { id: 'project02', permission: TextInfoPermission.Read }, - { id: 'resource03', permission: TextInfoPermission.Read } - ]; - const mockProjectDocs: SFProjectProfileDoc[] = []; - let projectNum = 1; - for (const { id, permission } of projectPermissions) { - const mockedProject = { - id, - data: createTestProjectProfile( - { - paratextId: id.startsWith('resource') ? `resource16char0${projectNum}` : `paratextId${projectNum}`, - userRoles: { user01: SFProjectRole.ParatextAdministrator }, - texts: [ - { - bookNum: 1, - chapters: [ - { number: 1, permissions: { user01: permission }, lastVerse: 31 }, - { number: 2, permissions: { user01: permission }, lastVerse: 25 } - ], - permissions: { user01: permission } - } - ] - }, - projectNum++ - ) - } as SFProjectProfileDoc; - mockProjectDocs.push(mockedProject); - } - if (projectDoc != null) { - mockProjectDocs.push(projectDoc); - } - when(mockedUserProjectsService.projectDocs$).thenReturn(of(mockProjectDocs)); - const mockedTextDoc = { - getNonEmptyVerses: (): string[] => ['verse_1_1', 'verse_1_2', 'verse_1_3'] - } as TextDoc; - when(mockedProjectService.getText(anything())).thenResolve(mockedTextDoc); - when(mockedTextDocService.userHasGeneralEditRight(anything())).thenReturn(true); - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts deleted file mode 100644 index 0c3f933fa34..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { AsyncPipe } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { - AbstractControl, - FormControl, - FormGroup, - FormsModule, - ReactiveFormsModule, - ValidationErrors, - Validators -} from '@angular/forms'; -import { MatButton } from '@angular/material/button'; -import { MatCheckbox } from '@angular/material/checkbox'; -import { - MAT_DIALOG_DATA, - MatDialogActions, - MatDialogClose, - MatDialogContent, - MatDialogRef, - MatDialogTitle -} from '@angular/material/dialog'; -import { MatError } from '@angular/material/form-field'; -import { MatProgressBar } from '@angular/material/progress-bar'; -import { TranslocoModule } from '@ngneat/transloco'; -import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; -import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; -import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; -import { BehaviorSubject, map } from 'rxjs'; -import { I18nService } from 'xforge-common/i18n.service'; -import { OnlineStatusService } from 'xforge-common/online-status.service'; -import { RouterLinkDirective } from 'xforge-common/router-link.directive'; -import { SFUserProjectsService } from 'xforge-common/user-projects.service'; -import { UserService } from 'xforge-common/user.service'; -import { filterNullish } from 'xforge-common/util/rxjs-util'; -import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; -import { TextDoc, TextDocId } from '../../../core/models/text-doc'; -import { ParatextService } from '../../../core/paratext.service'; -import { SFProjectService } from '../../../core/sf-project.service'; -import { TextDocService } from '../../../core/text-doc.service'; -import { ProjectSelectComponent } from '../../../project-select/project-select.component'; -import { NoticeComponent } from '../../../shared/notice/notice.component'; -import { compareProjectsForSorting } from '../../../shared/utils'; - -export interface DraftApplyDialogResult { - projectId: string; -} - -export interface DraftApplyDialogConfig { - initialParatextId?: string; - bookNum: number; - chapters: number[]; -} - -@Component({ - selector: 'app-draft-apply-dialog', - imports: [ - RouterLinkDirective, - MatButton, - MatCheckbox, - MatProgressBar, - MatDialogContent, - MatDialogClose, - MatDialogActions, - MatDialogTitle, - MatError, - FormsModule, - ReactiveFormsModule, - TranslocoModule, - AsyncPipe, - NoticeComponent, - ProjectSelectComponent - ], - templateUrl: './draft-apply-dialog.component.html', - styleUrl: './draft-apply-dialog.component.scss' -}) -export class DraftApplyDialogComponent implements OnInit { - /** An observable that emits the target project profile if the user has permission to write to the book. */ - targetProject$: BehaviorSubject = new BehaviorSubject( - undefined - ); - - _projects?: SFProjectProfile[]; - protected isLoading: boolean = true; - addToProjectForm = new FormGroup({ - targetParatextId: new FormControl(this.data.initialParatextId, Validators.required), - overwrite: new FormControl(false, Validators.requiredTrue), - createChapters: new FormControl(false, control => - !this.projectHasMissingChapters() || control.value ? null : { mustConfirmCreateChapters: true } - ) - }); - /** An observable that emits the number of chapters in the target project that have some text. */ - targetChapters$: BehaviorSubject = new BehaviorSubject(0); - addToProjectClicked: boolean = false; - - // the project id to add the draft to - private targetProjectId?: string; - private paratextIdToProjectId: Map = new Map(); - - constructor( - @Inject(MAT_DIALOG_DATA) private data: DraftApplyDialogConfig, - @Inject(MatDialogRef) private dialogRef: MatDialogRef, - private readonly userProjectsService: SFUserProjectsService, - private readonly projectService: SFProjectService, - private readonly textDocService: TextDocService, - readonly i18n: I18nService, - private readonly userService: UserService, - private readonly onlineStatusService: OnlineStatusService - ) { - this.targetProject$.pipe(filterNullish()).subscribe(async project => { - const chapters: number = await this.chaptersWithTextAsync(project); - this.targetChapters$.next(chapters); - }); - } - - get isValid(): boolean { - return this.addToProjectForm.controls.targetParatextId.valid; - } - - get projects(): SFProjectProfile[] { - return this._projects ?? []; - } - - get bookName(): string { - return this.i18n.localizeBook(this.data.bookNum); - } - - get isFormValid(): boolean { - return this.addToProjectForm.valid; - } - - get overwriteConfirmed(): boolean { - return !!this.addToProjectForm.controls.overwrite.value; - } - - get createChaptersControl(): FormControl { - return this.addToProjectForm.controls.createChapters; - } - - get confirmCreateChapters(): boolean { - return !!this.createChaptersControl.value; - } - - get projectSelectValid(): boolean { - return this.addToProjectForm.controls.targetParatextId.valid; - } - - get projectName(): string { - return this.targetProject$.value?.name ?? ''; - } - - get isAppOnline(): boolean { - return this.onlineStatusService.isOnline; - } - - ngOnInit(): void { - this.userProjectsService.projectDocs$ - .pipe( - filterNullish(), - map(resourceAndProjectDocs => { - const projects: SFProjectProfile[] = []; - const userProjectDocs: SFProjectProfileDoc[] = resourceAndProjectDocs.filter( - p => p.data != null && !ParatextService.isResource(p.data.paratextId) - ); - for (const projectDoc of userProjectDocs) { - if (projectDoc.data != null) { - projects.push(projectDoc.data); - this.paratextIdToProjectId.set(projectDoc.data.paratextId, projectDoc.id); - } - } - return projects.sort(compareProjectsForSorting); - }) - ) - .subscribe(projects => { - this._projects = projects; - this.isLoading = false; - }); - } - - async addToProject(): Promise { - this.addToProjectClicked = true; - this.addToProjectForm.controls.createChapters.updateValueAndValidity(); - const project = this.targetProject$.getValue(); - if ( - !this.isAppOnline || - !this.isFormValid || - this.targetProjectId == null || - project == null || - !this.canEditProject(project) - ) { - return; - } - this.dialogRef.close({ projectId: this.targetProjectId }); - } - - projectSelected(paratextId: string): void { - if (paratextId == null) { - this.targetProject$.next(undefined); - return; - } - - const project: SFProjectProfile | undefined = this.projects.find(p => p.paratextId === paratextId); - this.createChaptersControl.updateValueAndValidity(); - - if (project == null) { - this.targetProject$.next(undefined); - return; - } - - this.targetProjectId = this.paratextIdToProjectId.get(paratextId); - - // emit the project profile document - if (this.canEditProject(project)) { - this.targetProject$.next(project); - } else { - this.targetProject$.next(undefined); - } - } - - projectHasMissingChapters(): boolean { - const project = this.targetProject$.getValue(); - const targetBook: TextInfo | undefined = project?.texts.find(t => t.bookNum === this.data.bookNum); - const bookIsEmpty: boolean = targetBook?.chapters.length === 1 && targetBook?.chapters[0].lastVerse < 1; - const targetBookChapters: number[] = targetBook?.chapters.map(c => c.number) ?? []; - return bookIsEmpty || this.data.chapters.filter(c => !targetBookChapters.includes(c)).length > 0; - } - - targetBookExists(project: SFProjectProfile): boolean { - const targetBook: TextInfo | undefined = project.texts.find(t => t.bookNum === this.data.bookNum); - return targetBook != null; - } - - canEditProject(project: SFProjectProfile): boolean { - const targetBook: TextInfo | undefined = project.texts.find(t => t.bookNum === this.data.bookNum); - - return ( - this.textDocService.userHasGeneralEditRight(project) && - targetBook?.permissions[this.userService.currentUserId] === TextInfoPermission.Write - ); - } - - close(): void { - this.dialogRef.close(); - } - - private async chaptersWithTextAsync(project: SFProjectProfile): Promise { - if (this.targetProjectId == null) return 0; - const chapters: Chapter[] | undefined = project.texts.find(t => t.bookNum === this.data.bookNum)?.chapters; - if (chapters == null) return 0; - const textPromises: Promise[] = []; - for (const chapter of chapters) { - const textDocId = new TextDocId(this.targetProjectId, this.data.bookNum, chapter.number); - textPromises.push(this.isNotEmpty(textDocId)); - } - return (await Promise.all(textPromises)).filter(hasText => hasText).length; - } - - private async isNotEmpty(textDocId: TextDocId): Promise { - const textDoc: TextDoc = await this.projectService.getText(textDocId); - return textDoc.getNonEmptyVerses().length > 0; - } - - private _projectSelectCustomValidator(control: AbstractControl): ValidationErrors | null { - const project = control.value as SFProjectProfile | string | null; - if (project == null || typeof project === 'string') return null; - - const errors: { [key: string]: boolean } = {}; - if (!this.targetBookExists(project)) errors.bookNotFound = true; - if (!this.canEditProject(project)) errors.noWritePermissions = true; - return Object.keys(errors).length > 0 ? errors : null; - } - - projectSelectCustomValidator = this._projectSelectCustomValidator.bind(this); - - private _errorMessageMapper(errors: ValidationErrors): string | null { - if (errors.bookNotFound) { - return this.i18n.translateStatic('draft_apply_dialog.book_does_not_exist', { bookName: this.bookName }); - } - if (errors.noWritePermissions) { - return this.i18n.translateStatic('draft_apply_dialog.no_write_permissions'); - } - return null; - } - - errorMessageMapper = this._errorMessageMapper.bind(this); -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.stories.ts deleted file mode 100644 index bfcf4893daa..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.stories.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { MatDialogRef } from '@angular/material/dialog'; -import { ActivatedRoute } from '@angular/router'; -import { Meta } from '@storybook/angular'; -import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; -import { BehaviorSubject } from 'rxjs'; -import { anything, instance, mock, when } from 'ts-mockito'; -import { I18nService } from 'xforge-common/i18n.service'; -import { SFUserProjectsService } from 'xforge-common/user-projects.service'; -import { UserService } from 'xforge-common/user.service'; -import { MatDialogLaunchComponent, matDialogStory } from '../../../../../.storybook/util/mat-dialog-launch'; -import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; -import { SFProjectService } from '../../../core/sf-project.service'; -import { TextDocService } from '../../../core/text-doc.service'; -import { DraftApplyDialogComponent } from './draft-apply-dialog.component'; - -const mockedUserProjectService = mock(SFUserProjectsService); -const mockedI18nService = mock(I18nService); -const mockedDialogRef = mock(MatDialogRef); -const mockedProjectService = mock(SFProjectService); -const mockedTextDocService = mock(TextDocService); -const mockedUserService = mock(UserService); -const mockActivatedRoute = mock(ActivatedRoute); -const projectDoc = { - id: 'project01', - data: createTestProjectProfile() -} as SFProjectProfileDoc; -const projectDocs$: BehaviorSubject = new BehaviorSubject< - SFProjectProfileDoc[] | undefined ->([projectDoc]); - -when(mockedUserProjectService.projectDocs$).thenReturn(projectDocs$); -when(mockedI18nService.translateAndInsertTags(anything())).thenReturn( - 'Looking for a project that is not listed? Connect it on the projects page first.' -); - -const meta: Meta = { - title: 'Misc/Dialogs/Draft Apply Dialog', - component: MatDialogLaunchComponent -}; -export default meta; - -export const DraftApplyDialog = matDialogStory(DraftApplyDialogComponent, { - providers: [ - { provide: ActivatedRoute, useValue: instance(mockActivatedRoute) }, - { provide: SFUserProjectsService, useValue: instance(mockedUserProjectService) }, - { provide: I18nService, useValue: instance(mockedI18nService) }, - { provide: MatDialogRef, useValue: instance(mockedDialogRef) }, - { provide: SFProjectService, useValue: instance(mockedProjectService) }, - { provide: TextDocService, useValue: instance(mockedTextDocService) }, - { provide: UserService, useValue: instance(mockedUserService) } - ] -}); -DraftApplyDialog.args = { data: { bookNum: 1 } }; -DraftApplyDialog.parameters = { chromatic: { disableSnapshot: true } }; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.html deleted file mode 100644 index 665e90b73d3..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.html +++ /dev/null @@ -1,38 +0,0 @@ - - -
- @if (draftApplyProgress?.completed !== true) { -
- -

{{ t("add_draft_to_book", { bookName }) }}

-
- } @else { -
- @if (failedToApplyChapters != null && failedToApplyChapters.length > 0) { -
-

- warning - {{ t("some_chapters_not_applied", { bookName }) }} -

-
- {{ t("failed_to_apply_chapters") }} - @for (message of draftApplyProgress?.errorMessages; track $index) { - {{ message }} - } -
-
- } @else { -

- {{ t("successfully_applied_all_chapters", { bookName }) }} -

- } -
- } -
-
- - @if (draftApplyProgress?.completed === true) { - - } - -
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.scss deleted file mode 100644 index edd68e4c27f..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.scss +++ /dev/null @@ -1,61 +0,0 @@ -.progress-content { - min-width: 240px; - max-width: 500px; - display: flex; - flex-direction: column; - min-height: 0; - - .progress-container { - width: 100%; - flex-direction: column; - align-items: center; - - mat-progress-bar { - margin-top: 1em; - } - } - - .result-container { - display: flex; - flex-direction: column; - min-height: 0; - } - - .error-result { - display: flex; - flex-direction: column; - min-height: 0; - - .mat-icon { - flex-shrink: 0; - } - - .failed-chapters { - text-indent: 1em; - margin-top: 0.5em; - line-height: 1.2em; - } - - .error-content { - overflow-y: auto; - display: flex; - flex-direction: column; - } - } - - .failed-message { - display: flex; - column-gap: 8px; - align-items: center; - } -} - -.hide-scroll { - overflow: hidden; - display: flex; - flex-direction: column; -} - -.mat-mdc-dialog-actions { - justify-content: flex-end; -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.spec.ts deleted file mode 100644 index a6bad1158aa..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { BehaviorSubject } from 'rxjs'; -import { anything, mock, when } from 'ts-mockito'; -import { I18nService } from 'xforge-common/i18n.service'; -import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; -import { DraftApplyProgress, DraftApplyProgressDialogComponent } from './draft-apply-progress-dialog.component'; - -const mockI18nService = mock(I18nService); -const mockMatDialogRef = mock(MatDialogRef); - -describe('DraftApplyProgressDialogComponent', () => { - let env: TestEnvironment; - const progress$: BehaviorSubject = new BehaviorSubject({ - bookNum: 1, - completed: false, - chapters: [1, 2, 3], - chaptersApplied: [], - errorMessages: [] - }); - - configureTestingModule(() => ({ - imports: [getTestTranslocoModule()], - providers: [ - { provide: I18nService, useMock: mockI18nService }, - { provide: MatDialogRef, useMock: mockMatDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { draftApplyProgress$: progress$ } } - ] - })); - - beforeEach(async () => { - env = new TestEnvironment(); - }); - - it('shows progress', () => { - progress$.next({ bookNum: 1, chapters: [1, 2], chaptersApplied: [1], completed: false, errorMessages: [] }); - env.fixture.detectChanges(); - expect(env.progressContainer).not.toBeNull(); - expect(env.resultContainer).toBeNull(); - expect(env.component.progress).toBe(50); - }); - - it('shows apply draft completed', () => { - progress$.next({ bookNum: 1, chapters: [1, 2], chaptersApplied: [1, 2], completed: true, errorMessages: [] }); - env.fixture.detectChanges(); - expect(env.progressContainer).toBeNull(); - expect(env.resultContainer).not.toBeNull(); - expect(env.component.failedToApplyChapters).toBeUndefined(); - expect(env.resultContainer.textContent).toContain('Successfully applied all chapters'); - }); - - it('shows chapters that failed to be applied', () => { - progress$.next({ bookNum: 1, chapters: [1, 2], chaptersApplied: [1], completed: true, errorMessages: ['error'] }); - env.fixture.detectChanges(); - expect(env.progressContainer).toBeNull(); - expect(env.resultContainer).not.toBeNull(); - expect(env.component.failedToApplyChapters).toEqual('2'); - expect(env.resultContainer.textContent).toContain('warning'); - expect(env.component.draftApplyProgress?.errorMessages).toContain('error'); - }); -}); - -class TestEnvironment { - component: DraftApplyProgressDialogComponent; - fixture: ComponentFixture; - - constructor() { - this.fixture = TestBed.createComponent(DraftApplyProgressDialogComponent); - this.component = this.fixture.componentInstance; - this.fixture.detectChanges(); - when(mockI18nService.enumerateList(anything())).thenCall((items: string[]) => items.join(', ')); - } - - get progressContainer(): HTMLElement { - return this.fixture.nativeElement.querySelector('.progress-container'); - } - - get resultContainer(): HTMLElement { - return this.fixture.nativeElement.querySelector('.result-container'); - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.ts deleted file mode 100644 index d4adb94c790..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Component, DestroyRef, Inject } from '@angular/core'; -import { MatButton } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef } from '@angular/material/dialog'; -import { MatIcon } from '@angular/material/icon'; -import { MatProgressBar } from '@angular/material/progress-bar'; -import { TranslocoModule } from '@ngneat/transloco'; -import { Observable } from 'rxjs'; -import { I18nService } from 'xforge-common/i18n.service'; -import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; -export interface DraftApplyProgress { - bookNum: number; - chapters: number[]; - chaptersApplied: number[]; - completed: boolean; - errorMessages: string[]; -} - -@Component({ - selector: 'app-draft-apply-progress', - imports: [MatButton, MatIcon, MatProgressBar, MatDialogContent, MatDialogActions, TranslocoModule], - templateUrl: './draft-apply-progress-dialog.component.html', - styleUrl: './draft-apply-progress-dialog.component.scss' -}) -export class DraftApplyProgressDialogComponent { - draftApplyProgress?: DraftApplyProgress; - - constructor( - @Inject(MatDialogRef) private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) data: { draftApplyProgress$: Observable }, - private readonly i18n: I18nService, - destroyRef: DestroyRef - ) { - data.draftApplyProgress$ - .pipe(quietTakeUntilDestroyed(destroyRef)) - .subscribe(progress => (this.draftApplyProgress = progress)); - } - - get progress(): number | undefined { - if (this.draftApplyProgress == null) return undefined; - return (this.draftApplyProgress.chaptersApplied.length / this.draftApplyProgress.chapters.length) * 100; - } - - get bookName(): string { - if (this.draftApplyProgress == null) return ''; - return this.i18n.localizeBook(this.draftApplyProgress.bookNum); - } - - get failedToApplyChapters(): string | undefined { - if (this.draftApplyProgress == null || !this.draftApplyProgress.completed) return undefined; - const chapters: string[] = this.draftApplyProgress.chapters - .filter(c => !this.draftApplyProgress?.chaptersApplied.includes(c)) - .map(c => c.toString()); - return chapters.length > 0 ? this.i18n.enumerateList(chapters) : undefined; - } - - close(): void { - this.dialogRef.close(); - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.html index 92e11414f9d..55a8eadb795 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.html @@ -1,49 +1,26 @@ - @if (flat) { - - } @else { - - } + diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.ts index e9a9fe7a108..45b4a0c4327 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { MatButton } from '@angular/material/button'; +import { MatButton, MatButtonAppearance } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { TranslocoModule } from '@ngneat/transloco'; @@ -26,7 +26,7 @@ export class DraftDownloadButtonComponent { zipSubscription?: Subscription; @Input() build: BuildDto | undefined; - @Input() flat: boolean = false; + @Input() matButton: MatButtonAppearance = 'text'; constructor( private readonly activatedProject: ActivatedProjectService, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.html index 2f90e23ef4b..f4e51698760 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.html @@ -420,9 +420,9 @@

{{ t("draft_finishing_header") }}

} - @if (isOnline && featureFlags.newDraftHistory.enabled) { - - @if (isServalAdmin()) { + @if (featureFlags.newDraftHistory.enabled) { + + @if (isOnline && isServalAdmin()) {

Further information

} } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.scss index 5cf23bf7a13..3c6119d242e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.scss @@ -83,10 +83,6 @@ mat-divider.mat-divider-inset { flex-shrink: 0; } - app-draft-apply-progress { - width: 100%; - } - h2 { font-weight: 500; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts index d8c60374277..ad8de419b7a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts @@ -4,10 +4,7 @@ import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/ import { DeltaOperation } from 'rich-text'; import { of, throwError } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; -import { ErrorReportingService } from 'xforge-common/error-reporting.service'; -import { I18nService } from 'xforge-common/i18n.service'; import { configureTestingModule } from 'xforge-common/test-utils'; -import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { TextDocId } from '../../core/models/text-doc'; import { SFProjectService } from '../../core/sf-project.service'; import { TextDocService } from '../../core/text-doc.service'; @@ -18,9 +15,6 @@ import { DraftHandlingService } from './draft-handling.service'; const mockedProjectService = mock(SFProjectService); const mockedTextDocService = mock(TextDocService); const mockedDraftGenerationService = mock(DraftGenerationService); -const mockedSFProject = mock(SFProjectProfileDoc); -const mockedErrorReportingService = mock(ErrorReportingService); -const mockedI18nService = mock(I18nService); describe('DraftHandlingService', () => { let service: DraftHandlingService; @@ -29,9 +23,7 @@ describe('DraftHandlingService', () => { providers: [ { provide: SFProjectService, useMock: mockedProjectService }, { provide: TextDocService, useMock: mockedTextDocService }, - { provide: DraftGenerationService, useMock: mockedDraftGenerationService }, - { provide: ErrorReportingService, useMock: mockedErrorReportingService }, - { provide: I18nService, useMock: mockedI18nService } + { provide: DraftGenerationService, useMock: mockedDraftGenerationService } ] })); @@ -445,179 +437,6 @@ describe('DraftHandlingService', () => { }); }); - describe('getAndApplyDraftAsync', () => { - it('should get and apply draft', async () => { - const textDocId = new TextDocId('project01', 1, 1); - const draft: DeltaOperation[] = [ - { insert: { verse: { number: 1 } } }, - { insert: 'In the beginning', attributes: { segment: 'verse_1_1' } } - ]; - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(of(draft)); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(true); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).once(); - verify(mockedTextDocService.overwrite(textDocId, anything(), 'Draft')).once(); - verify( - mockedProjectService.onlineSetDraftApplied( - textDocId.projectId, - textDocId.bookNum, - textDocId.chapterNum, - true, - 1 - ) - ).once(); - verify( - mockedProjectService.onlineSetIsValid(textDocId.projectId, textDocId.bookNum, textDocId.chapterNum, true) - ).once(); - }); - - it('should not apply if user does not have permission', async () => { - const textDocId = new TextDocId('project01', 1, 1); - const draft: DeltaOperation[] = [{ insert: 'In the beginning', attributes: { segment: 'verse_1_1' } }]; - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(of(draft)); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(false); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).not.toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).never(); - verify(mockedTextDocService.overwrite(textDocId, anything(), 'Draft')).never(); - }); - - it('should not apply legacy USFM draft', async () => { - const textDocId = new TextDocId('project01', 1, 1); - const draft: DraftSegmentMap = { verse_1_1: 'In the beginning' }; - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(throwError(() => ({ status: 405 }))); - when(mockedDraftGenerationService.getGeneratedDraft(anything(), anything(), anything())).thenReturn(of(draft)); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(true); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).not.toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).once(); - verify(mockedDraftGenerationService.getGeneratedDraft('project01', 1, 1)).once(); - verify(mockedTextDocService.overwrite(textDocId, anything(), 'Draft')).never(); - }); - - it('should return false if applying a draft fails', async () => { - const textDocId = new TextDocId('project01', 1, 1); - const draft: DeltaOperation[] = [ - { insert: { verse: { number: 1 } } }, - { insert: 'In the beginning', attributes: { segment: 'verse_1_1' } } - ]; - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(of(draft)); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(true); - when( - mockedProjectService.onlineSetDraftApplied(anything(), anything(), anything(), anything(), anything()) - ).thenReturn(Promise.reject(new Error('Failed'))); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).not.toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).once(); - verify(mockedErrorReportingService.silentError(anything(), anything())).once(); - verify(mockedTextDocService.overwrite(textDocId, anything(), anything())).never(); - verify( - mockedProjectService.onlineSetDraftApplied( - textDocId.projectId, - textDocId.bookNum, - textDocId.chapterNum, - true, - 1 - ) - ).once(); - }); - - it('should return false if applying a draft fails at getting the draft', async () => { - const textDocId = new TextDocId('project01', 1, 1); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(true); - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(throwError(() => ({ message: 'Getting draft failed', status: 404 }))); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).not.toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).once(); - verify(mockedErrorReportingService.silentError(anything(), anything())).once(); - verify( - mockedProjectService.onlineSetDraftApplied( - textDocId.projectId, - textDocId.bookNum, - textDocId.chapterNum, - true, - anything() - ) - ).never(); - }); - }); - describe('opsHaveContent', () => { it('should return false if all ops are blank', () => { const ops: DeltaOperation[] = [{ insert: {} }, { insert: {} }]; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts index 06ca1fa62a9..3ac931484f5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts @@ -5,8 +5,6 @@ import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/ import { DraftUsfmConfig } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; import { DeltaOperation } from 'rich-text'; import { catchError, Observable, throwError } from 'rxjs'; -import { ErrorReportingService } from 'xforge-common/error-reporting.service'; -import { I18nService } from 'xforge-common/i18n.service'; import { isString } from '../../../type-utils'; import { TextDocId } from '../../core/models/text-doc'; import { SFProjectService } from '../../core/sf-project.service'; @@ -34,9 +32,7 @@ export class DraftHandlingService { constructor( private readonly projectService: SFProjectService, private readonly textDocService: TextDocService, - private readonly draftGenerationService: DraftGenerationService, - private readonly errorReportingService: ErrorReportingService, - private readonly i18n: I18nService + private readonly draftGenerationService: DraftGenerationService ) {} /** @@ -228,53 +224,6 @@ export class DraftHandlingService { await this.textDocService.overwrite(textDocId, draftDelta, 'Draft'); } - /** - * Retrieves and applies the draft to the text document. - * @param targetProject The project profile. - * @param draftTextDocId The text doc identifier of the draft of a chapter. - * @param targetTextDocId The text doc identifier to apply the draft to. - * @returns True if the draft was successfully applied, false if the draft was not applied i.e. the draft - * was in the legacy USFM format. - */ - async getAndApplyDraftAsync( - targetProject: SFProjectProfile, - draftTextDocId: TextDocId, - targetTextDocId: TextDocId, - timestamp?: Date - ): Promise { - if (!this.textDocService.canRestore(targetProject, targetTextDocId.bookNum, targetTextDocId.chapterNum)) { - return this.i18n.translateStatic('draft_apply_progress-dialog.fail_cannot_edit'); - } - - return await new Promise(resolve => { - this.getDraft(draftTextDocId, { isDraftLegacy: false, timestamp }).subscribe({ - next: async draft => { - let ops: DeltaOperation[] = []; - if (this.isDraftSegmentMap(draft)) { - // Do not support applying drafts for the legacy segment map format. - // This can be applied chapter by chapter. - resolve(this.i18n.translateStatic('draft_apply_progress-dialog.fail_legacy_format')); - return; - } else { - ops = draft; - } - const draftDelta: Delta = new Delta(ops); - await this.applyChapterDraftAsync(targetTextDocId, draftDelta).catch(err => { - // report the error to bugsnag - this.errorReportingService.silentError('Error applying a draft', ErrorReportingService.normalizeError(err)); - resolve(this.i18n.translateStatic('draft_apply_progress-dialog.fail_unknown')); - }); - resolve(undefined); - }, - error: err => { - // report the error to bugsnag - this.errorReportingService.silentError('Error applying a draft', ErrorReportingService.normalizeError(err)); - resolve(this.i18n.translateStatic('draft_apply_progress-dialog.fail_unknown')); - } - }); - }); - } - /** * Checks whether the ops have any content (text) in them. This is defined as any op having text content (verse * numbers and other format markers do not count as "content"). If the final op is a newline, it is not counted as diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.html index 15f2a029be9..99b0642018a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.html @@ -26,7 +26,7 @@ {{ t("select_formatting_options") }}

-
} @else { @if (draftIsAvailable) { -

{{ t("click_book_to_preview") }}

+

{{ t("click_book_to_preview_draft") }}

+

+ +

- + + @if (formattingOptionsSupported && isLatestBuild) { - } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts index 992035a3f96..8b7f2f44bbe 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts @@ -1,6 +1,7 @@ import { NgClass } from '@angular/common'; import { Component, DestroyRef, Input } from '@angular/core'; import { MatButton } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatExpansionPanel, MatExpansionPanelDescription, @@ -22,6 +23,7 @@ import { } from '@angular/material/table'; import { RouterLink } from '@angular/router'; import { TranslocoModule } from '@ngneat/transloco'; +import { TranslocoMarkupModule } from 'ngx-transloco-markup'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; import { I18nService } from 'xforge-common/i18n.service'; @@ -36,6 +38,7 @@ import { BuildStates } from '../../../../machine-api/build-states'; import { booksFromScriptureRange } from '../../../../shared/utils'; import { RIGHT_TO_LEFT_MARK } from '../../../../shared/verse-utils'; import { DraftDownloadButtonComponent } from '../../draft-download-button/draft-download-button.component'; +import { DraftImportWizardComponent } from '../../draft-import-wizard/draft-import-wizard.component'; import { DraftOptionsService } from '../../draft-options.service'; import { DraftPreviewBooksComponent } from '../../draft-preview-books/draft-preview-books.component'; import { TrainingDataService } from '../../training-data/training-data.service'; @@ -83,8 +86,9 @@ interface TrainingConfigurationRow { MatHeaderRowDef, MatRow, MatRowDef, + RouterLink, TranslocoModule, - RouterLink + TranslocoMarkupModule ], templateUrl: './draft-history-entry.component.html', styleUrl: './draft-history-entry.component.scss' @@ -336,7 +340,8 @@ export class DraftHistoryEntryComponent { readonly featureFlags: FeatureFlagService, private readonly draftOptionsService: DraftOptionsService, private readonly permissionsService: PermissionsService, - private readonly destroyRef: DestroyRef + private readonly destroyRef: DestroyRef, + private readonly dialog: MatDialog ) {} formatDate(date?: string): string { @@ -355,4 +360,16 @@ export class DraftHistoryEntryComponent { getScriptureRangeAsLocalizedBooks(scriptureRange: string): string { return this.i18n.enumerateList(booksFromScriptureRange(scriptureRange).map(b => this.i18n.localizeBook(b))); } + + openImportWizard(): void { + if (this._entry == null) return; + + this.dialog.open(DraftImportWizardComponent, { + data: this._entry, + width: '800px', + maxWidth: '90vw', + disableClose: false, + panelClass: 'use-application-text-color' + }); + } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/_draft-import-wizard-theme.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/_draft-import-wizard-theme.scss new file mode 100644 index 00000000000..f63543e0282 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/_draft-import-wizard-theme.scss @@ -0,0 +1,14 @@ +@use '@angular/material' as mat; + +@mixin theme($theme) { + .use-application-text-color { + .mat-mdc-dialog-container { + /* Use the same text color as the main application body, not the lighter dialog text color */ + @include mat.dialog-overrides( + ( + supporting-text-color: var(--mat-sys-on-surface) + ) + ); + } + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts new file mode 100644 index 00000000000..692b814cb2d --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts @@ -0,0 +1,398 @@ +import { DebugElement } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatStepper } from '@angular/material/stepper'; +import { By } from '@angular/platform-browser'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; +import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; +import { anything, mock, verify, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; +import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; +import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; +import { TestRealtimeService } from 'xforge-common/test-realtime.service'; +import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; +import { ParatextProject } from '../../../core/models/paratext-project'; +import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; +import { SF_TYPE_REGISTRY } from '../../../core/models/sf-type-registry'; +import { TextDoc } from '../../../core/models/text-doc'; +import { ParatextService } from '../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../core/project-notification.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { TextDocService } from '../../../core/text-doc.service'; +import { BuildDto } from '../../../machine-api/build-dto'; +import { ProjectSelectComponent } from '../../../project-select/project-select.component'; +import { ProgressService, ProjectProgress } from '../../../shared/progress-service/progress.service'; +import { DraftNotificationService } from '../draft-notification.service'; +import { DraftApplyState, DraftApplyStatus, DraftImportWizardComponent } from './draft-import-wizard.component'; + +const mockMatDialogRef = mock(MatDialogRef); +const mockParatextService = mock(ParatextService); +const mockProgressService = mock(ProgressService); +const mockDraftNotificationService = mock(DraftNotificationService); +const mockProjectNotificationService = mock(ProjectNotificationService); +const mockProjectService = mock(SFProjectService); +const mockTextDocService = mock(TextDocService); +const mockActivatedProjectService = mock(ActivatedProjectService); +const mockAuthService = mock(AuthService); + +describe('DraftImportWizardComponent', () => { + const buildDto: BuildDto = { + additionalInfo: { + dateFinished: '2026-01-14T15:16:17.18+00:00', + translationScriptureRanges: [{ projectId: 'P01', scriptureRange: 'GEN;EXO;LEV;NUM;DEU' }] + } + } as BuildDto; + + configureTestingModule(() => ({ + imports: [getTestTranslocoModule()], + providers: [ + provideTestOnlineStatus(), + provideTestRealtime(SF_TYPE_REGISTRY), + { provide: MatDialogRef, useMock: mockMatDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: buildDto }, + { provide: ParatextService, useMock: mockParatextService }, + { provide: ProgressService, useMock: mockProgressService }, + { provide: DraftNotificationService, useMock: mockDraftNotificationService }, + { provide: ProjectNotificationService, useMock: mockProjectNotificationService }, + { provide: SFProjectService, useMock: mockProjectService }, + { provide: TextDocService, useMock: mockTextDocService }, + { provide: OnlineStatusService, useClass: TestOnlineStatusService }, + { provide: ActivatedProjectService, useMock: mockActivatedProjectService }, + { provide: AuthService, useMock: mockAuthService }, + provideNoopAnimations() + ] + })); + + it('applies drafts to projects that are not connected', fakeAsync(() => { + // Setup test environment + const env = new TestEnvironment(); + env.wait(); + + // Step 1 + env.selectProject('paratext02'); + env.clickNextButton(1); + + // Step 2 + env.clickNextButton(2); + + // Step 3 + + // Connect to the target project + env.syncTargetProject(); + + // Step 4 + env.clickNextButton(4); + + // Step 5 + env.clickOverwriteCheckbox(); + env.clickNextButton(5); + + // Step 6 + env.importDraft(); + env.clickNextButton(6); + + // Step 7 + env.clickNextButton(7, 'sync'); + + // Sync the source project + env.syncSourceProject(); + + // Close the dialog and verify it closed + env.clickNextButton(7, 'done'); + verify(mockMatDialogRef.close(true)).once(); + })); + + it('applies drafts to projects that are already connected which do not have the books', fakeAsync(() => { + // Setup test environment + const env = new TestEnvironment(); + env.wait(); + + // Step 1 + env.selectProject('paratext03'); + env.clickNextButton(1); + + // Step 4 + env.clickNextButton(4); + + // Step 6 + env.importDraft(); + env.clickNextButton(6); + + // Step 7 + env.clickNextButton(7, 'skip'); + + // Close the dialog and verify it closed + env.clickNextButton(7, 'done'); + verify(mockMatDialogRef.close(true)).once(); + })); + + it('applies drafts to projects that are already connected which have the books', fakeAsync(() => { + // Setup test environment + const env = new TestEnvironment(); + env.wait(); + + // Step 1 + env.selectProject('paratext04'); + env.clickNextButton(1); + + // Step 4 + env.clickNextButton(4); + + // Step 5 + env.clickOverwriteCheckbox(); + env.clickNextButton(5); + + // Step 6 + env.importDraft(); + env.clickNextButton(6); + + // Step 7 + env.clickNextButton(7, 'skip'); + + // Close the dialog and verify it closed + env.clickNextButton(7, 'done'); + verify(mockMatDialogRef.close(true)).once(); + })); + + it('applies a single book draft to projects that are already connected which do not have the books', fakeAsync(() => { + // Configure draft to be just one book + configureDraftForOneBook(); + + // Setup test environment + const env = new TestEnvironment(); + env.wait(); + + // Step 1 + env.selectProject('paratext03'); + env.clickNextButton(1); + + // Step 6 + env.importDraft(); + env.clickNextButton(6); + + // Step 7 + env.clickNextButton(7, 'skip'); + + // Close the dialog and verify it closed + env.clickNextButton(7, 'done'); + verify(mockMatDialogRef.close(true)).once(); + })); + + it('applies a single book draft to projects that are already connected which have the books', fakeAsync(() => { + // Configure draft to be just one book + configureDraftForOneBook(); + + // Setup test environment + const env = new TestEnvironment(); + env.wait(); + + // Step 1 + env.selectProject('paratext04'); + env.clickNextButton(1); + + // Step 5 + env.clickOverwriteCheckbox(); + env.clickNextButton(5); + + // Step 6 + env.importDraft(); + env.clickNextButton(6); + + // Step 7 + env.clickNextButton(7, 'skip'); + + // Close the dialog and verify it closed + env.clickNextButton(7, 'done'); + verify(mockMatDialogRef.close(true)).once(); + })); +}); + +class TestEnvironment { + component: DraftImportWizardComponent; + fixture: ComponentFixture; + + private readonly realtimeService: TestRealtimeService = TestBed.inject(TestRealtimeService); + + constructor() { + this.fixture = TestBed.createComponent(DraftImportWizardComponent); + this.component = this.fixture.componentInstance; + + when(mockActivatedProjectService.projectId).thenReturn('project01'); + when(mockParatextService.getProjects()).thenResolve([ + // Source project + { + paratextId: 'paratext01', + name: 'Project 01', + shortName: 'P01', + projectId: 'project01', + isConnected: true + } as ParatextProject, + // Target project that is not yet on Scripture Forge + { + paratextId: 'paratext02', + name: 'Project 02', + shortName: 'P02', + isConnectable: true + } as ParatextProject, + // Target project that is on Scripture Forge and does not have the books + { + paratextId: 'paratext03', + name: 'Project 03', + shortName: 'P03', + projectId: 'project03', + isConnected: true + } as ParatextProject, + // Target project that is on Scripture Forge and has the books + { + paratextId: 'paratext04', + name: 'Project 04', + shortName: 'P04', + projectId: 'project04', + isConnected: true + } as ParatextProject + ]); + + this.realtimeService.addSnapshot(SFProjectProfileDoc.COLLECTION, { + id: 'project02', + data: createTestProjectProfile({}, 2) + }); + this.realtimeService.addSnapshot(SFProjectProfileDoc.COLLECTION, { + id: 'project03', + data: createTestProjectProfile({}, 3) + }); + this.realtimeService.addSnapshot(SFProjectProfileDoc.COLLECTION, { + id: 'project04', + data: createTestProjectProfile( + { + texts: [ + { bookNum: 1, chapters: [{ number: 1 }] }, + { bookNum: 2, chapters: [{ number: 1 }] }, + { bookNum: 3, chapters: [{ number: 1 }] }, + { bookNum: 4, chapters: [{ number: 1 }] } + ] + }, + 4 + ) + }); + + when(mockProgressService.getProgress(anything(), anything())).thenResolve( + new ProjectProgress([ + { bookId: 'GEN', verseSegments: 100, blankVerseSegments: 0 }, + { bookId: 'EXO', verseSegments: 100, blankVerseSegments: 0 }, + { bookId: 'LEV', verseSegments: 100, blankVerseSegments: 100 }, + { bookId: 'NUM', verseSegments: 22, blankVerseSegments: 2 }, + { bookId: 'DEU', verseSegments: 0, blankVerseSegments: 0 } + ]) + ); + when(mockProjectService.getText(anything())).thenResolve({ + getNonEmptyVerses: (): string[] => ['verse_1_1'] + } as TextDoc); + when(mockProjectService.onlineCreate(anything())).thenResolve('project02'); + when(mockProjectService.get(anything())).thenCall(id => + this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) + ); + when(mockTextDocService.userHasGeneralEditRight(anything())).thenReturn(true); + + this.fixture.detectChanges(); + } + + clickNextButton(step: number, suffix: string = 'next'): void { + this.isStepVisible(step); + const nextButton: DebugElement = this.fixture.debugElement.query( + By.css(`.button-strip button[data-test-id="step-${step}-${suffix}"]`) + ); + + // Verify the button is present and clickable + expect(nextButton).not.toBeNull(); + expect(nextButton.nativeElement.disabled).toBe(false); + + // Click the button + nextButton.nativeElement.click(); + this.wait(); + } + + isStepVisible(step: number): void { + const stepperDebug = this.fixture.debugElement.query(By.directive(MatStepper)); + const stepperInstance = stepperDebug.componentInstance; + + // Remove skipped steps + if (step >= 5 && !this.component.showOverwriteConfirmation) --step; + if (step >= 4 && !this.component.showBookSelection) --step; + if (step >= 3 && !this.component.needsConnection) --step; + if (step >= 2 && !this.component.needsConnection) --step; + + expect(stepperInstance.selectedIndex).toBe(step - 1); + } + + clickOverwriteCheckbox(): void { + const overwriteCheckbox: DebugElement = this.fixture.debugElement.query(By.css('mat-checkbox input')); + + // Verify the overwrite checkbox is present + expect(overwriteCheckbox).not.toBeNull(); + + // Click the checkbox + overwriteCheckbox.nativeElement.click(); + this.wait(); + } + + importDraft(): void { + // Ensure that the draft import was started + verify( + mockProjectService.onlineApplyPreTranslationToProject('project01', anything(), anything(), anything()) + ).once(); + + // Have the backend notify that the draft is imported + this.component.updateDraftApplyState('project01', { + bookNum: 0, + chapterNum: 0, + status: DraftApplyStatus.Successful, + totalChapters: 0 + } as DraftApplyState); + this.wait(); + } + + selectProject(paratextId: string): void { + const projectSelect: DebugElement = this.fixture.debugElement.query(By.css('app-project-select')); + + // Verify the project selector is present and clickable + expect(projectSelect).not.toBeNull(); + const projectSelectComponent = projectSelect.componentInstance as ProjectSelectComponent; + expect(projectSelectComponent.projects?.length ?? 0).toBeGreaterThan(0); + + // Set the selected project + projectSelectComponent.value = paratextId; + this.wait(); + } + + syncSourceProject(): void { + this.component.isSyncing = false; + this.component.syncComplete = true; + this.wait(); + } + + syncTargetProject(): void { + this.component.isConnecting = false; + this.component.showOverwriteConfirmation = true; + this.wait(); + } + + wait(): void { + tick(); + this.fixture.detectChanges(); + } +} + +function configureDraftForOneBook(): void { + // Configure draft to be just one book + const buildDto: BuildDto = { + additionalInfo: { + dateFinished: '2026-01-14T15:16:17.18+00:00', + translationScriptureRanges: [{ projectId: 'P01', scriptureRange: 'GEN' }] + } + } as BuildDto; + TestBed.overrideProvider(MAT_DIALOG_DATA, { useValue: buildDto }); +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html new file mode 100644 index 00000000000..188527d0da5 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html @@ -0,0 +1,450 @@ + + + + {{ index + 1 }} + + + + {{ t("select_project") }} + +

{{ t("select_project") }}

+

{{ t("select_project_description") }}

+ + @if (isLoadingProjects) { + + } + +
+ @if (!isLoadingProjects && !projectLoadingFailed) { + + + @if (isLoadingProject) { + + } + } + + @if (projectLoadingFailed && isAppOnline) { + {{ t("failed_to_load_projects") }} +
+ +
+ } + + @if (!isAppOnline) { + {{ t("connect_to_the_internet") }} + } + + @if (!canEditProject) { + {{ t("cannot_edit_project") }} + } + + @if (projectSelectionForm.valid && !isLoadingProject && (targetProjectDoc$ | async); as projectDoc) { + @if (projectDoc?.data != null) { + + {{ + t("ready_to_import", { + projectShortName: projectDoc?.data?.shortName, + projectName: projectDoc?.data?.name + }) + }} + + } + } + + @if (cannotAdvanceFromProjectSelection && !projectReadyToImport) { + {{ + !projectSelectionForm.valid ? t("select_project_description") : t("waiting_for_project_to_load") + }} + } +
+ +
+ + +
+
+ + + @if (needsConnection) { + + {{ t("connect_project") }} + +

{{ t("connect_to_project") }}

+ + @if (selectedParatextProject != null) { +

+ } + + @if (connectionError != null && connectionError.length > 0) { + {{ connectionError }} + } + +
+ + +
+
+ + + + {{ t("connecting") }} + + @if (isConnecting) { +

{{ t("connecting_to_project") }}

+ @if (targetProjectDoc$ | async; as projectDoc) { + + } @else { +

{{ t("setting_up_project", { projectName: selectedParatextProject?.name }) }}

+ + } + } + + @if (connectionError != null && connectionError.length > 0) { +

{{ t("connection_failed") }}

+ {{ connectionError }} +
+ + +
+ } @else if (!isConnecting) { +

{{ t("connected_to_project") }}

+ @if (targetProjectDoc$ | async; as projectDoc) { + @if (projectDoc?.data != null) { + + {{ + t("ready_to_import", { + projectShortName: projectDoc?.data?.shortName, + projectName: projectDoc?.data?.name + }) + }} + + } + } +
+ + +
+ } +
+ } + + + @if (showBookSelection) { + + {{ t("select_books") }} + +

{{ t("confirm_books_to_import") }}

+

{{ t("confirm_books_to_import_description") }}

+ + +
+ + +
+
+ } + + + @if (showOverwriteConfirmation) { + + {{ t("confirm_overwrite") }} + + @if (booksWithExistingText.length === 1) { + +

{{ t("overwrite_book_question", { bookName: singleBookName }) }}

+

+ } @else { + +

{{ t("overwrite_books_question") }}

+

+
    + @for (book of booksWithExistingText; track book.bookNum) { +
  • + {{ book.bookName }}: {{ book.chaptersWithText.length }} + {{ book.chaptersWithText.length === 1 ? t("chapter") : t("chapters") }} +
  • + } +
+ } + +
+ + {{ t("i_understand_overwrite_content") }} + +
+ +
+ + +
+
+ } + + + + {{ t("importing") }} + +

{{ t("importing_draft") }}

+ + @if (isImporting || importComplete || (importError != null && importError.length > 0)) { + @for (progress of importProgress; track progress.bookNum) { +
+

{{ progress.bookName }}

+ + + {{ progress.completedChapters.length }} / {{ progress.totalChapters }} {{ t("chapters") }} + + @if (progress.failedChapters.length > 0) { +
+ error + {{ t("failed") }} {{ getFailedChapters(progress) }} +
+ } +
+ } + } + + @if (importError != null && importError.length > 0) { + {{ importError }} +
+ + +
+ } + + @if (importComplete) { + {{ t("import_complete") }} +
+ +
+ } +
+ + + + {{ t("complete") }} + + @if (!isSyncing && !syncComplete && !skipSync && syncError == null) { +

{{ t("sync_project_question") }}

+

+ +
+ + +
+ } + + @if (isSyncing) { +

{{ t("syncing_project") }}

+ @if (targetProjectDoc$ | async; as projectDoc) { + + } + } + + @if (syncError != null && !syncComplete && !skipSync) { +

{{ t("sync_failed") }}

+ {{ syncError }} +
+ + +
+ } + + @if (syncComplete || skipSync) { +

{{ t("complete") }}

+ @if (skipSync) { +

+ } @else { +

+

+ {{ t("import_and_sync_complete_reminder") }} +

+ } + +
+ +
+ } +
+
+
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.scss new file mode 100644 index 00000000000..d734deebb40 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.scss @@ -0,0 +1,84 @@ +.button-strip { + display: flex; + justify-content: space-between; + margin-top: 1.5rem; + gap: 0.5rem; + + &.align-end { + justify-content: flex-end; + } +} + +.offline-message { + margin-top: 1rem; + display: block; +} + +.project-retry-row { + margin: 0.5rem 0 1.5rem; + display: flex; +} + +.book-progress { + margin-bottom: 1.5rem; + + p { + margin-bottom: 0.5rem; + font-weight: 500; + } + + mat-progress-bar { + margin-bottom: 0.25rem; + } + + .progress-text { + font-size: 0.875rem; + color: var(--mat-sys-on-surface-variant); + } + + .failed-chapters { + display: flex; + align-items: center; + column-gap: 0.35rem; + margin-top: 0.35rem; + color: var(--mat-sys-error); + + .error-icon { + font-size: 1.1rem; + } + } +} + +h1 { + margin-bottom: 1rem; +} + +mat-checkbox { + margin-top: 1rem; + display: block; +} + +ul { + margin: 1rem 0; + padding-left: 1.5rem; + + li { + margin-bottom: 0.5rem; + } +} + +app-notice { + margin-top: 1rem; + margin-bottom: 1rem; +} + +app-sync-progress { + margin: 2rem 0; +} + +::ng-deep { + /* Hide the wizard headings at the top of the dialog */ + mat-stepper .mat-horizontal-stepper-header-container { + display: none; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts new file mode 100644 index 00000000000..96f3467150e --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts @@ -0,0 +1,795 @@ +import { StepperSelectionEvent } from '@angular/cdk/stepper'; +import { AsyncPipe } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, DestroyRef, Inject, OnInit, ViewChild } from '@angular/core'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MAT_DIALOG_DATA, MatDialogContent, MatDialogRef } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressBar } from '@angular/material/progress-bar'; +import { + MatStep, + MatStepLabel, + MatStepper, + MatStepperIcon, + MatStepperNext, + MatStepperPrevious +} from '@angular/material/stepper'; +import { TranslocoModule } from '@ngneat/transloco'; +import { Canon } from '@sillsdev/scripture'; +import { BehaviorSubject } from 'rxjs'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; +import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { ParatextProject } from '../../../core/models/paratext-project'; +import { SFProjectDoc } from '../../../core/models/sf-project-doc'; +import { TextDoc, TextDocId } from '../../../core/models/text-doc'; +import { ParatextService } from '../../../core/paratext.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { TextDocService } from '../../../core/text-doc.service'; +import { BuildDto } from '../../../machine-api/build-dto'; +import { ProjectSelectComponent } from '../../../project-select/project-select.component'; +import { BookMultiSelectComponent } from '../../../shared/book-multi-select/book-multi-select.component'; +import { NoticeComponent } from '../../../shared/notice/notice.component'; +import { booksFromScriptureRange } from '../../../shared/utils'; +import { SyncProgressComponent } from '../../../sync/sync-progress/sync-progress.component'; +import { DraftNotificationService } from '../draft-notification.service'; + +/** + * Represents a book available for import with its draft chapters. + */ +export interface BookForImport { + number: number; // Alias for bookNum to match Book interface + bookNum: number; + bookId: string; + bookName: string; + selected: boolean; +} + +/** + * A book that the user will be prompted to overwrite. + */ +export interface BookWithExistingText { + bookNum: number; + bookName: string; + chaptersWithText: number[]; +} + +/** + * Tracks the progress of importing drafts. + */ +export interface ImportProgress { + bookNum: number; + bookId: string; + bookName: string; + totalChapters: number; + completedChapters: number[]; + failedChapters: { chapterNum: number; message?: string }[]; +} + +export interface DraftApplyState { + bookNum: number; + chapterNum: number; + totalChapters: number; + message?: string; + status: DraftApplyStatus; +} + +export enum DraftApplyStatus { + None = 0, + InProgress = 1, + Successful = 2, + Warning = 3, + Failed = 4 +} + +/** + * Multi-step wizard dialog for importing drafts to a project. + * Guides users through project selection, optional connection, book selection, + * overwrite confirmation, import progress, and sync completion. + */ +@Component({ + selector: 'app-draft-import-wizard', + imports: [ + AsyncPipe, + FormsModule, + ReactiveFormsModule, + MatButton, + MatCheckbox, + MatDialogContent, + MatIcon, + MatProgressBar, + MatStep, + MatStepLabel, + MatStepper, + MatStepperIcon, + MatStepperNext, + MatStepperPrevious, + TranslocoModule, + NoticeComponent, + ProjectSelectComponent, + BookMultiSelectComponent, + SyncProgressComponent + ], + templateUrl: './draft-import-wizard.component.html', + styleUrl: './draft-import-wizard.component.scss' +}) +export class DraftImportWizardComponent implements OnInit { + @ViewChild(MatStepper) stepper?: MatStepper; + @ViewChild('importStep') importStep?: MatStep; + + // Step 1: Project selection + projectSelectionForm = new FormGroup({ + targetParatextId: new FormControl(undefined, Validators.required) + }); + projects: ParatextProject[] = []; + isLoadingProject = false; + isLoadingProjects = true; + targetProjectId?: string; + selectedParatextProject?: ParatextProject; + targetProjectDoc$ = new BehaviorSubject(undefined); + canEditProject = true; + projectLoadingFailed = false; + sourceProjectId?: string; + cannotAdvanceFromProjectSelection = false; + + // Step 2-3: Project connection (conditional) + private _isConnecting = false; + public get isConnecting(): boolean { + return this._isConnecting; + } + public set isConnecting(value: boolean) { + this.dialogRef.disableClose = value; + this._isConnecting = value; + } + + needsConnection = false; + connectionError?: string; + + async connectToProject(skipStepperAdvance: boolean = false): Promise { + const paratextId = this.projectSelectionForm.value.targetParatextId; + if (paratextId == null) { + this.connectionError = this.i18n.translateStatic('draft_import_wizard.please_select_valid_project'); + return; + } + + if (this.isConnecting) { + return; + } + + if (!skipStepperAdvance) { + this.stepper?.next(); + } + + this.connectionError = undefined; + this.isConnecting = true; + + const paratextProject = this.projects.find(p => p.paratextId === paratextId); + if (paratextProject == null) { + this.connectionError = this.i18n.translateStatic('draft_import_wizard.please_select_valid_project'); + this.isConnecting = false; + return; + } + + try { + if (this.targetProjectId != null) { + // SF project exists, just add user to it + try { + await this.projectService.onlineAddCurrentUser(this.targetProjectId); + } catch (error) { + if (error instanceof CommandError && error.code === CommandErrorCode.NotFound) { + // The project was deleted, so just connect to it + await this.connectToNewProject(paratextId); + return; + } else { + throw error; + } + } + + // Reload project data after connection + const projectDoc = await this.projectService.get(this.targetProjectId); + await this.loadTargetProjectAndValidate(projectDoc); + + this.isConnecting = false; + this.stepper?.next(); + } else { + await this.connectToNewProject(paratextId); + } + } catch (error) { + this.connectionError = + error instanceof Error && error.message.length > 0 + ? error.message + : this.i18n.translateStatic('draft_import_wizard.failed_to_connect_project'); + this.isConnecting = false; + } + } + + async connectToNewProject(paratextId: string): Promise { + // Create SF project for this Paratext project + this.targetProjectId = await this.projectService.onlineCreate({ + paratextId: paratextId, + sourceParatextId: null, + checkingEnabled: false + }); + + // updateConnectStatus() will handle the sync finishing and move to the next step after "connecting" + this.stepper?.next(); + + const projectDoc = await this.projectService.get(this.targetProjectId); + this.targetProjectDoc$.next(projectDoc); + } + + retryProjectConnection(): void { + void this.connectToProject(true); + } + + updateConnectStatus(inProgress: boolean): void { + if (!inProgress) { + const projectDoc = this.targetProjectDoc$.value; + if (projectDoc?.data == null) { + this.isConnecting = false; + return; + } + void this.loadTargetProjectAndValidate(projectDoc).finally(() => { + this.isConnecting = false; + }); + } + } + + updateSyncStatus(inProgress: boolean): void { + if (!inProgress && this.isSyncing) { + this.isSyncing = false; + this.syncComplete = true; + } + } + + onStepSelectionChange(event: StepperSelectionEvent): void { + if (event.selectedStep === this.importStep && !this.importStepTriggered) { + this.importStepTriggered = true; + void this.startImport(); + } + } + + // Step 4: Book Selection (conditional) + availableBooksForImport: BookForImport[] = []; + showBookSelection = false; + + // Step 5: Overwrite confirmation (conditional) + showOverwriteConfirmation = false; + overwriteForm = new FormGroup({ + confirmOverwrite: new FormControl(false, Validators.requiredTrue) + }); + booksWithExistingText: BookWithExistingText[] = []; + + // Step 6: Import progress + private _isImporting = false; + public get isImporting(): boolean { + return this._isImporting; + } + public set isImporting(value: boolean) { + this.dialogRef.disableClose = value; + this._isImporting = value; + } + + importProgress: ImportProgress[] = []; + importError?: string; + importComplete = false; + importStepTriggered = false; + + // Step 7: Sync confirmation and completion + showSyncConfirmation = false; + isSyncing = false; + syncError?: string; + syncComplete = false; + skipSync = false; + + private readonly notifyDraftApplyProgressHandler = (projectId: string, draftApplyState: DraftApplyState): void => { + this.updateDraftApplyState(projectId, draftApplyState); + }; + + constructor( + @Inject(MAT_DIALOG_DATA) readonly data: BuildDto, + @Inject(MatDialogRef) private readonly dialogRef: MatDialogRef, + readonly destroyRef: DestroyRef, + private readonly paratextService: ParatextService, + private readonly draftNotificationService: DraftNotificationService, + private readonly projectService: SFProjectService, + private readonly textDocService: TextDocService, + readonly i18n: I18nService, + private readonly onlineStatusService: OnlineStatusService, + private readonly activatedProjectService: ActivatedProjectService, + private readonly authService: AuthService + ) { + this.draftNotificationService.setNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); + destroyRef.onDestroy(async () => { + // Stop the SignalR connection when the component is destroyed + await draftNotificationService.stop(); + this.draftNotificationService.removeNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); + }); + } + + get isAppOnline(): boolean { + return this.onlineStatusService.isOnline; + } + + get availableBooksFromBuild(): number[] { + const translationRanges = this.data.additionalInfo?.translationScriptureRanges ?? []; + const bookNumbers = translationRanges.flatMap(range => booksFromScriptureRange(range.scriptureRange)); + return Array.from(new Set(bookNumbers)); + } + + private _selectedBooks: BookForImport[] = []; + get selectedBooks(): BookForImport[] { + const value = this.availableBooksForImport.filter(b => b.selected); + if (this._selectedBooks.toString() !== value.toString()) { + this._selectedBooks = value; + } + + return this._selectedBooks; + } + + private getBooksToImport(): BookForImport[] { + return this.showBookSelection ? this.selectedBooks : this.availableBooksForImport; + } + + get singleBookName(): string { + if (this.booksWithExistingText.length === 1) { + return this.booksWithExistingText[0].bookName; + } + return ''; + } + + ngOnInit(): void { + void this.loadProjects(); + this.initializeAvailableBooks(); + this.sourceProjectId = this.activatedProjectService.projectId; + } + + private async loadProjects(): Promise { + try { + const allProjects = await this.paratextService.getProjects(); + // Filter to show only connectable projects (already have SF project or user is PT admin) + this.projects = this.filterConnectable(allProjects); + this.projectLoadingFailed = false; + } catch (error) { + this.projectLoadingFailed = true; + this.projects = []; + if (error instanceof HttpErrorResponse && error.status === 401) { + this.authService.requestParatextCredentialUpdate(); + } + } finally { + this.isLoadingProjects = false; + } + } + + reloadProjects(): void { + if (this.isLoadingProjects) { + return; + } + this.isLoadingProjects = true; + this.projectLoadingFailed = false; + void this.loadProjects(); + } + + /** + * From the given paratext projects, returns those that either: + * - already have a corresponding SF project + * - or have current user as admin on the PT project + */ + private filterConnectable(projects: ParatextProject[] | undefined): ParatextProject[] { + return projects?.filter(project => project.projectId != null || project.isConnectable) ?? []; + } + + private initializeAvailableBooks(): void { + const bookNums = this.availableBooksFromBuild; + this.availableBooksForImport = bookNums.map(bookNum => ({ + number: bookNum, // Alias for compatibility with Book interface + bookNum, + bookId: Canon.bookNumberToId(bookNum), + bookName: this.i18n.localizeBook(bookNum), + selected: true // Pre-select all books by default + })); + + // Show book selection only if multiple books + this.showBookSelection = this.availableBooksForImport.length > 1; + } + + async projectSelected(paratextId: string): Promise { + if (paratextId == null) { + this.targetProjectDoc$.next(undefined); + this.selectedParatextProject = undefined; + this.resetProjectValidation(); + this.resetImportState(); + return; + } + + const paratextProject = this.projects.find(p => p.paratextId === paratextId); + if (paratextProject == null) { + this.canEditProject = false; + this.targetProjectDoc$.next(undefined); + this.selectedParatextProject = undefined; + this.resetImportState(); + return; + } + + this.selectedParatextProject = paratextProject; + this.resetProjectValidation(); + this.resetImportState(); + + // Determine if we need to create SF project or connect to existing one + if (paratextProject.projectId != null) { + // SF project exists + this.targetProjectId = paratextProject.projectId; + this.needsConnection = !paratextProject.isConnected; + + // If the project still needs connection, we will analyze after connecting + if (!this.needsConnection) { + // Get the project profile to analyze + this.isLoadingProject = true; + try { + const projectDoc = await this.projectService.get(this.targetProjectId); + await this.loadTargetProjectAndValidate(projectDoc); + } finally { + this.isLoadingProject = false; + } + } else { + // Need to connect to the project + this.canEditProject = paratextProject.isConnectable; + this.targetProjectDoc$.next(undefined); + } + } else { + // Need to create SF project - this will happen after connection step + this.isLoadingProject = true; + try { + this.targetProjectId = undefined; + this.needsConnection = true; + this.canEditProject = paratextProject.isConnectable; + this.targetProjectDoc$.next(undefined); + } finally { + this.isLoadingProject = false; + } + } + } + + private async loadTargetProjectAndValidate(projectDoc: SFProjectDoc): Promise { + // Check permissions for all books + this.canEditProject = this.textDocService.userHasGeneralEditRight(projectDoc?.data); + if (this.canEditProject) { + this.targetProjectDoc$.next(projectDoc); + } else { + this.targetProjectDoc$.next(undefined); + } + + await this.analyzeBooksForOverwriteConfirmation(); + } + + private async ensureProjectExists(): Promise { + if (this.targetProjectId == null) { + return false; + } + + if (this.targetProjectDoc$.value?.data == null) { + const profileDoc = await this.projectService.get(this.targetProjectId); + if (profileDoc?.data == null) { + return false; + } + this.targetProjectDoc$.next(profileDoc); + } + + return true; + } + + private resetProjectValidation(): void { + this.canEditProject = true; + } + + onBookSelect(selectedBooks: number[]): void { + for (const book of this.availableBooksForImport) { + book.selected = selectedBooks.includes(book.bookNum); + } + this.resetImportState(); + void this.analyzeBooksForOverwriteConfirmation(); + } + + get projectReadyToImport(): boolean { + return this.projectSelectionForm.valid && !this.isConnecting && !this.isImporting && !this.isLoadingProject; + } + + async advanceFromProjectSelection(): Promise { + if (!this.projectReadyToImport) { + this.cannotAdvanceFromProjectSelection = true; + return; + } else { + this.cannotAdvanceFromProjectSelection = false; + } + + // If project needs connection, advance to connection step (targetProjectId may be undefined) + if (this.needsConnection) { + this.stepper?.next(); + return; + } + + // For connected projects, ensure we have a targetProjectId before proceeding + if (this.targetProjectId == null) { + this.cannotAdvanceFromProjectSelection = true; + return; + } + + this.isLoadingProject = true; + const projectExists = await this.ensureProjectExists(); + if (!projectExists) { + this.isLoadingProject = false; + return; + } + + await this.analyzeBooksForOverwriteConfirmation(); + + this.stepper?.next(); + this.isLoadingProject = false; + } + + private async analyzeBooksForOverwriteConfirmation(): Promise { + if (this.targetProjectId == null) return; + + this.booksWithExistingText = []; + const booksToCheck = this.getBooksToImport(); + + for (const book of booksToCheck) { + const chaptersWithText = await this.getChaptersWithText(book.bookNum); + if (chaptersWithText.length > 0) { + this.booksWithExistingText.push({ + bookNum: book.bookNum, + bookName: book.bookName, + chaptersWithText + }); + } + } + + this.showOverwriteConfirmation = this.booksWithExistingText.length > 0; + } + + private async getChaptersWithText(bookNum: number): Promise { + if (this.targetProjectId == null) return []; + + const project = this.targetProjectDoc$.value?.data; + if (project == null) return []; + + const targetBook = project.texts.find(t => t.bookNum === bookNum); + if (targetBook == null) return []; + + const chaptersWithText: number[] = []; + for (const chapter of targetBook.chapters) { + const textDocId = new TextDocId(this.targetProjectId, bookNum, chapter.number); + const hasText = await this.hasTextInChapter(textDocId); + if (hasText) { + chaptersWithText.push(chapter.number); + } + } + + return chaptersWithText; + } + + private async hasTextInChapter(textDocId: TextDocId): Promise { + const textDoc: TextDoc = await this.projectService.getText(textDocId); + return textDoc.getNonEmptyVerses().length > 0; + } + + async startImport(): Promise { + this.importStepTriggered = true; + if (this.targetProjectId == null || this.sourceProjectId == null) { + this.importError = this.i18n.translateStatic('draft_import_wizard.project_context_unavailable'); + return; + } + + this.isImporting = true; + this.importError = undefined; + this.importComplete = false; + + const booksToImport = this.getBooksToImport().filter(book => book.selected); + + if (booksToImport.length === 0) { + this.isImporting = false; + this.importError = this.i18n.translateStatic('draft_import_wizard.no_books_ready_for_import'); + return; + } + + // Initialize progress tracking + this.importProgress = booksToImport.map(book => ({ + bookNum: book.bookNum, + bookId: book.bookId, + bookName: book.bookName, + totalChapters: this.booksWithExistingText.find(b => b.bookNum === book.bookNum)?.chaptersWithText.length ?? 0, + completedChapters: [], + failedChapters: [] + })); + + try { + await this.performImport(booksToImport); + } catch (error) { + this.importError = error instanceof Error ? error.message : 'Unknown error occurred'; + this.isImporting = false; + } + } + + private async performImport(books: BookForImport[]): Promise { + if (this.targetProjectId == null || this.sourceProjectId == null) { + throw new Error('Missing project context for import'); + } + + // Subscribe to SignalR updates + await this.draftNotificationService.start(); + await this.draftNotificationService.subscribeToProject(this.sourceProjectId); + + // Build a scripture range and timestamp to import + const scriptureRange = books.map(b => b.bookId).join(';'); + const timestamp: Date = + this.data.additionalInfo?.dateGenerated != null ? new Date(this.data.additionalInfo.dateGenerated) : new Date(); + + // Apply the pre-translation draft to the project + try { + await this.projectService.onlineApplyPreTranslationToProject( + this.sourceProjectId, + scriptureRange, + this.targetProjectId, + timestamp + ); + } catch (error) { + if (error instanceof CommandError && error.code === CommandErrorCode.NotFound) { + this.importError = this.i18n.translateStatic('draft_import_wizard.project_deleted'); + // Reload the projects + void this.loadProjects(); + } else { + this.importError = error instanceof Error ? error.message : 'Unknown error occurred'; + } + this.isImporting = false; + } + } + + /** + * Handler for SignalR notifications when applying a draft. + * + * @param projectId The project identifier. + * @param draftApplyState The draft apply state from the backend. + */ + updateDraftApplyState(projectId: string, draftApplyState: DraftApplyState): void { + if (projectId !== this.sourceProjectId) return; + + // Update based on book or chapter + if (draftApplyState.bookNum === 0 && draftApplyState.chapterNum === 0) { + // Get the total number of failures + const failed = this.importProgress.reduce( + (sum, p) => sum + (p.failedChapters.some(c => c.chapterNum === 0) ? p.totalChapters : p.failedChapters.length), + 0 + ); + // Handle the final states + if (draftApplyState.status === DraftApplyStatus.Successful) { + // Check if there were any failures + this.isImporting = false; + const successful = this.importProgress.reduce((sum, p) => sum + p.completedChapters.length, 0); + if (failed > 0 && successful > 0) { + this.importError = this.i18n.translateStatic('draft_import_wizard.import_some_chapters_failed', { + successful, + failed + }); + } else if (failed > 0) { + this.importError = this.i18n.translateStatic('draft_import_wizard.import_all_chapters_failed', { + failed + }); + } else { + this.importComplete = true; + } + } else if (draftApplyState.status === DraftApplyStatus.Failed) { + // Clear all completed chapters + this.importProgress.forEach(p => { + p.completedChapters.length = 0; + }); + this.isImporting = false; + this.importError = draftApplyState.message; + if (failed > 0 && (this.importError == null || this.importError.length === 0)) { + this.importError = this.i18n.translateStatic('draft_import_wizard.import_all_chapters_failed', { + failed + }); + } + } + } else if (draftApplyState.bookNum > 0) { + // Handle the in-progress states + const progress: ImportProgress | undefined = this.importProgress.find(p => p.bookNum === draftApplyState.bookNum); + if (progress != null) { + if (draftApplyState.status === DraftApplyStatus.Failed) { + const failedChapter = progress.failedChapters.find(c => c.chapterNum === draftApplyState.chapterNum); + if (failedChapter != null && draftApplyState.message != null) { + failedChapter.message = draftApplyState.message; + } else { + progress.failedChapters.push({ chapterNum: draftApplyState.chapterNum, message: draftApplyState.message }); + } + } else if ( + draftApplyState.status === DraftApplyStatus.Successful && + !progress.completedChapters.includes(draftApplyState.chapterNum) + ) { + progress.completedChapters.push(draftApplyState.chapterNum); + } else if (draftApplyState.status === DraftApplyStatus.InProgress && draftApplyState.totalChapters > 0) { + progress.totalChapters = draftApplyState.totalChapters; + } + } + } + } + + clearImport(): void { + this.resetImportState(); + this.stepper?.previous(); + } + + retryImport(): void { + this.resetImportState(); + void this.startImport(); + } + + async selectSync(): Promise { + this.skipSync = false; + await this.performSync(); + } + + selectSkipSync(): void { + this.skipSync = true; + this.stepper?.next(); + } + + private async performSync(): Promise { + if (this.targetProjectId == null) return; + + this.syncError = undefined; + + try { + this.stepper?.next(); + await this.projectService.onlineSync(this.targetProjectId); + this.isSyncing = true; + } catch (error) { + if (error instanceof CommandError && error.code === CommandErrorCode.NotFound) { + this.syncError = this.i18n.translateStatic('draft_import_wizard.project_deleted'); + // Reload the projects + void this.loadProjects(); + } else { + this.syncError = error instanceof Error ? error.message : 'Sync failed'; + } + this.isSyncing = false; + } + } + + retrySync(): void { + this.syncError = undefined; + void this.performSync(); + } + + close(): void { + this.dialogRef.close(this.importComplete); + } + + cancel(): void { + this.dialogRef.close(false); + } + + getFailedChapters(progress: ImportProgress): string { + const failedChapters: number[] = progress.failedChapters.filter(f => f.chapterNum !== 0).map(f => f.chapterNum); + if (!progress.failedChapters.some(f => f.chapterNum === 0)) { + // A subset of chapters failed + return failedChapters.join(', '); + } else if (progress.totalChapters > 1) { + // All chapters failed, so display as a range + return `1-${progress.totalChapters}`; + } else { + // The only chapter in the book failed + return `${progress.totalChapters}`; + } + } + + private resetImportState(): void { + this.isImporting = false; + this.importProgress = []; + this.importError = undefined; + this.importComplete = false; + this.importStepTriggered = false; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts new file mode 100644 index 00000000000..5f5d7afce28 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts @@ -0,0 +1,416 @@ +import { AfterViewInit, Component, DestroyRef, Input, OnChanges, ViewChild } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Canon } from '@sillsdev/scripture'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { defaultTranslocoMarkupTranspilers } from 'ngx-transloco-markup'; +import { of } from 'rxjs'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { ParatextProject } from '../../../core/models/paratext-project'; +import { SFProjectDoc } from '../../../core/models/sf-project-doc'; +import { ParatextService } from '../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../core/project-notification.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { TextDocService } from '../../../core/text-doc.service'; +import { BuildDto } from '../../../machine-api/build-dto'; +import { ProgressService, ProjectProgress } from '../../../shared/progress-service/progress.service'; +import { DraftNotificationService } from '../draft-notification.service'; +import { + BookForImport, + BookWithExistingText, + DraftImportWizardComponent, + ImportProgress +} from './draft-import-wizard.component'; + +const mockDestroyRef = mock(DestroyRef); +const mockMatDialogRef = mock(MatDialogRef); +const mockParatextService = mock(ParatextService); +const mockProgressService = mock(ProgressService); +const mockDraftNotificationService = mock(DraftNotificationService); +const mockProjectNotificationService = mock(ProjectNotificationService); +const mockProjectService = mock(SFProjectService); +const mockTextDocService = mock(TextDocService); +const mockActivatedProjectService = mock(ActivatedProjectService); +const mockAuthService = mock(AuthService); +const mockOnlineStatusService = mock(OnlineStatusService); + +@Component({ + selector: 'app-draft-import-wizard-wrapper', + standalone: true, + imports: [DraftImportWizardComponent], + template: `` +}) +class DraftImportWizardWrapperComponent implements AfterViewInit, OnChanges { + @ViewChild(DraftImportWizardComponent) component!: DraftImportWizardComponent; + @Input() online: boolean = false; + @Input() canEditProject: boolean = true; + @Input() importComplete: boolean = false; + @Input() importStepTriggered: boolean = false; + @Input() isConnecting: boolean = false; + @Input() isImporting: boolean = false; + @Input() isLoadingProject: boolean = false; + @Input() isLoadingProjects: boolean = false; + @Input() isSyncing: boolean = false; + @Input() needsConnection: boolean = false; + @Input() projectLoadingFailed: boolean = false; + @Input() showBookSelection: boolean = false; + @Input() showOverwriteConfirmation: boolean = false; + @Input() skipSync: boolean = false; + @Input() syncComplete: boolean = false; + @Input() connectionError?: string; + @Input() importError?: string; + @Input() targetProjectId?: string; + @Input() selectedParatextProject?: ParatextProject; + @Input() availableBooksForImport: BookForImport[] = []; + @Input() booksWithExistingText: BookWithExistingText[] = []; + @Input() importProgress: ImportProgress[] = []; + @Input() step: number = 0; + + ngAfterViewInit(): void { + this.updateComponent(); + } + + ngOnChanges(): void { + this.updateComponent(); + } + + private updateComponent(): void { + if (!this.component) return; + setTimeout(() => { + // Set the story specific arguments + this.component.canEditProject = this.canEditProject; + this.component.importComplete = this.importComplete; + this.component.importStepTriggered = this.importStepTriggered; + this.component.isConnecting = this.isConnecting; + this.component.isImporting = this.isImporting; + this.component.isLoadingProject = this.isLoadingProject; + this.component.isLoadingProjects = this.isLoadingProjects; + this.component.isSyncing = this.isSyncing; + this.component.needsConnection = this.needsConnection; + this.component.projectLoadingFailed = this.projectLoadingFailed; + this.component.showBookSelection = this.showBookSelection; + this.component.showOverwriteConfirmation = this.showOverwriteConfirmation; + this.component.skipSync = this.skipSync; + this.component.syncComplete = this.syncComplete; + this.component.connectionError = this.connectionError; + this.component.importError = this.importError; + this.component.targetProjectId = this.targetProjectId; + if (this.targetProjectId != null) { + this.component.targetProjectDoc$.next({ id: this.targetProjectId } as SFProjectDoc); + } + this.component.selectedParatextProject = this.selectedParatextProject; + this.component.availableBooksForImport = this.availableBooksForImport; + this.component.booksWithExistingText = this.booksWithExistingText; + this.component.importProgress = this.importProgress; + + // Move the stepper to the specified step + if (this.component.stepper && this.component.stepper.selectedIndex !== this.step) { + this.component.stepper.reset(); + for (let i = 0; i < this.step - 1; i++) { + const step = this.component.stepper.steps.get(i); + if (step != null) step.completed = true; + this.component.stepper.next(); + } + } + }); + } +} + +interface DraftImportWizardComponentState { + online: boolean; + step: number; + canEditProject: boolean; + importComplete: boolean; + importStepTriggered: boolean; + isConnecting: boolean; + isImporting: boolean; + isLoadingProject: boolean; + isLoadingProjects: boolean; + isSyncing: boolean; + needsConnection: boolean; + projectLoadingFailed: boolean; + showBookSelection: boolean; + showOverwriteConfirmation: boolean; + skipSync: boolean; + syncComplete: boolean; + connectionError?: string; + importError?: string; + targetProjectId?: string; + selectedParatextProject?: ParatextProject; + availableBooksForImport: BookForImport[]; + booksWithExistingText: BookWithExistingText[]; + importProgress: ImportProgress[]; +} + +const defaultArgs: DraftImportWizardComponentState = { + online: true, + step: 0, + canEditProject: true, + importComplete: false, + importStepTriggered: false, + isConnecting: false, + isImporting: false, + isLoadingProject: false, + isLoadingProjects: false, + isSyncing: false, + needsConnection: false, + projectLoadingFailed: false, + showBookSelection: false, + showOverwriteConfirmation: false, + skipSync: false, + syncComplete: false, + connectionError: undefined, + importError: undefined, + targetProjectId: undefined, + selectedParatextProject: undefined, + availableBooksForImport: [], + booksWithExistingText: [], + importProgress: [] +}; + +const buildDto: BuildDto = { + additionalInfo: { + dateFinished: '2026-01-14T15:16:17.18+00:00', + translationScriptureRanges: [{ projectId: 'P02', scriptureRange: 'GEN;EXO;LEV;NUM;DEU' }] + } +} as BuildDto; + +export default { + title: 'Draft/Draft Import Wizard Dialog', + component: DraftImportWizardWrapperComponent, + decorators: [ + moduleMetadata({ + imports: [DraftImportWizardComponent, DraftImportWizardWrapperComponent], + providers: [ + { provide: DestroyRef, useValue: instance(mockDestroyRef) }, + { provide: MAT_DIALOG_DATA, useValue: buildDto }, + { provide: MatDialogRef, useValue: instance(mockMatDialogRef) }, + { provide: ParatextService, useValue: instance(mockParatextService) }, + { provide: DraftNotificationService, useValue: instance(mockDraftNotificationService) }, + { provide: ProjectNotificationService, useValue: instance(mockProjectNotificationService) }, + { provide: SFProjectService, useValue: instance(mockProjectService) }, + { provide: TextDocService, useValue: instance(mockTextDocService) }, + { provide: OnlineStatusService, useValue: instance(mockOnlineStatusService) }, + { provide: ActivatedProjectService, useValue: instance(mockActivatedProjectService) }, + { provide: ProgressService, useValue: instance(mockProgressService) }, + { provide: AuthService, useValue: instance(mockAuthService) }, + defaultTranslocoMarkupTranspilers() + ] + }) + ], + render: args => { + setUpMocks(args); + return { + component: DraftImportWizardWrapperComponent, + props: args + }; + }, + args: defaultArgs, + parameters: { + controls: { + include: Object.keys(defaultArgs) + } + }, + argTypes: { + online: { control: 'boolean' }, + canEditProject: { control: 'boolean' }, + importComplete: { control: 'boolean' }, + importStepTriggered: { control: 'boolean' }, + isConnecting: { control: 'boolean' }, + isImporting: { control: 'boolean' }, + isLoadingProject: { control: 'boolean' }, + isLoadingProjects: { control: 'boolean' }, + isSyncing: { control: 'boolean' }, + needsConnection: { control: 'boolean' }, + projectLoadingFailed: { control: 'boolean' }, + showBookSelection: { control: 'boolean' }, + showOverwriteConfirmation: { control: 'boolean' }, + skipSync: { control: 'boolean' }, + syncComplete: { control: 'boolean' }, + connectionError: { control: 'text' }, + importError: { control: 'text' }, + targetProjectId: { control: 'text' }, + selectedParatextProject: { control: 'object' }, + availableBooksForImport: { control: 'object' }, + booksWithExistingText: { control: 'object' }, + importProgress: { control: 'object' }, + step: { control: 'number' } + } +} as Meta; + +type Story = StoryObj; + +const Template: Story = {}; + +export const StepOne: Story = { + ...Template +}; + +export const StepTwo: Story = { + ...Template, + args: { + ...defaultArgs, + needsConnection: true, + step: 2, + selectedParatextProject: { shortName: 'P01', name: 'Project 01' } as ParatextProject + } +}; + +export const StepThree: Story = { + ...Template, + args: { + ...defaultArgs, + isConnecting: true, + needsConnection: true, + step: 3, + selectedParatextProject: { shortName: 'P01', name: 'Project 01' } as ParatextProject + } +}; + +export const StepFour: Story = { + ...Template, + args: { + ...defaultArgs, + needsConnection: true, + showBookSelection: true, + step: 4, + selectedParatextProject: { shortName: 'P01', name: 'Project 01' } as ParatextProject, + availableBooksForImport: [ + getBookForImport(1, true), + getBookForImport(2, true), + getBookForImport(3, true), + getBookForImport(4, true), + getBookForImport(5, false) + ] + } +}; + +export const StepFive: Story = { + ...Template, + args: { + ...defaultArgs, + isConnecting: false, + needsConnection: true, + showBookSelection: true, + showOverwriteConfirmation: true, + step: 5, + selectedParatextProject: { shortName: 'P01', name: 'Project 01' } as ParatextProject, + booksWithExistingText: [ + getBookWithExistingText(1, 1), + getBookWithExistingText(2, 4), + getBookWithExistingText(3, 7), + getBookWithExistingText(4, 5), + getBookWithExistingText(5, 2) + ] + } +}; + +export const StepSix: Story = { + ...Template, + args: { + ...defaultArgs, + importStepTriggered: true, + isConnecting: false, + isImporting: true, + needsConnection: true, + showBookSelection: true, + // Skip the Overwrite Confirmation (step 5) + step: 5, + targetProjectId: 'P01', + selectedParatextProject: { shortName: 'P01', name: 'Project 01' } as ParatextProject, + // availableBooksForImport is used if importStepTriggered is false + availableBooksForImport: [ + getBookForImport(1, true), + getBookForImport(2, true), + getBookForImport(3, true), + getBookForImport(4, true), + getBookForImport(5, false) + ], + // importProgress is used if importStepTriggered is true + importProgress: [ + getImportProgress(1, 1, 1, 0), + getImportProgress(2, 4, 3, 1), + getImportProgress(3, 7, 3, 0), + getImportProgress(4, 5, 0, 0) + ] + } +}; + +export const StepSeven: Story = { + ...Template, + args: { + ...defaultArgs, + importComplete: true, + importStepTriggered: true, + isConnecting: false, + isImporting: false, + needsConnection: false, + showBookSelection: false, + // Skipping steps 2, 3, 5 + step: 4, + targetProjectId: 'P01', + selectedParatextProject: { shortName: 'P01', name: 'Project 01' } as ParatextProject + } +}; + +function getBookForImport(bookNum: number, selected: boolean): BookForImport { + return { + bookId: Canon.bookNumberToId(bookNum), + bookNum: bookNum, + bookName: Canon.bookNumberToEnglishName(bookNum), + number: bookNum, + selected: selected + }; +} + +function getBookWithExistingText(bookNum: number, numberOfChaptersWithText: number): BookWithExistingText { + return { + bookNum: bookNum, + bookName: Canon.bookNumberToEnglishName(bookNum), + chaptersWithText: new Array(numberOfChaptersWithText).fill(null).map((_, i) => i + 1) + }; +} + +function getImportProgress( + bookNum: number, + totalChapters: number, + numberOfCompletedChapters: number, + numberOfFailedChapters: number +): ImportProgress { + return { + bookNum: bookNum, + bookId: Canon.bookNumberToId(bookNum), + bookName: Canon.bookNumberToEnglishName(bookNum), + totalChapters: totalChapters, + completedChapters: new Array(numberOfCompletedChapters).fill(null).map((_, i) => i + 1), + failedChapters: new Array(numberOfFailedChapters) + .fill(null) + .map((_, i) => i + 1) + .map(i => ({ + chapterNum: i + })) + }; +} + +function setUpMocks(args: DraftImportWizardComponentState): void { + when(mockActivatedProjectService.projectId).thenReturn('P02'); + when(mockOnlineStatusService.onlineStatus$).thenReturn(of(args.online)); + when(mockOnlineStatusService.isOnline).thenReturn(args.online); + when(mockOnlineStatusService.online).thenReturn( + new Promise(resolve => { + if (args.online) resolve(); + // Else, never resolve. + }) + ); + when(mockProgressService.getProgress(anything(), anything())).thenResolve( + new ProjectProgress([ + { bookId: 'GEN', verseSegments: 100, blankVerseSegments: 0 }, + { bookId: 'EXO', verseSegments: 100, blankVerseSegments: 0 }, + { bookId: 'LEV', verseSegments: 100, blankVerseSegments: 100 }, + { bookId: 'NUM', verseSegments: 22, blankVerseSegments: 2 }, + { bookId: 'DEU', verseSegments: 0, blankVerseSegments: 0 } + ]) + ); +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-notification.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-notification.service.ts new file mode 100644 index 00000000000..bdc7da4bde0 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-notification.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@angular/core'; +import { + AbortError, + HubConnection, + HubConnectionBuilder, + HubConnectionState, + IHttpConnectionOptions +} from '@microsoft/signalr'; +import { AuthService } from 'xforge-common/auth.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; + +@Injectable({ + providedIn: 'root' +}) +export class DraftNotificationService { + private connection: HubConnection; + private options: IHttpConnectionOptions = { + accessTokenFactory: async () => (await this.authService.getAccessToken()) ?? '' + }; + private openConnections: number = 0; + + constructor( + private authService: AuthService, + private readonly onlineService: OnlineStatusService + ) { + this.connection = new HubConnectionBuilder() + .withUrl('/draft-notifications', this.options) + .withAutomaticReconnect() + .withStatefulReconnect() + .build(); + } + + get appOnline(): boolean { + return this.onlineService.isOnline && this.onlineService.isBrowserOnline; + } + + removeNotifyDraftApplyProgressHandler(handler: any): void { + this.connection.off('notifyDraftApplyProgress', handler); + } + + setNotifyDraftApplyProgressHandler(handler: any): void { + this.connection.on('notifyDraftApplyProgress', handler); + } + + async start(): Promise { + this.openConnections++; + if ( + this.connection.state !== HubConnectionState.Connected && + this.connection.state !== HubConnectionState.Connecting && + this.connection.state !== HubConnectionState.Reconnecting + ) { + await this.connection.start().catch(err => { + // Suppress AbortErrors, as they are not caused by server error, but the SignalR connection state + // These will be thrown if a user navigates away quickly after + // starting the sync or the app loses internet connection + if (err instanceof AbortError || !this.appOnline) { + return; + } else { + throw err; + } + }); + } + } + + async stop(): Promise { + // Only stop the connection if this is the last open connection + if (--this.openConnections <= 0) { + await this.connection.stop(); + } + } + + async subscribeToProject(projectId: string): Promise { + await this.connection.send('subscribeToProject', projectId).catch(err => { + // This error is thrown when a user navigates away quickly after starting the sync + if (err.message === "Cannot send data if the connection is not in the 'Connected' State.") { + return; + } else { + throw err; + } + }); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.html index 195cce4d8db..c154396d2fa 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.html @@ -1,25 +1,13 @@ @for (book of booksWithDrafts$ | async; track book.bookId) { - - - {{ "canon.book_names." + book.bookId | transloco }} - - - more_vert - - - - - - + {{ "canon.book_names." + book.bookId | transloco }} + } @empty { {{ t("no_books_have_drafts") }} } 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 af2997e812c..7160c38d7ee 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 @@ -1,35 +1,25 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { MatDialogRef } from '@angular/material/dialog'; -import { MatMenuHarness } from '@angular/material/menu/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { Router } from '@angular/router'; 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'; -import { BehaviorSubject, filter, of, Subscription } from 'rxjs'; -import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; +import { of, Subscription } from 'rxjs'; +import { anything, capture, mock, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; -import { DialogService } from 'xforge-common/dialog.service'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { SFProjectService } from '../../../core/sf-project.service'; -import { TextDocService } from '../../../core/text-doc.service'; import { BuildDto } from '../../../machine-api/build-dto'; -import { DraftApplyDialogComponent } from '../draft-apply-dialog/draft-apply-dialog.component'; -import { DraftApplyProgress } from '../draft-apply-progress-dialog/draft-apply-progress-dialog.component'; -import { DraftHandlingService } from '../draft-handling.service'; import { BookWithDraft, DraftPreviewBooksComponent } from './draft-preview-books.component'; const mockedActivatedProjectService = mock(ActivatedProjectService); const mockedProjectService = mock(SFProjectService); const mockedUserService = mock(UserService); -const mockedDraftHandlingService = mock(DraftHandlingService); -const mockedDialogService = mock(DialogService); -const mockedTextService = mock(TextDocService); const mockedErrorReportingService = mock(ErrorReportingService); const mockedRouter = mock(Router); @@ -42,9 +32,6 @@ describe('DraftPreviewBooks', () => { { provide: ActivatedProjectService, useMock: mockedActivatedProjectService }, { provide: SFProjectService, useMock: mockedProjectService }, { provide: UserService, useMock: mockedUserService }, - { provide: DraftHandlingService, useMock: mockedDraftHandlingService }, - { provide: DialogService, useMock: mockedDialogService }, - { provide: TextDocService, useMock: mockedTextService }, { provide: ErrorReportingService, useMock: mockedErrorReportingService }, { provide: Router, useMock: mockedRouter }, provideNoopAnimations() @@ -69,7 +56,7 @@ describe('DraftPreviewBooks', () => { it('can navigate to a specific book', fakeAsync(() => { env = new TestEnvironment(); - env.getBookButtonAtIndex(0).querySelector('button')!.click(); + env.getBookButtonAtIndex(0).click(); tick(); env.fixture.detectChanges(); verify(mockedRouter.navigate(anything(), anything())).once(); @@ -79,313 +66,11 @@ describe('DraftPreviewBooks', () => { queryParams: { 'draft-active': true, 'draft-timestamp': undefined } }); })); - - it('opens more menu with options', fakeAsync(async () => { - env = new TestEnvironment(); - const moreButton: HTMLElement = env.getBookButtonAtIndex(0).querySelector('.book-more')!; - moreButton.click(); - tick(); - env.fixture.detectChanges(); - const harness: MatMenuHarness = await env.moreMenuHarness(); - const items = await harness.getItems(); - expect(items.length).toEqual(2); - harness.close(); - tick(); - env.fixture.detectChanges(); - })); - - it('does not apply draft if user cancels', fakeAsync(() => { - env = new TestEnvironment(); - const bookWithDraft: BookWithDraft = env.booksWithDrafts[0]; - setupDialog(); - expect(env.getBookButtonAtIndex(0).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())).never(); - })); - - it('notifies user if applying a draft failed due to an error', fakeAsync(() => { - env = new TestEnvironment(); - const bookWithDraft: BookWithDraft = env.booksWithDrafts[0]; - setupDialog('project01'); - when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())) - .thenReject(new Error('Draft error')) - .thenResolve(undefined) - .thenResolve('error'); - expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy(); - env.component.chooseProjectToAddDraft(bookWithDraft); - tick(); - env.fixture.detectChanges(); - expect(env.draftApplyProgress!.chaptersApplied).toEqual([2]); - expect(env.draftApplyProgress!.completed).toBe(true); - verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times(3); - verify(mockedErrorReportingService.silentError(anything(), anything())).once(); - })); - - it('can apply all chapters of a draft to a book', fakeAsync(() => { - env = new TestEnvironment(); - const bookWithDraft: BookWithDraft = env.booksWithDrafts[0]; - setupDialog('project01'); - when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).thenResolve( - undefined - ); - expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy(); - env.component.chooseProjectToAddDraft(bookWithDraft); - tick(); - env.fixture.detectChanges(); - expect(env.draftApplyProgress!.chaptersApplied).toEqual([1, 2, 3]); - expect(env.draftApplyProgress!.completed).toBe(true); - verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times(3); - })); - - it('can apply a historic draft', fakeAsync(() => { - env = new TestEnvironment({ - additionalInfo: { - translationScriptureRanges: [{ projectId: 'project01', scriptureRange: 'GEN;EXO;LEV' }], - dateGenerated: '2024-08-27T01:02:03.004+00:00' - } - } as BuildDto); - 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(2); - })); - - it('can open dialog with the current project', fakeAsync(() => { - env = new TestEnvironment(); - expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy(); - const mockedDialogRef: MatDialogRef = mock(MatDialogRef); - when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: 'project01' })); - when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn( - instance(mockedDialogRef) - ); - expect(env.component['projectParatextId']).toEqual(env.paratextId); - env.component.chooseProjectToAddDraft(env.booksWithDrafts[0], env.paratextId); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times( - env.booksWithDrafts[0].chaptersWithDrafts.length - ); - verify(mockedProjectService.onlineAddChapters('project01', anything(), anything())).never(); - })); - - it('can open dialog to apply draft to a different project', fakeAsync(() => { - env = new TestEnvironment(); - expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy(); - const mockedDialogRef: MatDialogRef = mock(MatDialogRef); - when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: 'otherProject' })); - when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn( - instance(mockedDialogRef) - ); - env.component.chooseProjectToAddDraft(env.booksWithDrafts[0]); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times( - env.booksWithDrafts[0].chaptersWithDrafts.length - ); - verify(mockedProjectService.onlineAddChapters('otherProject', anything(), anything())).never(); - })); - - it('translators can add draft to different project', fakeAsync(async () => { - env = new TestEnvironment(); - when(mockedUserService.currentUserId).thenReturn('user02'); - const moreButton: HTMLElement = env.getBookButtonAtIndex(0).querySelector('.book-more')!; - moreButton.click(); - tick(); - env.fixture.detectChanges(); - const harness: MatMenuHarness = await env.moreMenuHarness(); - const items = await harness.getItems(); - expect(items.length).toEqual(2); - harness.close(); - tick(); - env.fixture.detectChanges(); - })); - - it('does not apply draft if user cancels applying to a different project', fakeAsync(() => { - env = new TestEnvironment(); - setupDialog(); - env.component.chooseProjectToAddDraft(env.booksWithDrafts[0]); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).never(); - expect().nothing(); - })); - - it('creates the chapters in the project if they do not exist', fakeAsync(() => { - env = new TestEnvironment(); - expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy(); - const projectEmptyBook = 'projectEmptyBook'; - const mockedDialogRef: MatDialogRef = mock(MatDialogRef); - when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: projectEmptyBook })); - when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn( - instance(mockedDialogRef) - ); - - when(mockedProjectService.onlineAddChapters(projectEmptyBook, anything(), anything())).thenResolve(); - const projectWithChaptersMissing = createTestProjectProfile({ - texts: [ - { bookNum: 1, chapters: [{ number: 1, lastVerse: 0 }], permissions: { user01: TextInfoPermission.Write } } - ] - }); - when(mockedProjectService.getProfile(projectEmptyBook)).thenResolve({ - id: projectEmptyBook, - data: projectWithChaptersMissing - } as SFProjectProfileDoc); - env.component.chooseProjectToAddDraft(env.booksWithDrafts[0], 'project01'); - tick(); - env.fixture.detectChanges(); - verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once(); - verify(mockedProjectService.onlineAddChapters(projectEmptyBook, anything(), anything())).once(); - // needs to create 2 texts - verify(mockedTextService.createTextDoc(anything())).twice(); - verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).times( - env.booksWithDrafts[0].chaptersWithDrafts.length - ); - })); - - it('shows message to generate a new draft if legacy USFM draft', fakeAsync(() => { - env = new TestEnvironment(); - const bookWithDraft: BookWithDraft = env.booksWithDrafts[0]; - setupDialog('project01'); - when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())).thenResolve( - 'error: legacy format' - ); - expect(env.getBookButtonAtIndex(0).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(3); - })); - - it('can track progress of chapters applied', fakeAsync(() => { - env = new TestEnvironment(); - const bookWithDraft: BookWithDraft = env.booksWithDrafts[0]; - setupDialog('project01'); - const resolveSubject$: BehaviorSubject = new BehaviorSubject(false); - const promise: Promise = new Promise(resolve => { - resolveSubject$.pipe(filter(value => value)).subscribe(() => resolve(undefined)); - }); - when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything())) - .thenResolve(undefined) - .thenReturn(promise); - expect(env.getBookButtonAtIndex(0).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(3); - expect(env.component.numChaptersApplied).toEqual(1); - resolveSubject$.next(true); - resolveSubject$.complete(); - tick(); - env.fixture.detectChanges(); - expect(env.component.numChaptersApplied).toEqual(3); - })); - - describe('combineErrorMessages', () => { - it('should return an empty array if there are no error messages', () => { - const errorMessages: { chapter: number; message: string }[] = []; - const result = DraftPreviewBooksComponent['combineErrorMessages'](errorMessages); - expect(result).toEqual([]); - }); - - it('should format a single error message', () => { - const errorMessages = [{ chapter: 1, message: 'Error message' }]; - const result = DraftPreviewBooksComponent['combineErrorMessages'](errorMessages); - expect(result).toEqual(['1: Error message']); - }); - - it('should format multiple error messages for non-consecutive chapters', () => { - const errorMessages = [ - { chapter: 1, message: 'Error message' }, - { chapter: 3, message: 'Another error message' } - ]; - const result = DraftPreviewBooksComponent['combineErrorMessages'](errorMessages); - expect(result).toEqual(['1: Error message', '3: Another error message']); - }); - - it('should group consecutive chapters with the same error message', () => { - const errorMessages = [ - { chapter: 1, message: 'Error message' }, - { chapter: 2, message: 'Error message' }, - { chapter: 3, message: 'Error message' } - ]; - const result = DraftPreviewBooksComponent['combineErrorMessages'](errorMessages); - expect(result).toEqual(['1-3: Error message']); - }); - - it('should handle a mix of single and ranged error messages', () => { - const errorMessages = [ - { chapter: 1, message: 'Error message' }, - { chapter: 2, message: 'Error message' }, - { chapter: 4, message: 'Another error message' } - ]; - const result = DraftPreviewBooksComponent['combineErrorMessages'](errorMessages); - expect(result).toEqual(['1-2: Error message', '4: Another error message']); - }); - - it('should handle multiple ranges', () => { - const errorMessages = [ - { chapter: 1, message: 'Error message' }, - { chapter: 2, message: 'Error message' }, - { chapter: 4, message: 'Another error message' }, - { chapter: 5, message: 'Another error message' } - ]; - const result = DraftPreviewBooksComponent['combineErrorMessages'](errorMessages); - expect(result).toEqual(['1-2: Error message', '4-5: Another error message']); - }); - - it('should handle ranges and singletons with different messages', () => { - const errorMessages = [ - { chapter: 1, message: 'Error message 1' }, - { chapter: 2, message: 'Error message 1' }, - { chapter: 3, message: 'Error message 2' }, - { chapter: 4, message: 'Error message 1' }, - { chapter: 5, message: 'Error message 1' } - ]; - const result = DraftPreviewBooksComponent['combineErrorMessages'](errorMessages); - expect(result).toEqual(['1-2: Error message 1', '3: Error message 2', '4-5: Error message 1']); - }); - - it('should handle a single error at the end of a range', () => { - const errorMessages = [ - { chapter: 1, message: 'Error message 1' }, - { chapter: 2, message: 'Error message 1' }, - { chapter: 3, message: 'Error message 2' } - ]; - const result = DraftPreviewBooksComponent['combineErrorMessages'](errorMessages); - expect(result).toEqual(['1-2: Error message 1', '3: Error message 2']); - }); - }); - - function setupDialog(projectId?: string): void { - const mockedDialogRef: MatDialogRef = mock(MatDialogRef); - when(mockedDialogRef.afterClosed()).thenReturn(of(projectId ? { projectId } : undefined)); - when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn( - instance(mockedDialogRef) - ); - } }); class TestEnvironment { component: DraftPreviewBooksComponent; fixture: ComponentFixture; - draftApplyProgress?: DraftApplyProgress; progressSubscription?: Subscription; loader: HarnessLoader; readonly paratextId = 'pt01'; @@ -428,9 +113,6 @@ class TestEnvironment { constructor(build: BuildDto | undefined = undefined) { when(mockedActivatedProjectService.changes$).thenReturn(of(this.mockProjectDoc)); when(mockedActivatedProjectService.projectDoc).thenReturn(this.mockProjectDoc); - when( - mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything(), anything()) - ).thenResolve(); when(mockedActivatedProjectService.projectId).thenReturn('project01'); when(mockedUserService.currentUserId).thenReturn('user01'); when(mockedProjectService.hasDraft(anything(), anything())).thenReturn(true); @@ -439,20 +121,15 @@ class TestEnvironment { this.component = this.fixture.componentInstance; this.component.build = build; this.loader = TestbedHarnessEnvironment.loader(this.fixture); - this.component.draftApplyProgress$.subscribe(progress => (this.draftApplyProgress = progress)); tick(); this.fixture.detectChanges(); } draftBookCount(): number { - return this.fixture.nativeElement.querySelectorAll('.draft-book-option').length; + return this.fixture.nativeElement.querySelectorAll('.draft-book-button').length; } getBookButtonAtIndex(index: number): HTMLElement { - return this.fixture.nativeElement.querySelectorAll('.draft-book-option')[index]; - } - - async moreMenuHarness(): Promise { - return await this.loader.getHarness(MatMenuHarness.with({ selector: '.book-more' })); + return this.fixture.nativeElement.querySelectorAll('.draft-book-button')[index]; } } 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 52d40a74da6..51519dd8324 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 @@ -1,37 +1,18 @@ import { AsyncPipe } from '@angular/common'; import { Component, Input } from '@angular/core'; -import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; -import { MatDialogRef } from '@angular/material/dialog'; -import { MatIcon } from '@angular/material/icon'; -import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { MatButton } from '@angular/material/button'; import { Router } from '@angular/router'; import { TranslocoModule } from '@ngneat/transloco'; import { Canon } from '@sillsdev/scripture'; -import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; -import { BehaviorSubject, firstValueFrom, map, Observable, tap } from 'rxjs'; +import { map, Observable, tap } from 'rxjs'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; -import { DialogService } from 'xforge-common/dialog.service'; -import { ErrorReportingService } from 'xforge-common/error-reporting.service'; import { UserService } from 'xforge-common/user.service'; import { filterNullish } from 'xforge-common/util/rxjs-util'; -import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; -import { TextDocId } from '../../../core/models/text-doc'; import { SFProjectService } from '../../../core/sf-project.service'; -import { TextDocService } from '../../../core/text-doc.service'; import { BuildDto } from '../../../machine-api/build-dto'; import { booksFromScriptureRange } from '../../../shared/utils'; -import { - DraftApplyDialogComponent, - DraftApplyDialogConfig as DraftApplyDialogData, - DraftApplyDialogResult -} from '../draft-apply-dialog/draft-apply-dialog.component'; -import { - DraftApplyProgress, - DraftApplyProgressDialogComponent -} from '../draft-apply-progress-dialog/draft-apply-progress-dialog.component'; -import { DraftHandlingService } from '../draft-handling.service'; export interface BookWithDraft { bookNumber: number; @@ -45,16 +26,7 @@ export interface BookWithDraft { selector: 'app-draft-preview-books', templateUrl: './draft-preview-books.component.html', styleUrls: ['./draft-preview-books.component.scss'], - imports: [ - AsyncPipe, - MatButtonToggle, - MatMenu, - MatMenuItem, - MatMenuTrigger, - MatIcon, - MatButtonToggleGroup, - TranslocoModule - ] + imports: [AsyncPipe, MatButton, TranslocoModule] }) export class DraftPreviewBooksComponent { @Input() build: BuildDto | undefined; @@ -102,187 +74,22 @@ export class DraftPreviewBooksComponent { }) ); - draftApplyProgress$: BehaviorSubject = new BehaviorSubject< - DraftApplyProgress | undefined - >(undefined); - protected projectParatextId?: string; - private applyChapters: number[] = []; - private draftApplyBookNum: number = 0; - private chaptersApplied: number[] = []; - private errorMessages: { chapter: number; message: string }[] = []; - constructor( private readonly activatedProjectService: ActivatedProjectService, - private readonly projectService: SFProjectService, private readonly userService: UserService, - private readonly draftHandlingService: DraftHandlingService, - private readonly dialogService: DialogService, - private readonly textDocService: TextDocService, - private readonly errorReportingService: ErrorReportingService, - private readonly router: Router + private readonly router: Router, + private readonly projectService: SFProjectService ) {} - get numChaptersApplied(): number { - return this.chaptersApplied.length; - } - linkForBookAndChapter(bookId: string, chapterNumber: number): string[] { return ['/projects', this.activatedProjectService.projectId!, 'translate', bookId, chapterNumber.toString()]; } - async chooseProjectToAddDraft(bookWithDraft: BookWithDraft, paratextId?: string): Promise { - const dialogData: DraftApplyDialogData = { - initialParatextId: paratextId, - bookNum: bookWithDraft.bookNumber, - chapters: bookWithDraft.chaptersWithDrafts - }; - const dialogRef: MatDialogRef = this.dialogService.openMatDialog( - DraftApplyDialogComponent, - { data: dialogData, width: '600px' } - ); - const result: DraftApplyDialogResult | undefined = await firstValueFrom(dialogRef.afterClosed()); - if (result == null || result.projectId == null) { - return; - } - - const projectDoc: SFProjectProfileDoc = await this.projectService.getProfile(result.projectId); - const projectTextInfo: TextInfo = projectDoc.data?.texts.find( - t => t.bookNum === bookWithDraft.bookNumber && t.chapters - )!; - - const projectChapters: number[] = projectTextInfo.chapters.map(c => c.number); - const missingChapters: number[] = bookWithDraft.chaptersWithDrafts.filter(c => !projectChapters.includes(c)); - if (missingChapters.length > 0) { - await this.projectService.onlineAddChapters(result.projectId, bookWithDraft.bookNumber, missingChapters); - for (const chapter of missingChapters) { - const textDocId = new TextDocId(result.projectId, bookWithDraft.bookNumber, chapter); - await this.textDocService.createTextDoc(textDocId); - } - } - await this.applyBookDraftAsync(bookWithDraft, result.projectId); - } - - private async applyBookDraftAsync(bookWithDraft: BookWithDraft, targetProjectId: string): Promise { - this.applyChapters = bookWithDraft.chaptersWithDrafts; - this.draftApplyBookNum = bookWithDraft.bookNumber; - this.chaptersApplied = []; - this.errorMessages = []; - this.updateProgress(); - - const promises: Promise[] = []; - const targetProject = (await this.projectService.getProfile(targetProjectId)).data!; - for (const chapter of bookWithDraft.chaptersWithDrafts) { - const draftTextDocId = new TextDocId(this.activatedProjectService.projectId!, bookWithDraft.bookNumber, chapter); - const targetTextDocId = new TextDocId(targetProjectId, bookWithDraft.bookNumber, chapter); - promises.push(this.applyAndReportChapter(targetProject, draftTextDocId, targetTextDocId)); - } - - try { - this.openProgressDialog(); - const results: (string | undefined)[] = await Promise.all(promises); - if (results.some(result => result !== undefined)) { - this.updateProgress(undefined, true); - // The draft is in the legacy format. This can only be applied chapter by chapter. - return; - } - } catch (error) { - this.updateProgress(undefined, true); - // report the error to bugsnag - this.errorReportingService.silentError( - 'Error while trying to apply a draft', - ErrorReportingService.normalizeError(error) - ); - } - } - navigate(book: BookWithDraft): void { void this.router.navigate(this.linkForBookAndChapter(book.bookId, book.chaptersWithDrafts[0]), { queryParams: { 'draft-active': true, 'draft-timestamp': this.build?.additionalInfo?.dateGenerated } }); } - - openProgressDialog(): void { - this.dialogService.openMatDialog(DraftApplyProgressDialogComponent, { - data: { draftApplyProgress$: this.draftApplyProgress$ }, - disableClose: true - }); - } - - private async applyAndReportChapter( - project: SFProjectProfile, - draftTextDocId: TextDocId, - targetTextDocId: TextDocId - ): Promise { - let timestamp: Date | undefined = undefined; - if (this.build?.additionalInfo?.dateGenerated != null) { - timestamp = new Date(this.build.additionalInfo.dateGenerated); - } - return await this.draftHandlingService - .getAndApplyDraftAsync(project, draftTextDocId, targetTextDocId, timestamp) - .then(result => { - this.updateProgress( - result === undefined ? targetTextDocId.chapterNum : undefined, - undefined, - result === undefined ? undefined : { chapter: targetTextDocId.chapterNum, message: result } - ); - return result; - }); - } - - private updateProgress( - bookCompleted?: number, - completed?: boolean, - error?: { chapter: number; message: string } - ): void { - if (bookCompleted != null) { - this.chaptersApplied.push(bookCompleted); - } - if (error != null) { - this.errorMessages.push(error); - this.errorMessages.sort((a, b) => a.chapter - b.chapter); - } - - this.draftApplyProgress$.next({ - bookNum: this.draftApplyBookNum, - chapters: this.applyChapters, - chaptersApplied: this.chaptersApplied, - completed: !!completed ? completed : this.chaptersApplied.length === this.applyChapters.length, - errorMessages: DraftPreviewBooksComponent.combineErrorMessages(this.errorMessages) - }); - } - - private static combineErrorMessages(errorMessages: { chapter: number; message: string }[]): string[] { - const formattedErrors: string[] = []; - if (errorMessages.length > 0) { - let rangeStart = errorMessages[0].chapter; - let currentMessage = errorMessages[0].message; - - for (let i = 1; i < errorMessages.length; i++) { - const prevChapter = errorMessages[i - 1].chapter; - const currentChapter = errorMessages[i].chapter; - const message = errorMessages[i].message; - - if (message !== currentMessage || currentChapter !== prevChapter + 1) { - const rangeEnd = errorMessages[i - 1].chapter; - if (rangeStart === rangeEnd) { - formattedErrors.push(`${rangeStart}: ${currentMessage}`); - } else { - formattedErrors.push(`${rangeStart}-${rangeEnd}: ${currentMessage}`); - } - rangeStart = currentChapter; - currentMessage = message; - } - } - - const lastError = errorMessages[errorMessages.length - 1]; - if (rangeStart === lastError.chapter) { - formattedErrors.push(`${rangeStart}: ${currentMessage}`); - } else { - formattedErrors.push(`${rangeStart}-${lastError.chapter}: ${currentMessage}`); - } - } - return formattedErrors; - } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts index d026643476b..9c85c5399bc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts @@ -104,6 +104,7 @@ describe('TrainingDataService', () => { deepEqual({ projectId: 'project01', dataId: 'data01' }) ) ).once(); + expect().nothing(); })); it('should query training data docs', fakeAsync(async () => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts index 1ced70d8e4f..9e7853db390 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts @@ -17,6 +17,7 @@ import { TypeRegistry } from 'xforge-common/type-registry'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { TextDoc, TextDocId } from '../../../core/models/text-doc'; import { Revision } from '../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../core/project-notification.service'; import { SFProjectService } from '../../../core/sf-project.service'; import { TextComponent } from '../../../shared/text/text.component'; import { EditorHistoryComponent } from './editor-history.component'; @@ -28,6 +29,7 @@ describe('EditorHistoryComponent', () => { const mockSFProjectService = mock(SFProjectService); const mockI18nService = mock(I18nService); const mockHistoryChooserComponent = mock(HistoryChooserComponent); + const mockProjectNotificationService = mock(ProjectNotificationService); const revisionSelect$ = new Subject(); const showDiffChange$ = new Subject(); @@ -39,6 +41,7 @@ describe('EditorHistoryComponent', () => { provideTestRealtime(new TypeRegistry([TextDoc], [FileType.Audio], [])), provideTestOnlineStatus(), { provide: OnlineStatusService, useClass: TestOnlineStatusService }, + { provide: ProjectNotificationService, useMock: mockProjectNotificationService }, { provide: SFProjectService, useMock: mockSFProjectService }, { provide: I18nService, useMock: mockI18nService } ] diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts index 64db6e6b94a..72bde280f08 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts @@ -18,6 +18,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 { ParatextService } from '../../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../../core/project-notification.service'; import { SFProjectService } from '../../../../core/sf-project.service'; import { TextDocService } from '../../../../core/text-doc.service'; import { HistoryChooserComponent } from './history-chooser.component'; @@ -26,6 +27,7 @@ import { HistoryRevisionFormatPipe } from './history-revision-format.pipe'; const mockedDialogService = mock(DialogService); const mockedNoticeService = mock(NoticeService); const mockedParatextService = mock(ParatextService); +const mockedProjectNotificationService = mock(ProjectNotificationService); const mockedProjectService = mock(SFProjectService); const mockedTextDocService = mock(TextDocService); const mockedErrorReportingService = mock(ErrorReportingService); @@ -43,6 +45,7 @@ describe('HistoryChooserComponent', () => { { provide: SFProjectService, useMock: mockedProjectService }, { provide: TextDocService, useMock: mockedTextDocService }, { provide: ErrorReportingService, useMock: mockedErrorReportingService }, + { provide: ProjectNotificationService, useMock: mockedProjectNotificationService }, provideNoopAnimations() ] })); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts index e2a9af58779..0844f362121 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts @@ -1,5 +1,14 @@ import { AsyncPipe, NgClass } from '@angular/common'; -import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { + AfterViewInit, + Component, + DestroyRef, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges +} from '@angular/core'; import { MatOption } from '@angular/material/autocomplete'; import { MatButton } from '@angular/material/button'; import { MatFormField } from '@angular/material/form-field'; @@ -19,6 +28,7 @@ import { observeOn, startWith, Subject, + take, tap } from 'rxjs'; import { BlurOnClickDirective } from 'xforge-common/blur-on-click.directive'; @@ -30,11 +40,17 @@ import { Snapshot } from 'xforge-common/models/snapshot'; import { TextSnapshot } from 'xforge-common/models/textsnapshot'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile-doc'; import { TextDocId } from '../../../../core/models/text-doc'; import { ParatextService, Revision } from '../../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../../core/project-notification.service'; import { SFProjectService } from '../../../../core/sf-project.service'; import { TextDocService } from '../../../../core/text-doc.service'; +import { + DraftApplyState, + DraftApplyStatus +} from '../../../draft-generation/draft-import-wizard/draft-import-wizard.component'; import { HistoryRevisionFormatPipe } from './history-revision-format.pipe'; export interface RevisionSelectEvent { @@ -62,7 +78,19 @@ export interface RevisionSelectEvent { ] }) export class HistoryChooserComponent implements AfterViewInit, OnChanges { - @Input() projectId?: string; + private _projectId?: string; + private projectId$: Subject = new Subject(); + @Input() set projectId(id: string | undefined) { + if (id == null) { + return; + } + this._projectId = id; + this.projectId$.next(id); + } + get projectId(): string | undefined { + return this._projectId; + } + @Input() bookNum?: number; @Input() chapter?: number; @Input() showDiff = true; @@ -73,6 +101,18 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { selectedRevision: Revision | undefined; selectedSnapshot: TextSnapshot | undefined; + private readonly notifyDraftApplyProgressHandler = (projectId: string, draftApplyState: DraftApplyState): void => { + if ( + draftApplyState.bookNum === this.bookNum && + draftApplyState.chapterNum === this.chapter && + draftApplyState.status === DraftApplyStatus.Successful + ) { + // Clear then reload the history revisions + this.historyRevisions = []; + void this.loadHistory(); + } + }; + // 'asyncScheduler' prevents ExpressionChangedAfterItHasBeenCheckedError private loading$ = new BehaviorSubject(false); isLoading$: Observable = this.loading$.pipe(observeOn(asyncScheduler)); @@ -87,15 +127,32 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { private projectDoc: SFProjectProfileDoc | undefined; constructor( + destroyRef: DestroyRef, private readonly dialogService: DialogService, private readonly onlineStatusService: OnlineStatusService, private readonly noticeService: NoticeService, private readonly paratextService: ParatextService, + projectNotificationService: ProjectNotificationService, private readonly projectService: SFProjectService, private readonly textDocService: TextDocService, private readonly errorReportingService: ErrorReportingService, private readonly i18n: I18nService - ) {} + ) { + this.projectId$.pipe(quietTakeUntilDestroyed(destroyRef), filterNullish(), take(1)).subscribe(async projectId => { + // Start the connection to SignalR + await projectNotificationService.start(); + // Subscribe to notifications for this project + await projectNotificationService.subscribeToProject(projectId); + // When build notifications are received, reload the build history + // NOTE: We do not need the build state, so just ignore it. + projectNotificationService.setNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); + }); + destroyRef.onDestroy(async () => { + // Stop the SignalR connection when the component is destroyed + await projectNotificationService.stop(); + projectNotificationService.removeNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); + }); + } get canRestoreSnapshot(): boolean { return ( @@ -117,10 +174,6 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { } ngAfterViewInit(): void { - this.loadHistory(); - } - - loadHistory(): void { combineLatest([ this.onlineStatusService.onlineStatus$, this.inputChanged$.pipe( @@ -133,23 +186,29 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { }) ) ]).subscribe(async ([isOnline]) => { - if (isOnline && this.projectId != null && this.bookNum != null && this.chapter != null) { - this.loading$.next(true); - try { - this.projectDoc = await this.projectService.getProfile(this.projectId); - if (this.historyRevisions.length === 0) { - this.historyRevisions = - (await this.paratextService.getRevisions(this.projectId, this.bookId, this.chapter)) ?? []; - - if (this.historyRevisions.length > 0) { - await this.selectRevision(this.historyRevisions[0]); - } + if (isOnline) { + await this.loadHistory(); + } + }); + } + + async loadHistory(): Promise { + if (this.projectId != null && this.bookNum != null && this.chapter != null) { + this.loading$.next(true); + try { + this.projectDoc = await this.projectService.getProfile(this.projectId); + if (this.historyRevisions.length === 0) { + this.historyRevisions = + (await this.paratextService.getRevisions(this.projectId, this.bookId, this.chapter)) ?? []; + + if (this.historyRevisions.length > 0) { + await this.selectRevision(this.historyRevisions[0]); } - } finally { - this.loading$.next(false); } + } finally { + this.loading$.next(false); } - }); + } } async onSelectionChanged(e: MatSelectChange): Promise { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts index 8a824595505..206ada740ac 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts @@ -541,8 +541,9 @@ export class TextDocReader implements DocumentReader { return Promise.resolve([...this.textDocIds]); } - async read(uri: string): Promise> { + async read(uri: string): Promise | undefined> { const textDoc = await this.projectService.getText(uri); + if (textDoc.data == null) return undefined; return { format: 'scripture-delta', content: textDoc.data as Delta, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json index b32c4697669..cadc629a7d2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json +++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json @@ -280,7 +280,9 @@ "use_echo": "Use Echo Translation Engine" }, "draft_history_entry": { - "click_book_to_preview": "Click a book below to preview the draft and add it to your project.", + "add_to_project": "Add to project", + "click_add_to_project": "Click [b]Add to project[/b] to save the draft to a project of your choosing.", + "click_book_to_preview_draft": "Click a book below to preview the draft.", "download_zip": "Download draft as ZIP file", "draft_active": "Running", "draft_canceled": "Canceled", @@ -311,12 +313,65 @@ "older_drafts_not_available": "Older drafts requested before {{ date }} are not available.", "previously_generated_drafts": "Previously generated drafts" }, + "draft_import_wizard": { + "back": "Back", + "cancel": "Cancel", + "cannot_edit_project": "You do not have permission to edit this project, or it has been deleted. Please contact your project administrator", + "chapter": "chapter", + "chapters": "chapters", + "choose_project": "Choose a project", + "complete": "Complete", + "confirm_books_to_import": "Confirm books to import", + "confirm_books_to_import_description": "The following books will be imported. You may click a book to deselect and not import it.", + "confirm_overwrite": "Confirm overwrite", + "connect_project_description": "The selected project {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} has not yet been connected in Scripture Forge. Do you want to connect it so you can import the draft to it?", + "connect_project": "Connect project", + "connect_to_project": "Connect to project", + "connect_to_the_internet": "Connect to the internet to import drafts", + "connect": "Connect", + "connected_to_project": "Connected to project", + "connecting_to_project": "Connecting to project", + "connecting": "Connecting", + "connection_failed": "Connection failed", + "done": "Done", + "failed": "Failed:", + "failed_to_connect_project": "Something went wrong while connecting. Please try again.", + "failed_to_load_projects": "We couldn't load your Paratext projects. Check your internet connection and try again, or log out then log back in again.", + "i_understand_overwrite_content": "I understand that existing content will be overwritten", + "import": "Import", + "import_all_chapters_failed": "Failed to import {{ failed }} chapter(s). See details above.", + "import_and_sync_complete": "The draft has been imported into {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} and synced with Paratext.", + "import_and_sync_complete_reminder": "Please remember to do a Send/Receive in Paratext to see the draft.", + "import_complete_no_sync": "Make sure to sync {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} in Scripture Forge, then do a Send/Receive in Paratext to make the draft available in Paratext.", + "import_complete": "Import complete", + "import_some_chapters_failed": "Imported {{ successful }} chapter(s) successfully. Failed to import {{ failed }} chapter(s). See details above.", + "importing_draft": "Importing draft", + "importing": "Importing", + "next": "Next", + "no_books_ready_for_import": "No draft chapters are ready for import. Generate a draft and try again.", + "overwrite_book_description": "{{ bookName }} in {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} has {{ numChapters }} chapters with existing text that will be overwritten.", + "overwrite_book_question": "Overwrite {{ bookName }}?", + "overwrite_books_description": "The following books in {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} have chapters with existing text that will be overwritten:", + "overwrite_books_question": "Overwrite books?", + "please_select_valid_project": "Please select a valid project", + "project_context_unavailable": "Project context unavailable. Please close the dialog and try again.", + "project_deleted": "This project has been deleted. Please contact your project administrator.", + "ready_to_import": "Ready to import draft to “{{ projectShortName }} - {{ projectName }}”", + "retry": "Retry", + "setting_up_project": "Setting up {{ projectName }} ...", + "select_books": "Select books", + "select_project": "Select project", + "select_project_description": "Please select the project you want to add the draft to.", + "skip_sync": "Skip sync", + "sync_project_description": "It is recommended to sync {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} to save your changes to Paratext.", + "sync_project_question": "Sync project now?", + "sync": "Sync", + "sync_failed": "Sync failed", + "syncing_project": "Syncing project", + "waiting_for_project_to_load": "Please wait for the project to load before proceeding." + }, "draft_preview_books": { - "add_to_project": "Add to project", - "add_to_different_project": "Add to a different project", - "no_books_have_drafts": "No books have any drafts.", - "no_permission_to_edit_book": "You do not have permission to write to this book. Contact your project administrator to get permission.", - "readd_to_project": "Re-add to project" + "no_books_have_drafts": "No books have any drafts." }, "draft_sources": { "add_another_reference_project": "Add another reference project", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/karma.conf.js b/src/SIL.XForge.Scripture/ClientApp/src/karma.conf.js index 0ff1e462801..8ec00b489f9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/karma.conf.js +++ b/src/SIL.XForge.Scripture/ClientApp/src/karma.conf.js @@ -49,6 +49,7 @@ module.exports = function (config) { }, files: [], proxies: { + '/assets/icons/TagIcons/circle01.png': '', '/assets/icons/TagIcons/defaultIcon.png': '', '/assets/icons/TagIcons/flag01.png': '', '/assets/icons/TagIcons/flag04.png': '', diff --git a/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss b/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss index 34593d50479..086205ed850 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss @@ -24,6 +24,7 @@ @use 'src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form-theme' as sf-draft-onboarding-form; @use 'src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry-theme' as sf-draft-history-entry; +@use 'src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-theme' as sf-draft-import-wizard; @use 'src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format-theme' as sf-draft-usfm-format; @use 'src/app/translate/draft-generation/training-data/training-data-upload-dialog-theme' as sf-training-data-upload-dialog; @@ -64,6 +65,7 @@ @include sf-confirm-sources.theme($theme); @include sf-draft-generation-steps.theme($theme); @include sf-draft-history-entry.theme($theme); + @include sf-draft-import-wizard.theme($theme); @include sf-draft-sources.theme($theme); @include sf-draft-onboarding-form.theme($theme); @include sf-draft-usfm-format.theme($theme); diff --git a/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs b/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs index 37a2a206c08..14305e88b5a 100644 --- a/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs +++ b/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs @@ -35,7 +35,7 @@ IUserAccessor userAccessor // Keep a reference in this class to prevent duplicate allocation (Warning CS9107) private readonly IExceptionHandler _exceptionHandler = exceptionHandler; - public IRpcMethodResult ApplyPreTranslationToProject( + public async Task ApplyPreTranslationToProject( string projectId, string scriptureRange, string targetProjectId, @@ -44,6 +44,9 @@ DateTime timestamp { try { + // Ensure the target project exists + _ = await projectService.GetProjectAsync(targetProjectId); + // Run the background job string jobId = backgroundJobClient.Enqueue(r => r.ApplyPreTranslationToProjectAsync( @@ -57,6 +60,10 @@ DateTime timestamp ); return Ok(jobId); } + catch (DataNotFoundException dnfe) + { + return NotFoundError(dnfe.Message); + } catch (Exception) { _exceptionHandler.RecordEndpointInfoForException( @@ -1114,6 +1121,7 @@ public async Task DeleteAudioTimingData(string projectId, int } } + [Obsolete("Use ApplyPreTranslationToProject instead. Deprecated 2025-12")] public async Task AddChapters(string projectId, int book, int[] chapters) { try diff --git a/src/SIL.XForge.Scripture/Models/DraftApplyState.cs b/src/SIL.XForge.Scripture/Models/DraftApplyState.cs index 46fb274c649..15185ab10f0 100644 --- a/src/SIL.XForge.Scripture/Models/DraftApplyState.cs +++ b/src/SIL.XForge.Scripture/Models/DraftApplyState.cs @@ -2,7 +2,9 @@ namespace SIL.XForge.Scripture.Models; public class DraftApplyState { - public bool Failed { get; set; } - public string? State { get; set; } - public bool Success { get; set; } + public int BookNum { get; set; } + public int ChapterNum { get; set; } + public int TotalChapters { get; set; } + public string? Message { get; set; } + public DraftApplyStatus Status { get; set; } } diff --git a/src/SIL.XForge.Scripture/Models/DraftApplyStatus.cs b/src/SIL.XForge.Scripture/Models/DraftApplyStatus.cs new file mode 100644 index 00000000000..cb0659e5cf6 --- /dev/null +++ b/src/SIL.XForge.Scripture/Models/DraftApplyStatus.cs @@ -0,0 +1,10 @@ +namespace SIL.XForge.Scripture.Models; + +public enum DraftApplyStatus +{ + None = 0, + InProgress = 1, + Successful = 2, + Warning = 3, + Failed = 4, +} diff --git a/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs b/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs new file mode 100644 index 00000000000..6e12fe357a6 --- /dev/null +++ b/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs @@ -0,0 +1,38 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using SIL.XForge.Scripture.Models; + +namespace SIL.XForge.Scripture.Services; + +[Authorize] +public class DraftNotificationHub : Hub, IDraftNotifier +{ + /// + /// Notifies subscribers to a project of draft application progress. + /// + /// The Scripture Forge project identifier. + /// The state of the draft being applied. + /// The asynchronous task. + /// + /// This differs from the implementation in in that this version + /// does have stateful reconnection, and so there is a guarantee that it is received by clients. + /// + /// This is a blocking operation if the stateful reconnection buffer is full, so it should only + /// be subscribed to by the user performing the draft import. Using + /// is sufficient for all other users to subscribe to, although they will not receive all draft + /// progress notifications, only the final success message. + /// + public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) => + await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); + + /// + /// Subscribe to notifications for a project. + /// + /// This is called from the frontend via project-notification.service.ts. + /// + /// The Scripture Forge project identifier. + /// The asynchronous task. + public async Task SubscribeToProject(string projectId) => + await Groups.AddToGroupAsync(Context.ConnectionId, projectId); +} diff --git a/src/SIL.XForge.Scripture/Services/IDraftNotifier.cs b/src/SIL.XForge.Scripture/Services/IDraftNotifier.cs new file mode 100644 index 00000000000..e3fd1b43acb --- /dev/null +++ b/src/SIL.XForge.Scripture/Services/IDraftNotifier.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using SIL.XForge.Scripture.Models; + +namespace SIL.XForge.Scripture.Services; + +public interface IDraftNotifier +{ + Task NotifyDraftApplyProgress(string sfProjectId, DraftApplyState draftApplyState); + Task SubscribeToProject(string projectId); +} diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index ec65ee90c73..343a312bf65 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -46,6 +46,7 @@ public class MachineApiService( IExceptionHandler exceptionHandler, IHttpRequestAccessor httpRequestAccessor, IHubContext hubContext, + IHubContext draftHubContext, ILogger logger, IMachineProjectService machineProjectService, IParatextService paratextService, @@ -130,7 +131,9 @@ CancellationToken cancellationToken var result = new DraftApplyResult(); IDocument targetProjectDoc; List createdBooks = []; + List updatedBooks = []; Dictionary> createdChapters = []; + List<(int bookNum, int chapterNum)> chaptersToNotify = []; List<(ChapterDelta chapterDelta, int bookNum)> chapterDeltas = []; try { @@ -158,13 +161,8 @@ CancellationToken cancellationToken // Get the drafts for the scripture range foreach ((string book, List bookChapters) in booksAndChapters) { - await hubContext.NotifyDraftApplyProgress( - sfProjectId, - new DraftApplyState { State = $"Retrieving draft for {book}." } - ); + // Warn if the last chapter is different (this will affect chapter creation) int bookNum = Canon.BookIdToNumber(book); - - // Warn if the last chapter is different (this will affect chapter creation int lastChapter = versification.GetLastChapter(bookNum); int targetLastChapter = targetVersification.GetLastChapter(bookNum); if (lastChapter != targetLastChapter) @@ -174,7 +172,16 @@ await hubContext.NotifyDraftApplyProgress( + $" while the target project ({targetProjectDoc.Data.ShortName.Sanitize()}) has {targetLastChapter} chapters."; logger.LogWarning(message); result.Log += $"{message}\n"; - await hubContext.NotifyDraftApplyProgress(sfProjectId, new DraftApplyState { State = message }); + await draftHubContext.NotifyDraftApplyProgress( + sfProjectId, + new DraftApplyState + { + BookNum = bookNum, + ChapterNum = 0, + Status = DraftApplyStatus.Warning, + Message = message, + } + ); } // Ensure that if chapters is blank, it contains every chapter in the book. @@ -185,6 +192,16 @@ await hubContext.NotifyDraftApplyProgress( { chapters = [.. Enumerable.Range(1, lastChapter)]; } + await draftHubContext.NotifyDraftApplyProgress( + sfProjectId, + new DraftApplyState + { + BookNum = bookNum, + ChapterNum = 0, + Status = DraftApplyStatus.InProgress, + Message = $"Retrieving draft for {book}.", + } + ); // Store the USJ for each chapter, so if we download form Serval we only do it once per book List chapterUsj = []; @@ -222,11 +239,14 @@ await hubContext.NotifyDraftApplyProgress( if (string.IsNullOrWhiteSpace(usfm)) { result.Failures.Add(book); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { - State = $"No draft available for {Canon.BookNumberToId(bookNum)}.", + BookNum = bookNum, + ChapterNum = 0, + Status = DraftApplyStatus.Failed, + Message = $"No draft available for {Canon.BookNumberToId(bookNum)}.", } ); break; @@ -236,11 +256,14 @@ await hubContext.NotifyDraftApplyProgress( if (DeltaUsxMapper.ExtractBookId(usfm) != book) { result.Failures.Add(book); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { - State = + BookNum = bookNum, + ChapterNum = 0, + Status = DraftApplyStatus.Failed, + Message = $"Could not retrieve a valid draft for {Canon.BookNumberToId(bookNum)}.", } ); @@ -270,11 +293,14 @@ .. paratextService.GetChaptersAsUsj(userSecret, project.ParatextId, bookNum, usf { // A blank chapter from Serval result.Failures.Add($"{Canon.BookNumberToId(bookNum)} {chapterNum}"); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { - State = + BookNum = bookNum, + ChapterNum = chapterNum, + Status = DraftApplyStatus.Failed, + Message = $"Could not retrieve draft for {Canon.BookNumberToId(bookNum)} {chapterNum}.", } ); @@ -290,11 +316,14 @@ await hubContext.NotifyDraftApplyProgress( { // Likely a blank draft in the database result.Failures.Add($"{Canon.BookNumberToId(bookNum)} {chapterNum}"); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { - State = $"Could not retrieve draft for {Canon.BookNumberToId(bookNum)} {chapterNum}.", + BookNum = bookNum, + ChapterNum = chapterNum, + Status = DraftApplyStatus.Failed, + Message = $"Could not retrieve draft for {Canon.BookNumberToId(bookNum)} {chapterNum}.", } ); continue; @@ -313,6 +342,18 @@ await hubContext.NotifyDraftApplyProgress( chapterDeltas.Add((chapterDelta, bookNum)); } } + + await draftHubContext.NotifyDraftApplyProgress( + sfProjectId, + new DraftApplyState + { + BookNum = bookNum, + ChapterNum = 0, + TotalChapters = chapterDeltas.Count(cd => cd.bookNum == bookNum), + Status = DraftApplyStatus.InProgress, + Message = $"Calculated last chapter for {book}.", + } + ); } } catch (Exception e) @@ -324,9 +365,15 @@ await hubContext.NotifyDraftApplyProgress( exceptionHandler.ReportException(e); result.Log += $"{message}\n"; result.Log += $"{e}\n"; - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, - new DraftApplyState { Failed = true, State = result.Log } + new DraftApplyState + { + BookNum = 0, + ChapterNum = 0, + Status = DraftApplyStatus.Failed, + Message = result.Log, + } ); // Do not proceed to save the draft to the project @@ -370,10 +417,36 @@ await hubContext.NotifyDraftApplyProgress( .Chapters.FindIndex(c => c.Number == chapterDelta.Number); if (chapterIndex == -1) { - // Create a new chapter record - await targetProjectDoc.SubmitJson0OpAsync(op => - op.Add(pd => pd.Texts[textIndex].Chapters, chapter) - ); + // Get the index before and after + bool hasPrecedingChapter = targetProjectDoc + .Data.Texts[textIndex] + .Chapters.Any(c => c.Number < chapterDelta.Number); + int nextChapterIndex = targetProjectDoc + .Data.Texts[textIndex] + .Chapters.FindIndex(c => c.Number > chapterDelta.Number); + + // Insert the chapter at the correct position + if (nextChapterIndex == -1) + { + // Add a new chapter record to the end of the array + await targetProjectDoc.SubmitJson0OpAsync(op => + op.Add(pd => pd.Texts[textIndex].Chapters, chapter) + ); + } + else if (!hasPrecedingChapter) + { + // Insert before other chapters + await targetProjectDoc.SubmitJson0OpAsync(op => + op.Insert(pd => pd.Texts[textIndex].Chapters, 0, chapter) + ); + } + else + { + // Insert before the next chapter + await targetProjectDoc.SubmitJson0OpAsync(op => + op.Insert(pd => pd.Texts[textIndex].Chapters, nextChapterIndex, chapter) + ); + } // Record that the chapter was created if (createdChapters.TryGetValue(bookNum, out List chapters)) @@ -395,28 +468,42 @@ await targetProjectDoc.SubmitJson0OpAsync(op => op.Set(pd => pd.Texts[textIndex].Chapters[chapterIndex].LastVerse, chapter.LastVerse); }); } + + // Record that the book was updated + updatedBooks.Add(bookNum); } } // Update the permissions if (chapterDeltas.Count > 0) { - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, - new DraftApplyState { State = "Loading permissions from Paratext." } + new DraftApplyState + { + BookNum = 0, + ChapterNum = 0, + Status = DraftApplyStatus.InProgress, + Message = "Loading permissions from Paratext.", + } ); - if (createdBooks.Count == 0) + if (updatedBooks.Count > 0) { // Update books for which chapters were added await projectService.UpdatePermissionsAsync( curUserId, targetProjectDoc, users: null, - books: chapterDeltas.Select(c => c.bookNum).Distinct().ToList(), + books: chapterDeltas + .Where(c => updatedBooks.Contains(c.bookNum)) + .Select(c => c.bookNum) + .Distinct() + .ToList(), cancellationToken ); } - else + + if (createdBooks.Count > 0) { // Update permissions for new books await paratextService.UpdateParatextPermissionsForNewBooksAsync( @@ -439,9 +526,15 @@ await paratextService.UpdateParatextPermissionsForNewBooksAsync( if (result.Failures.Add(bookId)) { // Only notify the book failure once per book - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, - new DraftApplyState { State = $"Could not save draft for {Canon.BookNumberToId(bookNum)}." } + new DraftApplyState + { + BookNum = bookNum, + ChapterNum = 0, + Status = DraftApplyStatus.Failed, + Message = $"Could not save draft for {Canon.BookNumberToId(bookNum)}.", + } ); } @@ -463,9 +556,15 @@ await hubContext.NotifyDraftApplyProgress( if (result.Failures.Add(bookId)) { // Only notify the book failure once per book - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, - new DraftApplyState { State = $"Could not save draft for {Canon.BookNumberToId(bookNum)}." } + new DraftApplyState + { + BookNum = bookNum, + ChapterNum = 0, + Status = DraftApplyStatus.Failed, + Message = $"Could not save draft for {Canon.BookNumberToId(bookNum)}.", + } ); } @@ -479,11 +578,15 @@ await hubContext.NotifyDraftApplyProgress( if (chapterIndex == -1) { result.Failures.Add($"{Canon.BookNumberToId(bookNum)} {chapterDelta.Number}"); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { - State = $"Could not save draft for {Canon.BookNumberToId(bookNum)} {chapterDelta.Number}.", + BookNum = bookNum, + ChapterNum = chapterDelta.Number, + Status = DraftApplyStatus.Failed, + Message = + $"Could not save draft for {Canon.BookNumberToId(bookNum)} {chapterDelta.Number}.", } ); continue; @@ -509,11 +612,15 @@ await targetProjectDoc.SubmitJson0OpAsync(op => } result.Failures.Add($"{Canon.BookNumberToId(bookNum)} {chapterDelta.Number}"); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { - State = $"Could not save draft for {Canon.BookNumberToId(bookNum)} {chapterDelta.Number}.", + BookNum = bookNum, + ChapterNum = chapterDelta.Number, + Status = DraftApplyStatus.Failed, + Message = + $"Could not save draft for {Canon.BookNumberToId(bookNum)} {chapterDelta.Number}.", } ); continue; @@ -532,28 +639,35 @@ await hubContext.NotifyDraftApplyProgress( { await textDataDoc.SubmitOpAsync(diffDelta, OpSource.Draft); } - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { - State = $"Updating {Canon.BookNumberToId(bookNum)} {chapterDelta.Number}.", + BookNum = bookNum, + ChapterNum = chapterDelta.Number, + Status = DraftApplyStatus.Successful, + Message = $"Updated {Canon.BookNumberToId(bookNum)} {chapterDelta.Number}.", } ); } else { // Create a new text data document - await textDataDoc.CreateAsync(newTextData); - await hubContext.NotifyDraftApplyProgress( + await textDataDoc.CreateAsync(newTextData, OpSource.Draft); + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { - State = $"Creating {Canon.BookNumberToId(bookNum)} {chapterDelta.Number}.", + BookNum = bookNum, + ChapterNum = chapterDelta.Number, + Status = DraftApplyStatus.Successful, + Message = $"Created {Canon.BookNumberToId(bookNum)} {chapterDelta.Number}.", } ); } // A draft has been applied + chaptersToNotify.Add((bookNum, chapterDelta.Number)); successful = true; } } @@ -575,18 +689,39 @@ await hubContext.NotifyDraftApplyProgress( if (successful) { await connection.CommitTransactionAsync(); - await hubContext.NotifyDraftApplyProgress( - sfProjectId, - new DraftApplyState { Success = true, State = result.Log } - ); } else { connection.RollbackTransaction(); - await hubContext.NotifyDraftApplyProgress( - sfProjectId, - new DraftApplyState { Failed = true, State = result.Log } - ); + } + + await draftHubContext.NotifyDraftApplyProgress( + sfProjectId, + new DraftApplyState + { + BookNum = 0, + ChapterNum = 0, + Status = successful ? DraftApplyStatus.Successful : DraftApplyStatus.Failed, + Message = result.Log, + } + ); + + // Notify any listening users for each chapter that was updated in the target project + if (successful) + { + foreach ((int bookNum, int chapterNum) in chaptersToNotify) + { + // Use the non-blocking hub context which does not have stateful reconnect enabled + await hubContext.NotifyDraftApplyProgress( + targetProjectId, + new DraftApplyState + { + BookNum = bookNum, + ChapterNum = chapterNum, + Status = DraftApplyStatus.Successful, + } + ); + } } result.ChangesSaved = successful; @@ -2534,7 +2669,7 @@ private static async Task SaveTextDocumentAsync(IDocument textDocu // Create or update the text document if (!textDocument.IsLoaded) { - await textDocument.CreateAsync(chapterTextDocument); + await textDocument.CreateAsync(chapterTextDocument, OpSource.Draft); } else { diff --git a/src/SIL.XForge.Scripture/Services/NotificationHub.cs b/src/SIL.XForge.Scripture/Services/NotificationHub.cs index 462dd51aeb5..20cf61035e1 100644 --- a/src/SIL.XForge.Scripture/Services/NotificationHub.cs +++ b/src/SIL.XForge.Scripture/Services/NotificationHub.cs @@ -27,6 +27,10 @@ public async Task NotifyBuildProgress(string projectId, ServalBuildState buildSt /// The Scripture Forge project identifier. /// The state of the draft being applied. /// The asynchronous task. + /// + /// This differs from the implementation in in that this version + /// does not have stateful reconnection, and so there is no guarantee that the message is received. + /// public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) => await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); diff --git a/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs b/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs index 8d4747e7820..0c29a7f78ac 100644 --- a/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs +++ b/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs @@ -6,6 +6,12 @@ namespace SIL.XForge.Scripture.Services; public static class NotificationHubExtensions { + public static Task NotifyDraftApplyProgress( + this IHubContext hubContext, + string projectId, + DraftApplyState draftApplyState + ) => hubContext.Clients.Groups(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); + public static Task NotifyBuildProgress( this IHubContext hubContext, string projectId, diff --git a/src/SIL.XForge.Scripture/Services/ParatextService.cs b/src/SIL.XForge.Scripture/Services/ParatextService.cs index 2679635e606..00521b5f26b 100644 --- a/src/SIL.XForge.Scripture/Services/ParatextService.cs +++ b/src/SIL.XForge.Scripture/Services/ParatextService.cs @@ -2228,11 +2228,17 @@ int chapter int milestoneOps = 0; for (int i = ops.Length - 1; i >= 0; i--) { + // Emit the op as a milestone if it is: + // - Outside the current milestone period + // - From a different source or user + // - A draft applied or history restored, as these are user initiated actions Op op = ops[i]; if ( op.Metadata.Timestamp < milestonePeriod.AddMinutes(0 - interval) || op.Metadata.Source != documentRevision.Source || op.Metadata.UserId != documentRevision.UserId + || op.Metadata.Source == OpSource.Draft + || op.Metadata.Source == OpSource.History ) { // If this is not the first op, emit the revision diff --git a/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs b/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs index ab5619e6807..8f3ebbe2883 100644 --- a/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs +++ b/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs @@ -754,7 +754,7 @@ await _projectDoc.SubmitJson0OpAsync(op => biblicalTerm.ProjectRef = _projectDoc.Id; IDocument newBiblicalTermDoc = GetBiblicalTermDoc(biblicalTerm.DataId); async Task CreateBiblicalTermAsync(BiblicalTerm newBiblicalTerm) => - await newBiblicalTermDoc.CreateAsync(newBiblicalTerm); + await newBiblicalTermDoc.CreateAsync(newBiblicalTerm, OpSource.Paratext); tasks.Add(CreateBiblicalTermAsync(biblicalTerm)); _syncMetrics.BiblicalTerms.Added++; } @@ -1307,7 +1307,7 @@ async Task CreateText(Delta delta) await textDataDoc.FetchAsync(); if (textDataDoc.IsLoaded) await textDataDoc.DeleteAsync(); - await textDataDoc.CreateAsync(new TextData(delta)); + await textDataDoc.CreateAsync(new TextData(delta), OpSource.Paratext); } tasks.Add(CreateText(kvp.Value.Delta)); _syncMetrics.TextDocs.Added++; @@ -1401,7 +1401,8 @@ await doc.CreateAsync( Assignment = change.Assignment, BiblicalTermId = change.BiblicalTermId, ExtraHeadingInfo = change.ExtraHeadingInfo, - } + }, + OpSource.Paratext ); await SubmitChangesOnNoteThreadDocAsync(doc, change); } diff --git a/src/SIL.XForge.Scripture/Services/SFProjectService.cs b/src/SIL.XForge.Scripture/Services/SFProjectService.cs index 8e467c760fd..eac8a29984a 100644 --- a/src/SIL.XForge.Scripture/Services/SFProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/SFProjectService.cs @@ -157,7 +157,7 @@ public async Task CreateProjectAsync(string curUserId, SFProjectCreateSe { throw new InvalidOperationException(ErrorAlreadyConnectedKey); } - IDocument projectDoc = await conn.CreateAsync(projectId, project); + IDocument projectDoc = await conn.CreateAsync(projectId, project, source: null); await ProjectSecrets.InsertAsync(new SFProjectSecret { Id = projectDoc.Id }); IDocument userDoc = await conn.FetchAsync(curUserId); @@ -1142,7 +1142,8 @@ string audioUrl ProjectRef = projectId, // TODO (scripture audio) Should the ID be set here? How does the DataId differ from the document ID? DataId = textAudioId, - } + }, + source: null ); await textAudioDoc.SubmitJson0OpAsync(op => @@ -1275,7 +1276,8 @@ protected override async Task AddUserToProjectAsync( { await conn.CreateAsync( SFProjectUserConfig.GetDocId(projectDoc.Id, userDoc.Id), - new SFProjectUserConfig { ProjectRef = projectDoc.Id, OwnerRef = userDoc.Id } + new SFProjectUserConfig { ProjectRef = projectDoc.Id, OwnerRef = userDoc.Id }, + source: null ); } // Listeners can now assume the ProjectUserConfig is ready when the user is added. @@ -1509,6 +1511,7 @@ Dictionary chapterPermissions }); } + [Obsolete("Use MachineApiService.ApplyPreTranslationToProjectAsync instead. Deprecated 2025-12")] public async Task AddChaptersAsync(string userId, string projectId, int book, int[] chapters) { await using IConnection conn = await RealtimeService.ConnectAsync(); @@ -2066,7 +2069,7 @@ private async Task CreateResourceProjectInternalAsync(IConnection conn, throw new InvalidOperationException(ErrorAlreadyConnectedKey); } - IDocument projectDoc = await conn.CreateAsync(projectId, project); + IDocument projectDoc = await conn.CreateAsync(projectId, project, source: null); await ProjectSecrets.InsertAsync(new SFProjectSecret { Id = projectDoc.Id }); // Resource projects do not have administrators, so users are added as needed diff --git a/src/SIL.XForge.Scripture/Startup.cs b/src/SIL.XForge.Scripture/Startup.cs index a05f61a7e62..bcf7e422f17 100644 --- a/src/SIL.XForge.Scripture/Startup.cs +++ b/src/SIL.XForge.Scripture/Startup.cs @@ -293,6 +293,10 @@ IExceptionHandler exceptionHandler pattern: $"/{UrlConstants.ProjectNotifications}", options => options.AllowStatefulReconnects = false ); + endpoints.MapHub( + pattern: $"/{UrlConstants.DraftNotifications}", + options => options.AllowStatefulReconnects = true + ); var authOptions = Configuration.GetOptions(); endpoints.MapHangfireDashboard( new DashboardOptions diff --git a/src/SIL.XForge/Controllers/UrlConstants.cs b/src/SIL.XForge/Controllers/UrlConstants.cs index 231a650d09c..d3fa82b0a67 100644 --- a/src/SIL.XForge/Controllers/UrlConstants.cs +++ b/src/SIL.XForge/Controllers/UrlConstants.cs @@ -6,6 +6,7 @@ public static class UrlConstants public const string CommandApiNamespace = "command-api"; public const string Users = "users"; public const string Projects = "projects"; + public const string DraftNotifications = "draft-notifications"; public const string ProjectNotifications = "project-notifications"; public const string OnboardingRequests = "onboarding-requests"; } diff --git a/src/SIL.XForge/Realtime/Connection.cs b/src/SIL.XForge/Realtime/Connection.cs index c29836e7d6f..970bb5ac4de 100644 --- a/src/SIL.XForge/Realtime/Connection.cs +++ b/src/SIL.XForge/Realtime/Connection.cs @@ -128,7 +128,8 @@ await _realtimeServer.CreateDocAsync( queuedOperation.Collection, queuedOperation.Id, queuedOperation.Data, - queuedOperation.OtTypeName + queuedOperation.OtTypeName, + queuedOperation.Source ); break; case QueuedAction.Delete: @@ -178,10 +179,17 @@ await _realtimeServer.ReplaceDocAsync( /// The identifier. /// The data. /// Name of the OT type. + /// The source of the op. This is currently only used by text documents. /// /// A snapshot of the created document from the realtime server. /// - public async Task> CreateDocAsync(string collection, string id, T data, string otTypeName) + public async Task> CreateDocAsync( + string collection, + string id, + T data, + string otTypeName, + OpSource? source + ) { if (_isTransaction) { @@ -195,6 +203,7 @@ public async Task> CreateDocAsync(string collection, string id, T Handle = _handle, Id = id, OtTypeName = otTypeName, + Source = source, } ); @@ -203,7 +212,7 @@ public async Task> CreateDocAsync(string collection, string id, T } else { - return await _realtimeServer.CreateDocAsync(_handle, collection, id, data, otTypeName); + return await _realtimeServer.CreateDocAsync(_handle, collection, id, data, otTypeName, source); } } diff --git a/src/SIL.XForge/Realtime/Document.cs b/src/SIL.XForge/Realtime/Document.cs index 067022fdc5c..0f27781a6ca 100644 --- a/src/SIL.XForge/Realtime/Document.cs +++ b/src/SIL.XForge/Realtime/Document.cs @@ -38,12 +38,12 @@ internal Document(IConnection connection, string otTypeName, string collection, public bool IsLoaded => Data != null; - public async Task CreateAsync(T data) + public async Task CreateAsync(T data, OpSource? source) { await _lock.WaitAsync(); try { - Snapshot snapshot = await _connection.CreateDocAsync(Collection, Id, data, OTTypeName); + Snapshot snapshot = await _connection.CreateDocAsync(Collection, Id, data, OTTypeName, source); UpdateFromSnapshot(snapshot); } finally @@ -66,14 +66,14 @@ public async Task FetchAsync() } } - public async Task FetchOrCreateAsync(Func createData) + public async Task FetchOrCreateAsync(Func createData, OpSource? source) { await _lock.WaitAsync(); try { Snapshot snapshot = await _connection.FetchDocAsync(Collection, Id); if (snapshot.Data == null) - snapshot = await _connection.CreateDocAsync(Collection, Id, createData(), OTTypeName); + snapshot = await _connection.CreateDocAsync(Collection, Id, createData(), OTTypeName, source); UpdateFromSnapshot(snapshot); } finally diff --git a/src/SIL.XForge/Realtime/IConnection.cs b/src/SIL.XForge/Realtime/IConnection.cs index 8dd9df3cb59..72ecf150c38 100644 --- a/src/SIL.XForge/Realtime/IConnection.cs +++ b/src/SIL.XForge/Realtime/IConnection.cs @@ -10,7 +10,7 @@ public interface IConnection : IDisposable, IAsyncDisposable { void BeginTransaction(); Task CommitTransactionAsync(); - Task> CreateDocAsync(string collection, string id, T data, string otTypeName); + Task> CreateDocAsync(string collection, string id, T data, string otTypeName, OpSource? source); Task DeleteDocAsync(string collection, string id); void ExcludePropertyFromTransaction(Expression> field); Task> FetchDocAsync(string collection, string id); diff --git a/src/SIL.XForge/Realtime/IDocument.cs b/src/SIL.XForge/Realtime/IDocument.cs index 635662b7598..3cc33840df2 100644 --- a/src/SIL.XForge/Realtime/IDocument.cs +++ b/src/SIL.XForge/Realtime/IDocument.cs @@ -14,11 +14,11 @@ public interface IDocument T Data { get; } bool IsLoaded { get; } - Task CreateAsync(T data); + Task CreateAsync(T data, OpSource? source); Task FetchAsync(); - Task FetchOrCreateAsync(Func createData); + Task FetchOrCreateAsync(Func createData, OpSource? source); Task SubmitOpAsync(object op, OpSource? source); diff --git a/src/SIL.XForge/Realtime/IRealtimeServer.cs b/src/SIL.XForge/Realtime/IRealtimeServer.cs index 89c3a6d0186..32cd5a7a361 100644 --- a/src/SIL.XForge/Realtime/IRealtimeServer.cs +++ b/src/SIL.XForge/Realtime/IRealtimeServer.cs @@ -8,7 +8,14 @@ public interface IRealtimeServer { Task ApplyOpAsync(string otTypeName, T data, object op); Task ConnectAsync(string userId = null); - Task> CreateDocAsync(int handle, string collection, string id, T data, string otTypeName); + Task> CreateDocAsync( + int handle, + string collection, + string id, + T data, + string otTypeName, + OpSource? source + ); Task DeleteDocAsync(int handle, string collection, string id); void Disconnect(int handle); Task DisconnectAsync(int handle); diff --git a/src/SIL.XForge/Realtime/MemoryConnection.cs b/src/SIL.XForge/Realtime/MemoryConnection.cs index 9d4fe8a6305..8768afddf80 100644 --- a/src/SIL.XForge/Realtime/MemoryConnection.cs +++ b/src/SIL.XForge/Realtime/MemoryConnection.cs @@ -45,8 +45,13 @@ public void BeginTransaction() { } /// /// This is not supported by a . /// - public Task> CreateDocAsync(string collection, string id, T data, string otTypeName) => - throw new NotImplementedException(); + public Task> CreateDocAsync( + string collection, + string id, + T data, + string otTypeName, + OpSource? source + ) => throw new NotImplementedException(); /// /// Deletes a document asynchronously. diff --git a/src/SIL.XForge/Realtime/MemoryDocument.cs b/src/SIL.XForge/Realtime/MemoryDocument.cs index af87e0a1e40..f9c842a3dee 100644 --- a/src/SIL.XForge/Realtime/MemoryDocument.cs +++ b/src/SIL.XForge/Realtime/MemoryDocument.cs @@ -23,7 +23,7 @@ public class MemoryDocument(MemoryRepository repo, string otTypeName, stri public bool IsLoaded => Data != null; - public async Task CreateAsync(T data) + public async Task CreateAsync(T data, OpSource? source) { if (IsLoaded) throw new InvalidOperationException("The doc already exists."); @@ -57,11 +57,11 @@ public async Task FetchAsync() } } - public async Task FetchOrCreateAsync(Func createData) + public async Task FetchOrCreateAsync(Func createData, OpSource? source) { await FetchAsync(); if (!IsLoaded) - await CreateAsync(createData()); + await CreateAsync(createData(), source); } public async Task SubmitOpAsync(object op, OpSource? source) diff --git a/src/SIL.XForge/Realtime/RealtimeExtensions.cs b/src/SIL.XForge/Realtime/RealtimeExtensions.cs index 27e884a095e..469dd6b8347 100644 --- a/src/SIL.XForge/Realtime/RealtimeExtensions.cs +++ b/src/SIL.XForge/Realtime/RealtimeExtensions.cs @@ -41,19 +41,24 @@ public static async Task> FetchAsync(this IConnection conn, stri return doc; } - public static async Task> CreateAsync(this IConnection conn, string id, T data) + public static async Task> CreateAsync(this IConnection conn, string id, T data, OpSource? source) where T : IIdentifiable { IDocument doc = conn.Get(id); - await doc.CreateAsync(data); + await doc.CreateAsync(data, source); return doc; } - public static async Task> FetchOrCreateAsync(this IConnection conn, string id, Func createData) + public static async Task> FetchOrCreateAsync( + this IConnection conn, + string id, + Func createData, + OpSource? source + ) where T : IIdentifiable { IDocument doc = conn.Get(id); - await doc.FetchOrCreateAsync(createData); + await doc.FetchOrCreateAsync(createData, source); return doc; } } diff --git a/src/SIL.XForge/Realtime/RealtimeServer.cs b/src/SIL.XForge/Realtime/RealtimeServer.cs index c07978f5b1b..1d5a754703b 100644 --- a/src/SIL.XForge/Realtime/RealtimeServer.cs +++ b/src/SIL.XForge/Realtime/RealtimeServer.cs @@ -66,8 +66,14 @@ public Task ConnectAsync(string? userId = null) return InvokeExportAsync("connect"); } - public Task> CreateDocAsync(int handle, string collection, string id, T data, string otTypeName) => - InvokeExportAsync>("createDoc", handle, collection, id, data, otTypeName); + public Task> CreateDocAsync( + int handle, + string collection, + string id, + T data, + string otTypeName, + OpSource? source + ) => InvokeExportAsync>("createDoc", handle, collection, id, data, otTypeName, source); public Task> FetchDocAsync(int handle, string collection, string id) => InvokeExportAsync>("fetchDoc", handle, collection, id); diff --git a/src/SIL.XForge/Services/AuthServiceCollectionExtensions.cs b/src/SIL.XForge/Services/AuthServiceCollectionExtensions.cs index e31cdd69ae1..8cbf0946721 100644 --- a/src/SIL.XForge/Services/AuthServiceCollectionExtensions.cs +++ b/src/SIL.XForge/Services/AuthServiceCollectionExtensions.cs @@ -32,8 +32,13 @@ public static IServiceCollection AddXFAuthentication(this IServiceCollection ser string? accessToken = context.Request.Query["access_token"]; if ( !string.IsNullOrEmpty(accessToken) - && context.HttpContext.Request.Path.StartsWithSegments( - $"/{UrlConstants.ProjectNotifications}" + && ( + context.HttpContext.Request.Path.StartsWithSegments( + $"/{UrlConstants.ProjectNotifications}" + ) + || context.HttpContext.Request.Path.StartsWithSegments( + $"/{UrlConstants.DraftNotifications}" + ) ) ) { diff --git a/src/SIL.XForge/Services/UserService.cs b/src/SIL.XForge/Services/UserService.cs index 2d3460facd9..e7d5820ca72 100644 --- a/src/SIL.XForge/Services/UserService.cs +++ b/src/SIL.XForge/Services/UserService.cs @@ -75,7 +75,8 @@ public async Task UpdateUserFromProfileAsync(string curUserId, string userProfil ? (string)userProfile["nickname"] : name, IsDisplayNameConfirmed = displayNameSetAtSignup, - } + }, + source: null ); await userDoc.SubmitJson0OpAsync(op => { diff --git a/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs b/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs index 92e8b0955c9..1c852e195e3 100644 --- a/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs @@ -40,16 +40,40 @@ public class SFProjectsRpcControllerTests private static readonly Uri WebsiteUrl = new Uri("https://scriptureforge.org", UriKind.Absolute); [Test] - public void ApplyPreTranslationToProject_Success() + public async Task ApplyPreTranslationToProject_Success() { var env = new TestEnvironment(); // SUT - var result = env.Controller.ApplyPreTranslationToProject(Project01, "GEN-EXO", Project01, DateTime.UtcNow); + var result = await env.Controller.ApplyPreTranslationToProject( + Project01, + "GEN-EXO", + Project01, + DateTime.UtcNow + ); Assert.IsInstanceOf(result); env.BackgroundJobClient.Received().Create(Arg.Any(), Arg.Any()); } + [Test] + public async Task ApplyPreTranslationToProject_NotFound() + { + var env = new TestEnvironment(); + const string errorMessage = "Project Not Found"; + env.SFProjectService.GetProjectAsync(Project01).Throws(new DataNotFoundException(errorMessage)); + + var result = await env.Controller.ApplyPreTranslationToProject( + Project01, + "GEN-EXO", + Project01, + DateTime.UtcNow + ); + + Assert.IsInstanceOf(result); + Assert.AreEqual(errorMessage, (result as RpcMethodErrorResult)!.Message); + Assert.AreEqual(RpcControllerBase.NotFoundErrorCode, (result as RpcMethodErrorResult)!.ErrorCode); + } + [Test] public void ApplyPreTranslationToProject_UnknownError() { @@ -57,7 +81,7 @@ public void ApplyPreTranslationToProject_UnknownError() env.BackgroundJobClient.Create(Arg.Any(), Arg.Any()).Throws(new ArgumentNullException()); // SUT - Assert.Throws(() => + Assert.ThrowsAsync(() => env.Controller.ApplyPreTranslationToProject(Project01, "GEN-EXO", Project01, DateTime.UtcNow) ); env.ExceptionHandler.Received().RecordEndpointInfoForException(Arg.Any>()); @@ -630,6 +654,7 @@ public void RetrievePreTranslationStatus_UnknownError() } [Test] + [Obsolete] public async Task AddChapters_Success() { var env = new TestEnvironment(); @@ -643,6 +668,7 @@ public async Task AddChapters_Success() } [Test] + [Obsolete] public async Task AddChapters_Forbidden() { var env = new TestEnvironment(); @@ -657,6 +683,7 @@ public async Task AddChapters_Forbidden() } [Test] + [Obsolete] public async Task AddChapters_NotFound() { var env = new TestEnvironment(); diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 51dc8b49625..e53bba9421c 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -4791,6 +4791,7 @@ public TestEnvironment() HttpRequestAccessor = Substitute.For(); HttpRequestAccessor.SiteRoot.Returns(new Uri("https://scriptureforge.org", UriKind.Absolute)); var hubContext = Substitute.For>(); + var draftHubContext = Substitute.For>(); MachineProjectService = Substitute.For(); MockLogger = new MockLogger(); ParatextService = Substitute.For(); @@ -4849,7 +4850,7 @@ public TestEnvironment() new TextInfo { BookNum = 1, - Chapters = [new Chapter { Number = 1 }, new Chapter { Number = 2 }], + Chapters = [new Chapter { Number = 3 }, new Chapter { Number = 4 }], }, ], UserRoles = new Dictionary { { User01, SFProjectRole.Administrator } }, @@ -4928,6 +4929,7 @@ public TestEnvironment() ExceptionHandler, HttpRequestAccessor, hubContext, + draftHubContext, MockLogger, MachineProjectService, ParatextService, diff --git a/test/SIL.XForge.Scripture.Tests/Services/SFProjectServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/SFProjectServiceTests.cs index 77b051bf46b..7a2cbf4d20c 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/SFProjectServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/SFProjectServiceTests.cs @@ -3955,6 +3955,7 @@ public void SetUsfmConfigAsync_UserIsNotParatext() } [Test] + [Obsolete] public void AddChaptersAsync_BookMustBeInProject() { var env = new TestEnvironment(); @@ -3968,6 +3969,7 @@ public void AddChaptersAsync_BookMustBeInProject() } [Test] + [Obsolete] public void AddChaptersAsync_UserMustHaveBookPermission() { var env = new TestEnvironment(); @@ -3979,6 +3981,7 @@ public void AddChaptersAsync_UserMustHaveBookPermission() } [Test] + [Obsolete] public async Task AddChaptersAsync_Success() { var env = new TestEnvironment(); @@ -3996,6 +3999,7 @@ public async Task AddChaptersAsync_Success() } [Test] + [Obsolete] public async Task AddChaptersAsync_SuccessSkipsExistingChapters() { var env = new TestEnvironment(); diff --git a/test/SIL.XForge.Tests/Realtime/ConnectionTests.cs b/test/SIL.XForge.Tests/Realtime/ConnectionTests.cs index c4d6e7a8268..6be88bed763 100644 --- a/test/SIL.XForge.Tests/Realtime/ConnectionTests.cs +++ b/test/SIL.XForge.Tests/Realtime/ConnectionTests.cs @@ -78,7 +78,7 @@ public async Task CommitTransactionAsync_UsesUnderlyingRealtimeServerOnCommit() // Setup Queue env.Service.BeginTransaction(); - await env.Service.CreateDocAsync(collection, id, snapshot.Data, otTypeName); + await env.Service.CreateDocAsync(collection, id, snapshot.Data, otTypeName, source: null); await env.Service.SubmitOpAsync(collection, id, op, snapshot.Data, snapshot.Version, source: null); await env.Service.ReplaceDocAsync(collection, id, data, snapshot.Version, source: null); await env.Service.DeleteDocAsync(collection, id); @@ -95,7 +95,14 @@ public async Task CommitTransactionAsync_UsesUnderlyingRealtimeServerOnCommit() // Verify Submit Operations await env .RealtimeService.Server.Received(1) - .CreateDocAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + .CreateDocAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); await env .RealtimeService.Server.Received(1) .DeleteDocAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -128,10 +135,11 @@ public async Task CreateDocAsync_QueuesAction() string id = "id1"; var data = new TestProject { Id = id, Name = "Test Project 1" }; string otTypeName = OTType.Json0; + OpSource? source = OpSource.Draft; // SUT env.Service.BeginTransaction(); - var result = await env.Service.CreateDocAsync(collection, id, data, otTypeName); + var result = await env.Service.CreateDocAsync(collection, id, data, otTypeName, source); // Verify result Assert.AreEqual(result.Version, 1); @@ -145,11 +153,19 @@ public async Task CreateDocAsync_QueuesAction() Assert.AreEqual(queuedOperation.Data, data); Assert.AreEqual(queuedOperation.Id, id); Assert.AreEqual(queuedOperation.OtTypeName, otTypeName); + Assert.AreEqual(queuedOperation.Source, source); // Verify that the call was not passed to the underlying realtime server await env .RealtimeService.Server.Received(0) - .CreateDocAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + .CreateDocAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); } [Test] @@ -161,9 +177,10 @@ public async Task CreateDocAsync_UsesUnderlyingRealtimeServerOutsideOfATransacti string id = "id1"; var data = new TestProject { Id = id, Name = "Test Project 1" }; string otTypeName = OTType.Json0; + OpSource? source = OpSource.Draft; // SUT - await env.Service.CreateDocAsync(collection, id, data, otTypeName); + await env.Service.CreateDocAsync(collection, id, data, otTypeName, source); // Verify queue is empty Assert.AreEqual(env.Service.QueuedOperations.Count, 0); @@ -176,7 +193,8 @@ await env Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any() + Arg.Any(), + Arg.Any() ); } diff --git a/test/SIL.XForge.Tests/Realtime/DocumentTests.cs b/test/SIL.XForge.Tests/Realtime/DocumentTests.cs index c82a357689d..3e661e0fd69 100644 --- a/test/SIL.XForge.Tests/Realtime/DocumentTests.cs +++ b/test/SIL.XForge.Tests/Realtime/DocumentTests.cs @@ -27,12 +27,12 @@ public class DocumentTests public async Task CreateAsync_Success() { var env = new TestEnvironment(); - env.Connection.CreateDocAsync(Collection, Id, _data, OtTypeName).Returns(Task.FromResult(_snapshot)); + env.Connection.CreateDocAsync(Collection, Id, _data, OtTypeName, Source).Returns(Task.FromResult(_snapshot)); // SUT - await env.Document.CreateAsync(_data); + await env.Document.CreateAsync(_data, Source); - await env.Connection.Received(1).CreateDocAsync(Collection, Id, _data, OtTypeName); + await env.Connection.Received(1).CreateDocAsync(Collection, Id, _data, OtTypeName, Source); } [Test] @@ -62,15 +62,15 @@ public async Task FetchAsync_Success() public async Task FetchAsyncOrCreate_Creates() { var env = new TestEnvironment(); - env.Connection.CreateDocAsync(Collection, Id, _data, OtTypeName).Returns(Task.FromResult(_snapshot)); + env.Connection.CreateDocAsync(Collection, Id, _data, OtTypeName, Source).Returns(Task.FromResult(_snapshot)); env.Connection.FetchDocAsync(Collection, Id) .Returns(Task.FromResult(new Snapshot())); // SUT - await env.Document.FetchOrCreateAsync(() => _data); + await env.Document.FetchOrCreateAsync(() => _data, Source); await env.Connection.Received(1).FetchDocAsync(Collection, Id); - await env.Connection.Received(1).CreateDocAsync(Collection, Id, _data, OtTypeName); + await env.Connection.Received(1).CreateDocAsync(Collection, Id, _data, OtTypeName, Source); } [Test] @@ -80,10 +80,10 @@ public async Task FetchAsyncOrCreate_Fetches() env.Connection.FetchDocAsync(Collection, Id).Returns(Task.FromResult(_snapshot)); // SUT - await env.Document.FetchOrCreateAsync(() => _data); + await env.Document.FetchOrCreateAsync(() => _data, Source); await env.Connection.Received(1).FetchDocAsync(Collection, Id); - await env.Connection.Received(0).CreateDocAsync(Collection, Id, _data, OtTypeName); + await env.Connection.Received(0).CreateDocAsync(Collection, Id, _data, OtTypeName, Source); } [Test] diff --git a/test/SIL.XForge.Tests/Realtime/RealtimeServerTests.cs b/test/SIL.XForge.Tests/Realtime/RealtimeServerTests.cs index 0f515ad24cd..edb9711b81d 100644 --- a/test/SIL.XForge.Tests/Realtime/RealtimeServerTests.cs +++ b/test/SIL.XForge.Tests/Realtime/RealtimeServerTests.cs @@ -31,7 +31,7 @@ public async Task CreateDocAsync_Success() object data = new { }; // SUT - await env.Service.CreateDocAsync(0, string.Empty, string.Empty, data, OTType.Json0); + await env.Service.CreateDocAsync(0, string.Empty, string.Empty, data, OTType.Json0, source: null); await env .NodeJsProcess.Received(1)