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 @@

+ @if (formattingOptionsSupported && isLatestBuild) { +
+ } + + @if (!isAppOnline) { + {{ t("connect_to_the_internet") }} + } + + @if (noDraftsAvailable) { + {{ t("no_books_ready_for_import") }} + } + + @if (projectSelectionForm.valid && (targetProject$ | async); as project) { + + {{ t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) }} + + + @if (projectHasMissingChapters) { + + {{ t("create_missing_chapters") }} + + } + } + + @if (bookCreationError != null) { + {{ bookCreationError }} + } + + @if (isEnsuringBooks) { + {{ t("creating_missing_books") }} + + } + + +
+ + +
+ + + + @if (needsConnection) { + + {{ t("connect_project") }} + +

{{ t("connect_project") }}

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

+ {{ + t("connect_project_description", { + projectShortName: selectedParatextProject.shortName, + projectName: selectedParatextProject.name + }) + }} +

+ } + +
+ + +
+
+ + + + {{ t("connecting") }} + + @if (isConnecting) { +

{{ t("connecting_project") }}

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

{{ t("setting_up_project") }}

+ + } + } + + @if (connectionError != null) { +

{{ t("connection_failed") }}

+ {{ connectionError }} +
+ + +
+ } +
+ } + + + @if (showBookSelection) { + + {{ t("select_books") }} + +

{{ t("select_books_to_import") }}

+ + +
+ + +
+
+ } + + + @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") }} + + + } + +
+ + {{ t("i_understand_overwrite_content") }} + +
+ +
+ + +
+
+ } + + + + {{ 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 }} +
+ +
+ } + + @if (importComplete) { + {{ t("import_complete") }} +
+ +
+ } +
+ + + + {{ t("complete") }} + + @if (!isSyncing && !syncComplete && !skipSync) { +

{{ t("sync_project_question") }}

+

{{ t("sync_project_description") }}

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

{{ t("syncing_project") }}

+ @if (targetProjectDoc$ | async; as projectDoc) { + + } + } + + @if (syncError != null) { + {{ syncError }} +
+ + +
+ } + + @if (syncComplete || skipSync) { + + {{ skipSync ? t("import_complete_no_sync") : t("import_and_sync_complete") }} + +
+ +
+ } +
+ + + 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 - - - - - - + {{ "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") }} - - }
@@ -80,8 +79,7 @@

{{ t("select_project") }}

isImporting || noDraftsAvailable || projectLoadingFailed || - booksMissingWithoutPermission || - isEnsuringBooks + booksMissingWithoutPermission " > {{ t("next") }} 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 988d4d8fdc..fce8e9dd2d 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 @@ -19,12 +19,10 @@ import { 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'; @@ -104,6 +102,7 @@ export class DraftImportWizardComponent implements OnInit { targetParatextId: new FormControl(undefined, Validators.required) }); projects: ParatextProject[] = []; + isLoadingProject = false; isLoadingProjects = true; targetProjectId?: string; selectedParatextProject?: ParatextProject; @@ -113,7 +112,6 @@ export class DraftImportWizardComponent implements OnInit { booksMissingWithoutPermission = false; missingBookNames: string[] = []; bookCreationError?: string; - isEnsuringBooks = false; projectLoadingFailed = false; private sourceProjectId?: string; noDraftsAvailable = false; @@ -238,7 +236,6 @@ export class DraftImportWizardComponent implements OnInit { private readonly projectService: SFProjectService, private readonly textDocService: TextDocService, readonly i18n: I18nService, - private readonly userService: UserService, private readonly onlineStatusService: OnlineStatusService, private readonly activatedProjectService: ActivatedProjectService ) {} @@ -339,52 +336,30 @@ export class DraftImportWizardComponent implements OnInit { 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; - } + await this.applySourceProject(this.sourceProjectId); } - 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 async applySourceProject(projectId: string): Promise { + // Build a scripture range and timestamp to import + const scriptureRange = this.availableBooksForImport.map(b => b.bookId).join(';'); + const timestamp: Date = + this.data.additionalInfo?.dateGenerated != null ? new Date(this.data.additionalInfo.dateGenerated) : new Date(); - 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); + // Apply the pre-translation draft to the project + await this.projectService.onlineApplyPreTranslationToProject( + this.activatedProjectService.projectId!, + scriptureRange, + projectId, + timestamp + ); + + // TODO: Subscribe to SignalR for import status/updates + this.resetImportState(); } async projectSelected(paratextId: string): Promise { @@ -422,9 +397,14 @@ export class DraftImportWizardComponent implements OnInit { 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); + this.isLoadingProject = true; + try { + const projectDoc = await this.projectService.getProfile(this.targetProjectId); + if (projectDoc.data != null) { + await this.analyzeTargetProject(projectDoc.data, paratextProject); + } + } finally { + this.isLoadingProject = false; } } else { // Need to create SF project - this will happen after connection step @@ -439,29 +419,10 @@ export class DraftImportWizardComponent implements OnInit { 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; + this.canEditProject = this.textDocService.userHasGeneralEditRight(project); // Connection status comes from ParatextProject - this.needsConnection = !paratextProject.isConnected && hasWritePermissionForSelection; + this.needsConnection = !paratextProject.isConnected && this.canEditProject; if (this.canEditProject && this.targetProjectId != null) { this.targetProject$.next(project); @@ -518,31 +479,7 @@ export class DraftImportWizardComponent implements OnInit { this.booksMissingWithoutPermission = false; this.bookCreationError = undefined; - this.isEnsuringBooks = true; - - try { - for (const book of missingBooks) { - if (book.chapters.length === 0) { - 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); - 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; - } + return true; } private resetProjectValidation(): void { @@ -674,7 +611,7 @@ export class DraftImportWizardComponent implements OnInit { this.importError = undefined; this.importComplete = false; - const booksToImport = this.getBooksToImport().filter(book => book.selected && book.chapters.length > 0); + const booksToImport = this.getBooksToImport().filter(book => book.selected); if (booksToImport.length === 0) { this.isImporting = false; @@ -748,6 +685,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(); 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 80ff23f351..66eb23448c 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 @@ -322,7 +322,6 @@ "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", From 72fd59fd1d07f52f2d7deb1747b9ee3c6541df63 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 15 Dec 2025 13:37:01 +1300 Subject: [PATCH 06/40] SF-3633 Frontend work-in-progress part 4 --- .../app/core/project-notification.service.ts | 11 ++- .../draft-import-wizard.component.html | 18 ++-- .../draft-import-wizard.component.ts | 93 +++++++++++++------ .../src/assets/i18n/non_checking_en.json | 5 +- 4 files changed, 89 insertions(+), 38 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 eb54a551a7..bfaaf77e22 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/project-notification.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/project-notification.service.ts @@ -1,5 +1,11 @@ import { Injectable } from '@angular/core'; -import { AbortError, HubConnection, HubConnectionBuilder, IHttpConnectionOptions } from '@microsoft/signalr'; +import { + AbortError, + HubConnection, + HubConnectionBuilder, + HubConnectionState, + IHttpConnectionOptions +} from '@microsoft/signalr'; import { AuthService } from 'xforge-common/auth.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -43,6 +49,9 @@ export class ProjectNotificationService { } async start(): Promise { + if (this.connection.state !== HubConnectionState.Disconnected) { + await this.connection.stop(); + } await this.connection.start().catch(err => { // Suppress AbortErrors, as they are not caused by server error, but the SignalR connection state // These will be thrown if a user navigates away quickly after 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 0db1f9ffef..35a526a40a 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 @@ -96,7 +96,7 @@

{{ t("select_project") }}

> {{ t("connect_project") }} -

{{ t("connect_project") }}

+

{{ t("connect_to_project") }}

@if (selectedParatextProject != null) {

@@ -131,9 +131,12 @@

{{ t("connect_project") }}

{{ t("connecting") }} @if (isConnecting) { -

{{ t("connecting_project") }}

+

{{ t("connecting_to_project") }}

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

{{ t("setting_up_project") }}

@@ -182,7 +185,7 @@

{{ t("select_books_to_import") }}

matStepperNext [disabled]="selectedBooks.length === 0 || isConnecting || isImporting || noDraftsAvailable" > - {{ t("next") }} + {{ showOverwriteConfirmation ? t("next") : t("import") }} chevron_{{ i18n.forwardDirectionWord }}
@@ -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 }} + } +
+ } @else if (!isConnecting) { +

{{ t("connected_to_project") }}

+ @if (targetProject$ | async; as project) { + + {{ + t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) + }} + + } +
+ + +
}
} 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 }} -
+
+ - } @else { - - } + diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.ts index e9a9fe7a10..45b4a0c432 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-download-button/draft-download-button.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { MatButton } from '@angular/material/button'; +import { MatButton, MatButtonAppearance } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { TranslocoModule } from '@ngneat/transloco'; @@ -26,7 +26,7 @@ export class DraftDownloadButtonComponent { zipSubscription?: Subscription; @Input() build: BuildDto | undefined; - @Input() flat: boolean = false; + @Input() matButton: MatButtonAppearance = 'text'; constructor( private readonly activatedProject: ActivatedProjectService, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-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 77a3334f8a..20fef98d8f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.html @@ -26,7 +26,7 @@ {{ t("select_formatting_options") }}

- - + @if (formattingOptionsSupported && isLatestBuild) { - } From 94bed0bc82add7ba277b58abf23063422354a372 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 12 Jan 2026 12:44:54 +1300 Subject: [PATCH 11/40] SF-3633 Import dialog fixes --- .../draft-history-entry.component.ts | 2 +- .../draft-import-wizard.component.html | 706 +++++++++--------- .../draft-import-wizard.component.scss | 3 +- .../draft-import-wizard.component.ts | 33 +- .../src/assets/i18n/non_checking_en.json | 15 +- 5 files changed, 408 insertions(+), 351 deletions(-) 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 13afa6c0b1..b3a757279d 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 @@ -366,7 +366,7 @@ export class DraftHistoryEntryComponent { data: this._entry, width: '800px', maxWidth: '90vw', - disableClose: true + disableClose: false }); } } 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 570a9b94df..f08563d130 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 @@ -1,392 +1,426 @@ - -
- - - {{ index + 1 }} + + + + {{ index + 1 }} + + + + {{ t("select_project") }} + +

{{ t("select_project") }}

+

{{ t("select_project_description") }}

+ + @if (isLoadingProjects) { + + } - - - {{ t("select_project") }} +
+ @if (!isLoadingProjects && !projectLoadingFailed) { + + + @if (isLoadingProject) { + + } + } -

{{ t("select_project") }}

-

{{ t("select_project_description") }}

+ @if (projectLoadingFailed) { + {{ t("failed_to_load_projects") }} +
+ +
+ } - @if (isLoadingProjects) { - + @if (!isAppOnline) { + {{ t("connect_to_the_internet") }} } - - @if (!isLoadingProjects && !projectLoadingFailed) { - - - @if (isLoadingProject) { - - } - } + @if (noDraftsAvailable) { + {{ t("no_books_ready_for_import") }} + } - @if (projectLoadingFailed) { - {{ t("failed_to_load_projects") }} -
- -
- } + @if (projectSelectionForm.valid && !isLoadingProject && (targetProject$ | async); as project) { + + {{ t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) }} + + } - @if (!isAppOnline) { - {{ t("connect_to_the_internet") }} - } + @if (bookCreationError != null) { + {{ bookCreationError }} + } +
+ +
+ + +
+
+ + + @if (needsConnection) { + + {{ t("connect_project") }} - @if (noDraftsAvailable) { - {{ t("no_books_ready_for_import") }} - } +

{{ t("connect_to_project") }}

- @if (projectSelectionForm.valid && !isLoadingProject && (targetProject$ | async); as project) { - - {{ t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) }} - - } + @if (selectedParatextProject != null) { +

+ } - @if (bookCreationError != null) { - {{ bookCreationError }} - } - + @if (connectionError != null) { + {{ connectionError }} + }
-
- - @if (needsConnection) { - - {{ t("connect_project") }} - -

{{ t("connect_to_project") }}

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

- {{ - t("connect_project_description", { - projectShortName: selectedParatextProject.shortName, - projectName: selectedParatextProject.name - }) - }} -

- } - - @if (connectionError != null) { - {{ connectionError }} - } - -
- - -
-
- - - - {{ t("connecting") }} - - @if (isConnecting) { -

{{ t("connecting_to_project") }}

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

{{ t("setting_up_project") }}

- - } - } + + + {{ t("connecting") }} - @if (connectionError != null) { -

{{ t("connection_failed") }}

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

{{ t("connected_to_project") }}

- @if (targetProject$ | async; as project) { - - {{ - t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) - }} - - } -
- - -
+ @if (isConnecting) { +

{{ t("connecting_to_project") }}

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

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

+ } -
- } - - - @if (showBookSelection) { - - {{ t("select_books") }} - -

{{ t("select_books_to_import") }}

- + } + @if (connectionError != null) { +

{{ t("connection_failed") }}

+ {{ connectionError }}
- -
-
- } - - - @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") }} + } @else if (!isConnecting) { +

{{ t("connected_to_project") }}

