From 2ff8416750fe91c99428905c1e221cb1ff5b8f31 Mon Sep 17 00:00:00 2001
From: Peter Chapman
Date: Wed, 3 Dec 2025 13:37:58 +1300
Subject: [PATCH 01/40] SF-3633 Mark AddChapters obsolete so it can be removed
at a later date
---
.../Controllers/SFProjectsRpcController.cs | 1 +
src/SIL.XForge.Scripture/Services/SFProjectService.cs | 1 +
.../Controllers/SFProjectsRpcControllerTests.cs | 3 +++
.../Services/SFProjectServiceTests.cs | 4 ++++
4 files changed, 9 insertions(+)
diff --git a/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs b/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs
index 37a2a206c0..6937be73ce 100644
--- a/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs
+++ b/src/SIL.XForge.Scripture/Controllers/SFProjectsRpcController.cs
@@ -1114,6 +1114,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/Services/SFProjectService.cs b/src/SIL.XForge.Scripture/Services/SFProjectService.cs
index 8e467c760f..8868e091b3 100644
--- a/src/SIL.XForge.Scripture/Services/SFProjectService.cs
+++ b/src/SIL.XForge.Scripture/Services/SFProjectService.cs
@@ -1509,6 +1509,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();
diff --git a/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs b/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs
index 92e8b0955c..dd5fd054b6 100644
--- a/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs
+++ b/test/SIL.XForge.Scripture.Tests/Controllers/SFProjectsRpcControllerTests.cs
@@ -630,6 +630,7 @@ public void RetrievePreTranslationStatus_UnknownError()
}
[Test]
+ [Obsolete]
public async Task AddChapters_Success()
{
var env = new TestEnvironment();
@@ -643,6 +644,7 @@ public async Task AddChapters_Success()
}
[Test]
+ [Obsolete]
public async Task AddChapters_Forbidden()
{
var env = new TestEnvironment();
@@ -657,6 +659,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/SFProjectServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/SFProjectServiceTests.cs
index 77b051bf46..7a2cbf4d20 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();
From c503515404c697869d31530d4d9807b03f16a6a0 Mon Sep 17 00:00:00 2001
From: Peter Chapman
Date: Tue, 6 Jan 2026 13:16:22 +1300
Subject: [PATCH 02/40] SF-3633 Update backend to report draft import status
correctly
---
.../Models/DraftApplyState.cs | 8 +-
.../Models/DraftApplyStatus.cs | 10 ++
.../Services/MachineApiService.cs | 133 ++++++++++++++----
3 files changed, 121 insertions(+), 30 deletions(-)
create mode 100644 src/SIL.XForge.Scripture/Models/DraftApplyStatus.cs
diff --git a/src/SIL.XForge.Scripture/Models/DraftApplyState.cs b/src/SIL.XForge.Scripture/Models/DraftApplyState.cs
index 46fb274c64..15185ab10f 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 0000000000..cb0659e5cf
--- /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/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs
index ec65ee90c7..171281357f 100644
--- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs
+++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs
@@ -158,13 +158,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 +169,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 hubContext.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 +189,16 @@ await hubContext.NotifyDraftApplyProgress(
{
chapters = [.. Enumerable.Range(1, lastChapter)];
}
+ await hubContext.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 = [];
@@ -226,7 +240,10 @@ await hubContext.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;
@@ -240,7 +257,10 @@ await hubContext.NotifyDraftApplyProgress(
sfProjectId,
new DraftApplyState
{
- State =
+ BookNum = bookNum,
+ ChapterNum = 0,
+ Status = DraftApplyStatus.Failed,
+ Message =
$"Could not retrieve a valid draft for {Canon.BookNumberToId(bookNum)}.",
}
);
@@ -274,7 +294,10 @@ await hubContext.NotifyDraftApplyProgress(
sfProjectId,
new DraftApplyState
{
- State =
+ BookNum = bookNum,
+ ChapterNum = chapterNum,
+ Status = DraftApplyStatus.Failed,
+ Message =
$"Could not retrieve draft for {Canon.BookNumberToId(bookNum)} {chapterNum}.",
}
);
@@ -294,7 +317,10 @@ await hubContext.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 +339,18 @@ await hubContext.NotifyDraftApplyProgress(
chapterDeltas.Add((chapterDelta, bookNum));
}
}
+
+ await hubContext.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)
@@ -326,7 +364,13 @@ await hubContext.NotifyDraftApplyProgress(
result.Log += $"{e}\n";
await hubContext.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
@@ -403,7 +447,13 @@ await targetProjectDoc.SubmitJson0OpAsync(op =>
{
await hubContext.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)
{
@@ -441,7 +491,13 @@ await paratextService.UpdateParatextPermissionsForNewBooksAsync(
// Only notify the book failure once per book
await hubContext.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)}.",
+ }
);
}
@@ -465,7 +521,13 @@ await hubContext.NotifyDraftApplyProgress(
// Only notify the book failure once per book
await hubContext.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)}.",
+ }
);
}
@@ -483,7 +545,11 @@ await hubContext.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;
@@ -513,7 +579,11 @@ await hubContext.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;
@@ -536,7 +606,10 @@ await hubContext.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}.",
}
);
}
@@ -548,7 +621,10 @@ await hubContext.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}.",
}
);
}
@@ -575,20 +651,23 @@ 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 hubContext.NotifyDraftApplyProgress(
+ sfProjectId,
+ new DraftApplyState
+ {
+ BookNum = 0,
+ ChapterNum = 0,
+ Status = successful ? DraftApplyStatus.Successful : DraftApplyStatus.Failed,
+ Message = result.Log,
+ }
+ );
+
result.ChangesSaved = successful;
}
From 4aed29b2a63d17abaac6355d13377c7656187611 Mon Sep 17 00:00:00 2001
From: Peter Chapman
Date: Wed, 3 Dec 2025 13:26:48 +1300
Subject: [PATCH 03/40] SF-3633 Frontend work-in-progress
---
.../draft-history-entry.component.html | 3 +
.../draft-history-entry.component.ts | 16 +-
.../draft-import-wizard.component.html | 362 ++++++++
.../draft-import-wizard.component.scss | 83 ++
.../draft-import-wizard.component.ts | 867 ++++++++++++++++++
.../draft-preview-books.component.html | 24 +-
.../draft-preview-books.component.ts | 203 +---
.../src/assets/i18n/non_checking_en.json | 58 +-
8 files changed, 1398 insertions(+), 218 deletions(-)
create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html
create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.scss
create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts
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 15f2a029be..77a3334f8a 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
@@ -48,6 +48,9 @@
+
+ input {{ t("add_to_project") }}
+
@if (formattingOptionsSupported && isLatestBuild) {
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.ts
index 992035a3f9..13afa6c0b1 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,
@@ -36,6 +37,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';
@@ -336,7 +338,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 +358,15 @@ 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: true
+ });
+ }
}
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 0000000000..ddba6a3fed
--- /dev/null
+++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html
@@ -0,0 +1,362 @@
+
+
+
+
+ {{ index + 1 }}
+
+
+
+ {{ t("select_project") }}
+
+ {{ t("select_project") }}
+ {{ t("select_project_description") }}
+
+ @if (isLoadingProjects) {
+
+ }
+
+
+
+
+
+ close
+ {{ t("cancel") }}
+
+
+ {{ t("next") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+
+
+
+ @if (needsConnection) {
+
+ {{ t("connect_project") }}
+
+ {{ t("connect_project") }}
+
+ @if (selectedParatextProject != null) {
+
+ {{
+ t("connect_project_description", {
+ projectShortName: selectedParatextProject.shortName,
+ projectName: selectedParatextProject.name
+ })
+ }}
+
+ }
+
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ {{ t("connect") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+
+
+
+
+ {{ t("connecting") }}
+
+ @if (isConnecting) {
+ {{ t("connecting_project") }}
+ @if (targetProjectDoc$ | async; as projectDoc) {
+
+ } @else {
+ {{ t("setting_up_project") }}
+
+ }
+ }
+
+ @if (connectionError != null) {
+ {{ t("connection_failed") }}
+ {{ connectionError }}
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ refresh
+ {{ t("retry") }}
+
+
+ }
+
+ }
+
+
+ @if (showBookSelection) {
+ 0" [editable]="!isConnecting && !isImporting">
+ {{ t("select_books") }}
+
+ {{ t("select_books_to_import") }}
+
+
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ {{ t("next") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+
+ }
+
+
+ @if (showOverwriteConfirmation) {
+
+ {{ t("confirm_overwrite") }}
+
+ @if (booksWithExistingText.length === 1) {
+
+ {{ t("overwrite_book_question", { bookName: singleBookName }) }}
+
+ {{
+ t("overwrite_book_description", {
+ bookName: singleBookName,
+ numChapters: booksWithExistingText[0].chaptersWithText.length
+ })
+ }}
+
+ } @else {
+
+ {{ t("overwrite_books_question") }}
+ {{ t("overwrite_books_description") }}
+
+ @for (book of booksWithExistingText; track book.bookNum) {
+ {{ book.bookName }}: {{ t("num_chapters", { count: book.chaptersWithText.length }) }}
+ }
+
+ }
+
+ @if (isConnecting || isImporting) {
+
+ {{ isConnecting ? t("waiting_for_connection_to_finish") : t("waiting_for_import_to_finish") }}
+
+
+ }
+
+
+
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ {{ t("continue") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+
+ }
+
+
+
+ {{ t("importing") }}
+
+ {{ t("importing_draft") }}
+
+ @if (isImporting || importComplete || importError != null) {
+ @for (progress of importProgress; track progress.bookNum) {
+
+
{{ progress.bookName }}
+
+
+ {{ progress.completedChapters }} / {{ progress.totalChapters }} {{ t("chapters") }}
+
+ @if (progress.failedChapters.length > 0 && !isImporting) {
+
+ error
+ Failed: {{ getFailedChapters(progress) }}
+
+ }
+
+ }
+ }
+
+ @if (importError != null) {
+ {{ importError }}
+
+
+ refresh
+ {{ t("retry") }}
+
+
+ }
+
+ @if (importComplete) {
+ {{ t("import_complete") }}
+
+
+ {{ t("next") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
+ }
+
+
+
+
+ {{ t("complete") }}
+
+ @if (!isSyncing && !syncComplete && !skipSync) {
+ {{ t("sync_project_question") }}
+ {{ t("sync_project_description") }}
+
+
+
+ {{ t("skip_sync") }}
+
+
+ {{ t("sync") }}
+ sync
+
+
+ }
+
+ @if (isSyncing) {
+ {{ t("syncing_project") }}
+ @if (targetProjectDoc$ | async; as projectDoc) {
+
+ }
+ }
+
+ @if (syncError != null) {
+ {{ syncError }}
+
+
+ {{ t("skip_sync") }}
+
+
+ refresh
+ {{ t("retry") }}
+
+
+ }
+
+ @if (syncComplete || skipSync) {
+
+ {{ skipSync ? t("import_complete_no_sync") : t("import_and_sync_complete") }}
+
+
+
+ {{ 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 0000000000..c1f6094a69
--- /dev/null
+++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.scss
@@ -0,0 +1,83 @@
+.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(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
+ }
+
+ .failed-chapters {
+ display: flex;
+ align-items: center;
+ column-gap: 0.35rem;
+ margin-top: 0.35rem;
+ color: var(--mdc-theme-error, #b00020);
+
+ .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 {
+ mat-dialog-content {
+ min-height: 400px;
+ }
+}
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 0000000000..59825bee91
--- /dev/null
+++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts
@@ -0,0 +1,867 @@
+import { StepperSelectionEvent } from '@angular/cdk/stepper';
+import { AsyncPipe } from '@angular/common';
+import { Component, 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 { MatError } from '@angular/material/form-field';
+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 { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project';
+import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission';
+import { BehaviorSubject } from 'rxjs';
+import { ActivatedProjectService } from 'xforge-common/activated-project.service';
+import { I18nService } from 'xforge-common/i18n.service';
+import { OnlineStatusService } from 'xforge-common/online-status.service';
+import { UserService } from 'xforge-common/user.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 { CustomValidatorState as CustomErrorState, SFValidators } from '../../../shared/sfvalidators';
+import { booksFromScriptureRange } from '../../../shared/utils';
+import { SyncProgressComponent } from '../../../sync/sync-progress/sync-progress.component';
+import { DraftHandlingService } from '../draft-handling.service';
+
+/**
+ * Represents a book available for import with its draft chapters.
+ */
+interface BookForImport {
+ number: number; // Alias for bookNum to match Book interface
+ bookNum: number;
+ bookId: string;
+ bookName: string;
+ chapters: number[];
+ selected: boolean;
+}
+
+/**
+ * Tracks the progress of importing drafts.
+ */
+interface ImportProgress {
+ bookNum: number;
+ bookName: string;
+ totalChapters: number;
+ completedChapters: number;
+ failedChapters: { chapter: number; message: string }[];
+}
+
+/**
+ * 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,
+ MatError,
+ 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(ProjectSelectComponent) projectSelect?: ProjectSelectComponent;
+ @ViewChild('importStep') importStep?: MatStep;
+
+ // Step 1: Project selection
+ projectSelectionForm = new FormGroup({
+ targetParatextId: new FormControl(undefined, Validators.required),
+ createChapters: new FormControl(false)
+ });
+ projects: ParatextProject[] = [];
+ isLoadingProjects = true;
+ targetProjectId?: string;
+ selectedParatextProject?: ParatextProject;
+ targetProject$ = new BehaviorSubject(undefined);
+ targetProjectDoc$ = new BehaviorSubject(undefined);
+ canEditProject = true;
+ booksMissingWithoutPermission = false;
+ missingBookNames: string[] = [];
+ bookCreationError?: string;
+ isEnsuringBooks = false;
+ projectHasMissingChapters = false;
+ projectLoadingFailed = false;
+ private sourceProjectId?: string;
+ noDraftsAvailable = false;
+
+ // Step 2-3: Project connection (conditional)
+ needsConnection = false;
+ isConnecting = 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
+ await this.projectService.onlineAddCurrentUser(this.targetProjectId);
+
+ // Reload project data after connection
+ const projectDoc = await this.projectService.get(this.targetProjectId);
+ this.targetProjectDoc$.next(projectDoc);
+ if (projectDoc.data != null) {
+ await this.analyzeTargetProject(projectDoc.data, paratextProject);
+ }
+ } else {
+ // Create SF project for this Paratext project
+ this.targetProjectId = await this.projectService.onlineCreateResourceProject(paratextId);
+
+ if (this.targetProjectId != null) {
+ const projectDoc = await this.projectService.get(this.targetProjectId);
+ this.targetProjectDoc$.next(projectDoc);
+ if (projectDoc.data != null) {
+ await this.analyzeTargetProject(projectDoc.data, paratextProject);
+ }
+ }
+ }
+
+ try {
+ await this.analyzeBooksForOverwrite();
+ } catch (analysisError) {
+ console.error('Failed to analyze books for overwrite', analysisError);
+ }
+
+ this.isConnecting = false;
+ this.stepper?.next();
+ this.needsConnection = false;
+ } 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;
+ }
+ }
+
+ retryProjectConnection(): void {
+ void this.connectToProject(true);
+ }
+
+ onStepSelectionChange(event: StepperSelectionEvent): void {
+ if (event.selectedStep === this.importStep && !this.importStepTriggered) {
+ if (this.noDraftsAvailable) {
+ return;
+ }
+ 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: { bookNum: number; bookName: string; chaptersWithText: number[] }[] = [];
+
+ // Step 6: Import progress
+ isImporting = false;
+ importProgress: ImportProgress[] = [];
+ importError?: string;
+ importComplete = false;
+ private importStepTriggered = false;
+
+ // Step 7: Sync confirmation and completion
+ showSyncConfirmation = false;
+ isSyncing = false;
+ syncError?: string;
+ syncComplete = false;
+ skipSync = false;
+
+ invalidMessageMapper: { [key: string]: string } = {};
+
+ constructor(
+ @Inject(MAT_DIALOG_DATA) readonly data: BuildDto,
+ @Inject(MatDialogRef) private readonly dialogRef: MatDialogRef,
+ private readonly paratextService: ParatextService,
+ private readonly projectService: SFProjectService,
+ private readonly textDocService: TextDocService,
+ private readonly draftHandlingService: DraftHandlingService,
+ readonly i18n: I18nService,
+ private readonly userService: UserService,
+ private readonly onlineStatusService: OnlineStatusService,
+ private readonly activatedProjectService: ActivatedProjectService
+ ) {}
+
+ 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));
+ }
+
+ get selectedBooks(): BookForImport[] {
+ return this.availableBooksForImport.filter(b => b.selected);
+ }
+
+ 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 {
+ this.setupInvalidMessageMapper();
+ void this.loadProjects();
+ this.initializeAvailableBooks();
+ void this.loadSourceProjectContext();
+ }
+
+ private setupInvalidMessageMapper(): void {
+ this.invalidMessageMapper = {
+ invalidProject: this.i18n.translateStatic('draft_import_wizard.please_select_valid_project'),
+ bookNotFound: this.i18n.translateStatic('draft_import_wizard.book_does_not_exist'),
+ noWritePermissions: this.i18n.translateStatic('draft_import_wizard.no_write_permissions')
+ };
+ }
+
+ 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 = [];
+ console.error('Failed to load projects:', error);
+ } 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),
+ chapters: [], // Will be populated when we have project context
+ selected: true // Pre-select all books by default
+ }));
+
+ // Show book selection only if multiple books
+ this.showBookSelection = this.availableBooksForImport.length > 1;
+ }
+
+ private async loadSourceProjectContext(): Promise {
+ this.sourceProjectId = this.activatedProjectService.projectId;
+ const cachedDoc = this.activatedProjectService.projectDoc;
+ if (cachedDoc?.data != null) {
+ this.applySourceProject(cachedDoc.data);
+ return;
+ }
+
+ if (this.sourceProjectId == null) {
+ this.noDraftsAvailable = this.availableBooksForImport.length === 0;
+ return;
+ }
+
+ const projectProfileDoc = await this.projectService.getProfile(this.sourceProjectId);
+ if (projectProfileDoc.data != null) {
+ this.applySourceProject(projectProfileDoc.data);
+ } else {
+ this.noDraftsAvailable = true;
+ }
+ }
+
+ private applySourceProject(project: SFProjectProfile): void {
+ const updatedBooks: BookForImport[] = [];
+ for (const book of this.availableBooksForImport) {
+ const chaptersWithDrafts = this.getDraftChapters(project, book.bookNum);
+ if (chaptersWithDrafts.length === 0) {
+ continue;
+ }
+ updatedBooks.push({
+ ...book,
+ chapters: chaptersWithDrafts,
+ selected: book.selected !== false
+ });
+ }
+ this.availableBooksForImport = updatedBooks;
+ this.showBookSelection = this.availableBooksForImport.length > 1;
+ this.noDraftsAvailable = this.availableBooksForImport.length === 0;
+ this.resetImportState();
+ }
+
+ private getDraftChapters(project: SFProjectProfile, bookNum: number): number[] {
+ const text = project.texts.find(t => t.bookNum === bookNum);
+ if (text == null) {
+ return [];
+ }
+ return text.chapters
+ .filter(chapter => chapter.hasDraft === true && chapter.number > 0)
+ .map(chapter => chapter.number);
+ }
+
+ async projectSelected(paratextId: string): Promise {
+ this.projectSelectionForm.controls.createChapters.setValue(false);
+ if (paratextId == null) {
+ this.targetProject$.next(undefined);
+ 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.booksMissingWithoutPermission = false;
+ this.bookCreationError = undefined;
+ this.missingBookNames = [];
+ this.targetProject$.next(undefined);
+ this.targetProjectDoc$.next(undefined);
+ this.selectedParatextProject = undefined;
+ await this.validateProject();
+ 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;
+
+ // Get the project profile to analyze
+ const projectDoc = await this.projectService.get(this.targetProjectId);
+ if (projectDoc.data != null) {
+ await this.analyzeTargetProject(projectDoc.data, paratextProject);
+ }
+ } else {
+ // Need to create SF project - this will happen after connection step
+ this.targetProjectId = undefined;
+ this.needsConnection = true;
+ this.canEditProject = paratextProject.isConnectable;
+ this.targetProject$.next(undefined);
+ this.targetProjectDoc$.next(undefined);
+ await this.validateProject();
+ }
+ }
+
+ private async analyzeTargetProject(project: SFProjectProfile, paratextProject: ParatextProject): Promise {
+ // Check permissions for all books
+ const hasGeneralEditRight = this.textDocService.userHasGeneralEditRight(project);
+ let hasWritePermissionForSelection = hasGeneralEditRight;
+ const booksToImport = this.getBooksToImport().map(book => book.bookNum);
+
+ if (hasWritePermissionForSelection) {
+ for (const bookNum of booksToImport) {
+ const targetBook = project.texts.find(t => t.bookNum === bookNum);
+ if (targetBook == null) {
+ continue;
+ }
+
+ const userPermission = targetBook.permissions?.[this.userService.currentUserId];
+ if (userPermission !== TextInfoPermission.Write) {
+ hasWritePermissionForSelection = false;
+ break;
+ }
+ }
+ }
+
+ this.canEditProject = hasWritePermissionForSelection;
+
+ // Connection status comes from ParatextProject
+ this.needsConnection = !paratextProject.isConnected && hasWritePermissionForSelection;
+
+ // Update chapters for available books and check for missing chapters
+ this.projectHasMissingChapters = false;
+ for (const book of this.getBooksToImport()) {
+ if (book.chapters.length === 0) {
+ continue;
+ }
+ const targetBook = project.texts.find(t => t.bookNum === book.bookNum);
+ if (targetBook == null) {
+ continue;
+ }
+ const targetChapterNumbers = targetBook.chapters.map(c => c.number);
+ const missingChapters = book.chapters.filter(chapter => !targetChapterNumbers.includes(chapter));
+ if (missingChapters.length > 0) {
+ this.projectHasMissingChapters = true;
+ }
+ const bookIsEmpty =
+ targetBook.chapters.length === 0 || (targetBook.chapters.length === 1 && targetBook.chapters[0].lastVerse < 1);
+ if (bookIsEmpty) {
+ this.projectHasMissingChapters = true;
+ }
+ }
+
+ if (this.projectHasMissingChapters) {
+ this.projectSelectionForm.controls.createChapters.addValidators(Validators.requiredTrue);
+ } else {
+ this.projectSelectionForm.controls.createChapters.clearValidators();
+ }
+ this.projectSelectionForm.controls.createChapters.updateValueAndValidity();
+
+ if (this.canEditProject && this.targetProjectId != null) {
+ this.targetProject$.next(project);
+ // Load the full project doc for sync component
+ const projectDoc = await this.projectService.get(this.targetProjectId);
+ this.targetProjectDoc$.next(projectDoc);
+ } else {
+ this.targetProject$.next(undefined);
+ this.targetProjectDoc$.next(undefined);
+ }
+
+ await this.validateProject();
+ }
+
+ private async ensureSelectedBooksExist(): Promise {
+ if (this.targetProjectId == null) {
+ return false;
+ }
+
+ let project = this.targetProject$.value;
+ if (project == null) {
+ const profileDoc = await this.projectService.getProfile(this.targetProjectId);
+ project = profileDoc.data ?? undefined;
+ if (project != null) {
+ this.targetProject$.next(project);
+ } else {
+ return false;
+ }
+ }
+
+ const booksToImport = this.getBooksToImport();
+ const missingBooks: BookForImport[] = booksToImport.filter(book =>
+ project.texts.every(text => text.bookNum !== book.bookNum)
+ );
+
+ this.missingBookNames = missingBooks.map(book => book.bookName);
+
+ if (missingBooks.length === 0) {
+ this.booksMissingWithoutPermission = false;
+ this.bookCreationError = undefined;
+ return true;
+ }
+
+ const canCreateBooks = this.textDocService.userHasGeneralEditRight(project);
+ if (!canCreateBooks) {
+ this.booksMissingWithoutPermission = true;
+ const booksDescription = this.missingBookNames.length > 0 ? ` (${this.missingBookNames.join(', ')})` : '';
+ this.bookCreationError = this.i18n.translateStatic('draft_import_wizard.books_missing_no_permission', {
+ booksDescription
+ });
+ await this.validateProject();
+ return false;
+ }
+
+ this.booksMissingWithoutPermission = false;
+ this.bookCreationError = undefined;
+ this.isEnsuringBooks = true;
+
+ try {
+ for (const book of missingBooks) {
+ if (book.chapters.length === 0) {
+ continue;
+ }
+ // Was onlineAddBookWithChapters()
+ await this.projectService.onlineAddChapters(this.targetProjectId, book.bookNum, book.chapters);
+ for (const chapter of book.chapters) {
+ const textDocId = new TextDocId(this.targetProjectId, book.bookNum, chapter);
+ await this.textDocService.createTextDoc(textDocId);
+ }
+ }
+
+ const refreshedProfile = await this.projectService.getProfile(this.targetProjectId);
+ if (refreshedProfile.data != null && this.selectedParatextProject != null) {
+ await this.analyzeTargetProject(refreshedProfile.data, this.selectedParatextProject);
+ }
+
+ return true;
+ } finally {
+ this.isEnsuringBooks = false;
+ }
+ }
+
+ private resetProjectValidation(): void {
+ this.canEditProject = true;
+ this.booksMissingWithoutPermission = false;
+ this.bookCreationError = undefined;
+ this.missingBookNames = [];
+ this.projectHasMissingChapters = false;
+ }
+
+ private async validateProject(): Promise {
+ await new Promise(resolve => {
+ setTimeout(() => {
+ this.projectSelect?.customValidate(SFValidators.customValidator(this.getCustomErrorState()));
+ resolve();
+ });
+ });
+ }
+
+ private getCustomErrorState(): CustomErrorState {
+ if (!this.projectSelectionForm.controls.targetParatextId.valid) {
+ return CustomErrorState.InvalidProject;
+ }
+ if (this.booksMissingWithoutPermission) {
+ return CustomErrorState.BookNotFound;
+ }
+ if (!this.canEditProject) {
+ return CustomErrorState.NoWritePermissions;
+ }
+ return CustomErrorState.None;
+ }
+
+ onBookSelect(selectedBooks: number[]): void {
+ for (const book of this.availableBooksForImport) {
+ book.selected = selectedBooks.includes(book.bookNum);
+ }
+ this.resetImportState();
+ this.booksMissingWithoutPermission = false;
+ this.bookCreationError = undefined;
+ this.missingBookNames = [];
+ void this.analyzeBooksForOverwrite();
+ void this.validateProject();
+ }
+
+ async advanceFromProjectSelection(): Promise {
+ if (!this.projectSelectionForm.valid) {
+ return;
+ }
+
+ if (this.noDraftsAvailable) {
+ return;
+ }
+
+ // 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) {
+ return;
+ }
+
+ const booksReady = await this.ensureSelectedBooksExist();
+ if (!booksReady) {
+ return;
+ }
+
+ // Analyze books for overwrite confirmation
+ await this.analyzeBooksForOverwrite();
+
+ this.stepper?.next();
+ }
+
+ private async analyzeBooksForOverwrite(): 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.targetProject$.value;
+ 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 && book.chapters.length > 0);
+
+ 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,
+ bookName: book.bookName,
+ totalChapters: book.chapters.length,
+ completedChapters: 0,
+ failedChapters: []
+ }));
+
+ try {
+ await this.performImport(booksToImport);
+
+ // Check if there were any failures
+ const totalFailures = this.importProgress.reduce((sum, p) => sum + p.failedChapters.length, 0);
+ if (totalFailures > 0) {
+ this.importError = `Failed to import ${totalFailures} chapter(s). See details above.`;
+ } else {
+ this.importComplete = true;
+ }
+ } catch (error) {
+ this.importError = error instanceof Error ? error.message : 'Unknown error occurred';
+ } finally {
+ this.isImporting = false;
+ }
+ }
+
+ private async performImport(books: BookForImport[]): Promise {
+ if (this.targetProjectId == null || this.sourceProjectId == null) {
+ throw new Error('Missing project context for import');
+ }
+
+ const targetProjectDoc = await this.projectService.getProfile(this.targetProjectId);
+ const targetProject = targetProjectDoc.data;
+ if (targetProject == null) {
+ throw new Error('Target project not found');
+ }
+
+ const sourceProjectId = this.sourceProjectId;
+ const allowChapterCreation = this.projectSelectionForm.value.createChapters === true;
+
+ for (const book of books) {
+ const progress = this.importProgress.find(p => p.bookNum === book.bookNum);
+ if (progress == null) continue;
+
+ // Create missing chapters if needed
+ const targetBook = targetProject.texts.find(t => t.bookNum === book.bookNum);
+ const existingChapters = targetBook?.chapters.map(c => c.number) ?? [];
+ const missingChapters = book.chapters.filter(c => !existingChapters.includes(c));
+
+ if (missingChapters.length > 0) {
+ if (!allowChapterCreation) {
+ for (const chapter of missingChapters) {
+ progress.failedChapters.push({
+ chapter,
+ message: this.i18n.translateStatic('draft_import_wizard.missing_chapter_needs_creation')
+ });
+ }
+ continue;
+ }
+
+ await this.projectService.onlineAddChapters(this.targetProjectId, book.bookNum, missingChapters);
+ for (const chapter of missingChapters) {
+ const textDocId = new TextDocId(this.targetProjectId, book.bookNum, chapter);
+ await this.textDocService.createTextDoc(textDocId);
+ }
+ }
+
+ // Apply draft to each chapter
+ for (const chapter of book.chapters) {
+ try {
+ const sourceTextDocId = new TextDocId(sourceProjectId, book.bookNum, chapter);
+ const targetTextDocId = new TextDocId(this.targetProjectId, book.bookNum, chapter);
+
+ let timestamp: Date | undefined;
+ if (this.data.additionalInfo?.dateGenerated != null) {
+ timestamp = new Date(this.data.additionalInfo.dateGenerated);
+ }
+
+ const error = await this.draftHandlingService.getAndApplyDraftAsync(
+ targetProject,
+ sourceTextDocId,
+ targetTextDocId,
+ timestamp
+ );
+
+ if (error != null) {
+ progress.failedChapters.push({ chapter, message: error });
+ } else {
+ progress.completedChapters++;
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ progress.failedChapters.push({ chapter, message: errorMessage });
+ }
+ }
+ }
+ }
+
+ 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.isSyncing = true;
+ this.syncError = undefined;
+
+ try {
+ await this.projectService.onlineSync(this.targetProjectId);
+ this.syncComplete = true;
+ this.stepper?.next();
+ } catch (error) {
+ this.syncError = error instanceof Error ? error.message : 'Sync failed';
+ } finally {
+ 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 {
+ return progress.failedChapters.map(f => f.chapter).join(', ');
+ }
+
+ 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-preview-books/draft-preview-books.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.html
index 195cce4d8d..c154396d2f 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.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts
index 52d40a74da..51519dd832 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/assets/i18n/non_checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json
index b32c469766..147d21705a 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,8 @@
"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_book_to_preview": "Click a book below to preview the draft and add it to your project",
"download_zip": "Download draft as ZIP file",
"draft_active": "Running",
"draft_canceled": "Canceled",
@@ -311,6 +312,61 @@
"older_drafts_not_available": "Older drafts requested before {{ date }} are not available.",
"previously_generated_drafts": "Previously generated drafts"
},
+ "draft_import_wizard": {
+ "add_to_project": "Add to project",
+ "book_does_not_exist": "One or more selected books do not exist in this project, and you do not have permission to create them",
+ "books_missing_no_permission": "The selected books{{ booksDescription }} do not exist in this project, and you do not have permission to create them. Choose another project or contact a project administrator for access.",
+ "back": "Back",
+ "cancel": "Cancel",
+ "chapters": "chapters",
+ "choose_project": "Choose a project",
+ "complete": "Complete",
+ "confirm_overwrite": "Confirm overwrite",
+ "creating_missing_books": "Creating the selected books in the project...",
+ "connect_project_description": "The project you selected, {{ projectShortName }} - {{ projectName }}, 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_the_internet": "Connect to the internet to import drafts",
+ "connect": "Connect",
+ "connecting_project": "Connecting project",
+ "connecting": "Connecting",
+ "connection_failed": "Connection failed",
+ "continue": "Continue",
+ "create_missing_chapters": "Create missing chapters in the project",
+ "done": "Done",
+ "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.",
+ "i_understand_overwrite_content": "I understand that existing content will be overwritten",
+ "import_and_sync_complete": "Import and sync complete! Your draft has been added to the project.",
+ "import_complete_no_sync": "Import complete! Your draft has been added to the project.",
+ "import_complete": "Import complete",
+ "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.",
+ "no_write_permissions": "You do not have write permissions for the selected book(s)",
+ "missing_chapter_needs_creation": "Chapter must be created in the project before importing.",
+ "num_chapters": "{{ count }} chapters",
+ "overwrite_book_description": "{{ bookName }} has {{ numChapters }} chapters with existing text that will be overwritten.",
+ "overwrite_book_question": "Overwrite {{ bookName }}?",
+ "overwrite_books_description": "The following books 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 wizard and try again.",
+ "ready_to_import": "Ready to import draft to “{{ projectShortName }} - {{ projectName }}”",
+ "retry": "Retry",
+ "setting_up_project": "Setting up the project...",
+ "select_books_to_import": "Select books to import",
+ "select_books": "Select books",
+ "select_project": "Select project",
+ "select_project_description": "Select the project you want to add the draft to.",
+ "skip_sync": "Skip sync",
+ "sync_project_description": "It is recommended to sync the project to save your changes to Paratext.",
+ "sync_project_question": "Sync project now?",
+ "sync": "Sync",
+ "syncing_project": "Syncing project",
+ "waiting_for_connection_to_finish": "Please wait while the project finishes connecting.",
+ "waiting_for_import_to_finish": "Please wait until the current import finishes."
+ },
"draft_preview_books": {
"add_to_project": "Add to project",
"add_to_different_project": "Add to a different project",
From 9860c4f11892cdaf9f9227a36ebb495faf96502f Mon Sep 17 00:00:00 2001
From: Peter Chapman
Date: Wed, 3 Dec 2025 15:08:05 +1300
Subject: [PATCH 04/40] SF-3633 Frontend work-in-progress part 2
---
.../draft-import-wizard.component.html | 6 -
.../draft-import-wizard.component.ts | 118 +++---------------
.../src/assets/i18n/non_checking_en.json | 1 -
3 files changed, 18 insertions(+), 107 deletions(-)
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
index ddba6a3fed..9c8415102f 100644
--- 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
@@ -53,12 +53,6 @@ {{ t("select_project") }}
{{ t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) }}
-
- @if (projectHasMissingChapters) {
-
- {{ t("create_missing_chapters") }}
-
- }
}
@if (bookCreationError != null) {
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
index 59825bee91..988d4d8fdc 100644
--- 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
@@ -38,7 +38,6 @@ import { NoticeComponent } from '../../../shared/notice/notice.component';
import { CustomValidatorState as CustomErrorState, SFValidators } from '../../../shared/sfvalidators';
import { booksFromScriptureRange } from '../../../shared/utils';
import { SyncProgressComponent } from '../../../sync/sync-progress/sync-progress.component';
-import { DraftHandlingService } from '../draft-handling.service';
/**
* Represents a book available for import with its draft chapters.
@@ -102,8 +101,7 @@ export class DraftImportWizardComponent implements OnInit {
// Step 1: Project selection
projectSelectionForm = new FormGroup({
- targetParatextId: new FormControl(undefined, Validators.required),
- createChapters: new FormControl(false)
+ targetParatextId: new FormControl(undefined, Validators.required)
});
projects: ParatextProject[] = [];
isLoadingProjects = true;
@@ -116,7 +114,6 @@ export class DraftImportWizardComponent implements OnInit {
missingBookNames: string[] = [];
bookCreationError?: string;
isEnsuringBooks = false;
- projectHasMissingChapters = false;
projectLoadingFailed = false;
private sourceProjectId?: string;
noDraftsAvailable = false;
@@ -240,7 +237,6 @@ export class DraftImportWizardComponent implements OnInit {
private readonly paratextService: ParatextService,
private readonly projectService: SFProjectService,
private readonly textDocService: TextDocService,
- private readonly draftHandlingService: DraftHandlingService,
readonly i18n: I18nService,
private readonly userService: UserService,
private readonly onlineStatusService: OnlineStatusService,
@@ -257,8 +253,14 @@ export class DraftImportWizardComponent implements OnInit {
return Array.from(new Set(bookNumbers));
}
+ private _selectedBooks: BookForImport[] = [];
get selectedBooks(): BookForImport[] {
- return this.availableBooksForImport.filter(b => b.selected);
+ const value = this.availableBooksForImport.filter(b => b.selected);
+ if (this._selectedBooks.toString() !== value.toString()) {
+ this._selectedBooks = value;
+ }
+
+ return this._selectedBooks;
}
private getBooksToImport(): BookForImport[] {
@@ -386,7 +388,6 @@ export class DraftImportWizardComponent implements OnInit {
}
async projectSelected(paratextId: string): Promise {
- this.projectSelectionForm.controls.createChapters.setValue(false);
if (paratextId == null) {
this.targetProject$.next(undefined);
this.targetProjectDoc$.next(undefined);
@@ -462,35 +463,6 @@ export class DraftImportWizardComponent implements OnInit {
// Connection status comes from ParatextProject
this.needsConnection = !paratextProject.isConnected && hasWritePermissionForSelection;
- // Update chapters for available books and check for missing chapters
- this.projectHasMissingChapters = false;
- for (const book of this.getBooksToImport()) {
- if (book.chapters.length === 0) {
- continue;
- }
- const targetBook = project.texts.find(t => t.bookNum === book.bookNum);
- if (targetBook == null) {
- continue;
- }
- const targetChapterNumbers = targetBook.chapters.map(c => c.number);
- const missingChapters = book.chapters.filter(chapter => !targetChapterNumbers.includes(chapter));
- if (missingChapters.length > 0) {
- this.projectHasMissingChapters = true;
- }
- const bookIsEmpty =
- targetBook.chapters.length === 0 || (targetBook.chapters.length === 1 && targetBook.chapters[0].lastVerse < 1);
- if (bookIsEmpty) {
- this.projectHasMissingChapters = true;
- }
- }
-
- if (this.projectHasMissingChapters) {
- this.projectSelectionForm.controls.createChapters.addValidators(Validators.requiredTrue);
- } else {
- this.projectSelectionForm.controls.createChapters.clearValidators();
- }
- this.projectSelectionForm.controls.createChapters.updateValueAndValidity();
-
if (this.canEditProject && this.targetProjectId != null) {
this.targetProject$.next(project);
// Load the full project doc for sync component
@@ -554,6 +526,7 @@ export class DraftImportWizardComponent implements OnInit {
continue;
}
// Was onlineAddBookWithChapters()
+ // TODO: Remove? I don't think this should be called
await this.projectService.onlineAddChapters(this.targetProjectId, book.bookNum, book.chapters);
for (const chapter of book.chapters) {
const textDocId = new TextDocId(this.targetProjectId, book.bookNum, chapter);
@@ -577,7 +550,6 @@ export class DraftImportWizardComponent implements OnInit {
this.booksMissingWithoutPermission = false;
this.bookCreationError = undefined;
this.missingBookNames = [];
- this.projectHasMissingChapters = false;
}
private async validateProject(): Promise {
@@ -736,76 +708,22 @@ export class DraftImportWizardComponent implements OnInit {
}
}
- private async performImport(books: BookForImport[]): Promise {
+ private async performImport(_books: BookForImport[]): Promise {
if (this.targetProjectId == null || this.sourceProjectId == null) {
throw new Error('Missing project context for import');
}
- const targetProjectDoc = await this.projectService.getProfile(this.targetProjectId);
- const targetProject = targetProjectDoc.data;
- if (targetProject == null) {
- throw new Error('Target project not found');
- }
-
- const sourceProjectId = this.sourceProjectId;
- const allowChapterCreation = this.projectSelectionForm.value.createChapters === true;
-
- for (const book of books) {
- const progress = this.importProgress.find(p => p.bookNum === book.bookNum);
- if (progress == null) continue;
-
- // Create missing chapters if needed
- const targetBook = targetProject.texts.find(t => t.bookNum === book.bookNum);
- const existingChapters = targetBook?.chapters.map(c => c.number) ?? [];
- const missingChapters = book.chapters.filter(c => !existingChapters.includes(c));
-
- if (missingChapters.length > 0) {
- if (!allowChapterCreation) {
- for (const chapter of missingChapters) {
- progress.failedChapters.push({
- chapter,
- message: this.i18n.translateStatic('draft_import_wizard.missing_chapter_needs_creation')
- });
- }
- continue;
- }
-
- await this.projectService.onlineAddChapters(this.targetProjectId, book.bookNum, missingChapters);
- for (const chapter of missingChapters) {
- const textDocId = new TextDocId(this.targetProjectId, book.bookNum, chapter);
- await this.textDocService.createTextDoc(textDocId);
- }
- }
-
- // Apply draft to each chapter
- for (const chapter of book.chapters) {
- try {
- const sourceTextDocId = new TextDocId(sourceProjectId, book.bookNum, chapter);
- const targetTextDocId = new TextDocId(this.targetProjectId, book.bookNum, chapter);
-
- let timestamp: Date | undefined;
+ // TODO: Generate a scripture range
+ // TODO: Get timestamp
+ /*
+ let timestamp: Date | undefined;
if (this.data.additionalInfo?.dateGenerated != null) {
timestamp = new Date(this.data.additionalInfo.dateGenerated);
}
-
- const error = await this.draftHandlingService.getAndApplyDraftAsync(
- targetProject,
- sourceTextDocId,
- targetTextDocId,
- timestamp
- );
-
- if (error != null) {
- progress.failedChapters.push({ chapter, message: error });
- } else {
- progress.completedChapters++;
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
- progress.failedChapters.push({ chapter, message: errorMessage });
- }
- }
- }
+ */
+ // TODO: Submit to the backend
+ // TODO: Update this.progress.failedChapters
+ // TODO: Update this.progress.completedChapters
}
retryImport(): void {
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 147d21705a..80ff23f351 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
@@ -344,7 +344,6 @@
"next": "Next",
"no_books_ready_for_import": "No draft chapters are ready for import. Generate a draft and try again.",
"no_write_permissions": "You do not have write permissions for the selected book(s)",
- "missing_chapter_needs_creation": "Chapter must be created in the project before importing.",
"num_chapters": "{{ count }} chapters",
"overwrite_book_description": "{{ bookName }} has {{ numChapters }} chapters with existing text that will be overwritten.",
"overwrite_book_question": "Overwrite {{ bookName }}?",
From 11d0e146c39d098b61cb6f77f0d1a42960115c2e Mon Sep 17 00:00:00 2001
From: Peter Chapman
Date: Mon, 8 Dec 2025 14:36:59 +1300
Subject: [PATCH 05/40] SF-3633 Frontend work-in-progress part 3
---
.../draft-import-wizard.component.html | 12 +-
.../draft-import-wizard.component.ts | 122 +++++-------------
.../src/assets/i18n/non_checking_en.json | 1 -
3 files changed, 35 insertions(+), 100 deletions(-)
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
index 9c8415102f..0db1f9ffef 100644
--- 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
@@ -29,6 +29,10 @@ {{ t("select_project") }}
(projectSelect)="projectSelected($event.paratextId)"
formControlName="targetParatextId"
>
+
+ @if (isLoadingProject) {
+
+ }
}
@if (projectLoadingFailed) {
@@ -58,11 +62,6 @@ {{ t("select_project") }}
@if (bookCreationError != null) {
{{ bookCreationError }}
}
-
- @if (isEnsuringBooks) {
- {{ t("creating_missing_books") }}
-
- }
@@ -244,7 +247,7 @@ {{ t("overwrite_books_question") }}
matStepperNext
[disabled]="!overwriteForm.valid || isConnecting || isImporting"
>
- {{ t("continue") }}
+ {{ t("import") }}
chevron_{{ i18n.forwardDirectionWord }}
@@ -321,7 +324,10 @@ {{ t("sync_project_question") }}
@if (isSyncing) {
{{ t("syncing_project") }}
@if (targetProjectDoc$ | async; as projectDoc) {
-
+
}
}
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
index fce8e9dd2d..817ee41bd4 100644
--- 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
@@ -155,30 +155,33 @@ export class DraftImportWizardComponent implements OnInit {
const projectDoc = await this.projectService.get(this.targetProjectId);
this.targetProjectDoc$.next(projectDoc);
if (projectDoc.data != null) {
- await this.analyzeTargetProject(projectDoc.data, paratextProject);
+ await this.analyzeTargetProject(projectDoc.data, paratextProject.isConnected);
}
+
+ try {
+ await this.analyzeBooksForOverwrite();
+ } catch (analysisError) {
+ console.error('Failed to analyze books for overwrite', analysisError);
+ }
+
+ this.isConnecting = false;
+ this.stepper?.next();
} else {
// Create SF project for this Paratext project
- this.targetProjectId = await this.projectService.onlineCreateResourceProject(paratextId);
+ this.targetProjectId = await this.projectService.onlineCreate({
+ paratextId: paratextId,
+ sourceParatextId: null,
+ checkingEnabled: false
+ });
+
+ // updateSyncStatus() will handle the sync finishing and move to the next step after "connecting"
+ this.stepper?.next();
if (this.targetProjectId != null) {
const projectDoc = await this.projectService.get(this.targetProjectId);
this.targetProjectDoc$.next(projectDoc);
- if (projectDoc.data != null) {
- await this.analyzeTargetProject(projectDoc.data, paratextProject);
- }
}
}
-
- try {
- await this.analyzeBooksForOverwrite();
- } catch (analysisError) {
- console.error('Failed to analyze books for overwrite', analysisError);
- }
-
- this.isConnecting = false;
- this.stepper?.next();
- this.needsConnection = false;
} catch (error) {
this.connectionError =
error instanceof Error && error.message.length > 0
@@ -192,6 +195,36 @@ export class DraftImportWizardComponent implements OnInit {
void this.connectToProject(true);
}
+ updateConnectStatus(inProgress: boolean): void {
+ if (!inProgress) {
+ const projectDoc = this.targetProjectDoc$.value;
+ if (projectDoc?.data == null) {
+ this.isConnecting = false;
+ this.stepper?.next();
+ return;
+ }
+
+ void this.analyzeTargetProject(projectDoc.data, false)
+ .then(async () => {
+ try {
+ return await this.analyzeBooksForOverwrite();
+ } catch (analysisError) {
+ console.error('Failed to analyze books for overwrite', analysisError);
+ }
+ })
+ .finally(() => {
+ this.isConnecting = false;
+ this.stepper?.next();
+ // TODO: Why is this step not advancing?
+ // TODO: Why is the book step shown as ticked?
+ });
+ }
+ }
+
+ updateSyncStatus(inProgress: boolean): void {
+ if (!inProgress) this.syncComplete = true;
+ }
+
onStepSelectionChange(event: StepperSelectionEvent): void {
if (event.selectedStep === this.importStep && !this.importStepTriggered) {
if (this.noDraftsAvailable) {
@@ -340,11 +373,10 @@ export class DraftImportWizardComponent implements OnInit {
this.noDraftsAvailable = this.availableBooksForImport.length === 0;
return;
}
-
- await this.applySourceProject(this.sourceProjectId);
}
- private async applySourceProject(projectId: string): Promise {
+ async applyDraftToProject(projectId: string): Promise {
+ // TODO: Use this function!
// Build a scripture range and timestamp to import
const scriptureRange = this.availableBooksForImport.map(b => b.bookId).join(';');
const timestamp: Date =
@@ -401,28 +433,33 @@ export class DraftImportWizardComponent implements OnInit {
try {
const projectDoc = await this.projectService.getProfile(this.targetProjectId);
if (projectDoc.data != null) {
- await this.analyzeTargetProject(projectDoc.data, paratextProject);
+ await this.analyzeTargetProject(projectDoc.data, paratextProject.isConnected);
}
} finally {
this.isLoadingProject = false;
}
} else {
// Need to create SF project - this will happen after connection step
- this.targetProjectId = undefined;
- this.needsConnection = true;
- this.canEditProject = paratextProject.isConnectable;
- this.targetProject$.next(undefined);
- this.targetProjectDoc$.next(undefined);
- await this.validateProject();
+ this.isLoadingProject = true;
+ try {
+ this.targetProjectId = undefined;
+ this.needsConnection = true;
+ this.canEditProject = paratextProject.isConnectable;
+ this.targetProject$.next(undefined);
+ this.targetProjectDoc$.next(undefined);
+ await this.validateProject();
+ } finally {
+ this.isLoadingProject = false;
+ }
}
}
- private async analyzeTargetProject(project: SFProjectProfile, paratextProject: ParatextProject): Promise {
+ private async analyzeTargetProject(project: SFProjectProfile, isConnected: boolean): Promise {
// Check permissions for all books
this.canEditProject = this.textDocService.userHasGeneralEditRight(project);
// Connection status comes from ParatextProject
- this.needsConnection = !paratextProject.isConnected && this.canEditProject;
+ this.needsConnection = !isConnected && this.canEditProject;
if (this.canEditProject && this.targetProjectId != null) {
this.targetProject$.next(project);
@@ -685,9 +722,7 @@ export class DraftImportWizardComponent implements OnInit {
this.syncError = undefined;
try {
- // TODO: Register for SignalR updates for sync
await this.projectService.onlineSync(this.targetProjectId);
- this.syncComplete = true;
this.stepper?.next();
} catch (error) {
this.syncError = error instanceof Error ? error.message : 'Sync failed';
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 66eb23448c..1c274ac812 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
@@ -324,17 +324,18 @@
"confirm_overwrite": "Confirm overwrite",
"connect_project_description": "The project you selected, {{ projectShortName }} - {{ projectName }}, 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",
- "connecting_project": "Connecting project",
+ "connecting_to_project": "Connecting to project",
"connecting": "Connecting",
"connection_failed": "Connection failed",
- "continue": "Continue",
"create_missing_chapters": "Create missing chapters in the project",
"done": "Done",
"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.",
"i_understand_overwrite_content": "I understand that existing content will be overwritten",
+ "import": "Import",
"import_and_sync_complete": "Import and sync complete! Your draft has been added to the project.",
"import_complete_no_sync": "Import complete! Your draft has been added to the project.",
"import_complete": "Import complete",
From 8fcf700053ffe32509d7695a76816b7207e025d1 Mon Sep 17 00:00:00 2001
From: Peter Chapman
Date: Tue, 16 Dec 2025 10:56:36 +1300
Subject: [PATCH 07/40] SF-3633 Frontend work-in-progress part 5
---
.../draft-import-wizard.component.html | 27 +++++++++++++++++--
.../draft-import-wizard.component.ts | 6 +----
.../src/assets/i18n/non_checking_en.json | 3 ++-
3 files changed, 28 insertions(+), 8 deletions(-)
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
index 35a526a40a..7dc488ae8b 100644
--- 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
@@ -91,7 +91,7 @@ {{ t("select_project") }}
@if (needsConnection) {
{{ t("connect_project") }}
@@ -109,6 +109,10 @@ {{ t("connect_to_project") }}
}
+ @if (connectionError != null) {
+ {{ connectionError }}
+ }
+
chevron_{{ i18n.backwardDirectionWord }}
@@ -118,7 +122,7 @@ {{ t("connect_to_project") }}
mat-flat-button
color="primary"
(click)="connectToProject()"
- [disabled]="isConnecting || isImporting"
+ [disabled]="isConnecting || isImporting || connectionError != null"
>
{{ t("connect") }}
chevron_{{ i18n.forwardDirectionWord }}
@@ -156,6 +160,25 @@ {{ t("connection_failed") }}
{{ t("retry") }}
+ } @else if (!isConnecting) {
+ {{ t("connected_to_project") }}
+ @if (targetProject$ | async; as project) {
+
+ {{
+ t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name })
+ }}
+
+ }
+
+
+ chevron_{{ i18n.backwardDirectionWord }}
+ {{ t("back") }}
+
+
+ {{ t("next") }}
+ chevron_{{ i18n.forwardDirectionWord }}
+
+
}
}
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
index 817ee41bd4..9a1d6ad3d9 100644
--- 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
@@ -174,7 +174,7 @@ export class DraftImportWizardComponent implements OnInit {
checkingEnabled: false
});
- // updateSyncStatus() will handle the sync finishing and move to the next step after "connecting"
+ // updateConnectStatus() will handle the sync finishing and move to the next step after "connecting"
this.stepper?.next();
if (this.targetProjectId != null) {
@@ -200,7 +200,6 @@ export class DraftImportWizardComponent implements OnInit {
const projectDoc = this.targetProjectDoc$.value;
if (projectDoc?.data == null) {
this.isConnecting = false;
- this.stepper?.next();
return;
}
@@ -214,9 +213,6 @@ export class DraftImportWizardComponent implements OnInit {
})
.finally(() => {
this.isConnecting = false;
- this.stepper?.next();
- // TODO: Why is this step not advancing?
- // TODO: Why is the book step shown as ticked?
});
}
}
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 1c274ac812..0b532af509 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
@@ -327,6 +327,7 @@
"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",
@@ -357,7 +358,7 @@
"select_books_to_import": "Select books to import",
"select_books": "Select books",
"select_project": "Select project",
- "select_project_description": "Select the project you want to add the draft to.",
+ "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 the project to save your changes to Paratext.",
"sync_project_question": "Sync project now?",
From 07034abc42b8e076ccb2a08a238ac132bc921d79 Mon Sep 17 00:00:00 2001
From: Peter Chapman
Date: Tue, 6 Jan 2026 14:47:51 +1300
Subject: [PATCH 08/40] SF-3633 Frontend work-in-progress part 6
---
.../app/core/project-notification.service.ts | 4 +
.../draft-import-wizard.component.html | 19 +-
.../draft-import-wizard.component.ts | 159 ++++++---
.../draft-preview-books.component.spec.ts | 331 +-----------------
4 files changed, 133 insertions(+), 380 deletions(-)
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 bfaaf77e22..97f231bddf 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
@@ -40,6 +40,10 @@ export class ProjectNotificationService {
this.connection.off('notifySyncProgress', handler);
}
+ setNotifyDraftApplyProgressHandler(handler: any): void {
+ this.connection.on('notifyDraftApplyProgress', handler);
+ }
+
setNotifyBuildProgressHandler(handler: any): void {
this.connection.on('notifyBuildProgress', handler);
}
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
index 7dc488ae8b..570a9b94df 100644
--- 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
@@ -53,7 +53,7 @@ {{ t("select_project") }}
{{ t("no_books_ready_for_import") }}
}
- @if (projectSelectionForm.valid && (targetProject$ | async); as project) {
+ @if (projectSelectionForm.valid && !isLoadingProject && (targetProject$ | async); as project) {
{{ t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) }}
@@ -77,6 +77,7 @@ {{ t("select_project") }}
!projectSelectionForm.valid ||
isConnecting ||
isImporting ||
+ isLoadingProject ||
noDraftsAvailable ||
projectLoadingFailed ||
booksMissingWithoutPermission
@@ -278,7 +279,11 @@ {{ t("overwrite_books_question") }}
}
-
+
{{ t("importing") }}
{{ t("importing_draft") }}
@@ -289,10 +294,10 @@ {{ t("importing_draft") }}
{{ progress.bookName }}
- {{ progress.completedChapters }} / {{ progress.totalChapters }} {{ t("chapters") }}
+ {{ progress.completedChapters.length }} / {{ progress.totalChapters }} {{ t("chapters") }}
@if (progress.failedChapters.length > 0 && !isImporting) {
@@ -306,7 +311,11 @@
{{ t("importing_draft") }}
@if (importError != null) {
{{ importError }}
-