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") }}
-
-
-
-
-
-
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") }}
+
+
+
-
+
+ input {{ t("add_to_project") }}
+
+
@if (formattingOptionsSupported && isLatestBuild) {
-
+
build {{ t("formatting_options") }}
}
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) {
+
+ }
+
+
+
+
+
+ close
+ {{ t("cancel") }}
+
+
+ {{ needsConnection || showBookSelection || showOverwriteConfirmation ? t("next") : t("import") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+
+
+
+ @if (needsConnection) {
+
+ {{ t("connect_project") }}
+
+ {{ t("connect_to_project") }}
+
+ @if (selectedParatextProject != null) {
+
+ }
+
+ @if (connectionError != null && connectionError.length > 0) {
+ {{ connectionError }}
+ }
+
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+ 0)
+ "
+ >
+ {{ t("connect") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+
+
+
+
+ {{ 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 }}
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ refresh
+ {{ t("retry") }}
+
+
+ } @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
+ })
+ }}
+
+ }
+ }
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ {{ showBookSelection || showOverwriteConfirmation ? t("next") : t("import") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+ }
+
+ }
+
+
+ @if (showBookSelection) {
+ 0">
+ {{ t("select_books") }}
+
+ {{ t("confirm_books_to_import") }}
+ {{ t("confirm_books_to_import_description") }}
+
+
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ {{ showOverwriteConfirmation ? t("next") : t("import") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+
+ }
+
+
+ @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") }}
+
+ }
+
+ }
+
+
+
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ {{ t("import") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+
+ }
+
+
+
+ {{ 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 }}
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ refresh
+ {{ t("retry") }}
+
+
+ }
+
+ @if (importComplete) {
+ {{ t("import_complete") }}
+
+
+ {{ t("next") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+ }
+
+
+
+
+ {{ t("complete") }}
+
+ @if (!isSyncing && !syncComplete && !skipSync && syncError == null) {
+ {{ t("sync_project_question") }}
+
+
+
+
+ {{ t("skip_sync") }}
+
+
+ {{ t("sync") }}
+ sync
+
+
+ }
+
+ @if (isSyncing) {
+ {{ t("syncing_project") }}
+ @if (targetProjectDoc$ | async; as projectDoc) {
+
+ }
+ }
+
+ @if (syncError != null && !syncComplete && !skipSync) {
+ {{ t("sync_failed") }}
+ {{ syncError }}
+
+
+ {{ t("skip_sync") }}
+
+
+ refresh
+ {{ t("retry") }}
+
+
+ }
+
+ @if (syncComplete || skipSync) {
+ {{ t("complete") }}
+ @if (skipSync) {
+
+ } @else {
+
+
+ {{ t("import_and_sync_complete_reminder") }}
+
+ }
+
+
+
+ {{ t("done") }}
+
+
+ }
+
+
+
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
-
-
-
-
- input{{ book.draftApplied ? t("readd_to_project") : t("add_to_project") }}
-
-
- output{{ t("add_to_different_project") }}
-
-
+ {{ "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