+ @if (targetProject$ | async; as project) { + + {{ t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) }} - } - -
- - {{ t("i_understand_overwrite_content") }} - -
-
- -
-
- } + } +
+ } + + + @if (showBookSelection) { + + {{ t("select_books") }} + +

{{ t("select_books_to_import") }}

+ + +
+ + +
+
+ } - + + @if (showOverwriteConfirmation) { - {{ t("importing") }} - -

{{ t("importing_draft") }}

- - @if (isImporting || importComplete || importError != null) { - @for (progress of importProgress; track progress.bookNum) { -
-

{{ progress.bookName }}

- - - {{ progress.completedChapters.length }} / {{ progress.totalChapters }} {{ t("chapters") }} - - @if (progress.failedChapters.length > 0 && !isImporting) { -
- error - Failed: {{ getFailedChapters(progress) }} -
- } -
- } + {{ t("confirm_overwrite") }} + + @if (booksWithExistingText.length === 1) { + +

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

+

+ } @else { + +

{{ t("overwrite_books_question") }}

+

+
    + @for (book of booksWithExistingText; track book.bookNum) { +
  • {{ book.bookName }}: {{ t("num_chapters", { count: book.chaptersWithText.length }) }}
  • + } +
} - @if (importError != null) { - {{ importError }} -
- - -
+ @if (isConnecting || isImporting) { + + {{ isConnecting ? t("waiting_for_connection_to_finish") : t("waiting_for_import_to_finish") }} + + } - @if (importComplete) { - {{ t("import_complete") }} -
- +
+ + {{ t("i_understand_overwrite_content") }} + +
+ +
+ + +
+ + } + + + + {{ t("importing") }} + +

{{ t("importing_draft") }}

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

{{ progress.bookName }}

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

{{ t("sync_project_question") }}

+

- @if (!isSyncing && !syncComplete && !skipSync) { -

{{ t("sync_project_question") }}

-

{{ t("sync_project_description") }}

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

{{ t("syncing_project") }}

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

{{ t("syncing_project") }}

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

{{ t("sync") }}

+ {{ syncError }} +
+ + +
+ } - @if (syncError != null) { - {{ syncError }} -
- - -
+ @if (syncComplete || skipSync) { +

{{ t("complete") }}

+ @if (skipSync) { +

+ } @else { +

+

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

} - @if (syncComplete || skipSync) { - - {{ skipSync ? t("import_complete_no_sync") : t("import_and_sync_complete") }} - -
- -
- } -
- -
- +
+ +
+ } +
+
+
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 index 894c1dc0f6..b58a465fef 100644 --- 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 @@ -78,7 +78,8 @@ app-sync-progress { ::ng-deep { mat-dialog-content { - min-height: 400px; + /* Use the same text color as the main application body, not the lighter dialog text color */ + color: var(--mat-sidenav-content-text-color, var(--mat-sys-on-background)) !important; } /* Hide the wizard headings at the top of the dialog */ 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 d48494b6b2..083a905ec9 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 @@ -134,8 +134,16 @@ export class DraftImportWizardComponent implements OnInit { noDraftsAvailable = false; // Step 2-3: Project connection (conditional) + private _isConnecting = false; + public get isConnecting(): boolean { + return this._isConnecting; + } + public set isConnecting(value: boolean) { + this.dialogRef.disableClose = value; + this._isConnecting = value; + } + needsConnection = false; - isConnecting = false; connectionError?: string; async connectToProject(skipStepperAdvance: boolean = false): Promise { @@ -235,7 +243,10 @@ export class DraftImportWizardComponent implements OnInit { } updateSyncStatus(inProgress: boolean): void { - if (!inProgress) this.syncComplete = true; + if (!inProgress && this.isSyncing) { + this.isSyncing = false; + this.syncComplete = true; + } } onStepSelectionChange(event: StepperSelectionEvent): void { @@ -260,7 +271,15 @@ export class DraftImportWizardComponent implements OnInit { booksWithExistingText: { bookNum: number; bookName: string; chaptersWithText: number[] }[] = []; // Step 6: Import progress - isImporting = false; + private _isImporting = false; + public get isImporting(): boolean { + return this._isImporting; + } + public set isImporting(value: boolean) { + this.dialogRef.disableClose = value; + this._isImporting = value; + } + importProgress: ImportProgress[] = []; importError?: string; importComplete = false; @@ -440,6 +459,9 @@ export class DraftImportWizardComponent implements OnInit { if (projectDoc.data != null) { await this.analyzeTargetProject(projectDoc.data, paratextProject.isConnected); } + + // Analyze books for overwrite confirmation + await this.analyzeBooksForOverwrite(); } finally { this.isLoadingProject = false; } @@ -775,15 +797,14 @@ export class DraftImportWizardComponent implements OnInit { private async performSync(): Promise { if (this.targetProjectId == null) return; - this.isSyncing = true; this.syncError = undefined; try { - await this.projectService.onlineSync(this.targetProjectId); this.stepper?.next(); + await this.projectService.onlineSync(this.targetProjectId); + this.isSyncing = true; } catch (error) { this.syncError = error instanceof Error ? error.message : 'Sync failed'; - } finally { this.isSyncing = false; } } 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 0b532af509..9b8ec2809e 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 @@ -322,7 +322,7 @@ "choose_project": "Choose a project", "complete": "Complete", "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_description": "The selected project {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} has not yet been connected in Scripture Forge. Do you want to connect it so you can import the draft to it?", "connect_project": "Connect project", "connect_to_project": "Connect to project", "connect_to_the_internet": "Connect to the internet to import drafts", @@ -337,8 +337,9 @@ "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_and_sync_complete": "The draft has been imported to {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} with Paratext and synced with Paratext.", + "import_and_sync_complete_reminder": "Please remember to do a Send/Receive in Paratext to see the draft.", + "import_complete_no_sync": "Make sure to sync {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} in Scripture Forge, then do a Send/Receive in Paratext to make the draft available in Paratext.", "import_complete": "Import complete", "importing_draft": "Importing draft", "importing": "Importing", @@ -346,21 +347,21 @@ "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)", "num_chapters": "{{ count }} chapters", - "overwrite_book_description": "{{ bookName }} has {{ numChapters }} chapters with existing text that will be overwritten.", + "overwrite_book_description": "{{ bookName }} in {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} has {{ numChapters }} chapters with existing text that will be overwritten.", "overwrite_book_question": "Overwrite {{ bookName }}?", - "overwrite_books_description": "The following books have chapters with existing text that will be overwritten:", + "overwrite_books_description": "The following books in {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} have chapters with existing text that will be overwritten:", "overwrite_books_question": "Overwrite books?", "please_select_valid_project": "Please select a valid project", "project_context_unavailable": "Project context unavailable. Please close the wizard and try again.", "ready_to_import": "Ready to import draft to “{{ projectShortName }} - {{ projectName }}”", "retry": "Retry", - "setting_up_project": "Setting up the project...", + "setting_up_project": "Setting up {{ projectName }}...", "select_books_to_import": "Select books to import", "select_books": "Select books", "select_project": "Select project", "select_project_description": "Please select the project you want to add the draft to.", "skip_sync": "Skip sync", - "sync_project_description": "It is recommended to sync the project to save your changes to Paratext.", + "sync_project_description": "It is recommended to sync {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} to save your changes to Paratext.", "sync_project_question": "Sync project now?", "sync": "Sync", "syncing_project": "Syncing project", From dc0bffe3bc464edb1e8618199464fa929a9d0016 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Wed, 14 Jan 2026 12:07:06 +1300 Subject: [PATCH 12/40] SF-3633 Allow clicking next before the project is selected --- .../draft-import-wizard.component.html | 21 ++++++------------ .../draft-import-wizard.component.ts | 22 ++++++++++++++----- .../src/assets/i18n/non_checking_en.json | 3 ++- 3 files changed, 26 insertions(+), 20 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 f08563d130..fdf6e73c82 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 @@ -61,6 +61,12 @@

{{ t("select_project") }}

@if (bookCreationError != null) { {{ bookCreationError }} } + + @if (cannotAdvanceFromProjectSelection && !projectReadyToImport) { + {{ + !projectSelectionForm.valid ? t("select_project_description") : t("waiting_for_project_to_load") + }} + }
@@ -68,20 +74,7 @@

{{ t("select_project") }}

close {{ t("cancel") }} - 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 083a905ec9..f10156774d 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 @@ -132,6 +132,7 @@ export class DraftImportWizardComponent implements OnInit { projectLoadingFailed = false; private sourceProjectId?: string; noDraftsAvailable = false; + cannotAdvanceFromProjectSelection = false; // Step 2-3: Project connection (conditional) private _isConnecting = false; @@ -587,13 +588,24 @@ export class DraftImportWizardComponent implements OnInit { void this.validateProject(); } - async advanceFromProjectSelection(): Promise { - if (!this.projectSelectionForm.valid) { - return; - } + get projectReadyToImport(): boolean { + return ( + this.projectSelectionForm.valid && + !this.isConnecting && + !this.isImporting && + !this.isLoadingProject && + !this.noDraftsAvailable && + !this.projectLoadingFailed && + !this.booksMissingWithoutPermission + ); + } - if (this.noDraftsAvailable) { + async advanceFromProjectSelection(): Promise { + if (!this.projectReadyToImport) { + this.cannotAdvanceFromProjectSelection = true; return; + } else { + this.cannotAdvanceFromProjectSelection = false; } // If project needs connection, advance to connection step (targetProjectId may be undefined) 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 9b8ec2809e..62e7700492 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 @@ -366,7 +366,8 @@ "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." + "waiting_for_import_to_finish": "Please wait until the current import finishes.", + "waiting_for_project_to_load": "Please wait for the project to load before proceeding." }, "draft_preview_books": { "add_to_project": "Add to project", From fa1219963cfd70d9ea03238f77afd40d9d05e095 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Wed, 14 Jan 2026 14:59:52 +1300 Subject: [PATCH 13/40] SF-3633 Work on the storybook, and fixes found when using it --- .../draft-import-wizard-component.spec.ts | 67 ++++++++ .../draft-import-wizard.component.html | 9 +- .../draft-import-wizard.component.ts | 2 - .../draft-import-wizard.stories.ts | 153 ++++++++++++++++++ 4 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts new file mode 100644 index 0000000000..b1bc3002ce --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts @@ -0,0 +1,67 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { mock } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; +import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; +import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; +import { ParatextService } from '../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../core/project-notification.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { TextDocService } from '../../../core/text-doc.service'; +import { BuildDto } from '../../../machine-api/build-dto'; +import { DraftImportWizardComponent } from './draft-import-wizard.component'; + +const mockI18nService = mock(I18nService); +const mockMatDialogRef = mock(MatDialogRef); +const mockParatextService = mock(ParatextService); +const mockProjectNotificationService = mock(ProjectNotificationService); +const mockProjectService = mock(SFProjectService); +const mockTextDocService = mock(TextDocService); +const mockActivatedProjectService = mock(ActivatedProjectService); + +describe('DraftImportWizardComponent', () => { + let env: TestEnvironment; + const buildDto: BuildDto = { + additionalInfo: { dateFinished: '2026-01-14T15:16:17.18+00:00' } + } as BuildDto; + + configureTestingModule(() => ({ + imports: [getTestTranslocoModule()], + providers: [ + provideTestOnlineStatus(), + { provide: I18nService, useMock: mockI18nService }, + { provide: MatDialogRef, useMock: mockMatDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: buildDto }, + { provide: ParatextService, useMock: mockParatextService }, + { provide: ProjectNotificationService, useMock: mockProjectNotificationService }, + { provide: SFProjectService, useMock: mockProjectService }, + { provide: TextDocService, useMock: mockTextDocService }, + { provide: OnlineStatusService, useClass: TestOnlineStatusService }, + { provide: ActivatedProjectService, useMock: mockActivatedProjectService }, + provideNoopAnimations() + ] + })); + + beforeEach(async () => { + env = new TestEnvironment(); + }); + + it('shows step 1', () => { + expect(env.fixture.nativeElement).not.toBeNull(); + }); +}); + +class TestEnvironment { + component: DraftImportWizardComponent; + fixture: ComponentFixture; + + constructor() { + this.fixture = TestBed.createComponent(DraftImportWizardComponent); + this.component = this.fixture.componentInstance; + this.fixture.detectChanges(); + } +} 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 fdf6e73c82..e44cf5aa0a 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 @@ -58,7 +58,7 @@

{{ t("select_project") }}

} - @if (bookCreationError != null) { + @if (bookCreationError != null && bookCreationError.length > 0) { {{ bookCreationError }} } @@ -74,7 +74,12 @@

{{ t("select_project") }}

close {{ t("cancel") }} - 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 f10156774d..dc55370375 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 @@ -594,8 +594,6 @@ export class DraftImportWizardComponent implements OnInit { !this.isConnecting && !this.isImporting && !this.isLoadingProject && - !this.noDraftsAvailable && - !this.projectLoadingFailed && !this.booksMissingWithoutPermission ); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts new file mode 100644 index 0000000000..e0b23d756f --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts @@ -0,0 +1,153 @@ +import { AfterViewInit, Component, DestroyRef, Input, OnChanges, ViewChild } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { defaultTranslocoMarkupTranspilers } from 'ngx-transloco-markup'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { ParatextService } from '../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../core/project-notification.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { TextDocService } from '../../../core/text-doc.service'; +import { BuildDto } from '../../../machine-api/build-dto'; +import { DraftImportWizardComponent } from './draft-import-wizard.component'; + +const mockDestroyRef = mock(DestroyRef); +const mockI18nService = mock(I18nService); +const mockMatDialogRef = mock(MatDialogRef); +const mockParatextService = mock(ParatextService); +const mockProjectNotificationService = mock(ProjectNotificationService); +const mockProjectService = mock(SFProjectService); +const mockTextDocService = mock(TextDocService); +const mockActivatedProjectService = mock(ActivatedProjectService); +const mockOnlineStatusService = mock(OnlineStatusService); + +@Component({ + selector: 'app-draft-import-wizard-wrapper', + standalone: true, + imports: [DraftImportWizardComponent], + template: `` +}) +class DraftImportWizardWrapperComponent implements AfterViewInit, OnChanges { + @ViewChild(DraftImportWizardComponent) component!: DraftImportWizardComponent; + @Input() online: boolean = false; + @Input() isLoadingProject: boolean = false; + @Input() isLoadingProjects: boolean = false; + @Input() noDraftsAvailable: boolean = false; + @Input() projectLoadingFailed: boolean = false; + @Input() bookCreationError?: string; + @Input() step: number = 0; + + ngAfterViewInit(): void { + this.updateComponent(); + } + + ngOnChanges(): void { + this.updateComponent(); + } + + private updateComponent(): void { + if (!this.component) return; + setTimeout(() => { + this.component.isLoadingProject = this.isLoadingProject; + this.component.isLoadingProjects = this.isLoadingProjects; + this.component.noDraftsAvailable = this.noDraftsAvailable; + this.component.projectLoadingFailed = this.projectLoadingFailed; + this.component.bookCreationError = this.bookCreationError; + if (this.component.stepper && this.component.stepper.selectedIndex !== this.step) { + this.component.stepper.selectedIndex = this.step; + } + }); + } +} + +interface DraftImportWizardComponentState { + online: boolean; + step: number; + isLoadingProject: boolean; + isLoadingProjects: boolean; + noDraftsAvailable: boolean; + projectLoadingFailed: boolean; + bookCreationError?: string; +} + +const defaultArgs: DraftImportWizardComponentState = { + online: true, + step: 0, + isLoadingProject: false, + isLoadingProjects: false, + noDraftsAvailable: false, + projectLoadingFailed: false, + bookCreationError: undefined +}; + +const buildDto: BuildDto = { + additionalInfo: { dateFinished: '2026-01-14T15:16:17.18+00:00' } +} as BuildDto; + +export default { + title: 'Draft/Draft Import Wizard Dialog', + component: DraftImportWizardComponent, + decorators: [ + moduleMetadata({ + providers: [ + { provide: DestroyRef, useValue: instance(mockDestroyRef) }, + { provide: MAT_DIALOG_DATA, useValue: instance(buildDto) }, + { provide: I18nService, useValue: instance(mockI18nService) }, + { provide: MatDialogRef, useValue: instance(mockMatDialogRef) }, + { provide: ParatextService, useValue: instance(mockParatextService) }, + { provide: ProjectNotificationService, useValue: instance(mockProjectNotificationService) }, + { provide: SFProjectService, useValue: instance(mockProjectService) }, + { provide: TextDocService, useValue: instance(mockTextDocService) }, + { provide: OnlineStatusService, useValue: instance(mockOnlineStatusService) }, + { provide: ActivatedProjectService, useValue: instance(mockActivatedProjectService) }, + defaultTranslocoMarkupTranspilers() + ] + }) + ], + render: args => { + setUpMocks(args); + return { + component: DraftImportWizardWrapperComponent, + props: args + }; + }, + args: defaultArgs, + parameters: { + controls: { + include: Object.keys(defaultArgs) + } + }, + argTypes: { + online: { control: 'boolean' }, + isLoadingProject: { control: 'boolean' }, + isLoadingProjects: { control: 'boolean' }, + noDraftsAvailable: { control: 'boolean' }, + projectLoadingFailed: { control: 'boolean' }, + bookCreationError: { control: 'text' }, + step: { control: 'number' } + } +} as Meta; + +type Story = StoryObj; + +const Template: Story = {}; + +export const StepOne: Story = { + ...Template +}; + +function setUpMocks(args: DraftImportWizardComponentState): void { + when(mockOnlineStatusService.onlineStatus$).thenReturn(of(args.online)); + when(mockOnlineStatusService.isOnline).thenReturn(args.online); + when(mockOnlineStatusService.online).thenReturn( + new Promise(resolve => { + if (args.online) resolve(); + // Else, never resolve. + }) + ); + when(mockI18nService.forwardDirectionWord).thenReturn('right'); + when(mockI18nService.backwardDirectionWord).thenReturn('left'); +} From b2e2f72b1f0988d935f35970814dccf2d0004609 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 19 Jan 2026 14:37:43 +1300 Subject: [PATCH 14/40] SF-3633 Adding all of the steps to the storybook --- .../draft-import-wizard.component.html | 12 +- .../draft-import-wizard.component.ts | 19 +- .../draft-import-wizard.stories.ts | 285 +++++++++++++++++- 3 files changed, 293 insertions(+), 23 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 e44cf5aa0a..20e613fb19 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 @@ -107,7 +107,7 @@

{{ t("connect_to_project") }}

>

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

{{ t("connect_to_project") }}

matButton="filled" color="primary" (click)="connectToProject()" - [disabled]="isConnecting || isImporting || connectionError != null" + [disabled]="isConnecting || isImporting || (connectionError != null && connectionError.length > 0)" > {{ t("connect") }} chevron_{{ i18n.forwardDirectionWord }} @@ -145,7 +145,7 @@

{{ t("connecting_to_project") }}

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

{{ t("connection_failed") }}

{{ connectionError }}
@@ -292,7 +292,7 @@

{{ t("overwrite_books_question") }}

{{ t("importing_draft") }}

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

{{ progress.bookName }}

@@ -303,7 +303,7 @@

{{ t("importing_draft") }}

{{ progress.completedChapters.length }} / {{ progress.totalChapters }} {{ t("chapters") }} - @if (progress.failedChapters.length > 0 && !isImporting) { + @if (progress.failedChapters.length > 0) {
error Failed: {{ getFailedChapters(progress) }} @@ -313,7 +313,7 @@

{{ t("importing_draft") }}

} } - @if (importError != null) { + @if (importError != null && importError.length > 0) { {{ importError }}
- @@ -202,6 +210,7 @@

{{ t("select_books_to_import") }}

matButton="filled" color="primary" matStepperNext + data-test-id="step-4-next" [disabled]="selectedBooks.length === 0 || isConnecting || isImporting || noDraftsAvailable" > {{ showOverwriteConfirmation ? t("next") : t("import") }} @@ -273,6 +282,7 @@

{{ t("overwrite_books_question") }}

matButton="filled" color="primary" matStepperNext + data-test-id="step-5-next" [disabled]="!overwriteForm.valid || isConnecting || isImporting" > {{ t("import") }} @@ -330,7 +340,13 @@

{{ t("importing_draft") }}

@if (importComplete) { {{ t("import_complete") }}
- @@ -354,10 +370,21 @@

{{ t("sync_project_question") }}

>

- - @@ -414,7 +441,13 @@

{{ t("complete") }}

}
-
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 14a81b8c2b..0b5b2364c8 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 @@ -70,7 +70,7 @@ export interface ImportProgress { failedChapters: { chapterNum: number; message?: string }[]; } -interface DraftApplyState { +export interface DraftApplyState { bookNum: number; chapterNum: number; totalChapters: number; @@ -78,7 +78,7 @@ interface DraftApplyState { status: DraftApplyStatus; } -enum DraftApplyStatus { +export enum DraftApplyStatus { None = 0, InProgress = 1, Successful = 2, From 4674c0d8d07516f62725dd432824763a62310ab8 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 26 Jan 2026 16:09:52 +1300 Subject: [PATCH 16/40] SF-3633 Fix unit tests and storybook stories --- .../draft-import-wizard-component.spec.ts | 20 +++++++++--------- .../draft-import-wizard.stories.ts | 21 ++++++++++--------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts index 8bd30e758e..f38af60c86 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts @@ -6,7 +6,6 @@ import { By } from '@angular/platform-browser'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; -import { of } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -25,7 +24,7 @@ import { SFProjectService } from '../../../core/sf-project.service'; import { TextDocService } from '../../../core/text-doc.service'; import { BuildDto } from '../../../machine-api/build-dto'; import { ProjectSelectComponent } from '../../../project-select/project-select.component'; -import { ProgressService, TextProgress } from '../../../shared/progress-service/progress.service'; +import { ProgressService, ProjectProgress } from '../../../shared/progress-service/progress.service'; import { DraftApplyState, DraftApplyStatus, DraftImportWizardComponent } from './draft-import-wizard.component'; const mockMatDialogRef = mock(MatDialogRef); @@ -274,14 +273,15 @@ class TestEnvironment { ) }); - when(mockProgressService.isLoaded$).thenReturn(of(true)); - when(mockProgressService.texts).thenReturn([ - { text: { bookNum: 1 } } as TextProgress, - { text: { bookNum: 2 } } as TextProgress, - { text: { bookNum: 3 } } as TextProgress, - { text: { bookNum: 4 } } as TextProgress, - { text: { bookNum: 5 } } as TextProgress - ]); + when(mockProgressService.getProgress(anything(), anything())).thenResolve( + new ProjectProgress([ + { bookId: 'GEN', verseSegments: 100, blankVerseSegments: 0 }, + { bookId: 'EXO', verseSegments: 100, blankVerseSegments: 0 }, + { bookId: 'LEV', verseSegments: 100, blankVerseSegments: 100 }, + { bookId: 'NUM', verseSegments: 22, blankVerseSegments: 2 }, + { bookId: 'DEU', verseSegments: 0, blankVerseSegments: 0 } + ]) + ); when(mockProjectService.getText(anything())).thenResolve({ getNonEmptyVerses: (): string[] => ['verse_1_1'] } as TextDoc); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts index 96bf92c796..0ac10f313d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts @@ -4,7 +4,7 @@ import { Canon } from '@sillsdev/scripture'; import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { defaultTranslocoMarkupTranspilers } from 'ngx-transloco-markup'; import { of } from 'rxjs'; -import { instance, mock, when } from 'ts-mockito'; +import { anything, instance, mock, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { ParatextProject } from '../../../core/models/paratext-project'; @@ -14,7 +14,7 @@ import { ProjectNotificationService } from '../../../core/project-notification.s import { SFProjectService } from '../../../core/sf-project.service'; import { TextDocService } from '../../../core/text-doc.service'; import { BuildDto } from '../../../machine-api/build-dto'; -import { ProgressService, TextProgress } from '../../../shared/progress-service/progress.service'; +import { ProgressService, ProjectProgress } from '../../../shared/progress-service/progress.service'; import { BookForImport, BookWithExistingText, @@ -403,12 +403,13 @@ function setUpMocks(args: DraftImportWizardComponentState): void { // Else, never resolve. }) ); - when(mockProgressService.isLoaded$).thenReturn(of(true)); - when(mockProgressService.texts).thenReturn([ - { text: { bookNum: 1 } } as TextProgress, - { text: { bookNum: 2 } } as TextProgress, - { text: { bookNum: 3 } } as TextProgress, - { text: { bookNum: 4 } } as TextProgress, - { text: { bookNum: 5 } } as TextProgress - ]); + when(mockProgressService.getProgress(anything(), anything())).thenResolve( + new ProjectProgress([ + { bookId: 'GEN', verseSegments: 100, blankVerseSegments: 0 }, + { bookId: 'EXO', verseSegments: 100, blankVerseSegments: 0 }, + { bookId: 'LEV', verseSegments: 100, blankVerseSegments: 100 }, + { bookId: 'NUM', verseSegments: 22, blankVerseSegments: 2 }, + { bookId: 'DEU', verseSegments: 0, blankVerseSegments: 0 } + ]) + ); } From c1e83da8ae5253289b4982c1e894799688525e07 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 26 Jan 2026 16:32:33 +1300 Subject: [PATCH 17/40] SF-3633 Fix issues raised with the draft wizard --- .../draft-history-entry.component.ts | 3 +- .../_draft-import-wizard-theme.scss | 14 ++++++++ .../draft-import-wizard.component.html | 36 ++++--------------- .../draft-import-wizard.component.scss | 9 ++--- .../draft-import-wizard.component.ts | 35 +++++++----------- .../src/assets/i18n/non_checking_en.json | 2 -- .../ClientApp/src/material-styles.scss | 2 ++ 7 files changed, 40 insertions(+), 61 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/_draft-import-wizard-theme.scss 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 b3a757279d..b87d84900c 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 @@ -366,7 +366,8 @@ export class DraftHistoryEntryComponent { data: this._entry, width: '800px', maxWidth: '90vw', - disableClose: false + disableClose: false, + panelClass: 'use-application-text-color' }); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/_draft-import-wizard-theme.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/_draft-import-wizard-theme.scss new file mode 100644 index 0000000000..f63543e028 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/_draft-import-wizard-theme.scss @@ -0,0 +1,14 @@ +@use '@angular/material' as mat; + +@mixin theme($theme) { + .use-application-text-color { + .mat-mdc-dialog-container { + /* Use the same text color as the main application body, not the lighter dialog text color */ + @include mat.dialog-overrides( + ( + supporting-text-color: var(--mat-sys-on-surface) + ) + ); + } + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html index 28c680f29c..c3ffa2d1cd 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 @@ -4,11 +4,7 @@ {{ index + 1 }} - + {{ t("select_project") }}

{{ t("select_project") }}

@@ -89,10 +85,7 @@

{{ t("select_project") }}

@if (needsConnection) { - + {{ t("connect_project") }}

{{ t("connect_to_project") }}

@@ -131,7 +124,7 @@

{{ t("connect_to_project") }}

- + {{ t("connecting") }} @if (isConnecting) { @@ -189,7 +182,7 @@

{{ t("connected_to_project") }}

@if (showBookSelection) { - + {{ t("select_books") }}

{{ t("select_books_to_import") }}

@@ -222,11 +215,7 @@

{{ t("select_books_to_import") }}

@if (showOverwriteConfirmation) { - + {{ t("confirm_overwrite") }} @if (booksWithExistingText.length === 1) { @@ -260,13 +249,6 @@

{{ t("overwrite_books_question") }}

} - @if (isConnecting || isImporting) { - - {{ isConnecting ? t("waiting_for_connection_to_finish") : t("waiting_for_import_to_finish") }} - - - } -
{{ t("i_understand_overwrite_content") }} @@ -293,11 +275,7 @@

{{ t("overwrite_books_question") }}

} - + {{ t("importing") }}

{{ t("importing_draft") }}

@@ -355,7 +333,7 @@

{{ t("importing_draft") }}

- + {{ t("complete") }} @if (!isSyncing && !syncComplete && !skipSync) { 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 index b58a465fef..d734deebb4 100644 --- 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 @@ -33,7 +33,7 @@ .progress-text { font-size: 0.875rem; - color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6)); + color: var(--mat-sys-on-surface-variant); } .failed-chapters { @@ -41,7 +41,7 @@ align-items: center; column-gap: 0.35rem; margin-top: 0.35rem; - color: var(--mdc-theme-error, #b00020); + color: var(--mat-sys-error); .error-icon { font-size: 1.1rem; @@ -77,11 +77,6 @@ app-sync-progress { } ::ng-deep { - mat-dialog-content { - /* Use the same text color as the main application body, not the lighter dialog text color */ - color: var(--mat-sidenav-content-text-color, var(--mat-sys-on-background)) !important; - } - /* Hide the wizard headings at the top of the dialog */ mat-stepper .mat-horizontal-stepper-header-container { display: none; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.ts index 0b5b2364c8..dbefbae243 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 @@ -190,14 +190,10 @@ 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.isConnected); + await this.loadTargetProjectAndValidate(projectDoc.data, paratextProject.isConnected); } - try { - await this.analyzeBooksForOverwrite(); - } catch (analysisError) { - console.error('Failed to analyze books for overwrite', analysisError); - } + await this.determineBooksAndChaptersWithText(); this.isConnecting = false; this.stepper?.next(); @@ -212,10 +208,8 @@ export class DraftImportWizardComponent implements OnInit { // updateConnectStatus() 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); - } + const projectDoc = await this.projectService.get(this.targetProjectId); + this.targetProjectDoc$.next(projectDoc); } } catch (error) { this.connectionError = @@ -238,13 +232,9 @@ export class DraftImportWizardComponent implements OnInit { return; } - void this.analyzeTargetProject(projectDoc.data, false) + void this.loadTargetProjectAndValidate(projectDoc.data, false) .then(async () => { - try { - return await this.analyzeBooksForOverwrite(); - } catch (analysisError) { - console.error('Failed to analyze books for overwrite', analysisError); - } + return await this.determineBooksAndChaptersWithText(); }) .finally(() => { this.isConnecting = false; @@ -467,11 +457,11 @@ export class DraftImportWizardComponent implements OnInit { try { const projectDoc = await this.projectService.getProfile(this.targetProjectId); if (projectDoc.data != null) { - await this.analyzeTargetProject(projectDoc.data, paratextProject.isConnected); + await this.loadTargetProjectAndValidate(projectDoc.data, paratextProject.isConnected); } // Analyze books for overwrite confirmation - await this.analyzeBooksForOverwrite(); + await this.determineBooksAndChaptersWithText(); } finally { this.isLoadingProject = false; } @@ -491,7 +481,7 @@ export class DraftImportWizardComponent implements OnInit { } } - private async analyzeTargetProject(project: SFProjectProfile, isConnected: boolean): Promise { + private async loadTargetProjectAndValidate(project: SFProjectProfile, isConnected: boolean): Promise { // Check permissions for all books this.canEditProject = this.textDocService.userHasGeneralEditRight(project); @@ -565,6 +555,7 @@ export class DraftImportWizardComponent implements OnInit { private async validateProject(): Promise { await new Promise(resolve => { + // setTimeout prevents a "changed after checked" exception (may be removable after SF-3014) setTimeout(() => { this.projectSelect?.customValidate(SFValidators.customValidator(this.getCustomErrorState())); resolve(); @@ -593,7 +584,7 @@ export class DraftImportWizardComponent implements OnInit { this.booksMissingWithoutPermission = false; this.bookCreationError = undefined; this.missingBookNames = []; - void this.analyzeBooksForOverwrite(); + void this.determineBooksAndChaptersWithText(); void this.validateProject(); } @@ -634,13 +625,13 @@ export class DraftImportWizardComponent implements OnInit { } // Analyze books for overwrite confirmation - await this.analyzeBooksForOverwrite(); + await this.determineBooksAndChaptersWithText(); this.stepper?.next(); this.isLoadingProject = false; } - private async analyzeBooksForOverwrite(): Promise { + private async determineBooksAndChaptersWithText(): Promise { if (this.targetProjectId == null) return; this.booksWithExistingText = []; 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 62e7700492..25cb95accf 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 @@ -365,8 +365,6 @@ "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.", "waiting_for_project_to_load": "Please wait for the project to load before proceeding." }, "draft_preview_books": { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss b/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss index 34593d5047..086205ed85 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss @@ -24,6 +24,7 @@ @use 'src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form-theme' as sf-draft-onboarding-form; @use 'src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry-theme' as sf-draft-history-entry; +@use 'src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-theme' as sf-draft-import-wizard; @use 'src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format-theme' as sf-draft-usfm-format; @use 'src/app/translate/draft-generation/training-data/training-data-upload-dialog-theme' as sf-training-data-upload-dialog; @@ -64,6 +65,7 @@ @include sf-confirm-sources.theme($theme); @include sf-draft-generation-steps.theme($theme); @include sf-draft-history-entry.theme($theme); + @include sf-draft-import-wizard.theme($theme); @include sf-draft-sources.theme($theme); @include sf-draft-onboarding-form.theme($theme); @include sf-draft-usfm-format.theme($theme); From 35fb4adffed2c340f3680eaecb074edfa1bc1345 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 27 Jan 2026 14:06:06 +1300 Subject: [PATCH 18/40] SF-3633 Fix issues raised in code review feedback --- .../draft-import-wizard.component.ts | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) 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 dbefbae243..89d8906a6a 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 @@ -135,6 +135,10 @@ export class DraftImportWizardComponent implements OnInit { targetProject$ = new BehaviorSubject(undefined); targetProjectDoc$ = new BehaviorSubject(undefined); canEditProject = true; + /** + * If true, text is to be imported to books in the target project which do not exist, + * and which the user does not have permission to create those books. + */ booksMissingWithoutPermission = false; missingBookNames: string[] = []; bookCreationError?: string; @@ -232,7 +236,7 @@ export class DraftImportWizardComponent implements OnInit { return; } - void this.loadTargetProjectAndValidate(projectDoc.data, false) + void this.loadTargetProjectAndValidate(projectDoc.data, true) .then(async () => { return await this.determineBooksAndChaptersWithText(); }) @@ -352,7 +356,7 @@ export class DraftImportWizardComponent implements OnInit { this.setupInvalidMessageMapper(); void this.loadProjects(); this.initializeAvailableBooks(); - void this.loadSourceProjectContext(); + this.sourceProjectId = this.activatedProjectService.projectId; } private setupInvalidMessageMapper(): void { @@ -410,14 +414,6 @@ export class DraftImportWizardComponent implements OnInit { this.showBookSelection = this.availableBooksForImport.length > 1; } - private async loadSourceProjectContext(): Promise { - this.sourceProjectId = this.activatedProjectService.projectId; - if (this.sourceProjectId == null) { - this.noDraftsAvailable = this.availableBooksForImport.length === 0; - return; - } - } - async projectSelected(paratextId: string): Promise { if (paratextId == null) { this.targetProject$.next(undefined); @@ -509,12 +505,11 @@ export class DraftImportWizardComponent implements OnInit { 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 { + project = profileDoc.data; + if (project == null) { return false; } + this.targetProject$.next(project); } const booksToImport = this.getBooksToImport(); @@ -533,7 +528,7 @@ export class DraftImportWizardComponent implements OnInit { const canCreateBooks = this.textDocService.userHasGeneralEditRight(project); if (!canCreateBooks) { this.booksMissingWithoutPermission = true; - const booksDescription = this.missingBookNames.length > 0 ? ` (${this.missingBookNames.join(', ')})` : ''; + const booksDescription = ` (${this.missingBookNames.join(', ')})`; this.bookCreationError = this.i18n.translateStatic('draft_import_wizard.books_missing_no_permission', { booksDescription }); From 36a427024cae1babed9e4eec92ed3f4a01abef05 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 27 Jan 2026 15:34:51 +1300 Subject: [PATCH 19/40] SF-3633 Pruning unused translation strings --- .../ClientApp/src/assets/i18n/non_checking_en.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 25cb95accf..421c3247fd 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 @@ -313,7 +313,6 @@ "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", @@ -331,7 +330,6 @@ "connecting_to_project": "Connecting to project", "connecting": "Connecting", "connection_failed": "Connection failed", - "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.", @@ -368,11 +366,7 @@ "waiting_for_project_to_load": "Please wait for the project to load before proceeding." }, "draft_preview_books": { - "add_to_project": "Add to project", - "add_to_different_project": "Add to a different project", - "no_books_have_drafts": "No books have any drafts.", - "no_permission_to_edit_book": "You do not have permission to write to this book. Contact your project administrator to get permission.", - "readd_to_project": "Re-add to project" + "no_books_have_drafts": "No books have any drafts." }, "draft_sources": { "add_another_reference_project": "Add another reference project", From a8443db6c50060396a7094e7594596f6498af0a1 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 27 Jan 2026 16:15:16 +1300 Subject: [PATCH 20/40] SF-3633 Fixed chapter plurality bug --- .../draft-import-wizard/draft-import-wizard.component.html | 5 ++++- .../draft-import-wizard/draft-import-wizard.stories.ts | 4 ++-- .../ClientApp/src/assets/i18n/non_checking_en.json | 2 +- 3 files changed, 7 insertions(+), 4 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 c3ffa2d1cd..3f27f7f2a1 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 @@ -244,7 +244,10 @@

{{ t("overwrite_books_question") }}

>

    @for (book of booksWithExistingText; track book.bookNum) { -
  • {{ book.bookName }}: {{ t("num_chapters", { count: book.chaptersWithText.length }) }}
  • +
  • + {{ book.bookName }}: {{ book.chaptersWithText.length }} + {{ book.chaptersWithText.length === 1 ? t("chapter") : t("chapters") }} +
  • }
} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts index 0ac10f313d..628fce12fe 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts @@ -297,7 +297,7 @@ export const StepFive: Story = { step: 5, selectedParatextProject: { shortName: 'P01', name: 'Project 01' } as ParatextProject, booksWithExistingText: [ - getBookWithExistingText(1, 3), + getBookWithExistingText(1, 1), getBookWithExistingText(2, 4), getBookWithExistingText(3, 7), getBookWithExistingText(4, 5), @@ -329,7 +329,7 @@ export const StepSix: Story = { ], // importProgress is used if importStepTriggered is true importProgress: [ - getImportProgress(1, 3, 3, 0), + getImportProgress(1, 1, 1, 0), getImportProgress(2, 4, 3, 1), getImportProgress(3, 7, 3, 0), getImportProgress(4, 5, 0, 0) 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 421c3247fd..a608b988a5 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 @@ -317,6 +317,7 @@ "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", + "chapter": "chapter", "chapters": "chapters", "choose_project": "Choose a project", "complete": "Complete", @@ -344,7 +345,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)", - "num_chapters": "{{ count }} chapters", "overwrite_book_description": "{{ bookName }} in {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} has {{ numChapters }} chapters with existing text that will be overwritten.", "overwrite_book_question": "Overwrite {{ bookName }}?", "overwrite_books_description": "The following books in {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} have chapters with existing text that will be overwritten:", From f95e6aa0a4f3035fbf0a31c337c257ad778903fd Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 2 Feb 2026 11:56:57 +1300 Subject: [PATCH 21/40] SF-3633 Removed the old draft apply dialog and progress --- .../draft-apply-dialog.component.html | 68 --- .../draft-apply-dialog.component.scss | 34 -- .../draft-apply-dialog.component.spec.ts | 403 ------------------ .../draft-apply-dialog.component.ts | 285 ------------- .../draft-apply-dialog.stories.ts | 54 --- ...draft-apply-progress-dialog.component.html | 38 -- ...draft-apply-progress-dialog.component.scss | 61 --- ...ft-apply-progress-dialog.component.spec.ts | 82 ---- .../draft-apply-progress-dialog.component.ts | 59 --- .../draft-generation.component.scss | 4 - .../draft-preview-books.component.spec.ts | 2 - 11 files changed, 1090 deletions(-) delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.scss delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.stories.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.html delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.scss delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.spec.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html deleted file mode 100644 index 796bc38b0c..0000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html +++ /dev/null @@ -1,68 +0,0 @@ -@if (isLoading) { - -} - - -

{{ t("select_alternate_project") }}

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

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

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

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

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

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

- } -
- } -
-
- - @if (draftApplyProgress?.completed === true) { - - } - -
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.scss deleted file mode 100644 index edd68e4c27..0000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.scss +++ /dev/null @@ -1,61 +0,0 @@ -.progress-content { - min-width: 240px; - max-width: 500px; - display: flex; - flex-direction: column; - min-height: 0; - - .progress-container { - width: 100%; - flex-direction: column; - align-items: center; - - mat-progress-bar { - margin-top: 1em; - } - } - - .result-container { - display: flex; - flex-direction: column; - min-height: 0; - } - - .error-result { - display: flex; - flex-direction: column; - min-height: 0; - - .mat-icon { - flex-shrink: 0; - } - - .failed-chapters { - text-indent: 1em; - margin-top: 0.5em; - line-height: 1.2em; - } - - .error-content { - overflow-y: auto; - display: flex; - flex-direction: column; - } - } - - .failed-message { - display: flex; - column-gap: 8px; - align-items: center; - } -} - -.hide-scroll { - overflow: hidden; - display: flex; - flex-direction: column; -} - -.mat-mdc-dialog-actions { - justify-content: flex-end; -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.spec.ts deleted file mode 100644 index a6bad1158a..0000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { BehaviorSubject } from 'rxjs'; -import { anything, mock, when } from 'ts-mockito'; -import { I18nService } from 'xforge-common/i18n.service'; -import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; -import { DraftApplyProgress, DraftApplyProgressDialogComponent } from './draft-apply-progress-dialog.component'; - -const mockI18nService = mock(I18nService); -const mockMatDialogRef = mock(MatDialogRef); - -describe('DraftApplyProgressDialogComponent', () => { - let env: TestEnvironment; - const progress$: BehaviorSubject = new BehaviorSubject({ - bookNum: 1, - completed: false, - chapters: [1, 2, 3], - chaptersApplied: [], - errorMessages: [] - }); - - configureTestingModule(() => ({ - imports: [getTestTranslocoModule()], - providers: [ - { provide: I18nService, useMock: mockI18nService }, - { provide: MatDialogRef, useMock: mockMatDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { draftApplyProgress$: progress$ } } - ] - })); - - beforeEach(async () => { - env = new TestEnvironment(); - }); - - it('shows progress', () => { - progress$.next({ bookNum: 1, chapters: [1, 2], chaptersApplied: [1], completed: false, errorMessages: [] }); - env.fixture.detectChanges(); - expect(env.progressContainer).not.toBeNull(); - expect(env.resultContainer).toBeNull(); - expect(env.component.progress).toBe(50); - }); - - it('shows apply draft completed', () => { - progress$.next({ bookNum: 1, chapters: [1, 2], chaptersApplied: [1, 2], completed: true, errorMessages: [] }); - env.fixture.detectChanges(); - expect(env.progressContainer).toBeNull(); - expect(env.resultContainer).not.toBeNull(); - expect(env.component.failedToApplyChapters).toBeUndefined(); - expect(env.resultContainer.textContent).toContain('Successfully applied all chapters'); - }); - - it('shows chapters that failed to be applied', () => { - progress$.next({ bookNum: 1, chapters: [1, 2], chaptersApplied: [1], completed: true, errorMessages: ['error'] }); - env.fixture.detectChanges(); - expect(env.progressContainer).toBeNull(); - expect(env.resultContainer).not.toBeNull(); - expect(env.component.failedToApplyChapters).toEqual('2'); - expect(env.resultContainer.textContent).toContain('warning'); - expect(env.component.draftApplyProgress?.errorMessages).toContain('error'); - }); -}); - -class TestEnvironment { - component: DraftApplyProgressDialogComponent; - fixture: ComponentFixture; - - constructor() { - this.fixture = TestBed.createComponent(DraftApplyProgressDialogComponent); - this.component = this.fixture.componentInstance; - this.fixture.detectChanges(); - when(mockI18nService.enumerateList(anything())).thenCall((items: string[]) => items.join(', ')); - } - - get progressContainer(): HTMLElement { - return this.fixture.nativeElement.querySelector('.progress-container'); - } - - get resultContainer(): HTMLElement { - return this.fixture.nativeElement.querySelector('.result-container'); - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.ts deleted file mode 100644 index d4adb94c79..0000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-progress-dialog/draft-apply-progress-dialog.component.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Component, DestroyRef, Inject } from '@angular/core'; -import { MatButton } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef } from '@angular/material/dialog'; -import { MatIcon } from '@angular/material/icon'; -import { MatProgressBar } from '@angular/material/progress-bar'; -import { TranslocoModule } from '@ngneat/transloco'; -import { Observable } from 'rxjs'; -import { I18nService } from 'xforge-common/i18n.service'; -import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; -export interface DraftApplyProgress { - bookNum: number; - chapters: number[]; - chaptersApplied: number[]; - completed: boolean; - errorMessages: string[]; -} - -@Component({ - selector: 'app-draft-apply-progress', - imports: [MatButton, MatIcon, MatProgressBar, MatDialogContent, MatDialogActions, TranslocoModule], - templateUrl: './draft-apply-progress-dialog.component.html', - styleUrl: './draft-apply-progress-dialog.component.scss' -}) -export class DraftApplyProgressDialogComponent { - draftApplyProgress?: DraftApplyProgress; - - constructor( - @Inject(MatDialogRef) private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) data: { draftApplyProgress$: Observable }, - private readonly i18n: I18nService, - destroyRef: DestroyRef - ) { - data.draftApplyProgress$ - .pipe(quietTakeUntilDestroyed(destroyRef)) - .subscribe(progress => (this.draftApplyProgress = progress)); - } - - get progress(): number | undefined { - if (this.draftApplyProgress == null) return undefined; - return (this.draftApplyProgress.chaptersApplied.length / this.draftApplyProgress.chapters.length) * 100; - } - - get bookName(): string { - if (this.draftApplyProgress == null) return ''; - return this.i18n.localizeBook(this.draftApplyProgress.bookNum); - } - - get failedToApplyChapters(): string | undefined { - if (this.draftApplyProgress == null || !this.draftApplyProgress.completed) return undefined; - const chapters: string[] = this.draftApplyProgress.chapters - .filter(c => !this.draftApplyProgress?.chaptersApplied.includes(c)) - .map(c => c.toString()); - return chapters.length > 0 ? this.i18n.enumerateList(chapters) : undefined; - } - - close(): void { - this.dialogRef.close(); - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.scss index 5cf23bf7a1..3c6119d242 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.scss @@ -83,10 +83,6 @@ mat-divider.mat-divider-inset { flex-shrink: 0; } - app-draft-apply-progress { - width: 100%; - } - h2 { font-weight: 500; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts index 89ad64f2bf..7160c38d7e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts @@ -15,7 +15,6 @@ import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { SFProjectService } from '../../../core/sf-project.service'; import { BuildDto } from '../../../machine-api/build-dto'; -import { DraftApplyProgress } from '../draft-apply-progress-dialog/draft-apply-progress-dialog.component'; import { BookWithDraft, DraftPreviewBooksComponent } from './draft-preview-books.component'; const mockedActivatedProjectService = mock(ActivatedProjectService); @@ -72,7 +71,6 @@ describe('DraftPreviewBooks', () => { class TestEnvironment { component: DraftPreviewBooksComponent; fixture: ComponentFixture; - draftApplyProgress?: DraftApplyProgress; progressSubscription?: Subscription; loader: HarnessLoader; readonly paratextId = 'pt01'; From 56cde0b301cdd97b08fc2300a107df7c8a87390f Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 26 Jan 2026 15:16:11 +1300 Subject: [PATCH 22/40] SF-3633 Release SignalR notification handlers --- .../src/app/core/project-notification.service.ts | 4 ++++ .../draft-import-wizard.component.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 5 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 97f231bddf..0d26c13346 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 @@ -32,6 +32,10 @@ export class ProjectNotificationService { return this.onlineService.isOnline && this.onlineService.isBrowserOnline; } + removeNotifyDraftApplyProgressHandler(handler: any): void { + this.connection.off('notifyDraftApplyProgress', handler); + } + removeNotifyBuildProgressHandler(handler: any): void { this.connection.off('notifyBuildProgress', handler); } 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 89d8906a6a..c1bc6ba153 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 @@ -298,6 +298,10 @@ export class DraftImportWizardComponent implements OnInit { invalidMessageMapper: { [key: string]: string } = {}; + private readonly notifyDraftApplyProgressHandler = (projectId: string, draftApplyState: DraftApplyState): void => { + this.updateDraftApplyState(projectId, draftApplyState); + }; + constructor( @Inject(MAT_DIALOG_DATA) readonly data: BuildDto, @Inject(MatDialogRef) private readonly dialogRef: MatDialogRef, @@ -310,14 +314,11 @@ export class DraftImportWizardComponent implements OnInit { private readonly onlineStatusService: OnlineStatusService, private readonly activatedProjectService: ActivatedProjectService ) { - this.projectNotificationService.setNotifyDraftApplyProgressHandler( - (projectId: string, draftApplyState: DraftApplyState) => { - this.updateDraftApplyState(projectId, draftApplyState); - } - ); + this.projectNotificationService.setNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); destroyRef.onDestroy(async () => { // Stop the SignalR connection when the component is destroyed await projectNotificationService.stop(); + this.projectNotificationService.removeNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); }); } From 07a09c42365e4ca8d3d7433170eb36ca6cac217e Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 2 Feb 2026 10:38:47 +1300 Subject: [PATCH 23/40] SF-3633 Removed dependency on custom validators --- .../draft-import-wizard.component.html | 13 +- .../draft-import-wizard.component.ts | 138 +++++------------- .../draft-import-wizard.stories.ts | 10 +- .../src/assets/i18n/non_checking_en.json | 5 +- 4 files changed, 46 insertions(+), 120 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 3f27f7f2a1..3a738d942d 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 @@ -20,7 +20,6 @@

{{ t("select_project") }}

[projects]="projects" [placeholder]="t('choose_project')" [isDisabled]="projects.length === 0 || isImporting || projectLoadingFailed" - [invalidMessageMapper]="invalidMessageMapper" (projectSelect)="projectSelected($event.paratextId)" formControlName="targetParatextId" > @@ -44,6 +43,10 @@

{{ t("select_project") }}

{{ t("connect_to_the_internet") }} } + @if (!canEditProject) { + {{ t("cannot_edit_project") }} + } + @if (noDraftsAvailable) { {{ t("no_books_ready_for_import") }} } @@ -54,10 +57,6 @@

{{ t("select_project") }}

} - @if (bookCreationError != null && bookCreationError.length > 0) { - {{ bookCreationError }} - } - @if (cannotAdvanceFromProjectSelection && !projectReadyToImport) { {{ !projectSelectionForm.valid ? t("select_project_description") : t("waiting_for_project_to_load") @@ -75,7 +74,7 @@

{{ t("select_project") }}

color="primary" data-test-id="step-1-next" (click)="advanceFromProjectSelection()" - [disabled]="!isAppOnline || noDraftsAvailable || projectLoadingFailed" + [disabled]="!isAppOnline || noDraftsAvailable || projectLoadingFailed || !canEditProject" > {{ needsConnection || showBookSelection || showOverwriteConfirmation ? t("next") : t("import") }} chevron_{{ i18n.forwardDirectionWord }} @@ -297,7 +296,7 @@

{{ t("importing_draft") }}

@if (progress.failedChapters.length > 0) {
error - Failed: {{ getFailedChapters(progress) }} + {{ t("failed") }} {{ getFailedChapters(progress) }}
}
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 c1bc6ba153..2478a66068 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 @@ -34,7 +34,6 @@ 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'; @@ -120,7 +119,6 @@ export enum DraftApplyStatus { }) export class DraftImportWizardComponent implements OnInit { @ViewChild(MatStepper) stepper?: MatStepper; - @ViewChild(ProjectSelectComponent) projectSelect?: ProjectSelectComponent; @ViewChild('importStep') importStep?: MatStep; // Step 1: Project selection @@ -135,13 +133,6 @@ export class DraftImportWizardComponent implements OnInit { targetProject$ = new BehaviorSubject(undefined); targetProjectDoc$ = new BehaviorSubject(undefined); canEditProject = true; - /** - * If true, text is to be imported to books in the target project which do not exist, - * and which the user does not have permission to create those books. - */ - booksMissingWithoutPermission = false; - missingBookNames: string[] = []; - bookCreationError?: string; projectLoadingFailed = false; sourceProjectId?: string; noDraftsAvailable = false; @@ -296,8 +287,6 @@ export class DraftImportWizardComponent implements OnInit { syncComplete = false; skipSync = false; - invalidMessageMapper: { [key: string]: string } = {}; - private readonly notifyDraftApplyProgressHandler = (projectId: string, draftApplyState: DraftApplyState): void => { this.updateDraftApplyState(projectId, draftApplyState); }; @@ -354,20 +343,11 @@ export class DraftImportWizardComponent implements OnInit { } ngOnInit(): void { - this.setupInvalidMessageMapper(); void this.loadProjects(); this.initializeAvailableBooks(); this.sourceProjectId = this.activatedProjectService.projectId; } - 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(); @@ -428,13 +408,9 @@ export class DraftImportWizardComponent implements OnInit { 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; } @@ -449,18 +425,21 @@ export class DraftImportWizardComponent implements OnInit { this.targetProjectId = paratextProject.projectId; this.needsConnection = !paratextProject.isConnected; - // Get the project profile to analyze - this.isLoadingProject = true; - try { - const projectDoc = await this.projectService.getProfile(this.targetProjectId); - if (projectDoc.data != null) { - await this.loadTargetProjectAndValidate(projectDoc.data, paratextProject.isConnected); - } + // If the project still needs connection, we will analyze after connecting + if (!this.needsConnection) { + // Get the project profile to analyze + this.isLoadingProject = true; + try { + const projectDoc = await this.projectService.getProfile(this.targetProjectId); + if (projectDoc.data != null) { + await this.loadTargetProjectAndValidate(projectDoc.data, paratextProject.isConnected); + } - // Analyze books for overwrite confirmation - await this.determineBooksAndChaptersWithText(); - } finally { - this.isLoadingProject = false; + // Analyze books for overwrite confirmation + await this.determineBooksAndChaptersWithText(); + } finally { + this.isLoadingProject = false; + } } } else { // Need to create SF project - this will happen after connection step @@ -471,7 +450,6 @@ export class DraftImportWizardComponent implements OnInit { this.canEditProject = paratextProject.isConnectable; this.targetProject$.next(undefined); this.targetProjectDoc$.next(undefined); - await this.validateProject(); } finally { this.isLoadingProject = false; } @@ -494,11 +472,9 @@ export class DraftImportWizardComponent implements OnInit { this.targetProject$.next(undefined); this.targetProjectDoc$.next(undefined); } - - await this.validateProject(); } - private async ensureSelectedBooksExist(): Promise { + private async ensureProjectExists(): Promise { if (this.targetProjectId == null) { return false; } @@ -513,63 +489,11 @@ export class DraftImportWizardComponent implements OnInit { this.targetProject$.next(project); } - 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.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; return true; } private resetProjectValidation(): void { this.canEditProject = true; - this.booksMissingWithoutPermission = false; - this.bookCreationError = undefined; - this.missingBookNames = []; - } - - private async validateProject(): Promise { - await new Promise(resolve => { - // setTimeout prevents a "changed after checked" exception (may be removable after SF-3014) - 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 { @@ -577,21 +501,11 @@ export class DraftImportWizardComponent implements OnInit { book.selected = selectedBooks.includes(book.bookNum); } this.resetImportState(); - this.booksMissingWithoutPermission = false; - this.bookCreationError = undefined; - this.missingBookNames = []; void this.determineBooksAndChaptersWithText(); - void this.validateProject(); } get projectReadyToImport(): boolean { - return ( - this.projectSelectionForm.valid && - !this.isConnecting && - !this.isImporting && - !this.isLoadingProject && - !this.booksMissingWithoutPermission - ); + return this.projectSelectionForm.valid && !this.isConnecting && !this.isImporting && !this.isLoadingProject; } async advanceFromProjectSelection(): Promise { @@ -614,8 +528,8 @@ export class DraftImportWizardComponent implements OnInit { } this.isLoadingProject = true; - const booksReady = await this.ensureSelectedBooksExist(); - if (!booksReady) { + const projectExists = await this.ensureProjectExists(); + if (!projectExists) { this.isLoadingProject = false; return; } @@ -761,6 +675,10 @@ export class DraftImportWizardComponent implements OnInit { }); this.isImporting = false; this.importError = draftApplyState.message; + const totalFailures = this.importProgress.reduce((sum, p) => sum + p.failedChapters.length, 0); + if (totalFailures > 0 && (this.importError == null || this.importError.length === 0)) { + this.importError = `Failed to import ${totalFailures} chapter(s). See details above.`; + } } } else if (draftApplyState.bookNum > 0) { // Handle the in-progress states @@ -829,7 +747,17 @@ export class DraftImportWizardComponent implements OnInit { } getFailedChapters(progress: ImportProgress): string { - return progress.failedChapters.map(f => f.chapterNum).join(', '); + const failedChapters: number[] = progress.failedChapters.filter(f => f.chapterNum !== 0).map(f => f.chapterNum); + if (!progress.failedChapters.some(f => f.chapterNum === 0)) { + // A subset of chapters failed + return failedChapters.join(', '); + } else if (progress.totalChapters > 1) { + // All chapters failed, so display as a range + return `1-${progress.totalChapters}`; + } else { + // The only chapter in the book failed + return `${progress.totalChapters}`; + } } private resetImportState(): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts index 628fce12fe..c13f58f733 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts @@ -41,6 +41,7 @@ const mockOnlineStatusService = mock(OnlineStatusService); class DraftImportWizardWrapperComponent implements AfterViewInit, OnChanges { @ViewChild(DraftImportWizardComponent) component!: DraftImportWizardComponent; @Input() online: boolean = false; + @Input() canEditProject: boolean = true; @Input() importComplete: boolean = false; @Input() importStepTriggered: boolean = false; @Input() isConnecting: boolean = false; @@ -55,7 +56,6 @@ class DraftImportWizardWrapperComponent implements AfterViewInit, OnChanges { @Input() showOverwriteConfirmation: boolean = false; @Input() skipSync: boolean = false; @Input() syncComplete: boolean = false; - @Input() bookCreationError?: string; @Input() connectionError?: string; @Input() importError?: string; @Input() targetProjectId?: string; @@ -77,6 +77,7 @@ class DraftImportWizardWrapperComponent implements AfterViewInit, OnChanges { if (!this.component) return; setTimeout(() => { // Set the story specific arguments + this.component.canEditProject = this.canEditProject; this.component.importComplete = this.importComplete; this.component.importStepTriggered = this.importStepTriggered; this.component.isConnecting = this.isConnecting; @@ -91,7 +92,6 @@ class DraftImportWizardWrapperComponent implements AfterViewInit, OnChanges { this.component.showOverwriteConfirmation = this.showOverwriteConfirmation; this.component.skipSync = this.skipSync; this.component.syncComplete = this.syncComplete; - this.component.bookCreationError = this.bookCreationError; this.component.connectionError = this.connectionError; this.component.importError = this.importError; this.component.targetProjectId = this.targetProjectId; @@ -119,6 +119,7 @@ class DraftImportWizardWrapperComponent implements AfterViewInit, OnChanges { interface DraftImportWizardComponentState { online: boolean; step: number; + canEditProject: boolean; importComplete: boolean; importStepTriggered: boolean; isConnecting: boolean; @@ -133,7 +134,6 @@ interface DraftImportWizardComponentState { showOverwriteConfirmation: boolean; skipSync: boolean; syncComplete: boolean; - bookCreationError?: string; connectionError?: string; importError?: string; targetProjectId?: string; @@ -146,6 +146,7 @@ interface DraftImportWizardComponentState { const defaultArgs: DraftImportWizardComponentState = { online: true, step: 0, + canEditProject: true, importComplete: false, importStepTriggered: false, isConnecting: false, @@ -160,7 +161,6 @@ const defaultArgs: DraftImportWizardComponentState = { showOverwriteConfirmation: false, skipSync: false, syncComplete: false, - bookCreationError: undefined, connectionError: undefined, importError: undefined, targetProjectId: undefined, @@ -213,6 +213,7 @@ export default { }, argTypes: { online: { control: 'boolean' }, + canEditProject: { control: 'boolean' }, importComplete: { control: 'boolean' }, importStepTriggered: { control: 'boolean' }, isConnecting: { control: 'boolean' }, @@ -227,7 +228,6 @@ export default { showOverwriteConfirmation: { control: 'boolean' }, skipSync: { control: 'boolean' }, syncComplete: { control: 'boolean' }, - bookCreationError: { control: 'text' }, connectionError: { control: 'text' }, importError: { control: 'text' }, targetProjectId: { control: 'text' }, 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 a608b988a5..ae7764508b 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 @@ -313,10 +313,9 @@ "previously_generated_drafts": "Previously generated drafts" }, "draft_import_wizard": { - "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", + "cannot_edit_project": "You do not have permission to edit this project", "chapter": "chapter", "chapters": "chapters", "choose_project": "Choose a project", @@ -332,6 +331,7 @@ "connecting": "Connecting", "connection_failed": "Connection failed", "done": "Done", + "failed": "Failed:", "failed_to_connect_project": "Something went wrong while connecting. Please try again.", "failed_to_load_projects": "We couldn't load your Paratext projects. Check your internet connection and try again.", "i_understand_overwrite_content": "I understand that existing content will be overwritten", @@ -344,7 +344,6 @@ "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)", "overwrite_book_description": "{{ bookName }} in {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} has {{ numChapters }} chapters with existing text that will be overwritten.", "overwrite_book_question": "Overwrite {{ bookName }}?", "overwrite_books_description": "The following books in {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} have chapters with existing text that will be overwritten:", From 26ef360f307135169553c0349271b28042cd5f29 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 2 Feb 2026 10:02:13 +1300 Subject: [PATCH 24/40] SF-3633 Updates based on code review feedback --- .../draft-import-wizard-component.spec.ts | 2 +- .../draft-import-wizard.component.html | 38 +++++----- .../draft-import-wizard.component.ts | 72 ++++++------------- .../draft-import-wizard.stories.ts | 5 -- .../src/assets/i18n/non_checking_en.json | 6 +- 5 files changed, 43 insertions(+), 80 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts index f38af60c86..ba90e4cab3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts @@ -286,7 +286,7 @@ class TestEnvironment { getNonEmptyVerses: (): string[] => ['verse_1_1'] } as TextDoc); when(mockProjectService.onlineCreate(anything())).thenResolve('project02'); - when(mockProjectService.getProfile(anything())).thenCall(id => + when(mockProjectService.get(anything())).thenCall(id => this.realtimeService.subscribe(SFProjectProfileDoc.COLLECTION, id) ); when(mockTextDocService.userHasGeneralEditRight(anything())).thenReturn(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 index 3a738d942d..d06c016cb4 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 @@ -47,13 +47,14 @@

{{ t("select_project") }}

{{ t("cannot_edit_project") }} } - @if (noDraftsAvailable) { - {{ t("no_books_ready_for_import") }} - } - - @if (projectSelectionForm.valid && !isLoadingProject && (targetProject$ | async); as project) { + @if (projectSelectionForm.valid && !isLoadingProject && (targetProjectDoc$ | async); as project) { - {{ t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) }} + {{ + t("ready_to_import", { + projectShortName: project?.data?.shortName, + projectName: project?.data?.name + }) + }} } @@ -74,7 +75,7 @@

{{ t("select_project") }}

color="primary" data-test-id="step-1-next" (click)="advanceFromProjectSelection()" - [disabled]="!isAppOnline || noDraftsAvailable || projectLoadingFailed || !canEditProject" + [disabled]="!isAppOnline || projectLoadingFailed || !canEditProject" > {{ needsConnection || showBookSelection || showOverwriteConfirmation ? t("next") : t("import") }} chevron_{{ i18n.forwardDirectionWord }} @@ -129,10 +130,7 @@

{{ t("connect_to_project") }}

@if (isConnecting) {

{{ t("connecting_to_project") }}

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

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

@@ -154,9 +152,14 @@

{{ t("connection_failed") }}

} @else if (!isConnecting) {

{{ t("connected_to_project") }}

- @if (targetProject$ | async; as project) { + @if (targetProjectDoc$ | async; as projectDoc) { - {{ t("ready_to_import", { projectShortName: $any(project).shortName, projectName: $any(project).name }) }} + {{ + t("ready_to_import", { + projectShortName: projectDoc?.data?.shortName, + projectName: projectDoc?.data?.name + }) + }} }
@@ -189,7 +192,7 @@

{{ t("select_books_to_import") }}

[availableBooks]="availableBooksForImport" [selectedBooks]="selectedBooks" [basicMode]="true" - [readonly]="isImporting || noDraftsAvailable" + [readonly]="isImporting" (bookSelect)="onBookSelect($event)" > @@ -203,7 +206,7 @@

{{ t("select_books_to_import") }}

color="primary" matStepperNext data-test-id="step-4-next" - [disabled]="selectedBooks.length === 0 || isConnecting || isImporting || noDraftsAvailable" + [disabled]="selectedBooks.length === 0 || isConnecting || isImporting" > {{ showOverwriteConfirmation ? t("next") : t("import") }} chevron_{{ i18n.forwardDirectionWord }} @@ -374,10 +377,7 @@

{{ 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 2478a66068..041173e13b 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 @@ -18,7 +18,6 @@ import { } 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 { BehaviorSubject } from 'rxjs'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { I18nService } from 'xforge-common/i18n.service'; @@ -130,12 +129,10 @@ export class DraftImportWizardComponent implements OnInit { isLoadingProjects = true; targetProjectId?: string; selectedParatextProject?: ParatextProject; - targetProject$ = new BehaviorSubject(undefined); targetProjectDoc$ = new BehaviorSubject(undefined); canEditProject = true; projectLoadingFailed = false; sourceProjectId?: string; - noDraftsAvailable = false; cannotAdvanceFromProjectSelection = false; // Step 2-3: Project connection (conditional) @@ -183,12 +180,7 @@ export class DraftImportWizardComponent implements OnInit { // Reload project data after connection const projectDoc = await this.projectService.get(this.targetProjectId); - this.targetProjectDoc$.next(projectDoc); - if (projectDoc.data != null) { - await this.loadTargetProjectAndValidate(projectDoc.data, paratextProject.isConnected); - } - - await this.determineBooksAndChaptersWithText(); + await this.loadTargetProjectAndValidate(projectDoc); this.isConnecting = false; this.stepper?.next(); @@ -226,14 +218,9 @@ export class DraftImportWizardComponent implements OnInit { this.isConnecting = false; return; } - - void this.loadTargetProjectAndValidate(projectDoc.data, true) - .then(async () => { - return await this.determineBooksAndChaptersWithText(); - }) - .finally(() => { - this.isConnecting = false; - }); + void this.loadTargetProjectAndValidate(projectDoc).finally(() => { + this.isConnecting = false; + }); } } @@ -246,9 +233,6 @@ export class DraftImportWizardComponent implements OnInit { onStepSelectionChange(event: StepperSelectionEvent): void { if (event.selectedStep === this.importStep && !this.importStepTriggered) { - if (this.noDraftsAvailable) { - return; - } this.importStepTriggered = true; void this.startImport(); } @@ -397,7 +381,6 @@ export class DraftImportWizardComponent implements OnInit { async projectSelected(paratextId: string): Promise { if (paratextId == null) { - this.targetProject$.next(undefined); this.targetProjectDoc$.next(undefined); this.selectedParatextProject = undefined; this.resetProjectValidation(); @@ -408,7 +391,6 @@ export class DraftImportWizardComponent implements OnInit { const paratextProject = this.projects.find(p => p.paratextId === paratextId); if (paratextProject == null) { this.canEditProject = false; - this.targetProject$.next(undefined); this.targetProjectDoc$.next(undefined); this.selectedParatextProject = undefined; this.resetImportState(); @@ -430,13 +412,8 @@ export class DraftImportWizardComponent implements OnInit { // Get the project profile to analyze this.isLoadingProject = true; try { - const projectDoc = await this.projectService.getProfile(this.targetProjectId); - if (projectDoc.data != null) { - await this.loadTargetProjectAndValidate(projectDoc.data, paratextProject.isConnected); - } - - // Analyze books for overwrite confirmation - await this.determineBooksAndChaptersWithText(); + const projectDoc = await this.projectService.get(this.targetProjectId); + await this.loadTargetProjectAndValidate(projectDoc); } finally { this.isLoadingProject = false; } @@ -448,7 +425,6 @@ export class DraftImportWizardComponent implements OnInit { this.targetProjectId = undefined; this.needsConnection = true; this.canEditProject = paratextProject.isConnectable; - this.targetProject$.next(undefined); this.targetProjectDoc$.next(undefined); } finally { this.isLoadingProject = false; @@ -456,22 +432,16 @@ export class DraftImportWizardComponent implements OnInit { } } - private async loadTargetProjectAndValidate(project: SFProjectProfile, isConnected: boolean): Promise { + private async loadTargetProjectAndValidate(projectDoc: SFProjectDoc): Promise { // Check permissions for all books - this.canEditProject = this.textDocService.userHasGeneralEditRight(project); - - // Connection status comes from ParatextProject - this.needsConnection = !isConnected && this.canEditProject; - - 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.canEditProject = this.textDocService.userHasGeneralEditRight(projectDoc?.data); + if (this.canEditProject) { this.targetProjectDoc$.next(projectDoc); } else { - this.targetProject$.next(undefined); this.targetProjectDoc$.next(undefined); } + + await this.analyzeBooksForOverwriteConfirmation(); } private async ensureProjectExists(): Promise { @@ -479,14 +449,12 @@ export class DraftImportWizardComponent implements OnInit { return false; } - let project = this.targetProject$.value; - if (project == null) { - const profileDoc = await this.projectService.getProfile(this.targetProjectId); - project = profileDoc.data; - if (project == null) { + if (this.targetProjectDoc$.value?.data == null) { + const profileDoc = await this.projectService.get(this.targetProjectId); + if (profileDoc?.data == null) { return false; } - this.targetProject$.next(project); + this.targetProjectDoc$.next(profileDoc); } return true; @@ -501,7 +469,7 @@ export class DraftImportWizardComponent implements OnInit { book.selected = selectedBooks.includes(book.bookNum); } this.resetImportState(); - void this.determineBooksAndChaptersWithText(); + void this.analyzeBooksForOverwriteConfirmation(); } get projectReadyToImport(): boolean { @@ -524,6 +492,7 @@ export class DraftImportWizardComponent implements OnInit { // For connected projects, ensure we have a targetProjectId before proceeding if (this.targetProjectId == null) { + this.cannotAdvanceFromProjectSelection = true; return; } @@ -534,14 +503,13 @@ export class DraftImportWizardComponent implements OnInit { return; } - // Analyze books for overwrite confirmation - await this.determineBooksAndChaptersWithText(); + await this.analyzeBooksForOverwriteConfirmation(); this.stepper?.next(); this.isLoadingProject = false; } - private async determineBooksAndChaptersWithText(): Promise { + private async analyzeBooksForOverwriteConfirmation(): Promise { if (this.targetProjectId == null) return; this.booksWithExistingText = []; @@ -564,7 +532,7 @@ export class DraftImportWizardComponent implements OnInit { private async getChaptersWithText(bookNum: number): Promise { if (this.targetProjectId == null) return []; - const project = this.targetProject$.value; + const project = this.targetProjectDoc$.value?.data; if (project == null) return []; const targetBook = project.texts.find(t => t.bookNum === bookNum); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts index c13f58f733..49ebb52184 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts @@ -50,7 +50,6 @@ class DraftImportWizardWrapperComponent implements AfterViewInit, OnChanges { @Input() isLoadingProjects: boolean = false; @Input() isSyncing: boolean = false; @Input() needsConnection: boolean = false; - @Input() noDraftsAvailable: boolean = false; @Input() projectLoadingFailed: boolean = false; @Input() showBookSelection: boolean = false; @Input() showOverwriteConfirmation: boolean = false; @@ -86,7 +85,6 @@ class DraftImportWizardWrapperComponent implements AfterViewInit, OnChanges { this.component.isLoadingProjects = this.isLoadingProjects; this.component.isSyncing = this.isSyncing; this.component.needsConnection = this.needsConnection; - this.component.noDraftsAvailable = this.noDraftsAvailable; this.component.projectLoadingFailed = this.projectLoadingFailed; this.component.showBookSelection = this.showBookSelection; this.component.showOverwriteConfirmation = this.showOverwriteConfirmation; @@ -128,7 +126,6 @@ interface DraftImportWizardComponentState { isLoadingProjects: boolean; isSyncing: boolean; needsConnection: boolean; - noDraftsAvailable: boolean; projectLoadingFailed: boolean; showBookSelection: boolean; showOverwriteConfirmation: boolean; @@ -155,7 +152,6 @@ const defaultArgs: DraftImportWizardComponentState = { isLoadingProjects: false, isSyncing: false, needsConnection: false, - noDraftsAvailable: false, projectLoadingFailed: false, showBookSelection: false, showOverwriteConfirmation: false, @@ -222,7 +218,6 @@ export default { isLoadingProjects: { control: 'boolean' }, isSyncing: { control: 'boolean' }, needsConnection: { control: 'boolean' }, - noDraftsAvailable: { control: 'boolean' }, projectLoadingFailed: { control: 'boolean' }, showBookSelection: { control: 'boolean' }, showOverwriteConfirmation: { control: 'boolean' }, 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 ae7764508b..2d18ba19ae 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 @@ -336,7 +336,7 @@ "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": "The draft has been imported to {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} with Paratext and synced with Paratext.", + "import_and_sync_complete": "The draft has been imported into {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} and synced with Paratext.", "import_and_sync_complete_reminder": "Please remember to do a Send/Receive in Paratext to see the draft.", "import_complete_no_sync": "Make sure to sync {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} in Scripture Forge, then do a Send/Receive in Paratext to make the draft available in Paratext.", "import_complete": "Import complete", @@ -349,10 +349,10 @@ "overwrite_books_description": "The following books in {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} have chapters with existing text that will be overwritten:", "overwrite_books_question": "Overwrite books?", "please_select_valid_project": "Please select a valid project", - "project_context_unavailable": "Project context unavailable. Please close the wizard and try again.", + "project_context_unavailable": "Project context unavailable. Please close the dialog and try again.", "ready_to_import": "Ready to import draft to “{{ projectShortName }} - {{ projectName }}”", "retry": "Retry", - "setting_up_project": "Setting up {{ projectName }}...", + "setting_up_project": "Setting up {{ projectName }} ...", "select_books_to_import": "Select books to import", "select_books": "Select books", "select_project": "Select project", From 3315e6c7cdec34c4c845b2aac1dbad7c1a32b7da Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 10 Feb 2026 14:03:36 +1300 Subject: [PATCH 25/40] SF-3633 Fixed issues found in testing on 4 Feb --- .../src/app/project-select/project-select.component.ts | 8 ++++---- .../draft-import-wizard.component.html | 6 ++++-- .../editor/lynx/insights/lynx-workspace.service.ts | 3 ++- .../ClientApp/src/assets/i18n/non_checking_en.json | 3 ++- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/project-select/project-select.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/project-select/project-select.component.ts index 9b78165e32..47e92c8e1b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/project-select/project-select.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/project-select/project-select.component.ts @@ -172,16 +172,16 @@ export class ProjectSelectComponent implements ControlValueAccessor, OnDestroy { @Input() errorMessageMapper?: null | ((errors: ValidationErrors) => string | null) = null; - private externalValidators: ValidatorFn[] = []; + private externalValidators: ValidatorFn[] | 'disabled' = []; @Input() - set validators(value: ValidatorFn[]) { + set validators(value: ValidatorFn[] | 'disabled') { this.externalValidators = value; - const validators = [this.validateProject.bind(this)].concat(value); + const validators = value === 'disabled' ? [] : [this.validateProject.bind(this)].concat(value); for (const validator of validators) if (typeof validator !== 'function') throw new Error(`The validator is not a function: ${validator}`); this.paratextIdControl.setValidators(validators); } - get validators(): ValidatorFn[] { + get validators(): ValidatorFn[] | 'disabled' { return this.externalValidators; } 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 d06c016cb4..5a8b1989c1 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 @@ -1,4 +1,4 @@ - + {{ index + 1 }} @@ -22,6 +22,7 @@

{{ t("select_project") }}

[isDisabled]="projects.length === 0 || isImporting || projectLoadingFailed" (projectSelect)="projectSelected($event.paratextId)" formControlName="targetParatextId" + validators="disabled" > @if (isLoadingProject) { @@ -187,7 +188,8 @@

{{ t("connected_to_project") }}

{{ t("select_books") }} -

{{ t("select_books_to_import") }}

+

{{ t("confirm_books_to_import") }}

+

{{ t("confirm_books_to_import_description") }}

{ return Promise.resolve([...this.textDocIds]); } - async read(uri: string): Promise> { + async read(uri: string): Promise | undefined> { const textDoc = await this.projectService.getText(uri); + if (textDoc.data == null) return undefined; return { format: 'scripture-delta', content: textDoc.data as Delta, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json index 2d18ba19ae..96d5e3fcca 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 @@ -320,6 +320,8 @@ "chapters": "chapters", "choose_project": "Choose a project", "complete": "Complete", + "confirm_books_to_import": "Confirm books to import", + "confirm_books_to_import_description": "The following books will be imported. Please click on a book to deselect it and not import it.", "confirm_overwrite": "Confirm overwrite", "connect_project_description": "The selected project {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} has not yet been connected in Scripture Forge. Do you want to connect it so you can import the draft to it?", "connect_project": "Connect project", @@ -353,7 +355,6 @@ "ready_to_import": "Ready to import draft to “{{ projectShortName }} - {{ projectName }}”", "retry": "Retry", "setting_up_project": "Setting up {{ projectName }} ...", - "select_books_to_import": "Select books to import", "select_books": "Select books", "select_project": "Select project", "select_project_description": "Please select the project you want to add the draft to.", From c900881cb333f49e480a590083aeaaafe47a873d Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Wed, 11 Feb 2026 12:17:48 +1300 Subject: [PATCH 26/40] SF-3633 Fixed issues found in testing on 5 Feb --- .../draft-generation.component.html | 6 ++-- .../draft-import-wizard-component.spec.ts | 3 ++ .../draft-import-wizard.component.html | 31 ++++++++++++------- .../draft-import-wizard.component.ts | 14 +++++++-- .../draft-import-wizard.stories.ts | 3 ++ .../src/assets/i18n/non_checking_en.json | 2 +- 6 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.html index 2f90e23ef4..18b9c91485 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.html @@ -420,9 +420,9 @@

{{ t("draft_finishing_header") }}

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

Further information

} } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts index ba90e4cab3..7fbb672fe9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts @@ -8,6 +8,7 @@ import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/ import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { anything, mock, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; @@ -34,6 +35,7 @@ const mockProjectNotificationService = mock(ProjectNotificationService); const mockProjectService = mock(SFProjectService); const mockTextDocService = mock(TextDocService); const mockActivatedProjectService = mock(ActivatedProjectService); +const mockAuthService = mock(AuthService); describe('DraftImportWizardComponent', () => { const buildDto: BuildDto = { @@ -57,6 +59,7 @@ describe('DraftImportWizardComponent', () => { { provide: TextDocService, useMock: mockTextDocService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: ActivatedProjectService, useMock: mockActivatedProjectService }, + { provide: AuthService, useMock: mockAuthService }, provideNoopAnimations() ] })); 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 5a8b1989c1..76d20952fc 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 @@ -19,7 +19,7 @@

{{ t("select_project") }}

{{ t("select_project") }} } } - @if (projectLoadingFailed) { + @if (projectLoadingFailed && isAppOnline) { {{ t("failed_to_load_projects") }}
- @@ -173,7 +180,7 @@

{{ t("connected_to_project") }}

matStepperNext color="primary" data-test-id="step-3-next" - [disabled]="isImporting" + [disabled]="!isAppOnline || isImporting" > {{ showBookSelection || showOverwriteConfirmation ? t("next") : t("import") }} chevron_{{ i18n.forwardDirectionWord }} @@ -208,7 +215,7 @@

{{ t("confirm_books_to_import") }}

color="primary" matStepperNext data-test-id="step-4-next" - [disabled]="selectedBooks.length === 0 || isConnecting || isImporting" + [disabled]="!isAppOnline || selectedBooks.length === 0 || isConnecting || isImporting" > {{ showOverwriteConfirmation ? t("next") : t("import") }} chevron_{{ i18n.forwardDirectionWord }} @@ -272,7 +279,7 @@

{{ t("overwrite_books_question") }}

color="primary" matStepperNext data-test-id="step-5-next" - [disabled]="!overwriteForm.valid || isConnecting || isImporting" + [disabled]="!overwriteForm.valid || isConnecting || isImporting || !isAppOnline" > {{ t("import") }} chevron_{{ i18n.forwardDirectionWord }} @@ -311,11 +318,11 @@

{{ t("importing_draft") }}

@if (importError != null && importError.length > 0) { {{ importError }}
- - @@ -368,7 +375,7 @@

{{ t("sync_project_question") }}

color="primary" data-test-id="step-7-sync" (click)="selectSync()" - [disabled]="isSyncing || isImporting" + [disabled]="isSyncing || isImporting || !isAppOnline" > {{ t("sync") }} sync @@ -390,7 +397,7 @@

{{ t("sync") }}

- 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 041173e13b..9bf89a820b 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 @@ -1,5 +1,6 @@ import { StepperSelectionEvent } from '@angular/cdk/stepper'; import { AsyncPipe } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; import { Component, DestroyRef, Inject, OnInit, ViewChild } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButton } from '@angular/material/button'; @@ -20,6 +21,7 @@ import { TranslocoModule } from '@ngneat/transloco'; import { Canon } from '@sillsdev/scripture'; import { BehaviorSubject } from 'rxjs'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; import { I18nService } from 'xforge-common/i18n.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { ParatextProject } from '../../../core/models/paratext-project'; @@ -285,7 +287,8 @@ export class DraftImportWizardComponent implements OnInit { private readonly textDocService: TextDocService, readonly i18n: I18nService, private readonly onlineStatusService: OnlineStatusService, - private readonly activatedProjectService: ActivatedProjectService + private readonly activatedProjectService: ActivatedProjectService, + private readonly authService: AuthService ) { this.projectNotificationService.setNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); destroyRef.onDestroy(async () => { @@ -341,7 +344,9 @@ export class DraftImportWizardComponent implements OnInit { } catch (error) { this.projectLoadingFailed = true; this.projects = []; - console.error('Failed to load projects:', error); + if (error instanceof HttpErrorResponse && error.status === 401) { + this.authService.requestParatextCredentialUpdate(); + } } finally { this.isLoadingProjects = false; } @@ -671,6 +676,11 @@ export class DraftImportWizardComponent implements OnInit { } } + clearImport(): void { + this.resetImportState(); + this.stepper?.previous(); + } + retryImport(): void { this.resetImportState(); void this.startImport(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts index 49ebb52184..77f00c186d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts @@ -6,6 +6,7 @@ import { defaultTranslocoMarkupTranspilers } from 'ngx-transloco-markup'; import { of } from 'rxjs'; import { anything, instance, mock, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { ParatextProject } from '../../../core/models/paratext-project'; import { SFProjectDoc } from '../../../core/models/sf-project-doc'; @@ -30,6 +31,7 @@ const mockProjectNotificationService = mock(ProjectNotificationService); const mockProjectService = mock(SFProjectService); const mockTextDocService = mock(TextDocService); const mockActivatedProjectService = mock(ActivatedProjectService); +const mockAuthService = mock(AuthService); const mockOnlineStatusService = mock(OnlineStatusService); @Component({ @@ -190,6 +192,7 @@ export default { { provide: OnlineStatusService, useValue: instance(mockOnlineStatusService) }, { provide: ActivatedProjectService, useValue: instance(mockActivatedProjectService) }, { provide: ProgressService, useValue: instance(mockProgressService) }, + { provide: AuthService, useValue: instance(mockAuthService) }, defaultTranslocoMarkupTranspilers() ] }) 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 96d5e3fcca..72ea398616 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 @@ -335,7 +335,7 @@ "done": "Done", "failed": "Failed:", "failed_to_connect_project": "Something went wrong while connecting. Please try again.", - "failed_to_load_projects": "We couldn't load your Paratext projects. Check your internet connection and try again.", + "failed_to_load_projects": "We couldn't load your Paratext projects. Check your internet connection and try again, or log out then log back in again.", "i_understand_overwrite_content": "I understand that existing content will be overwritten", "import": "Import", "import_and_sync_complete": "The draft has been imported into {{ boldStart }}{{ projectShortName }} - {{ projectName }}{{ boldEnd }} and synced with Paratext.", From f01ffbb8c4a7d79cb50820ee0fca9582705c41cb Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Thu, 12 Feb 2026 09:00:47 +1300 Subject: [PATCH 27/40] SF-3633 Remove redundant code --- .../src/app/core/sf-project.service.spec.ts | 9 - .../src/app/core/sf-project.service.ts | 4 - .../src/app/core/text-doc.service.spec.ts | 19 -- .../src/app/core/text-doc.service.ts | 17 +- .../draft-handling.service.spec.ts | 183 +----------------- .../draft-handling.service.ts | 53 +---- .../draft-import-wizard.component.ts | 2 - 7 files changed, 3 insertions(+), 284 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts index fc44c5dc63..f0f15c1ba4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts @@ -119,15 +119,6 @@ describe('SFProjectService', () => { })); }); - describe('onlineAddChapters', () => { - it('should invoke the command service', fakeAsync(async () => { - const env = new TestEnvironment(); - await env.service.onlineAddChapters('project01', 1, [2, 3]); - verify(mockedCommandService.onlineInvoke(anything(), 'addChapters', anything())).once(); - expect().nothing(); - })); - }); - describe('onlineSetDraftApplied', () => { it('should invoke the command service', fakeAsync(async () => { const env = new TestEnvironment(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts index d1b3c56174..23589a7d6a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts @@ -236,10 +236,6 @@ export class SFProjectService extends ProjectService { }); } - onlineAddChapters(projectId: string, book: number, chapters: number[]): Promise { - return this.onlineInvoke('addChapters', { projectId, book, chapters }); - } - onlineUpdateSettings(id: string, settings: SFProjectSettings): Promise { return this.onlineInvoke('updateSettings', { projectId: id, settings }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts index 72b9819a4f..8822e79a1c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts @@ -183,25 +183,6 @@ describe('TextDocService', () => { }); }); - describe('createTextDoc', () => { - it('should throw error if text doc already exists', fakeAsync(() => { - const env = new TestEnvironment(); - expect(() => { - env.textDocService.createTextDoc(env.textDocId, getTextDoc(env.textDocId)); - tick(); - }).toThrowError(); - })); - - it('creates the text doc if it does not already exist', fakeAsync(async () => { - const env = new TestEnvironment(); - const textDocId = new TextDocId('project01', 40, 2); - const textDoc = await env.textDocService.createTextDoc(textDocId, getTextDoc(textDocId)); - tick(); - - expect(textDoc.data).toBeDefined(); - })); - }); - describe('isDataInSync', () => { it('should return true if the project is undefined', () => { const env = new TestEnvironment(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts index 2a56ec96d9..edb30060a1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts @@ -6,9 +6,7 @@ import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scri import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; -import { type } from 'rich-text'; import { Observable, Subject } from 'rxjs'; -import { RealtimeService } from 'xforge-common/realtime.service'; import { UserService } from 'xforge-common/user.service'; import { TextDoc, TextDocId, TextDocSource } from './models/text-doc'; import { SFProjectService } from './sf-project.service'; @@ -21,8 +19,7 @@ export class TextDocService { constructor( private readonly projectService: SFProjectService, - private readonly userService: UserService, - private readonly realtimeService: RealtimeService + private readonly userService: UserService ) {} /** @@ -81,18 +78,6 @@ export class TextDocService { ); } - async createTextDoc(textDocId: TextDocId, data?: TextData): Promise { - let textDoc: TextDoc = await this.projectService.getText(textDocId); - - if (textDoc?.data != null) { - throw new Error(`Text Doc already exists for ${textDocId}`); - } - - data ??= { ops: [] }; - textDoc = await this.realtimeService.create(TextDoc.COLLECTION, textDocId.toString(), data, type.uri); - return textDoc; - } - /** * Determines if the data is in sync for the project. * diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts index d8c6037427..ad8de419b7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts @@ -4,10 +4,7 @@ import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/ import { DeltaOperation } from 'rich-text'; import { of, throwError } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; -import { ErrorReportingService } from 'xforge-common/error-reporting.service'; -import { I18nService } from 'xforge-common/i18n.service'; import { configureTestingModule } from 'xforge-common/test-utils'; -import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { TextDocId } from '../../core/models/text-doc'; import { SFProjectService } from '../../core/sf-project.service'; import { TextDocService } from '../../core/text-doc.service'; @@ -18,9 +15,6 @@ import { DraftHandlingService } from './draft-handling.service'; const mockedProjectService = mock(SFProjectService); const mockedTextDocService = mock(TextDocService); const mockedDraftGenerationService = mock(DraftGenerationService); -const mockedSFProject = mock(SFProjectProfileDoc); -const mockedErrorReportingService = mock(ErrorReportingService); -const mockedI18nService = mock(I18nService); describe('DraftHandlingService', () => { let service: DraftHandlingService; @@ -29,9 +23,7 @@ describe('DraftHandlingService', () => { providers: [ { provide: SFProjectService, useMock: mockedProjectService }, { provide: TextDocService, useMock: mockedTextDocService }, - { provide: DraftGenerationService, useMock: mockedDraftGenerationService }, - { provide: ErrorReportingService, useMock: mockedErrorReportingService }, - { provide: I18nService, useMock: mockedI18nService } + { provide: DraftGenerationService, useMock: mockedDraftGenerationService } ] })); @@ -445,179 +437,6 @@ describe('DraftHandlingService', () => { }); }); - describe('getAndApplyDraftAsync', () => { - it('should get and apply draft', async () => { - const textDocId = new TextDocId('project01', 1, 1); - const draft: DeltaOperation[] = [ - { insert: { verse: { number: 1 } } }, - { insert: 'In the beginning', attributes: { segment: 'verse_1_1' } } - ]; - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(of(draft)); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(true); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).once(); - verify(mockedTextDocService.overwrite(textDocId, anything(), 'Draft')).once(); - verify( - mockedProjectService.onlineSetDraftApplied( - textDocId.projectId, - textDocId.bookNum, - textDocId.chapterNum, - true, - 1 - ) - ).once(); - verify( - mockedProjectService.onlineSetIsValid(textDocId.projectId, textDocId.bookNum, textDocId.chapterNum, true) - ).once(); - }); - - it('should not apply if user does not have permission', async () => { - const textDocId = new TextDocId('project01', 1, 1); - const draft: DeltaOperation[] = [{ insert: 'In the beginning', attributes: { segment: 'verse_1_1' } }]; - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(of(draft)); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(false); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).not.toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).never(); - verify(mockedTextDocService.overwrite(textDocId, anything(), 'Draft')).never(); - }); - - it('should not apply legacy USFM draft', async () => { - const textDocId = new TextDocId('project01', 1, 1); - const draft: DraftSegmentMap = { verse_1_1: 'In the beginning' }; - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(throwError(() => ({ status: 405 }))); - when(mockedDraftGenerationService.getGeneratedDraft(anything(), anything(), anything())).thenReturn(of(draft)); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(true); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).not.toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).once(); - verify(mockedDraftGenerationService.getGeneratedDraft('project01', 1, 1)).once(); - verify(mockedTextDocService.overwrite(textDocId, anything(), 'Draft')).never(); - }); - - it('should return false if applying a draft fails', async () => { - const textDocId = new TextDocId('project01', 1, 1); - const draft: DeltaOperation[] = [ - { insert: { verse: { number: 1 } } }, - { insert: 'In the beginning', attributes: { segment: 'verse_1_1' } } - ]; - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(of(draft)); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(true); - when( - mockedProjectService.onlineSetDraftApplied(anything(), anything(), anything(), anything(), anything()) - ).thenReturn(Promise.reject(new Error('Failed'))); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).not.toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).once(); - verify(mockedErrorReportingService.silentError(anything(), anything())).once(); - verify(mockedTextDocService.overwrite(textDocId, anything(), anything())).never(); - verify( - mockedProjectService.onlineSetDraftApplied( - textDocId.projectId, - textDocId.bookNum, - textDocId.chapterNum, - true, - 1 - ) - ).once(); - }); - - it('should return false if applying a draft fails at getting the draft', async () => { - const textDocId = new TextDocId('project01', 1, 1); - when(mockedTextDocService.canRestore(anything(), 1, 1)).thenReturn(true); - when( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations( - anything(), - anything(), - anything(), - anything(), - anything() - ) - ).thenReturn(throwError(() => ({ message: 'Getting draft failed', status: 404 }))); - const result: string | undefined = await service.getAndApplyDraftAsync( - mockedSFProject.data!, - textDocId, - textDocId, - undefined - ); - expect(result).not.toBe(undefined); - verify( - mockedDraftGenerationService.getGeneratedDraftDeltaOperations('project01', 1, 1, undefined, undefined) - ).once(); - verify(mockedErrorReportingService.silentError(anything(), anything())).once(); - verify( - mockedProjectService.onlineSetDraftApplied( - textDocId.projectId, - textDocId.bookNum, - textDocId.chapterNum, - true, - anything() - ) - ).never(); - }); - }); - describe('opsHaveContent', () => { it('should return false if all ops are blank', () => { const ops: DeltaOperation[] = [{ insert: {} }, { insert: {} }]; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts index 06ca1fa62a..3ac931484f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts @@ -5,8 +5,6 @@ import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/ import { DraftUsfmConfig } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; import { DeltaOperation } from 'rich-text'; import { catchError, Observable, throwError } from 'rxjs'; -import { ErrorReportingService } from 'xforge-common/error-reporting.service'; -import { I18nService } from 'xforge-common/i18n.service'; import { isString } from '../../../type-utils'; import { TextDocId } from '../../core/models/text-doc'; import { SFProjectService } from '../../core/sf-project.service'; @@ -34,9 +32,7 @@ export class DraftHandlingService { constructor( private readonly projectService: SFProjectService, private readonly textDocService: TextDocService, - private readonly draftGenerationService: DraftGenerationService, - private readonly errorReportingService: ErrorReportingService, - private readonly i18n: I18nService + private readonly draftGenerationService: DraftGenerationService ) {} /** @@ -228,53 +224,6 @@ export class DraftHandlingService { await this.textDocService.overwrite(textDocId, draftDelta, 'Draft'); } - /** - * Retrieves and applies the draft to the text document. - * @param targetProject The project profile. - * @param draftTextDocId The text doc identifier of the draft of a chapter. - * @param targetTextDocId The text doc identifier to apply the draft to. - * @returns True if the draft was successfully applied, false if the draft was not applied i.e. the draft - * was in the legacy USFM format. - */ - async getAndApplyDraftAsync( - targetProject: SFProjectProfile, - draftTextDocId: TextDocId, - targetTextDocId: TextDocId, - timestamp?: Date - ): Promise { - if (!this.textDocService.canRestore(targetProject, targetTextDocId.bookNum, targetTextDocId.chapterNum)) { - return this.i18n.translateStatic('draft_apply_progress-dialog.fail_cannot_edit'); - } - - return await new Promise(resolve => { - this.getDraft(draftTextDocId, { isDraftLegacy: false, timestamp }).subscribe({ - next: async draft => { - let ops: DeltaOperation[] = []; - if (this.isDraftSegmentMap(draft)) { - // Do not support applying drafts for the legacy segment map format. - // This can be applied chapter by chapter. - resolve(this.i18n.translateStatic('draft_apply_progress-dialog.fail_legacy_format')); - return; - } else { - ops = draft; - } - const draftDelta: Delta = new Delta(ops); - await this.applyChapterDraftAsync(targetTextDocId, draftDelta).catch(err => { - // report the error to bugsnag - this.errorReportingService.silentError('Error applying a draft', ErrorReportingService.normalizeError(err)); - resolve(this.i18n.translateStatic('draft_apply_progress-dialog.fail_unknown')); - }); - resolve(undefined); - }, - error: err => { - // report the error to bugsnag - this.errorReportingService.silentError('Error applying a draft', ErrorReportingService.normalizeError(err)); - resolve(this.i18n.translateStatic('draft_apply_progress-dialog.fail_unknown')); - } - }); - }); - } - /** * Checks whether the ops have any content (text) in them. This is defined as any op having text content (verse * numbers and other format markers do not count as "content"). If the final op is a newline, it is not counted as diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-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 9bf89a820b..6acad85170 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 @@ -6,7 +6,6 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } 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 { @@ -100,7 +99,6 @@ export enum DraftApplyStatus { MatButton, MatCheckbox, MatDialogContent, - MatError, MatIcon, MatProgressBar, MatStep, From 6eaf22dbee51bbe2fa5a864e40a7ed183757004f Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 16 Feb 2026 14:21:37 +1300 Subject: [PATCH 28/40] SF-3633 Create draft notification service with stateful reconnection --- .../app/core/project-notification.service.ts | 8 -- .../draft-import-wizard-component.spec.ts | 3 + .../draft-import-wizard.component.ts | 14 ++-- .../draft-import-wizard.stories.ts | 3 + .../draft-notification.service.ts | 74 +++++++++++++++++++ .../Services/DraftNotificationHub.cs | 29 ++++++++ .../Services/IDraftNotifier.cs | 10 +++ .../Services/INotifier.cs | 1 - .../Services/MachineApiService.cs | 33 +++++---- .../Services/NotificationHub.cs | 9 --- .../Services/NotificationHubExtensions.cs | 2 +- src/SIL.XForge.Scripture/Startup.cs | 4 + src/SIL.XForge/Controllers/UrlConstants.cs | 1 + .../AuthServiceCollectionExtensions.cs | 9 ++- .../Services/MachineApiServiceTests.cs | 2 + 15 files changed, 158 insertions(+), 44 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-notification.service.ts create mode 100644 src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs create mode 100644 src/SIL.XForge.Scripture/Services/IDraftNotifier.cs 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 0d26c13346..bfaaf77e22 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 @@ -32,10 +32,6 @@ export class ProjectNotificationService { return this.onlineService.isOnline && this.onlineService.isBrowserOnline; } - removeNotifyDraftApplyProgressHandler(handler: any): void { - this.connection.off('notifyDraftApplyProgress', handler); - } - removeNotifyBuildProgressHandler(handler: any): void { this.connection.off('notifyBuildProgress', handler); } @@ -44,10 +40,6 @@ 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.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts index 7fbb672fe9..3477b68bdb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard-component.spec.ts @@ -26,11 +26,13 @@ import { TextDocService } from '../../../core/text-doc.service'; import { BuildDto } from '../../../machine-api/build-dto'; import { ProjectSelectComponent } from '../../../project-select/project-select.component'; import { ProgressService, ProjectProgress } from '../../../shared/progress-service/progress.service'; +import { DraftNotificationService } from '../draft-notification.service'; import { DraftApplyState, DraftApplyStatus, DraftImportWizardComponent } from './draft-import-wizard.component'; const mockMatDialogRef = mock(MatDialogRef); const mockParatextService = mock(ParatextService); const mockProgressService = mock(ProgressService); +const mockDraftNotificationService = mock(DraftNotificationService); const mockProjectNotificationService = mock(ProjectNotificationService); const mockProjectService = mock(SFProjectService); const mockTextDocService = mock(TextDocService); @@ -54,6 +56,7 @@ describe('DraftImportWizardComponent', () => { { provide: MAT_DIALOG_DATA, useValue: buildDto }, { provide: ParatextService, useMock: mockParatextService }, { provide: ProgressService, useMock: mockProgressService }, + { provide: DraftNotificationService, useMock: mockDraftNotificationService }, { provide: ProjectNotificationService, useMock: mockProjectNotificationService }, { provide: SFProjectService, useMock: mockProjectService }, { provide: TextDocService, useMock: mockTextDocService }, 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 6acad85170..c280e5b953 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 @@ -27,7 +27,6 @@ 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 { ProjectNotificationService } from '../../../core/project-notification.service'; import { SFProjectService } from '../../../core/sf-project.service'; import { TextDocService } from '../../../core/text-doc.service'; import { BuildDto } from '../../../machine-api/build-dto'; @@ -36,6 +35,7 @@ import { BookMultiSelectComponent } from '../../../shared/book-multi-select/book import { NoticeComponent } from '../../../shared/notice/notice.component'; import { booksFromScriptureRange } from '../../../shared/utils'; import { SyncProgressComponent } from '../../../sync/sync-progress/sync-progress.component'; +import { DraftNotificationService } from '../draft-notification.service'; /** * Represents a book available for import with its draft chapters. @@ -280,7 +280,7 @@ export class DraftImportWizardComponent implements OnInit { @Inject(MatDialogRef) private readonly dialogRef: MatDialogRef, readonly destroyRef: DestroyRef, private readonly paratextService: ParatextService, - private readonly projectNotificationService: ProjectNotificationService, + private readonly draftNotificationService: DraftNotificationService, private readonly projectService: SFProjectService, private readonly textDocService: TextDocService, readonly i18n: I18nService, @@ -288,11 +288,11 @@ export class DraftImportWizardComponent implements OnInit { private readonly activatedProjectService: ActivatedProjectService, private readonly authService: AuthService ) { - this.projectNotificationService.setNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); + this.draftNotificationService.setNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); destroyRef.onDestroy(async () => { // Stop the SignalR connection when the component is destroyed - await projectNotificationService.stop(); - this.projectNotificationService.removeNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); + await draftNotificationService.stop(); + this.draftNotificationService.removeNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); }); } @@ -601,8 +601,8 @@ export class DraftImportWizardComponent implements OnInit { } // Subscribe to SignalR updates - await this.projectNotificationService.start(); - await this.projectNotificationService.subscribeToProject(this.sourceProjectId); + await this.draftNotificationService.start(); + await this.draftNotificationService.subscribeToProject(this.sourceProjectId); // Build a scripture range and timestamp to import const scriptureRange = books.map(b => b.bookId).join(';'); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts index 77f00c186d..5f5d7afce2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.stories.ts @@ -16,6 +16,7 @@ import { SFProjectService } from '../../../core/sf-project.service'; import { TextDocService } from '../../../core/text-doc.service'; import { BuildDto } from '../../../machine-api/build-dto'; import { ProgressService, ProjectProgress } from '../../../shared/progress-service/progress.service'; +import { DraftNotificationService } from '../draft-notification.service'; import { BookForImport, BookWithExistingText, @@ -27,6 +28,7 @@ const mockDestroyRef = mock(DestroyRef); const mockMatDialogRef = mock(MatDialogRef); const mockParatextService = mock(ParatextService); const mockProgressService = mock(ProgressService); +const mockDraftNotificationService = mock(DraftNotificationService); const mockProjectNotificationService = mock(ProjectNotificationService); const mockProjectService = mock(SFProjectService); const mockTextDocService = mock(TextDocService); @@ -186,6 +188,7 @@ export default { { provide: MAT_DIALOG_DATA, useValue: buildDto }, { provide: MatDialogRef, useValue: instance(mockMatDialogRef) }, { provide: ParatextService, useValue: instance(mockParatextService) }, + { provide: DraftNotificationService, useValue: instance(mockDraftNotificationService) }, { provide: ProjectNotificationService, useValue: instance(mockProjectNotificationService) }, { provide: SFProjectService, useValue: instance(mockProjectService) }, { provide: TextDocService, useValue: instance(mockTextDocService) }, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-notification.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-notification.service.ts new file mode 100644 index 0000000000..2ab9ca7cda --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-notification.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core'; +import { + AbortError, + HubConnection, + HubConnectionBuilder, + HubConnectionState, + IHttpConnectionOptions +} from '@microsoft/signalr'; +import { AuthService } from 'xforge-common/auth.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; + +@Injectable({ + providedIn: 'root' +}) +export class DraftNotificationService { + private connection: HubConnection; + private options: IHttpConnectionOptions = { + accessTokenFactory: async () => (await this.authService.getAccessToken()) ?? '' + }; + + constructor( + private authService: AuthService, + private readonly onlineService: OnlineStatusService + ) { + this.connection = new HubConnectionBuilder() + .withUrl('/draft-notifications', this.options) + .withAutomaticReconnect() + .withStatefulReconnect() + .build(); + } + + get appOnline(): boolean { + return this.onlineService.isOnline && this.onlineService.isBrowserOnline; + } + + removeNotifyDraftApplyProgressHandler(handler: any): void { + this.connection.off('notifyDraftApplyProgress', handler); + } + + setNotifyDraftApplyProgressHandler(handler: any): void { + this.connection.on('notifyDraftApplyProgress', handler); + } + + async start(): Promise { + if (this.connection.state !== HubConnectionState.Disconnected) { + await this.connection.stop(); + } + await this.connection.start().catch(err => { + // Suppress AbortErrors, as they are not caused by server error, but the SignalR connection state + // These will be thrown if a user navigates away quickly after + // starting the sync or the app loses internet connection + if (err instanceof AbortError || !this.appOnline) { + return; + } else { + throw err; + } + }); + } + + async stop(): Promise { + await this.connection.stop(); + } + + async subscribeToProject(projectId: string): Promise { + await this.connection.send('subscribeToProject', projectId).catch(err => { + // This error is thrown when a user navigates away quickly after starting the sync + if (err.message === "Cannot send data if the connection is not in the 'Connected' State.") { + return; + } else { + throw err; + } + }); + } +} diff --git a/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs b/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs new file mode 100644 index 0000000000..a0d65311ef --- /dev/null +++ b/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using SIL.XForge.Scripture.Models; + +namespace SIL.XForge.Scripture.Services; + +[Authorize] +public class DraftNotificationHub : Hub, IDraftNotifier +{ + /// + /// Notifies subscribers to a project of draft application progress. + /// + /// The Scripture Forge project identifier. + /// The state of the draft being applied. + /// The asynchronous task. + public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) => + await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); + + /// + /// Subscribe to notifications for a project. + /// + /// This is called from the frontend via project-notification.service.ts. + /// + /// The Scripture Forge project identifier. + /// The asynchronous task. + public async Task SubscribeToProject(string projectId) => + await Groups.AddToGroupAsync(Context.ConnectionId, projectId); +} diff --git a/src/SIL.XForge.Scripture/Services/IDraftNotifier.cs b/src/SIL.XForge.Scripture/Services/IDraftNotifier.cs new file mode 100644 index 0000000000..e3fd1b43ac --- /dev/null +++ b/src/SIL.XForge.Scripture/Services/IDraftNotifier.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using SIL.XForge.Scripture.Models; + +namespace SIL.XForge.Scripture.Services; + +public interface IDraftNotifier +{ + Task NotifyDraftApplyProgress(string sfProjectId, DraftApplyState draftApplyState); + Task SubscribeToProject(string projectId); +} diff --git a/src/SIL.XForge.Scripture/Services/INotifier.cs b/src/SIL.XForge.Scripture/Services/INotifier.cs index 7b421df877..e3b45da2b8 100644 --- a/src/SIL.XForge.Scripture/Services/INotifier.cs +++ b/src/SIL.XForge.Scripture/Services/INotifier.cs @@ -6,7 +6,6 @@ namespace SIL.XForge.Scripture.Services; public interface INotifier { Task NotifyBuildProgress(string sfProjectId, ServalBuildState buildState); - Task NotifyDraftApplyProgress(string sfProjectId, DraftApplyState draftApplyState); Task NotifySyncProgress(string sfProjectId, ProgressState progressState); Task SubscribeToProject(string projectId); } diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index 171281357f..b0565dff75 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -46,6 +46,7 @@ public class MachineApiService( IExceptionHandler exceptionHandler, IHttpRequestAccessor httpRequestAccessor, IHubContext hubContext, + IHubContext draftHubContext, ILogger logger, IMachineProjectService machineProjectService, IParatextService paratextService, @@ -169,7 +170,7 @@ CancellationToken cancellationToken + $" while the target project ({targetProjectDoc.Data.ShortName.Sanitize()}) has {targetLastChapter} chapters."; logger.LogWarning(message); result.Log += $"{message}\n"; - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -189,7 +190,7 @@ await hubContext.NotifyDraftApplyProgress( { chapters = [.. Enumerable.Range(1, lastChapter)]; } - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -236,7 +237,7 @@ await hubContext.NotifyDraftApplyProgress( if (string.IsNullOrWhiteSpace(usfm)) { result.Failures.Add(book); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -253,7 +254,7 @@ await hubContext.NotifyDraftApplyProgress( if (DeltaUsxMapper.ExtractBookId(usfm) != book) { result.Failures.Add(book); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -290,7 +291,7 @@ .. paratextService.GetChaptersAsUsj(userSecret, project.ParatextId, bookNum, usf { // A blank chapter from Serval result.Failures.Add($"{Canon.BookNumberToId(bookNum)} {chapterNum}"); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -313,7 +314,7 @@ await hubContext.NotifyDraftApplyProgress( { // Likely a blank draft in the database result.Failures.Add($"{Canon.BookNumberToId(bookNum)} {chapterNum}"); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -340,7 +341,7 @@ await hubContext.NotifyDraftApplyProgress( } } - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -362,7 +363,7 @@ await hubContext.NotifyDraftApplyProgress( exceptionHandler.ReportException(e); result.Log += $"{message}\n"; result.Log += $"{e}\n"; - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -445,7 +446,7 @@ await targetProjectDoc.SubmitJson0OpAsync(op => // Update the permissions if (chapterDeltas.Count > 0) { - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -489,7 +490,7 @@ await paratextService.UpdateParatextPermissionsForNewBooksAsync( if (result.Failures.Add(bookId)) { // Only notify the book failure once per book - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -519,7 +520,7 @@ await hubContext.NotifyDraftApplyProgress( if (result.Failures.Add(bookId)) { // Only notify the book failure once per book - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -541,7 +542,7 @@ await hubContext.NotifyDraftApplyProgress( if (chapterIndex == -1) { result.Failures.Add($"{Canon.BookNumberToId(bookNum)} {chapterDelta.Number}"); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -575,7 +576,7 @@ await targetProjectDoc.SubmitJson0OpAsync(op => } result.Failures.Add($"{Canon.BookNumberToId(bookNum)} {chapterDelta.Number}"); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -602,7 +603,7 @@ await hubContext.NotifyDraftApplyProgress( { await textDataDoc.SubmitOpAsync(diffDelta, OpSource.Draft); } - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -617,7 +618,7 @@ await hubContext.NotifyDraftApplyProgress( { // Create a new text data document await textDataDoc.CreateAsync(newTextData); - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { @@ -657,7 +658,7 @@ await hubContext.NotifyDraftApplyProgress( connection.RollbackTransaction(); } - await hubContext.NotifyDraftApplyProgress( + await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState { diff --git a/src/SIL.XForge.Scripture/Services/NotificationHub.cs b/src/SIL.XForge.Scripture/Services/NotificationHub.cs index 462dd51aeb..8f17d103ab 100644 --- a/src/SIL.XForge.Scripture/Services/NotificationHub.cs +++ b/src/SIL.XForge.Scripture/Services/NotificationHub.cs @@ -21,15 +21,6 @@ public class NotificationHub : Hub, INotifier public async Task NotifyBuildProgress(string projectId, ServalBuildState buildState) => await Clients.Group(projectId).NotifyBuildProgress(projectId, buildState); - /// - /// Notifies subscribers to a project of draft application progress. - /// - /// The Scripture Forge project identifier. - /// The state of the draft being applied. - /// The asynchronous task. - public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) => - await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); - /// /// Notifies subscribers to a project of sync progress. /// diff --git a/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs b/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs index 8d4747e782..3af78a79a0 100644 --- a/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs +++ b/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs @@ -13,7 +13,7 @@ ServalBuildState buildState ) => hubContext.Clients.Groups(projectId).NotifyBuildProgress(projectId, buildState); public static Task NotifyDraftApplyProgress( - this IHubContext hubContext, + this IHubContext hubContext, string projectId, DraftApplyState draftApplyState ) => hubContext.Clients.Groups(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); diff --git a/src/SIL.XForge.Scripture/Startup.cs b/src/SIL.XForge.Scripture/Startup.cs index a05f61a7e6..bcf7e422f1 100644 --- a/src/SIL.XForge.Scripture/Startup.cs +++ b/src/SIL.XForge.Scripture/Startup.cs @@ -293,6 +293,10 @@ IExceptionHandler exceptionHandler pattern: $"/{UrlConstants.ProjectNotifications}", options => options.AllowStatefulReconnects = false ); + endpoints.MapHub( + pattern: $"/{UrlConstants.DraftNotifications}", + options => options.AllowStatefulReconnects = true + ); var authOptions = Configuration.GetOptions(); endpoints.MapHangfireDashboard( new DashboardOptions diff --git a/src/SIL.XForge/Controllers/UrlConstants.cs b/src/SIL.XForge/Controllers/UrlConstants.cs index 231a650d09..d3fa82b0a6 100644 --- a/src/SIL.XForge/Controllers/UrlConstants.cs +++ b/src/SIL.XForge/Controllers/UrlConstants.cs @@ -6,6 +6,7 @@ public static class UrlConstants public const string CommandApiNamespace = "command-api"; public const string Users = "users"; public const string Projects = "projects"; + public const string DraftNotifications = "draft-notifications"; public const string ProjectNotifications = "project-notifications"; public const string OnboardingRequests = "onboarding-requests"; } diff --git a/src/SIL.XForge/Services/AuthServiceCollectionExtensions.cs b/src/SIL.XForge/Services/AuthServiceCollectionExtensions.cs index e31cdd69ae..8cbf094672 100644 --- a/src/SIL.XForge/Services/AuthServiceCollectionExtensions.cs +++ b/src/SIL.XForge/Services/AuthServiceCollectionExtensions.cs @@ -32,8 +32,13 @@ public static IServiceCollection AddXFAuthentication(this IServiceCollection ser string? accessToken = context.Request.Query["access_token"]; if ( !string.IsNullOrEmpty(accessToken) - && context.HttpContext.Request.Path.StartsWithSegments( - $"/{UrlConstants.ProjectNotifications}" + && ( + context.HttpContext.Request.Path.StartsWithSegments( + $"/{UrlConstants.ProjectNotifications}" + ) + || context.HttpContext.Request.Path.StartsWithSegments( + $"/{UrlConstants.DraftNotifications}" + ) ) ) { diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 51dc8b4962..1565860599 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -4791,6 +4791,7 @@ public TestEnvironment() HttpRequestAccessor = Substitute.For(); HttpRequestAccessor.SiteRoot.Returns(new Uri("https://scriptureforge.org", UriKind.Absolute)); var hubContext = Substitute.For>(); + var draftHubContext = Substitute.For>(); MachineProjectService = Substitute.For(); MockLogger = new MockLogger(); ParatextService = Substitute.For(); @@ -4928,6 +4929,7 @@ public TestEnvironment() ExceptionHandler, HttpRequestAccessor, hubContext, + draftHubContext, MockLogger, MachineProjectService, ParatextService, From 0f2e7cc7086316f06757a29f86a4bb9d44a0cdb9 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 17 Feb 2026 11:15:19 +1300 Subject: [PATCH 29/40] SF-3633 Record source when creating ShareDB documents --- src/RealtimeServer/common/index.ts | 15 ++++++++-- .../common/utils/sharedb-utils.ts | 10 +++++-- src/RealtimeServer/common/utils/test-utils.ts | 15 ++++++++-- .../services/text-service.spec.ts | 17 +++++++++++ .../Services/MachineApiService.cs | 4 +-- .../Services/ParatextSyncRunner.cs | 7 +++-- .../Services/SFProjectService.cs | 10 ++++--- src/SIL.XForge/Realtime/Connection.cs | 15 ++++++++-- src/SIL.XForge/Realtime/Document.cs | 8 ++--- src/SIL.XForge/Realtime/IConnection.cs | 2 +- src/SIL.XForge/Realtime/IDocument.cs | 4 +-- src/SIL.XForge/Realtime/IRealtimeServer.cs | 9 +++++- src/SIL.XForge/Realtime/MemoryConnection.cs | 9 ++++-- src/SIL.XForge/Realtime/MemoryDocument.cs | 6 ++-- src/SIL.XForge/Realtime/RealtimeExtensions.cs | 13 +++++--- src/SIL.XForge/Realtime/RealtimeServer.cs | 10 +++++-- src/SIL.XForge/Services/UserService.cs | 3 +- .../Realtime/ConnectionTests.cs | 30 +++++++++++++++---- .../Realtime/DocumentTests.cs | 16 +++++----- .../Realtime/RealtimeServerTests.cs | 2 +- 20 files changed, 152 insertions(+), 53 deletions(-) diff --git a/src/RealtimeServer/common/index.ts b/src/RealtimeServer/common/index.ts index 0960de95d4..55f1bf966f 100644 --- a/src/RealtimeServer/common/index.ts +++ b/src/RealtimeServer/common/index.ts @@ -210,7 +210,8 @@ export = { collection: string, id: string, data: any, - typeName: OTType + typeName: OTType, + source: string | undefined ): void => { if (server == null) { callback(new Error('Server not started.')); @@ -221,7 +222,17 @@ export = { callback(new Error('Connection not found.')); return; } - doc.create(data, typeName, err => callback(err, createSnapshot(doc))); + const options: any = {}; + doc.submitSource = source != null; + if (source != null) { + options.source = source; + } + doc.create(data, typeName, options, err => { + if (source != null) { + doc.submitSource = false; + } + callback(err, createSnapshot(doc)); + }); }, fetchDoc: (callback: InteropCallback, handle: number, collection: string, id: string): void => { diff --git a/src/RealtimeServer/common/utils/sharedb-utils.ts b/src/RealtimeServer/common/utils/sharedb-utils.ts index 176896d728..1d41695a10 100644 --- a/src/RealtimeServer/common/utils/sharedb-utils.ts +++ b/src/RealtimeServer/common/utils/sharedb-utils.ts @@ -13,9 +13,15 @@ export function docFetch(doc: Doc): Promise { }); } -export function docCreate(doc: Doc, data: any, type?: OTType): Promise { +export function docCreate( + doc: Doc, + data: any, + type?: OTType, + source: boolean | any | undefined = undefined +): Promise { + const options: ShareDBSourceOptions = source != null ? { source } : {}; return new Promise((resolve, reject) => { - doc.create(data, type, err => { + doc.create(data, type, options, err => { if (err != null) { reject(err); } else { diff --git a/src/RealtimeServer/common/utils/test-utils.ts b/src/RealtimeServer/common/utils/test-utils.ts index fddf41b311..34811299ac 100644 --- a/src/RealtimeServer/common/utils/test-utils.ts +++ b/src/RealtimeServer/common/utils/test-utils.ts @@ -20,8 +20,19 @@ export async function hasDoc(conn: Connection, collection: string, id: string): return doc.data != null; } -export function createDoc(conn: Connection, collection: string, id: string, data: T, type?: OTType): Promise { - return docCreate(conn.get(collection, id), data, type); +export function createDoc( + conn: Connection, + collection: string, + id: string, + data: T, + type?: OTType, + source: boolean | any | undefined = undefined +): Promise { + const doc = conn.get(collection, id); + if (source != null) { + doc.submitSource = true; + } + return docCreate(doc, data, type, source); } export async function submitOp( diff --git a/src/RealtimeServer/scriptureforge/services/text-service.spec.ts b/src/RealtimeServer/scriptureforge/services/text-service.spec.ts index 34fb7cd669..034ba53a76 100644 --- a/src/RealtimeServer/scriptureforge/services/text-service.spec.ts +++ b/src/RealtimeServer/scriptureforge/services/text-service.spec.ts @@ -75,6 +75,23 @@ describe('TextService', () => { }); }); }); + + it('writes the op source to the database on create', async () => { + const env = new TestEnvironment(); + await env.createData(); + + const conn = clientConnect(env.server, 'administrator'); + const id: string = getTextDocId('project01', 40, 2); + const source: string = 'history'; + await createDoc(conn, TEXTS_COLLECTION, id, new Delta(), 'rich-text', source); + await new Promise(resolve => { + env.db.getOps(TEXTS_COLLECTION, id, 0, null, { metadata: true }, (_, ops) => { + console.log(JSON.stringify(ops)); + expect(ops[0].m.source).toBe(source); + resolve(); + }); + }); + }); }); class TestEnvironment { diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index b0565dff75..d777b9a5d6 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -617,7 +617,7 @@ await draftHubContext.NotifyDraftApplyProgress( else { // Create a new text data document - await textDataDoc.CreateAsync(newTextData); + await textDataDoc.CreateAsync(newTextData, OpSource.Draft); await draftHubContext.NotifyDraftApplyProgress( sfProjectId, new DraftApplyState @@ -2614,7 +2614,7 @@ private static async Task SaveTextDocumentAsync(IDocument textDocu // Create or update the text document if (!textDocument.IsLoaded) { - await textDocument.CreateAsync(chapterTextDocument); + await textDocument.CreateAsync(chapterTextDocument, OpSource.Draft); } else { diff --git a/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs b/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs index ab5619e680..8f3ebbe288 100644 --- a/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs +++ b/src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs @@ -754,7 +754,7 @@ await _projectDoc.SubmitJson0OpAsync(op => biblicalTerm.ProjectRef = _projectDoc.Id; IDocument newBiblicalTermDoc = GetBiblicalTermDoc(biblicalTerm.DataId); async Task CreateBiblicalTermAsync(BiblicalTerm newBiblicalTerm) => - await newBiblicalTermDoc.CreateAsync(newBiblicalTerm); + await newBiblicalTermDoc.CreateAsync(newBiblicalTerm, OpSource.Paratext); tasks.Add(CreateBiblicalTermAsync(biblicalTerm)); _syncMetrics.BiblicalTerms.Added++; } @@ -1307,7 +1307,7 @@ async Task CreateText(Delta delta) await textDataDoc.FetchAsync(); if (textDataDoc.IsLoaded) await textDataDoc.DeleteAsync(); - await textDataDoc.CreateAsync(new TextData(delta)); + await textDataDoc.CreateAsync(new TextData(delta), OpSource.Paratext); } tasks.Add(CreateText(kvp.Value.Delta)); _syncMetrics.TextDocs.Added++; @@ -1401,7 +1401,8 @@ await doc.CreateAsync( Assignment = change.Assignment, BiblicalTermId = change.BiblicalTermId, ExtraHeadingInfo = change.ExtraHeadingInfo, - } + }, + OpSource.Paratext ); await SubmitChangesOnNoteThreadDocAsync(doc, change); } diff --git a/src/SIL.XForge.Scripture/Services/SFProjectService.cs b/src/SIL.XForge.Scripture/Services/SFProjectService.cs index 8868e091b3..eac8a29984 100644 --- a/src/SIL.XForge.Scripture/Services/SFProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/SFProjectService.cs @@ -157,7 +157,7 @@ public async Task CreateProjectAsync(string curUserId, SFProjectCreateSe { throw new InvalidOperationException(ErrorAlreadyConnectedKey); } - IDocument projectDoc = await conn.CreateAsync(projectId, project); + IDocument projectDoc = await conn.CreateAsync(projectId, project, source: null); await ProjectSecrets.InsertAsync(new SFProjectSecret { Id = projectDoc.Id }); IDocument userDoc = await conn.FetchAsync(curUserId); @@ -1142,7 +1142,8 @@ string audioUrl ProjectRef = projectId, // TODO (scripture audio) Should the ID be set here? How does the DataId differ from the document ID? DataId = textAudioId, - } + }, + source: null ); await textAudioDoc.SubmitJson0OpAsync(op => @@ -1275,7 +1276,8 @@ protected override async Task AddUserToProjectAsync( { await conn.CreateAsync( SFProjectUserConfig.GetDocId(projectDoc.Id, userDoc.Id), - new SFProjectUserConfig { ProjectRef = projectDoc.Id, OwnerRef = userDoc.Id } + new SFProjectUserConfig { ProjectRef = projectDoc.Id, OwnerRef = userDoc.Id }, + source: null ); } // Listeners can now assume the ProjectUserConfig is ready when the user is added. @@ -2067,7 +2069,7 @@ private async Task CreateResourceProjectInternalAsync(IConnection conn, throw new InvalidOperationException(ErrorAlreadyConnectedKey); } - IDocument projectDoc = await conn.CreateAsync(projectId, project); + IDocument projectDoc = await conn.CreateAsync(projectId, project, source: null); await ProjectSecrets.InsertAsync(new SFProjectSecret { Id = projectDoc.Id }); // Resource projects do not have administrators, so users are added as needed diff --git a/src/SIL.XForge/Realtime/Connection.cs b/src/SIL.XForge/Realtime/Connection.cs index c29836e7d6..970bb5ac4d 100644 --- a/src/SIL.XForge/Realtime/Connection.cs +++ b/src/SIL.XForge/Realtime/Connection.cs @@ -128,7 +128,8 @@ await _realtimeServer.CreateDocAsync( queuedOperation.Collection, queuedOperation.Id, queuedOperation.Data, - queuedOperation.OtTypeName + queuedOperation.OtTypeName, + queuedOperation.Source ); break; case QueuedAction.Delete: @@ -178,10 +179,17 @@ await _realtimeServer.ReplaceDocAsync( /// The identifier. /// The data. /// Name of the OT type. + /// The source of the op. This is currently only used by text documents. /// /// A snapshot of the created document from the realtime server. /// - public async Task> CreateDocAsync(string collection, string id, T data, string otTypeName) + public async Task> CreateDocAsync( + string collection, + string id, + T data, + string otTypeName, + OpSource? source + ) { if (_isTransaction) { @@ -195,6 +203,7 @@ public async Task> CreateDocAsync(string collection, string id, T Handle = _handle, Id = id, OtTypeName = otTypeName, + Source = source, } ); @@ -203,7 +212,7 @@ public async Task> CreateDocAsync(string collection, string id, T } else { - return await _realtimeServer.CreateDocAsync(_handle, collection, id, data, otTypeName); + return await _realtimeServer.CreateDocAsync(_handle, collection, id, data, otTypeName, source); } } diff --git a/src/SIL.XForge/Realtime/Document.cs b/src/SIL.XForge/Realtime/Document.cs index 067022fdc5..0f27781a6c 100644 --- a/src/SIL.XForge/Realtime/Document.cs +++ b/src/SIL.XForge/Realtime/Document.cs @@ -38,12 +38,12 @@ internal Document(IConnection connection, string otTypeName, string collection, public bool IsLoaded => Data != null; - public async Task CreateAsync(T data) + public async Task CreateAsync(T data, OpSource? source) { await _lock.WaitAsync(); try { - Snapshot snapshot = await _connection.CreateDocAsync(Collection, Id, data, OTTypeName); + Snapshot snapshot = await _connection.CreateDocAsync(Collection, Id, data, OTTypeName, source); UpdateFromSnapshot(snapshot); } finally @@ -66,14 +66,14 @@ public async Task FetchAsync() } } - public async Task FetchOrCreateAsync(Func createData) + public async Task FetchOrCreateAsync(Func createData, OpSource? source) { await _lock.WaitAsync(); try { Snapshot snapshot = await _connection.FetchDocAsync(Collection, Id); if (snapshot.Data == null) - snapshot = await _connection.CreateDocAsync(Collection, Id, createData(), OTTypeName); + snapshot = await _connection.CreateDocAsync(Collection, Id, createData(), OTTypeName, source); UpdateFromSnapshot(snapshot); } finally diff --git a/src/SIL.XForge/Realtime/IConnection.cs b/src/SIL.XForge/Realtime/IConnection.cs index 8dd9df3cb5..72ecf150c3 100644 --- a/src/SIL.XForge/Realtime/IConnection.cs +++ b/src/SIL.XForge/Realtime/IConnection.cs @@ -10,7 +10,7 @@ public interface IConnection : IDisposable, IAsyncDisposable { void BeginTransaction(); Task CommitTransactionAsync(); - Task> CreateDocAsync(string collection, string id, T data, string otTypeName); + Task> CreateDocAsync(string collection, string id, T data, string otTypeName, OpSource? source); Task DeleteDocAsync(string collection, string id); void ExcludePropertyFromTransaction(Expression> field); Task> FetchDocAsync(string collection, string id); diff --git a/src/SIL.XForge/Realtime/IDocument.cs b/src/SIL.XForge/Realtime/IDocument.cs index 635662b759..3cc33840df 100644 --- a/src/SIL.XForge/Realtime/IDocument.cs +++ b/src/SIL.XForge/Realtime/IDocument.cs @@ -14,11 +14,11 @@ public interface IDocument T Data { get; } bool IsLoaded { get; } - Task CreateAsync(T data); + Task CreateAsync(T data, OpSource? source); Task FetchAsync(); - Task FetchOrCreateAsync(Func createData); + Task FetchOrCreateAsync(Func createData, OpSource? source); Task SubmitOpAsync(object op, OpSource? source); diff --git a/src/SIL.XForge/Realtime/IRealtimeServer.cs b/src/SIL.XForge/Realtime/IRealtimeServer.cs index 89c3a6d018..32cd5a7a36 100644 --- a/src/SIL.XForge/Realtime/IRealtimeServer.cs +++ b/src/SIL.XForge/Realtime/IRealtimeServer.cs @@ -8,7 +8,14 @@ public interface IRealtimeServer { Task ApplyOpAsync(string otTypeName, T data, object op); Task ConnectAsync(string userId = null); - Task> CreateDocAsync(int handle, string collection, string id, T data, string otTypeName); + Task> CreateDocAsync( + int handle, + string collection, + string id, + T data, + string otTypeName, + OpSource? source + ); Task DeleteDocAsync(int handle, string collection, string id); void Disconnect(int handle); Task DisconnectAsync(int handle); diff --git a/src/SIL.XForge/Realtime/MemoryConnection.cs b/src/SIL.XForge/Realtime/MemoryConnection.cs index 9d4fe8a630..8768afddf8 100644 --- a/src/SIL.XForge/Realtime/MemoryConnection.cs +++ b/src/SIL.XForge/Realtime/MemoryConnection.cs @@ -45,8 +45,13 @@ public void BeginTransaction() { } /// /// This is not supported by a . /// - public Task> CreateDocAsync(string collection, string id, T data, string otTypeName) => - throw new NotImplementedException(); + public Task> CreateDocAsync( + string collection, + string id, + T data, + string otTypeName, + OpSource? source + ) => throw new NotImplementedException(); /// /// Deletes a document asynchronously. diff --git a/src/SIL.XForge/Realtime/MemoryDocument.cs b/src/SIL.XForge/Realtime/MemoryDocument.cs index af87e0a1e4..f9c842a3de 100644 --- a/src/SIL.XForge/Realtime/MemoryDocument.cs +++ b/src/SIL.XForge/Realtime/MemoryDocument.cs @@ -23,7 +23,7 @@ public class MemoryDocument(MemoryRepository repo, string otTypeName, stri public bool IsLoaded => Data != null; - public async Task CreateAsync(T data) + public async Task CreateAsync(T data, OpSource? source) { if (IsLoaded) throw new InvalidOperationException("The doc already exists."); @@ -57,11 +57,11 @@ public async Task FetchAsync() } } - public async Task FetchOrCreateAsync(Func createData) + public async Task FetchOrCreateAsync(Func createData, OpSource? source) { await FetchAsync(); if (!IsLoaded) - await CreateAsync(createData()); + await CreateAsync(createData(), source); } public async Task SubmitOpAsync(object op, OpSource? source) diff --git a/src/SIL.XForge/Realtime/RealtimeExtensions.cs b/src/SIL.XForge/Realtime/RealtimeExtensions.cs index 27e884a095..469dd6b834 100644 --- a/src/SIL.XForge/Realtime/RealtimeExtensions.cs +++ b/src/SIL.XForge/Realtime/RealtimeExtensions.cs @@ -41,19 +41,24 @@ public static async Task> FetchAsync(this IConnection conn, stri return doc; } - public static async Task> CreateAsync(this IConnection conn, string id, T data) + public static async Task> CreateAsync(this IConnection conn, string id, T data, OpSource? source) where T : IIdentifiable { IDocument doc = conn.Get(id); - await doc.CreateAsync(data); + await doc.CreateAsync(data, source); return doc; } - public static async Task> FetchOrCreateAsync(this IConnection conn, string id, Func createData) + public static async Task> FetchOrCreateAsync( + this IConnection conn, + string id, + Func createData, + OpSource? source + ) where T : IIdentifiable { IDocument doc = conn.Get(id); - await doc.FetchOrCreateAsync(createData); + await doc.FetchOrCreateAsync(createData, source); return doc; } } diff --git a/src/SIL.XForge/Realtime/RealtimeServer.cs b/src/SIL.XForge/Realtime/RealtimeServer.cs index c07978f5b1..1d5a754703 100644 --- a/src/SIL.XForge/Realtime/RealtimeServer.cs +++ b/src/SIL.XForge/Realtime/RealtimeServer.cs @@ -66,8 +66,14 @@ public Task ConnectAsync(string? userId = null) return InvokeExportAsync("connect"); } - public Task> CreateDocAsync(int handle, string collection, string id, T data, string otTypeName) => - InvokeExportAsync>("createDoc", handle, collection, id, data, otTypeName); + public Task> CreateDocAsync( + int handle, + string collection, + string id, + T data, + string otTypeName, + OpSource? source + ) => InvokeExportAsync>("createDoc", handle, collection, id, data, otTypeName, source); public Task> FetchDocAsync(int handle, string collection, string id) => InvokeExportAsync>("fetchDoc", handle, collection, id); diff --git a/src/SIL.XForge/Services/UserService.cs b/src/SIL.XForge/Services/UserService.cs index 2d3460facd..e7d5820ca7 100644 --- a/src/SIL.XForge/Services/UserService.cs +++ b/src/SIL.XForge/Services/UserService.cs @@ -75,7 +75,8 @@ public async Task UpdateUserFromProfileAsync(string curUserId, string userProfil ? (string)userProfile["nickname"] : name, IsDisplayNameConfirmed = displayNameSetAtSignup, - } + }, + source: null ); await userDoc.SubmitJson0OpAsync(op => { diff --git a/test/SIL.XForge.Tests/Realtime/ConnectionTests.cs b/test/SIL.XForge.Tests/Realtime/ConnectionTests.cs index c4d6e7a826..6be88bed76 100644 --- a/test/SIL.XForge.Tests/Realtime/ConnectionTests.cs +++ b/test/SIL.XForge.Tests/Realtime/ConnectionTests.cs @@ -78,7 +78,7 @@ public async Task CommitTransactionAsync_UsesUnderlyingRealtimeServerOnCommit() // Setup Queue env.Service.BeginTransaction(); - await env.Service.CreateDocAsync(collection, id, snapshot.Data, otTypeName); + await env.Service.CreateDocAsync(collection, id, snapshot.Data, otTypeName, source: null); await env.Service.SubmitOpAsync(collection, id, op, snapshot.Data, snapshot.Version, source: null); await env.Service.ReplaceDocAsync(collection, id, data, snapshot.Version, source: null); await env.Service.DeleteDocAsync(collection, id); @@ -95,7 +95,14 @@ public async Task CommitTransactionAsync_UsesUnderlyingRealtimeServerOnCommit() // Verify Submit Operations await env .RealtimeService.Server.Received(1) - .CreateDocAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + .CreateDocAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); await env .RealtimeService.Server.Received(1) .DeleteDocAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -128,10 +135,11 @@ public async Task CreateDocAsync_QueuesAction() string id = "id1"; var data = new TestProject { Id = id, Name = "Test Project 1" }; string otTypeName = OTType.Json0; + OpSource? source = OpSource.Draft; // SUT env.Service.BeginTransaction(); - var result = await env.Service.CreateDocAsync(collection, id, data, otTypeName); + var result = await env.Service.CreateDocAsync(collection, id, data, otTypeName, source); // Verify result Assert.AreEqual(result.Version, 1); @@ -145,11 +153,19 @@ public async Task CreateDocAsync_QueuesAction() Assert.AreEqual(queuedOperation.Data, data); Assert.AreEqual(queuedOperation.Id, id); Assert.AreEqual(queuedOperation.OtTypeName, otTypeName); + Assert.AreEqual(queuedOperation.Source, source); // Verify that the call was not passed to the underlying realtime server await env .RealtimeService.Server.Received(0) - .CreateDocAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + .CreateDocAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); } [Test] @@ -161,9 +177,10 @@ public async Task CreateDocAsync_UsesUnderlyingRealtimeServerOutsideOfATransacti string id = "id1"; var data = new TestProject { Id = id, Name = "Test Project 1" }; string otTypeName = OTType.Json0; + OpSource? source = OpSource.Draft; // SUT - await env.Service.CreateDocAsync(collection, id, data, otTypeName); + await env.Service.CreateDocAsync(collection, id, data, otTypeName, source); // Verify queue is empty Assert.AreEqual(env.Service.QueuedOperations.Count, 0); @@ -176,7 +193,8 @@ await env Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any() + Arg.Any(), + Arg.Any() ); } diff --git a/test/SIL.XForge.Tests/Realtime/DocumentTests.cs b/test/SIL.XForge.Tests/Realtime/DocumentTests.cs index c82a357689..3e661e0fd6 100644 --- a/test/SIL.XForge.Tests/Realtime/DocumentTests.cs +++ b/test/SIL.XForge.Tests/Realtime/DocumentTests.cs @@ -27,12 +27,12 @@ public class DocumentTests public async Task CreateAsync_Success() { var env = new TestEnvironment(); - env.Connection.CreateDocAsync(Collection, Id, _data, OtTypeName).Returns(Task.FromResult(_snapshot)); + env.Connection.CreateDocAsync(Collection, Id, _data, OtTypeName, Source).Returns(Task.FromResult(_snapshot)); // SUT - await env.Document.CreateAsync(_data); + await env.Document.CreateAsync(_data, Source); - await env.Connection.Received(1).CreateDocAsync(Collection, Id, _data, OtTypeName); + await env.Connection.Received(1).CreateDocAsync(Collection, Id, _data, OtTypeName, Source); } [Test] @@ -62,15 +62,15 @@ public async Task FetchAsync_Success() public async Task FetchAsyncOrCreate_Creates() { var env = new TestEnvironment(); - env.Connection.CreateDocAsync(Collection, Id, _data, OtTypeName).Returns(Task.FromResult(_snapshot)); + env.Connection.CreateDocAsync(Collection, Id, _data, OtTypeName, Source).Returns(Task.FromResult(_snapshot)); env.Connection.FetchDocAsync(Collection, Id) .Returns(Task.FromResult(new Snapshot())); // SUT - await env.Document.FetchOrCreateAsync(() => _data); + await env.Document.FetchOrCreateAsync(() => _data, Source); await env.Connection.Received(1).FetchDocAsync(Collection, Id); - await env.Connection.Received(1).CreateDocAsync(Collection, Id, _data, OtTypeName); + await env.Connection.Received(1).CreateDocAsync(Collection, Id, _data, OtTypeName, Source); } [Test] @@ -80,10 +80,10 @@ public async Task FetchAsyncOrCreate_Fetches() env.Connection.FetchDocAsync(Collection, Id).Returns(Task.FromResult(_snapshot)); // SUT - await env.Document.FetchOrCreateAsync(() => _data); + await env.Document.FetchOrCreateAsync(() => _data, Source); await env.Connection.Received(1).FetchDocAsync(Collection, Id); - await env.Connection.Received(0).CreateDocAsync(Collection, Id, _data, OtTypeName); + await env.Connection.Received(0).CreateDocAsync(Collection, Id, _data, OtTypeName, Source); } [Test] diff --git a/test/SIL.XForge.Tests/Realtime/RealtimeServerTests.cs b/test/SIL.XForge.Tests/Realtime/RealtimeServerTests.cs index 0f515ad24c..edb9711b81 100644 --- a/test/SIL.XForge.Tests/Realtime/RealtimeServerTests.cs +++ b/test/SIL.XForge.Tests/Realtime/RealtimeServerTests.cs @@ -31,7 +31,7 @@ public async Task CreateDocAsync_Success() object data = new { }; // SUT - await env.Service.CreateDocAsync(0, string.Empty, string.Empty, data, OTType.Json0); + await env.Service.CreateDocAsync(0, string.Empty, string.Empty, data, OTType.Json0, source: null); await env .NodeJsProcess.Received(1) From e00c2f7f4904182566164af2daa11ef37ac9585e Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 17 Feb 2026 12:39:22 +1300 Subject: [PATCH 30/40] SF-3633 Update the history tab a draft is applied --- .../app/core/project-notification.service.ts | 8 ++ .../editor-history.component.spec.ts | 3 + .../history-chooser.component.spec.ts | 3 + .../history-chooser.component.ts | 101 ++++++++++++++---- .../Services/DraftNotificationHub.cs | 9 ++ .../Services/INotifier.cs | 1 + .../Services/MachineApiService.cs | 20 ++++ .../Services/NotificationHub.cs | 13 +++ .../Services/NotificationHubExtensions.cs | 8 +- 9 files changed, 144 insertions(+), 22 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..1c50836ebf 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 @@ -36,6 +36,10 @@ export class ProjectNotificationService { this.connection.off('notifyBuildProgress', handler); } + removeNotifyDraftApplyProgressHandler(handler: any): void { + this.connection.off('notifyDraftApplyProgress', handler); + } + removeNotifySyncProgressHandler(handler: any): void { this.connection.off('notifySyncProgress', handler); } @@ -44,6 +48,10 @@ export class ProjectNotificationService { this.connection.on('notifyBuildProgress', handler); } + setNotifyDraftApplyProgressHandler(handler: any): void { + this.connection.on('notifyDraftApplyProgress', handler); + } + setNotifySyncProgressHandler(handler: any): void { this.connection.on('notifySyncProgress', handler); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts index 1ced70d8e4..9e7853db39 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts @@ -17,6 +17,7 @@ import { TypeRegistry } from 'xforge-common/type-registry'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { TextDoc, TextDocId } from '../../../core/models/text-doc'; import { Revision } from '../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../core/project-notification.service'; import { SFProjectService } from '../../../core/sf-project.service'; import { TextComponent } from '../../../shared/text/text.component'; import { EditorHistoryComponent } from './editor-history.component'; @@ -28,6 +29,7 @@ describe('EditorHistoryComponent', () => { const mockSFProjectService = mock(SFProjectService); const mockI18nService = mock(I18nService); const mockHistoryChooserComponent = mock(HistoryChooserComponent); + const mockProjectNotificationService = mock(ProjectNotificationService); const revisionSelect$ = new Subject(); const showDiffChange$ = new Subject(); @@ -39,6 +41,7 @@ describe('EditorHistoryComponent', () => { provideTestRealtime(new TypeRegistry([TextDoc], [FileType.Audio], [])), provideTestOnlineStatus(), { provide: OnlineStatusService, useClass: TestOnlineStatusService }, + { provide: ProjectNotificationService, useMock: mockProjectNotificationService }, { provide: SFProjectService, useMock: mockSFProjectService }, { provide: I18nService, useMock: mockI18nService } ] diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts index 64db6e6b94..72bde280f0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.spec.ts @@ -18,6 +18,7 @@ import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/te import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile-doc'; import { SF_TYPE_REGISTRY } from '../../../../core/models/sf-type-registry'; import { ParatextService } from '../../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../../core/project-notification.service'; import { SFProjectService } from '../../../../core/sf-project.service'; import { TextDocService } from '../../../../core/text-doc.service'; import { HistoryChooserComponent } from './history-chooser.component'; @@ -26,6 +27,7 @@ import { HistoryRevisionFormatPipe } from './history-revision-format.pipe'; const mockedDialogService = mock(DialogService); const mockedNoticeService = mock(NoticeService); const mockedParatextService = mock(ParatextService); +const mockedProjectNotificationService = mock(ProjectNotificationService); const mockedProjectService = mock(SFProjectService); const mockedTextDocService = mock(TextDocService); const mockedErrorReportingService = mock(ErrorReportingService); @@ -43,6 +45,7 @@ describe('HistoryChooserComponent', () => { { provide: SFProjectService, useMock: mockedProjectService }, { provide: TextDocService, useMock: mockedTextDocService }, { provide: ErrorReportingService, useMock: mockedErrorReportingService }, + { provide: ProjectNotificationService, useMock: mockedProjectNotificationService }, provideNoopAnimations() ] })); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts index e2a9af5877..2725d7c255 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts @@ -1,5 +1,14 @@ import { AsyncPipe, NgClass } from '@angular/common'; -import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { + AfterViewInit, + Component, + DestroyRef, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges +} from '@angular/core'; import { MatOption } from '@angular/material/autocomplete'; import { MatButton } from '@angular/material/button'; import { MatFormField } from '@angular/material/form-field'; @@ -19,6 +28,7 @@ import { observeOn, startWith, Subject, + take, tap } from 'rxjs'; import { BlurOnClickDirective } from 'xforge-common/blur-on-click.directive'; @@ -30,11 +40,17 @@ import { Snapshot } from 'xforge-common/models/snapshot'; import { TextSnapshot } from 'xforge-common/models/textsnapshot'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile-doc'; import { TextDocId } from '../../../../core/models/text-doc'; import { ParatextService, Revision } from '../../../../core/paratext.service'; +import { ProjectNotificationService } from '../../../../core/project-notification.service'; import { SFProjectService } from '../../../../core/sf-project.service'; import { TextDocService } from '../../../../core/text-doc.service'; +import { + DraftApplyState, + DraftApplyStatus +} from '../../../draft-generation/draft-import-wizard/draft-import-wizard.component'; import { HistoryRevisionFormatPipe } from './history-revision-format.pipe'; export interface RevisionSelectEvent { @@ -62,7 +78,19 @@ export interface RevisionSelectEvent { ] }) export class HistoryChooserComponent implements AfterViewInit, OnChanges { - @Input() projectId?: string; + private _projectId?: string; + private projectId$: Subject = new Subject(); + @Input() set projectId(id: string | undefined) { + if (id == null) { + return; + } + this._projectId = id; + this.projectId$.next(id); + } + get projectId(): string | undefined { + return this._projectId; + } + @Input() bookNum?: number; @Input() chapter?: number; @Input() showDiff = true; @@ -73,6 +101,18 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { selectedRevision: Revision | undefined; selectedSnapshot: TextSnapshot | undefined; + private readonly notifyDraftApplyProgressHandler = (projectId: string, draftApplyState: DraftApplyState): void => { + if ( + draftApplyState.bookNum === this.bookNum && + draftApplyState.chapterNum === this.chapter && + draftApplyState.status === DraftApplyStatus.Successful + ) { + // Clear then reload the history revisions + this.historyRevisions = []; + void this.loadHistory(); + } + }; + // 'asyncScheduler' prevents ExpressionChangedAfterItHasBeenCheckedError private loading$ = new BehaviorSubject(false); isLoading$: Observable = this.loading$.pipe(observeOn(asyncScheduler)); @@ -87,15 +127,32 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { private projectDoc: SFProjectProfileDoc | undefined; constructor( + destroyRef: DestroyRef, private readonly dialogService: DialogService, private readonly onlineStatusService: OnlineStatusService, private readonly noticeService: NoticeService, private readonly paratextService: ParatextService, + projectNotificationService: ProjectNotificationService, private readonly projectService: SFProjectService, private readonly textDocService: TextDocService, private readonly errorReportingService: ErrorReportingService, private readonly i18n: I18nService - ) {} + ) { + this.projectId$.pipe(quietTakeUntilDestroyed(destroyRef), filterNullish(), take(1)).subscribe(async projectId => { + // Start the connection to SignalR + await projectNotificationService.start(); + // Subscribe to notifications for this project + await projectNotificationService.subscribeToProject(projectId); + // When build notifications are received, reload the build history + // NOTE: We do not need the build state, so just ignore it. + projectNotificationService.setNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); + }); + destroyRef.onDestroy(async () => { + // Stop the SignalR connection when the component is destroyed + await projectNotificationService.stop(); + projectNotificationService.setNotifyDraftApplyProgressHandler(this.notifyDraftApplyProgressHandler); + }); + } get canRestoreSnapshot(): boolean { return ( @@ -117,10 +174,6 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { } ngAfterViewInit(): void { - this.loadHistory(); - } - - loadHistory(): void { combineLatest([ this.onlineStatusService.onlineStatus$, this.inputChanged$.pipe( @@ -133,23 +186,29 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { }) ) ]).subscribe(async ([isOnline]) => { - if (isOnline && this.projectId != null && this.bookNum != null && this.chapter != null) { - this.loading$.next(true); - try { - this.projectDoc = await this.projectService.getProfile(this.projectId); - if (this.historyRevisions.length === 0) { - this.historyRevisions = - (await this.paratextService.getRevisions(this.projectId, this.bookId, this.chapter)) ?? []; - - if (this.historyRevisions.length > 0) { - await this.selectRevision(this.historyRevisions[0]); - } + if (isOnline) { + await this.loadHistory(); + } + }); + } + + async loadHistory(): Promise { + if (this.projectId != null && this.bookNum != null && this.chapter != null) { + this.loading$.next(true); + try { + this.projectDoc = await this.projectService.getProfile(this.projectId); + if (this.historyRevisions.length === 0) { + this.historyRevisions = + (await this.paratextService.getRevisions(this.projectId, this.bookId, this.chapter)) ?? []; + + if (this.historyRevisions.length > 0) { + await this.selectRevision(this.historyRevisions[0]); } - } finally { - this.loading$.next(false); } + } finally { + this.loading$.next(false); } - }); + } } async onSelectionChanged(e: MatSelectChange): Promise { diff --git a/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs b/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs index a0d65311ef..6e12fe357a 100644 --- a/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs +++ b/src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs @@ -14,6 +14,15 @@ public class DraftNotificationHub : Hub, IDraftNotifier /// The Scripture Forge project identifier. /// The state of the draft being applied. /// The asynchronous task. + /// + /// This differs from the implementation in in that this version + /// does have stateful reconnection, and so there is a guarantee that it is received by clients. + /// + /// This is a blocking operation if the stateful reconnection buffer is full, so it should only + /// be subscribed to by the user performing the draft import. Using + /// is sufficient for all other users to subscribe to, although they will not receive all draft + /// progress notifications, only the final success message. + /// public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) => await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); diff --git a/src/SIL.XForge.Scripture/Services/INotifier.cs b/src/SIL.XForge.Scripture/Services/INotifier.cs index e3b45da2b8..7b421df877 100644 --- a/src/SIL.XForge.Scripture/Services/INotifier.cs +++ b/src/SIL.XForge.Scripture/Services/INotifier.cs @@ -6,6 +6,7 @@ namespace SIL.XForge.Scripture.Services; public interface INotifier { Task NotifyBuildProgress(string sfProjectId, ServalBuildState buildState); + Task NotifyDraftApplyProgress(string sfProjectId, DraftApplyState draftApplyState); Task NotifySyncProgress(string sfProjectId, ProgressState progressState); Task SubscribeToProject(string projectId); } diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index d777b9a5d6..7ef09f71cb 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -132,6 +132,7 @@ CancellationToken cancellationToken IDocument targetProjectDoc; List createdBooks = []; Dictionary> createdChapters = []; + List<(int bookNum, int chapterNum)> chaptersToNotify = []; List<(ChapterDelta chapterDelta, int bookNum)> chapterDeltas = []; try { @@ -631,6 +632,7 @@ await draftHubContext.NotifyDraftApplyProgress( } // A draft has been applied + chaptersToNotify.Add((bookNum, chapterDelta.Number)); successful = true; } } @@ -669,6 +671,24 @@ await draftHubContext.NotifyDraftApplyProgress( } ); + // Notify any listening users for each chapter that was updated in the target project + if (successful) + { + foreach ((int bookNum, int chapterNum) in chaptersToNotify) + { + // Use the non-blocking hub context which does not have stateful reconnect enabled + await hubContext.NotifyDraftApplyProgress( + targetProjectId, + new DraftApplyState + { + BookNum = bookNum, + ChapterNum = chapterNum, + Status = DraftApplyStatus.Successful, + } + ); + } + } + result.ChangesSaved = successful; } diff --git a/src/SIL.XForge.Scripture/Services/NotificationHub.cs b/src/SIL.XForge.Scripture/Services/NotificationHub.cs index 8f17d103ab..20cf61035e 100644 --- a/src/SIL.XForge.Scripture/Services/NotificationHub.cs +++ b/src/SIL.XForge.Scripture/Services/NotificationHub.cs @@ -21,6 +21,19 @@ public class NotificationHub : Hub, INotifier public async Task NotifyBuildProgress(string projectId, ServalBuildState buildState) => await Clients.Group(projectId).NotifyBuildProgress(projectId, buildState); + /// + /// Notifies subscribers to a project of draft application progress. + /// + /// The Scripture Forge project identifier. + /// The state of the draft being applied. + /// The asynchronous task. + /// + /// This differs from the implementation in in that this version + /// does not have stateful reconnection, and so there is no guarantee that the message is received. + /// + public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) => + await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); + /// /// Notifies subscribers to a project of sync progress. /// diff --git a/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs b/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs index 3af78a79a0..0c29a7f78a 100644 --- a/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs +++ b/src/SIL.XForge.Scripture/Services/NotificationHubExtensions.cs @@ -6,6 +6,12 @@ namespace SIL.XForge.Scripture.Services; public static class NotificationHubExtensions { + public static Task NotifyDraftApplyProgress( + this IHubContext hubContext, + string projectId, + DraftApplyState draftApplyState + ) => hubContext.Clients.Groups(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); + public static Task NotifyBuildProgress( this IHubContext hubContext, string projectId, @@ -13,7 +19,7 @@ ServalBuildState buildState ) => hubContext.Clients.Groups(projectId).NotifyBuildProgress(projectId, buildState); public static Task NotifyDraftApplyProgress( - this IHubContext hubContext, + this IHubContext hubContext, string projectId, DraftApplyState draftApplyState ) => hubContext.Clients.Groups(projectId).NotifyDraftApplyProgress(projectId, draftApplyState); From 478a7a17d365810350f96364e92095e162267778 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 17 Feb 2026 12:41:49 +1300 Subject: [PATCH 31/40] SF-3633 Fix two test warnings noticed in testing --- .../draft-generation/training-data/training-data.service.spec.ts | 1 + src/SIL.XForge.Scripture/ClientApp/src/karma.conf.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts index d026643476..9c85c5399b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data.service.spec.ts @@ -104,6 +104,7 @@ describe('TrainingDataService', () => { deepEqual({ projectId: 'project01', dataId: 'data01' }) ) ).once(); + expect().nothing(); })); it('should query training data docs', fakeAsync(async () => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/karma.conf.js b/src/SIL.XForge.Scripture/ClientApp/src/karma.conf.js index 0ff1e46280..8ec00b489f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/karma.conf.js +++ b/src/SIL.XForge.Scripture/ClientApp/src/karma.conf.js @@ -49,6 +49,7 @@ module.exports = function (config) { }, files: [], proxies: { + '/assets/icons/TagIcons/circle01.png': '', '/assets/icons/TagIcons/defaultIcon.png': '', '/assets/icons/TagIcons/flag01.png': '', '/assets/icons/TagIcons/flag04.png': '', From 48da70f75f8e43d551e132b0bcb4d7c86b710974 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 17 Feb 2026 14:18:18 +1300 Subject: [PATCH 32/40] SF-3633 Fix unhandled errors when the project has been deleted --- .../draft-import-wizard.component.html | 6 ++-- .../draft-import-wizard.component.ts | 28 ++++++++++++----- .../src/assets/i18n/non_checking_en.json | 2 ++ .../Controllers/SFProjectsRpcController.cs | 9 +++++- .../SFProjectsRpcControllerTests.cs | 30 +++++++++++++++++-- 5 files changed, 61 insertions(+), 14 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 76d20952fc..74ead57928 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 @@ -350,7 +350,7 @@

{{ t("importing_draft") }}

{{ t("complete") }} - @if (!isSyncing && !syncComplete && !skipSync) { + @if (!isSyncing && !syncComplete && !skipSync && syncError == null) {

{{ t("sync_project_question") }}

} } - @if (syncError != null) { -

{{ t("sync") }}

+ @if (syncError != null && !syncComplete && !skipSync) { +

{{ t("sync_failed") }}

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

{{ t("click_book_to_preview") }}

+

{{ t("click_book_to_preview_draft") }}

+

+ +