From ea96adf3c216b4fdd6a45ce28233b9e2dd355564 Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Fri, 19 Jun 2026 15:18:32 -0400 Subject: [PATCH 01/17] SF-3817 Partial book drafting --- .../scriptureforge/models/translate-config.ts | 2 + .../ClientApp/e2e/characterize-tests.mts | 2 +- .../ClientApp/e2e/test-definitions.ts | 4 + .../ClientApp/e2e/test_characterization.json | 4 + .../test_data/partial_draft_training_1.tsv | 4 + .../test_data/partial_draft_training_2.tsv | 4 + .../e2e/workflows/partial-book-drafting.ts | 406 ++++++ .../ClientApp/src/app/app.component.html | 5 + .../ClientApp/src/app/app.component.ts | 7 + .../ClientApp/src/app/app.routes.ts | 6 + .../src/app/core/sf-project.service.ts | 12 +- .../serval-build-report.ts | 5 +- .../serval-builds.component.ts | 20 +- .../book-multi-select.component.html | 66 +- .../book-multi-select.component.spec.ts | 21 +- .../book-multi-select.component.ts | 36 +- .../progress-service/progress.service.spec.ts | 111 +- .../progress-service/progress.service.ts | 83 +- .../src/app/shared/scripture-range.spec.ts | 188 +++ .../src/app/shared/scripture-range.ts | 255 ++++ .../ClientApp/src/app/shared/utils.spec.ts | 15 +- .../ClientApp/src/app/shared/utils.ts | 26 +- .../confirm-sources.component.scss | 3 +- .../draft-generation-steps.component.ts | 35 +- .../draft-generation.component.spec.ts | 5 +- .../draft-generation.component.ts | 12 +- .../draft-generation.service.spec.ts | 69 + .../draft-generation.service.ts | 5 + .../draft-generation/draft-generation.ts | 6 + .../draft-import-wizard.component.spec.ts | 65 + .../draft-import-wizard.component.ts | 42 +- .../draft-preview-books.component.html | 3 + .../draft-preview-books.component.spec.ts | 29 + .../draft-preview-books.component.ts | 67 +- .../draft-onboarding-form.component.html | 1 - .../draft-generation/draft-source.ts | 19 + .../draft-sources.service.spec.ts | 25 +- .../draft-generation/draft-sources.service.ts | 5 +- .../draft-pending-updates.component.html | 47 + .../draft-pending-updates.component.scss | 45 + .../draft-pending-updates.component.spec.ts | 477 +++++++ ...draft-pending-updates.component.stories.ts | 204 +++ .../draft-pending-updates.component.ts | 162 +++ .../new-draft/new-draft-logic-handler.spec.ts | 1147 ++++++++++++++++ .../new-draft/new-draft-logic-handler.ts | 699 ++++++++++ .../new-draft/new-draft.component.html | 367 ++++++ .../new-draft/new-draft.component.scss | 193 +++ .../new-draft/new-draft.component.spec.ts | 1157 +++++++++++++++++ .../new-draft/new-draft.component.stories.ts | 505 +++++++ .../new-draft/new-draft.component.ts | 916 +++++++++++++ .../training-data-file-selection.spec.ts | 65 + .../new-draft/training-data-file-selection.ts | 33 + .../new-draft/training-data-summary.spec.ts | 103 ++ .../new-draft/training-data-summary.ts | 82 ++ .../src/assets/i18n/non_checking_en.json | 85 ++ ...xperimental-features-dialog.component.html | 24 + ...xperimental-features-dialog.component.scss | 40 + ...rimental-features-dialog.component.spec.ts | 142 ++ .../experimental-features-dialog.component.ts | 34 + .../experimental-features.service.ts | 53 + .../feature-flags/feature-flag.service.ts | 7 + .../src/xforge-common/i18n.service.ts | 13 +- .../src/xforge-common/util/set-util.spec.ts | 53 + .../src/xforge-common/util/set-util.ts | 34 + .../Models/BookProgress.cs | 20 + .../Models/BuildConfig.cs | 11 + .../Models/DraftConfig.cs | 7 + .../Services/BuildConfigJsonConverter.cs | 6 + .../Services/MachineApiService.cs | 10 + .../Services/MachineProjectService.cs | 67 +- .../Services/SFProjectService.cs | 28 + .../Services/BuildConfigJsonConverterTests.cs | 74 ++ .../Services/MachineApiServiceTests.cs | 26 + .../Services/MachineProjectServiceTests.cs | 63 + 74 files changed, 8460 insertions(+), 212 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_1.tsv create mode 100644 src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_2.tsv create mode 100644 src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.stories.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.ts diff --git a/src/RealtimeServer/scriptureforge/models/translate-config.ts b/src/RealtimeServer/scriptureforge/models/translate-config.ts index d1f4579e85d..541c78b9276 100644 --- a/src/RealtimeServer/scriptureforge/models/translate-config.ts +++ b/src/RealtimeServer/scriptureforge/models/translate-config.ts @@ -74,6 +74,8 @@ export interface DraftConfig { draftingSources: TranslateSource[]; trainingSources: TranslateSource[]; lastSelectedTrainingDataFiles: string[]; + /** Training data files available at the last build, to distinguish newly added files from deliberately deselected ones. */ + lastAvailableTrainingDataFiles?: string[]; lastSelectedTrainingScriptureRanges?: ProjectScriptureRange[]; lastSelectedTranslationScriptureRanges?: ProjectScriptureRange[]; fastTraining?: boolean; diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/characterize-tests.mts b/src/SIL.XForge.Scripture/ClientApp/e2e/characterize-tests.mts index 0c6ae3d57aa..89ffe98f982 100755 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/characterize-tests.mts +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/characterize-tests.mts @@ -6,7 +6,7 @@ import { numberOfTimesToAttemptTest } from './pass-probability.ts'; import { ScreenshotContext } from './presets.ts'; import { tests } from './test-definitions.ts'; -const retriesToStopAt = 3; // Stop characterization after a test is reliable enough to only need this many retries +const retriesToStopAt = 4; // Stop characterization after a test is reliable enough to only need this many retries const resultFilePath = 'test_characterization.json'; const testNames = Object.keys(tests) as (keyof typeof tests)[]; let mostRecentResultData = JSON.parse(await Deno.readTextFile(resultFilePath)); diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/test-definitions.ts b/src/SIL.XForge.Scripture/ClientApp/e2e/test-definitions.ts index 11725b76592..b075103cd3d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/test-definitions.ts +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/test-definitions.ts @@ -6,6 +6,7 @@ import { editTranslation } from './workflows/edit-translation.ts'; import { generateDraft } from './workflows/generate-draft.ts'; import { localizedScreenshots } from './workflows/localized-screenshots.ts'; import { onboardingFlow } from './workflows/onboarding-flow.ts'; +import { partialBookDrafting } from './workflows/partial-book-drafting.ts'; import { runSmokeTests, traverseHomePageAndLoginPage } from './workflows/smoke-tests.mts'; export const tests = { @@ -24,6 +25,9 @@ export const tests = { generate_draft: async (_engine: BrowserType, page: Page, screenshotContext: ScreenshotContext) => { await generateDraft(page, screenshotContext, secrets.users[0]); }, + partial_book_drafting: async (_engine: BrowserType, page: Page, screenshotContext: ScreenshotContext) => { + await partialBookDrafting(page, screenshotContext, secrets.users[0]); + }, onboarding_flow: async (engine: BrowserType, page: Page, screenshotContext: ScreenshotContext) => { await onboardingFlow(engine, page, screenshotContext, secrets.users[0]); }, diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/test_characterization.json b/src/SIL.XForge.Scripture/ClientApp/e2e/test_characterization.json index 4d8d2a4e41b..445fe7eb130 100644 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/test_characterization.json +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/test_characterization.json @@ -26,5 +26,9 @@ "onboarding_flow": { "success": 7, "failure": 0 + }, + "partial_book_drafting": { + "success": 7, + "failure": 0 } } diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_1.tsv b/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_1.tsv new file mode 100644 index 00000000000..070da6d5de2 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_1.tsv @@ -0,0 +1,4 @@ +Source Target +In the beginning God created the heavens and the earth. En el principio creó Dios los cielos y la tierra. +And God said, Let there be light: and there was light. Y dijo Dios: Sea la luz; y fue la luz. +And God called the light Day, and the darkness he called Night. Y llamó Dios a la luz Día, y a las tinieblas llamó Noche. diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_2.tsv b/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_2.tsv new file mode 100644 index 00000000000..93f688af826 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_2.tsv @@ -0,0 +1,4 @@ +Source Target +Now the serpent was more subtil than any beast of the field. Pero la serpiente era astuta, más que todos los animales del campo. +And they heard the voice of the LORD God walking in the garden. Y oyeron la voz de Jehová Dios que se paseaba en el huerto. +And the LORD God called unto Adam, and said unto him, Where art thou? Mas Jehová Dios llamó al hombre, y le dijo: ¿Dónde estás tú? diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts new file mode 100644 index 00000000000..1c5212c9322 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts @@ -0,0 +1,406 @@ +import { expect } from 'npm:@playwright/test'; +import { Locator, Page } from 'npm:playwright'; +import { E2E_SYNC_DEFAULT_TIMEOUT, preset, ScreenshotContext } from '../e2e-globals.ts'; +import { + enableDeveloperMode, + enableDraftingOnProjectAsServalAdmin, + enableFeatureFlag, + freshlyConnectProject, + getNewBrowserForSideWork, + installMouseFollower, + logInAsPTUser, + logInAsSiteAdmin, + logOut, + screenshot, + switchLanguage +} from '../e2e-utils.ts'; +import { UserEmulator } from '../user-emulator.mts'; + +type EngineMode = 'echo' | 'fast'; + +const ENGINE_MODE: EngineMode = 'echo'; + +const TARGET_PROJECT_SHORT_NAME = 'SFDDP'; +const DRAFTING_SOURCE = { shortName: 'ntv', optionName: 'NTV - Nueva Traducción' }; +const SECOND_REFERENCE = { shortName: 'dhh94', optionName: 'DHH94 - Spanish: Dios Habla' }; + +const PARTIAL_BOOK_NAME = 'Genesis'; +const DRAFT_CHAPTERS = '30-32'; +const FIRST_DRAFTED_CHAPTER = 30; +const AN_UNDRAFTED_CHAPTER = 33; + +// Whole books we train on from the (complete) New Testament, to verify training-book persistence on return. +const TRAINING_NT_BOOKS = ['Matthew', 'Mark']; +// A book we deliberately leave UNSELECTED in run 1. On return it must still be unselected. +const UNSELECTED_TRAINING_BOOK = 'Luke'; + +const TRAINING_FILES = [ + { path: 'test_data/partial_draft_training_1.tsv', title: 'partial_draft_training_1' }, + { path: 'test_data/partial_draft_training_2.tsv', title: 'partial_draft_training_2' } +]; +// Deselected in the wizard; verified as still-deselected on return. +const DESELECTED_FILE = TRAINING_FILES[1]; + +export async function partialBookDrafting( + page: Page, + context: ScreenshotContext, + credentials: { email: string; password: string } +): Promise { + await logInAsPTUser(page, credentials); + await switchLanguage(page, 'en'); + if (preset.showArrow) await installMouseFollower(page); + await page.waitForTimeout(500); + const user = new UserEmulator(page); + + await freshlyConnectProject(page, TARGET_PROJECT_SHORT_NAME); + + await enableDeveloperMode(page, { closeMenu: true }); + await enableFeatureFlag(page, 'Partial book drafting'); + + await user.click(page.getByRole('link', { name: 'Generate draft' })); + await expect(page.getByRole('heading', { name: 'Generate translation drafts' })).toBeVisible(); + + // Have a Serval admin enable pre-translation drafting on the project + const siteAdminBrowser = await getNewBrowserForSideWork(); + await logInAsSiteAdmin(siteAdminBrowser.page); + await enableDraftingOnProjectAsServalAdmin(siteAdminBrowser.page, TARGET_PROJECT_SHORT_NAME); + await siteAdminBrowser.browser.close(); + + await configureSources(page, user, context); + + // Launch a partial-book draft + await user.click(page.getByRole('button', { name: 'Generate draft' })); + await stepThroughPreface(page, user, context); + await selectPartialDraftBook(page, user, context); + await selectTrainingData(page, user, context); + await reviewSummaryAndGenerate(page, user, context); + + await waitForDraftToComplete(page, context); + // Verify what was actually drafted + await importDraft(page, user, context); + await verifyDraftedChaptersInEditor(page, user); + + // Return to the wizard and verify remembered selections + await verifyRememberedSelectionsOnReturn(page, user, context); + + await logOut(page); +} + +/** Configure the drafting source, two reference projects, and upload two training-data files, then save & sync. */ +async function configureSources(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + await user.click(page.getByRole('button', { name: 'Configure sources' })); + + const trainingDataSection = page.locator('mat-card').nth(0); + const translationDataSection = page.locator('mat-card').nth(1); + + await user.click(trainingDataSection.getByRole('combobox').first()); + await user.type(DRAFTING_SOURCE.shortName); + await user.click(page.getByRole('option', { name: DRAFTING_SOURCE.optionName })); + await user.click(page.getByRole('button', { name: 'Add another reference project' })); + await user.click(trainingDataSection.getByRole('combobox').nth(1)); + await user.type(SECOND_REFERENCE.shortName); + await user.click(page.getByRole('option', { name: SECOND_REFERENCE.optionName })); + + // Fixtures have a header row; skip it. + for (const file of TRAINING_FILES) { + await user.click(page.getByRole('button', { name: 'Upload spreadsheet' })); + await page.locator('mat-dialog-container input[type="file"]').setInputFiles(file.path); + await page.getByRole('checkbox', { name: 'Skip first row of data file' }).check(); + await page.locator('#upload-save-btn').click(); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + } + + await user.click(translationDataSection.getByRole('combobox')); + await user.type(DRAFTING_SOURCE.shortName); + await user.click(page.getByRole('option', { name: DRAFTING_SOURCE.optionName })); + + await user.check(page.getByRole('checkbox', { name: 'All the language codes are correct' })); + await screenshot(page, { pageName: 'partial_draft_configure_sources', ...context }); + await user.click(page.locator('#save_button')); + + // Saving/syncing can take several minutes on first sync. + const closeLocator = page.getByRole('button', { name: 'Close' }); + await expect(closeLocator).toBeVisible({ timeout: 5 * 60_000 }); + await user.click(closeLocator); +} + +/** The pre-step (pending updates) may or may not appear; then the read-only source-confirmation preface. */ +async function stepThroughPreface(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + const continueAnyway = page.getByRole('button', { name: 'Continue anyway' }); + if (await continueAnyway.isVisible().catch(() => false)) { + await user.click(continueAnyway); + } + + await expect(page.locator('app-confirm-sources')).toBeVisible(); + await expect(page.locator('app-confirm-sources')).toContainText(DRAFTING_SOURCE.shortName.toUpperCase()); + await screenshot(page, { pageName: 'partial_draft_preface', ...context }); + await user.click(page.getByRole('button', { name: 'Next' })); +} + +/** Select Genesis, assert the untranslated-tail default, exercise validation, then narrow to a known sub-range. */ +async function selectPartialDraftBook(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + await expect(page.getByRole('option', { name: PARTIAL_BOOK_NAME, exact: true })).toBeVisible(); + await user.click(page.getByRole('option', { name: PARTIAL_BOOK_NAME, exact: true })); + + const chapterInput = page.locator('.partial-book-drafting-table .chapter-input input'); + await expect(chapterInput).toBeVisible(); + + // Default = chapters in the source but not the target (the untranslated chapters). + const defaultRange = await chapterInput.inputValue(); + console.log(`Default drafting chapter range for ${PARTIAL_BOOK_NAME}: "${defaultRange}"`); + expect(defaultRange).toMatch(/-50$/); + expect(Number.parseInt(defaultRange)).toBeGreaterThan(1); + + await chapterInput.fill('23-21'); + await chapterInput.blur(); + await expect(page.locator('.partial-book-drafting-table .chapter-error')).toBeVisible(); + + await chapterInput.fill(DRAFT_CHAPTERS); + await chapterInput.blur(); + await expect(page.locator('.partial-book-drafting-table .chapter-error')).toHaveCount(0); + await expect(chapterInput).toHaveValue(DRAFT_CHAPTERS); + + await screenshot(page, { pageName: 'partial_draft_select_books', ...context }); + await user.click(page.getByRole('button', { name: 'Next' })); +} + +/** On the training step: pick target training NT books, per-source books, and deselect one training-data file. */ +async function selectTrainingData(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + // Pick a specific NT subset and leave UNSELECTED_TRAINING_BOOK untouched to verify both-ways persistence on return. + const bookSelects = page.locator('app-book-multi-select'); + const targetBookSelect = bookSelects.first(); + for (const book of TRAINING_NT_BOOKS) { + await user.click(targetBookSelect.getByRole('option', { name: book, exact: true })); + } + + // Selecting a target book auto-pairs it in each training source; verify auto-pairing happened in every source. + const sourceCount = await bookSelects.count(); + expect(sourceCount).toBeGreaterThan(1); // target + at least one training source + for (let i = 1; i < sourceCount; i++) { + for (const book of TRAINING_NT_BOOKS) { + await expectBookSelected(bookSelects.nth(i), book, true); + } + } + + // Deselecting an auto-paired book from one source is allowed while the other source still covers it. + const toggledBook = TRAINING_NT_BOOKS[TRAINING_NT_BOOKS.length - 1]; + const firstSourceSelect = bookSelects.nth(1); + await user.click(firstSourceSelect.getByRole('option', { name: toggledBook, exact: true })); + await expectBookSelected(firstSourceSelect, toggledBook, false); + await user.click(firstSourceSelect.getByRole('option', { name: toggledBook, exact: true })); + await expectBookSelected(firstSourceSelect, toggledBook, true); + + // Deselect the second training-data file; both start selected by default for a first build. + const fileCheckbox = page.locator('.training-data-files').getByRole('checkbox', { name: DESELECTED_FILE.title }); + await expect(fileCheckbox).toBeChecked(); + await user.click(fileCheckbox); + await expect(fileCheckbox).not.toBeChecked(); + + await screenshot(page, { pageName: 'partial_draft_training_data', ...context }); + await user.click(page.getByRole('button', { name: 'Next' })); +} + +/** The summary should reflect the partial book selection; set the engine and launch. */ +async function reviewSummaryAndGenerate(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + await expect(page.locator('.draft-books-list')).toContainText(`${PARTIAL_BOOK_NAME} (${DRAFT_CHAPTERS})`); + + if (ENGINE_MODE === 'echo') { + await user.check(page.getByRole('checkbox', { name: 'Echo Translation Engine' })); + } else if (ENGINE_MODE === 'fast') { + await user.check(page.getByRole('checkbox', { name: 'Fast Training' })); + } + + await screenshot(page, { pageName: 'partial_draft_summary', ...context }); + await user.click(page.getByRole('button', { name: 'Generate Draft' })); + console.log('Partial draft started'); +} + +/** Wait for the draft to complete, with stall detection. */ +async function waitForDraftToComplete(page: Page, context: ScreenshotContext): Promise { + const startTime = Date.now(); + const progressCardHeader = page.locator('.draft-progress-card mat-card-title'); + await expect(progressCardHeader).toContainText(PARTIAL_BOOK_NAME, { timeout: 60_000 }); + + const draftReadyLocator = page.getByRole('heading', { name: 'The draft is ready' }); + const inProgressTimeout = ENGINE_MODE === 'echo' ? 3 * 60_000 : 15 * 60_000; + await expect(page.getByRole('heading', { name: 'Draft in progress' }).or(draftReadyLocator)).toBeVisible({ + timeout: inProgressTimeout + }); + + let progress: number | null = null; + let lastProgressChange: number | null = null; + while (!(await draftReadyLocator.isVisible())) { + const currentProgressText = (await page.locator('circle-progress').allTextContents())[0]; + if (currentProgressText == null) break; + const currentProgress = Number.parseInt(currentProgressText); + if (progress !== currentProgress) { + lastProgressChange = Date.now(); + progress = currentProgress; + } + const progressChangeTimeoutMinutes = 3; + if (lastProgressChange != null && Date.now() - lastProgressChange > 60_000 * progressChangeTimeoutMinutes) { + throw new Error( + `Draft progress stalled at ${progress}% and unchanged in ${progressChangeTimeoutMinutes} minutes.` + ); + } + await page.waitForTimeout(100); + } + + await expect(draftReadyLocator).toBeVisible(); + console.log('Partial draft generation took', ((Date.now() - startTime) / 60_000).toFixed(2), 'minutes'); + await screenshot(page, { pageName: 'partial_draft_completed', ...context }); + + // Reloading triggers the draft-status update and avoids a known freeze on lower-end machines. + await page.reload(); + let finishing: boolean; + try { + await expect(page.getByText('Draft is Finishing')).toBeVisible({ timeout: 15_000 }); + finishing = true; + } catch { + finishing = false; + } + if (finishing) await expect(page.getByText('Draft is Finishing')).not.toBeVisible({ timeout: 15_000 }); +} + +/** Import the completed draft into the target project. */ +async function importDraft(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + // Formatting options must be chosen before the import actions appear. + await user.click(page.getByRole('button', { name: 'Formatting options' })); + await user.click(page.getByRole('button', { name: 'Save' })); + + await expect( + page.getByRole('button', { name: new RegExp(`${PARTIAL_BOOK_NAME}\\s*\\(${DRAFT_CHAPTERS}\\)`) }) + ).toBeVisible(); + + await user.click(page.getByRole('button', { name: 'Add to a project' })); + await user.click(page.getByRole('combobox', { name: 'Choose a project' })); + await user.type(TARGET_PROJECT_SHORT_NAME); + await user.click(page.getByRole('option', { name: `${TARGET_PROJECT_SHORT_NAME} -` })); + // Without content in the target, this starts the import directly; otherwise it advances to overwrite confirmation. + await user.click(page.locator('[data-test-id="step-1-next"]')); + + // Overwrite confirmation only appears when drafted chapters already have content in the target; handle defensively. + const understand = page.getByRole('checkbox', { name: /I understand that existing content will be overwritten/ }); + if (await understand.isVisible().catch(() => false)) { + await user.check(understand); + await user.click(page.locator('[data-test-id="step-5-next"]')); + } + + await expect(page.getByText('Import complete', { exact: true })).toBeVisible({ timeout: 5 * 60_000 }); + + // Finish the wizard through the sync step, as a real user would, so the imported draft lands in the project. + await user.click(page.locator('[data-test-id="step-6-next"]')); + await user.click(page.locator('[data-test-id="step-7-sync"]')); + await expect(page.getByText(`The draft has been imported into ${TARGET_PROJECT_SHORT_NAME}`)).toBeVisible({ + timeout: E2E_SYNC_DEFAULT_TIMEOUT + }); + await user.click(page.getByRole('button', { name: 'Done' })); + + await screenshot(page, { pageName: 'partial_draft_imported', ...context }); +} + +/** Verify a drafted chapter has content and the chapter after the range is still empty. */ +async function verifyDraftedChaptersInEditor(page: Page, user: UserEmulator): Promise { + await user.click(page.getByRole('link', { name: 'Edit & review' })); + await page.waitForSelector('#sync-icon:not(.sync-in-progress)'); + + await selectBookAndChapter(page, user, PARTIAL_BOOK_NAME, FIRST_DRAFTED_CHAPTER); + await expectEditorOnChapter(page, PARTIAL_BOOK_NAME, FIRST_DRAFTED_CHAPTER); + const draftedSegment = getTargetSegment(page, FIRST_DRAFTED_CHAPTER, 1); + await expect(draftedSegment).toBeVisible(); + expect(((await draftedSegment.textContent()) ?? '').trim().length).toBeGreaterThan(0); + + // Confirm navigation succeeded before asserting emptiness, to rule out a load failure masquerading as an empty chapter. + await selectBookAndChapter(page, user, PARTIAL_BOOK_NAME, AN_UNDRAFTED_CHAPTER); + await expectEditorOnChapter(page, PARTIAL_BOOK_NAME, AN_UNDRAFTED_CHAPTER); + const targetEditor = page.locator('app-tab-group:has(#target) .ql-editor').filter({ visible: true }); + await expect(targetEditor).toBeVisible(); + const verseTexts = await targetEditor.locator(`[data-segment^="verse_${AN_UNDRAFTED_CHAPTER}_"]`).allTextContents(); + const versesWithContent = verseTexts.filter(text => text.trim().length > 0); + expect(versesWithContent).toEqual([]); +} + +async function expectEditorOnChapter(page: Page, book: string, chapter: number): Promise { + const chooser = page.locator('.toolbar app-book-chapter-chooser'); + await expect(chooser.getByRole('combobox').first()).toContainText(book); + await expect(chooser.getByRole('combobox').last()).toContainText(String(chapter)); +} + +/** + * Returning to generate another draft, the wizard should restore the training selections (training source books, + * target training books, and training-data file selection) but NOT the drafting book/chapter selection. + */ +async function verifyRememberedSelectionsOnReturn( + page: Page, + user: UserEmulator, + context: ScreenshotContext +): Promise { + await user.click(page.getByRole('link', { name: 'Generate draft' })); + // After a draft exists, the CTA is labeled "New draft" rather than "Generate draft". + await user.click(page.getByRole('button', { name: 'New draft' })); + + await stepThroughPreface(page, user, context); + + // Drafting selection is intentionally NOT persisted: no book should be pre-selected on return. + await expect(page.getByRole('option', { name: PARTIAL_BOOK_NAME, exact: true })).toBeVisible(); + await expect(page.locator('.partial-book-drafting-table')).toHaveCount(0); + await user.click(page.getByRole('option', { name: PARTIAL_BOOK_NAME, exact: true })); + const chapterInput = page.locator('.partial-book-drafting-table .chapter-input input'); + await chapterInput.fill(DRAFT_CHAPTERS); + await chapterInput.blur(); + await user.click(page.getByRole('button', { name: 'Next' })); + + // Training selections ARE persisted. The books we picked come back selected; the deliberately unselected one stays unselected. + const bookSelects = page.locator('app-book-multi-select'); + const bookSelectCount = await bookSelects.count(); + expect(bookSelectCount).toBeGreaterThan(1); // target + at least one training source + for (let i = 0; i < bookSelectCount; i++) { + const bookSelect = bookSelects.nth(i); + for (const book of TRAINING_NT_BOOKS) { + await expectBookSelected(bookSelect, book, true); + } + } + + await expectBookSelected(bookSelects.first(), UNSELECTED_TRAINING_BOOK, false); + + const fileCheckbox = page.locator('.training-data-files').getByRole('checkbox', { name: DESELECTED_FILE.title }); + await expect(fileCheckbox).not.toBeChecked(); + + await screenshot(page, { pageName: 'partial_draft_remembered_selections', ...context }); +} + +async function expectBookSelected(bookSelect: Locator, book: string, selected: boolean): Promise { + const option = bookSelect.getByRole('option', { name: book, exact: true }); + if (selected) { + await expect(option).toHaveAttribute('aria-selected', 'true'); + } else { + await expect(option).not.toHaveAttribute('aria-selected', 'true'); + } +} + +// Editor helpers (mirrors edit-translation.ts) + +function getTargetSegment(page: Page, chapter: number, verse: number): Locator { + return page + .locator('app-tab-group:has(#target) .ql-editor') + .locator(`[data-segment="verse_${chapter}_${verse}"]`) + .filter({ visible: true }); +} + +async function selectBookAndChapter(page: Page, user: UserEmulator, book: string, chapter: number): Promise { + const bookChapterChooser = page.locator('.toolbar app-book-chapter-chooser'); + const bookChooser = bookChapterChooser.getByRole('combobox').first(); + const chapterChooser = bookChapterChooser.getByRole('combobox').last(); + + const currentBookText = (await bookChooser.textContent())?.trim() ?? ''; + if (book !== currentBookText) { + await user.click(bookChooser); + await user.click(page.getByRole('option', { name: book, exact: true })); + } + + const currentChapterText = (await chapterChooser.textContent())?.trim() ?? ''; + if (chapter.toString() !== currentChapterText) { + await user.click(chapterChooser); + await user.click(page.getByRole('option', { name: chapter.toString(), exact: true })); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html index 211542f123e..914e4e2cd87 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html @@ -149,6 +149,11 @@ + @if (experimentalFeatures.showExperimentalFeaturesInMenu) { + + } @if (featureFlags.showDeveloperTools.enabled) { } @empty { {{ t("no_books_have_drafts") }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts index 28d9d4218fb..3d45a5b8cfc 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 @@ -76,6 +76,35 @@ describe('DraftPreviewBooks', () => { queryParams: { 'draft-active': true, 'draft-timestamp': undefined } }); })); + + it('shows the chapter range and navigates to the first drafted chapter for a partial book', fakeAsync(() => { + // GEN (book 1) has chapters 1-3 in the project, but only 2-3 were drafted, so this is a partial draft. + env = new TestEnvironment({ + build: { + additionalInfo: { translationScriptureRanges: [{ projectId: 'project01', scriptureRange: 'GEN2-3' }] } + } as BuildDto + }); + + expect(env.getBookButtonAtIndex(0).textContent).toContain('2-3'); + + env.getBookButtonAtIndex(0).click(); + tick(); + env.fixture.detectChanges(); + + const [url] = capture(mockedRouter.navigate).first(); + expect(url).toEqual(['/projects', 'project01', 'translate', 'GEN', '2']); + })); + + it('does not show a chapter range when the whole book was drafted', fakeAsync(() => { + // Genesis has 50 chapters; all 50 were drafted, so it is a whole-book draft and no range is shown. + env = new TestEnvironment({ + build: { + additionalInfo: { translationScriptureRanges: [{ projectId: 'project01', scriptureRange: 'GEN1-50' }] } + } as BuildDto + }); + + expect(env.getBookButtonAtIndex(0).textContent).not.toContain('('); + })); }); class TestEnvironment { 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 16fa231cda1..827b63dd72f 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 @@ -9,12 +9,17 @@ import { map, Observable, tap } from 'rxjs'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { filterNullish } from 'xforge-common/util/rxjs-util'; import { BuildDto } from '../../../machine-api/build-dto'; -import { booksFromScriptureRange } from '../../../shared/utils'; +import { expectedBookChapters } from '../../../shared/progress-service/progress.service'; +import { ChapterSet, VerboseScriptureRange } from '../../../shared/scripture-range'; export interface BookWithDraft { bookNumber: number; bookId: string; chaptersInBook: number[]; + /** Chapters drafted for this book, sorted. Empty for a whole-book draft or when the draft has no chapter detail. */ + draftedChapters: number[]; + /** Compact range of drafted chapters (e.g. "30-32"), set only when part of the book was drafted. */ + draftedChapterRange?: string; } @Component({ @@ -33,31 +38,16 @@ export class DraftPreviewBooksComponent { if (projectDoc?.data == null) { return []; } - let draftBooks: BookWithDraft[]; + const texts = projectDoc.data.texts; + let rangeStrings: string[]; if (this.build == null) { - // show every book with generated drafts - draftBooks = booksFromScriptureRange(projectDoc.data.translateConfig.draftConfig.draftedScriptureRange) - .map(bookNum => ({ - bookNumber: bookNum, - bookId: Canon.bookNumberToId(bookNum), - chaptersInBook: projectDoc.data?.texts.find(t => t.bookNum === bookNum)?.chapters.map(ch => ch.number) ?? [] - })) - .sort((a, b) => a.bookNumber - b.bookNumber); + // Every book with generated drafts, combined across previous drafts. + rangeStrings = [projectDoc.data.translateConfig.draftConfig.draftedScriptureRange ?? '']; } else { // TODO: Support books from multiple translation projects - draftBooks = this.build.additionalInfo?.translationScriptureRanges - .flatMap(range => booksFromScriptureRange(range.scriptureRange)) - .map(bookNum => { - const text: TextInfo | undefined = projectDoc.data?.texts.find(t => t.bookNum === bookNum); - return { - bookNumber: bookNum, - bookId: Canon.bookNumberToId(bookNum), - chaptersInBook: text?.chapters?.map(ch => ch.number) ?? [] - }; - }) - .sort((a, b) => a.bookNumber - b.bookNumber) as BookWithDraft[]; + rangeStrings = (this.build.additionalInfo?.translationScriptureRanges ?? []).map(range => range.scriptureRange); } - return draftBooks; + return this.booksWithDraft(VerboseScriptureRange.fromCombinedRanges(rangeStrings), texts); }) ); @@ -73,8 +63,39 @@ export class DraftPreviewBooksComponent { } navigate(book: BookWithDraft): void { - void this.router.navigate(this.linkForBookAndChapter(book.bookId, book.chaptersInBook[0] ?? 1), { + // Go to the first drafted chapter (for a partial draft this is not chapter 1); fall back to the book's first + // chapter, then to chapter 1. + const firstChapter = book.draftedChapters[0] ?? book.chaptersInBook[0] ?? 1; + void this.router.navigate(this.linkForBookAndChapter(book.bookId, firstChapter), { queryParams: { 'draft-active': true, 'draft-timestamp': this.build?.additionalInfo?.dateGenerated } }); } + + /** Builds the per-book draft info, skipping non-canonical/unknown books, sorted in canonical order. */ + private booksWithDraft(draftedRange: VerboseScriptureRange, texts: TextInfo[]): BookWithDraft[] { + const books: BookWithDraft[] = []; + for (const [bookId, draftedChapterSet] of draftedRange.books) { + const bookNumber = Canon.bookIdToNumber(bookId); + if (bookNumber <= 0) { + continue; + } + const chaptersInBook = texts.find(t => t.bookNum === bookNumber)?.chapters.map(ch => ch.number) ?? []; + const draftedChapters = [...draftedChapterSet.chapters].sort((a, b) => a - b); + // Show a range only when part of the book was drafted. "Whole book" is the canonical chapter count + // (expectedBookChapters), not the target's current chapters, which may only contain what has been drafted so + // far. A chapter-less range (a legacy or whole-book draft) has no drafted chapters and shows no range. + // Assumption: the canonical count comes from eng.vrs, so for a project using a different versification this can + // be off by a chapter at the end of a book. The exact reference would be the drafting source's chapter count, + // which is not available in this component. + const onlyPartDrafted = draftedChapters.length > 0 && draftedChapters.length < expectedBookChapters(bookId); + books.push({ + bookNumber: bookNumber, + bookId: bookId, + chaptersInBook: chaptersInBook, + draftedChapters: draftedChapters, + draftedChapterRange: onlyPartDrafted ? new ChapterSet(draftedChapters).toStringForDisplay() : undefined + }); + } + return books.sort((a, b) => a.bookNumber - b.bookNumber); + } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html index cd1867a6e14..cec35d856e4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html @@ -169,7 +169,6 @@

{{ t("title") }}

[selectedBooks]="selectedSubmittedBooks" (bookSelect)="onSubmittedBooksSelect($event)" [basicMode]="true" - [projectName]="currentProjectDisplayName" > @if (showPlannedBooksRequiredError) { {{ t("planned_books_required") }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-source.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-source.ts index 7c5fcbdeb6d..899e8e95812 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-source.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-source.ts @@ -1,3 +1,4 @@ +import { uniqBy } from 'lodash-es'; import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; interface DraftTextInfo { @@ -11,6 +12,24 @@ export interface DraftSource extends TranslateSource { copyrightNotice?: string; } +/** Copyright banner (and optional notice) for a draft source project. */ +export interface CopyrightMessage { + banner: string; + notice?: string; +} + +/** The distinct copyright messages across the given sources, deduplicated by banner text. */ +export function getCopyrightMessages( + sources: { copyrightBanner?: string; copyrightNotice?: string }[] +): CopyrightMessage[] { + return uniqBy( + sources + .filter(source => source.copyrightBanner != null) + .map(source => ({ banner: source.copyrightBanner!, notice: source.copyrightNotice })), + message => message.banner + ); +} + export interface DraftSourcesAsArrays { trainingSources: DraftSource[]; trainingTargets: DraftSource[]; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts index 9912b5ba04d..408c79114c6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts @@ -34,21 +34,24 @@ describe('DraftSourcesService', () => { }); describe('getDraftProjectSources', () => { - it('should pass undefined properties if no projects loaded', done => { - when(mockActivatedProjectService.projectId).thenReturn(undefined); - when(mockActivatedProjectService.changes$).thenReturn( - new BehaviorSubject({ data: undefined } as SFProjectProfileDoc) - ); + it('skips emissions while the doc data is not loaded, then emits the real sources once it is', done => { + // Doc present but its data hasn't loaded yet. The service must not emit the all-empty fallback for this; + // otherwise a firstValueFrom consumer would latch it instead of the real sources. + const changes$ = new BehaviorSubject({ data: undefined } as SFProjectProfileDoc); + when(mockActivatedProjectService.changes$).thenReturn(changes$); + + const targetProject = createTestProjectProfile({ + translateConfig: { draftConfig: { draftingSources: [], trainingSources: [] } } + }); - // SUT service.getDraftProjectSources().subscribe(result => { - expect(result).toEqual({ - trainingSources: [], - trainingTargets: [], - draftingSources: [] - } as DraftSourcesAsArrays); + // The first (and only) emission is the loaded one, never the empty fallback (trainingTargets would be []). + expect(result.trainingTargets).toEqual([{ ...targetProject, projectRef: 'project01' }]); done(); }); + + // Provide the data after the initial (filtered) emission has had a chance to fire. + setTimeout(() => changes$.next({ id: 'project01', data: targetProject } as SFProjectProfileDoc), 0); }); it('should pass the values from the target project if no access', done => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts index b5199bd4ad2..1f623843f23 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; import { asyncScheduler, combineLatest, defer, from, Observable } from 'rxjs'; -import { switchMap, throttleTime } from 'rxjs/operators'; +import { filter, switchMap, throttleTime } from 'rxjs/operators'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { UserDoc } from 'xforge-common/models/user-doc'; import { UserService } from 'xforge-common/user.service'; @@ -41,6 +41,9 @@ export class DraftSourcesService { */ getDraftProjectSources(): Observable { return combineLatest([this.activatedProject.changes$, this.currentUser$]).pipe( + // Ignore emissions where the doc's data hasn't loaded yet; otherwise getDraftSourcesAsArrays returns all-empty + // arrays and a firstValueFrom consumer latches that instead of the real sources. + filter(([targetDoc]) => targetDoc?.data != null), throttleTime(this.projectChangeThrottlingMs, asyncScheduler, { leading: true, trailing: true }), switchMap(([targetDoc, _currentUser]) => this.getDraftSourcesAsArrays(targetDoc)) ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.html new file mode 100644 index 00000000000..3c597447db4 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.html @@ -0,0 +1,47 @@ +
+ @if (loading) { + + } @else { +

{{ t("heading") }}

+

{{ t("body") }}

+ +
+ @for (row of rows; track row.projectId) { +
+ {{ row.name }} + + @if (row.canSync) { + @if (row.syncState === "pending") { + + } @else if (row.syncState === "syncing") { + + } @else if (row.syncState === "synced") { + check_circle{{ t("synced") }} + } @else if (row.syncState === "failed") { + warning{{ t("sync_failed") }} + + } + } @else { + warning{{ t("cant_sync") }} + } + +
+ } +
+ +
+ + @if (syncableRows.length > 1) { + + } + +
+ @if (userSyncInProgress) { +

{{ t("wait_for_sync") }}

+ } + } +
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.scss new file mode 100644 index 00000000000..519c2547693 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.scss @@ -0,0 +1,45 @@ +@use 'src/variables'; + +.pending-project-rows { + display: flex; + flex-direction: column; + gap: 12px; + margin: 16px 0; +} + +.pending-project-row { + display: flex; + align-items: center; + gap: 16px; +} + +.project-name { + min-width: 160px; + font-weight: 500; +} + +.row-status { + display: flex; + align-items: center; + gap: 8px; +} + +.sync-done, +.sync-failed, +.cant-sync { + display: flex; + align-items: center; + gap: 4px; +} + +.pending-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.wait-for-sync { + color: variables.$lighterTextColor; + font-size: 0.85em; + margin: 8px 0 0; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts new file mode 100644 index 00000000000..85e6ffd7526 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts @@ -0,0 +1,477 @@ +import { DestroyRef } from '@angular/core'; +import { fakeAsync, tick } from '@angular/core/testing'; +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 { Subject } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { UserService } from 'xforge-common/user.service'; +import { SFProjectDoc } from '../../../../core/models/sf-project-doc'; +import { PermissionsService } from '../../../../core/permissions.service'; +import { SFProjectService } from '../../../../core/sf-project.service'; +import { DraftPendingUpdatesComponent } from './draft-pending-updates.component'; + +const USER_ID = 'test-user-id'; + +describe('DraftPendingUpdatesComponent', () => { + describe('ngOnInit', () => { + it('creates a syncable row when user has Texts.Edit on the project', async () => { + const env = new TestEnvironment([makeProject('proj1', 'Project One', SFProjectRole.ParatextAdministrator)]); + + await env.component.ngOnInit(); + + expect(env.component.rows.length).toBe(1); + expect(env.component.rows[0].projectId).toBe('proj1'); + expect(env.component.rows[0].name).toBe('Project One'); + expect(env.component.rows[0].canSync).toBeTrue(); + expect(env.component.rows[0].syncState).toBe('pending'); + }); + + it('creates a non-syncable row when user lacks Texts.Edit', async () => { + const env = new TestEnvironment([makeProject('proj1', 'Project One', SFProjectRole.CommunityChecker)]); + + await env.component.ngOnInit(); + + expect(env.component.rows[0].canSync).toBeFalse(); + }); + + it('creates a syncable row for a DBL resource when the user has any Paratext role', async () => { + // A Paratext observer cannot edit, but may still sync a DBL resource. + const env = new TestEnvironment([makeResource('res1', 'Resource One', SFProjectRole.ParatextObserver)]); + + await env.component.ngOnInit(); + + expect(env.component.rows[0].canSync).toBeTrue(); + }); + + it('creates a non-syncable row for a DBL resource when the user has no Paratext role', async () => { + const env = new TestEnvironment([makeResource('res1', 'Resource One', SFProjectRole.CommunityChecker)]); + + await env.component.ngOnInit(); + + expect(env.component.rows[0].canSync).toBeFalse(); + }); + + it('sets syncState to syncing when project is already syncing', async () => { + const env = new TestEnvironment([makeProject('proj1', 'Project One', SFProjectRole.ParatextAdministrator, 1)]); + + await env.component.ngOnInit(); + + expect(env.component.rows[0].syncState).toBe('syncing'); + }); + + it('sets loading to false after init completes', async () => { + const env = new TestEnvironment([]); + expect(env.component.loading).toBeTrue(); + + await env.component.ngOnInit(); + + expect(env.component.loading).toBeFalse(); + }); + + it('builds rows for multiple pending projects', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'Project One', SFProjectRole.ParatextAdministrator), + makeProject('proj2', 'Project Two', SFProjectRole.CommunityChecker) + ]); + + await env.component.ngOnInit(); + + expect(env.component.rows.length).toBe(2); + expect(env.component.rows[0].canSync).toBeTrue(); + expect(env.component.rows[1].canSync).toBeFalse(); + }); + }); + + describe('syncableRows', () => { + it('returns only rows where canSync is true', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator), + makeProject('proj2', 'P2', SFProjectRole.CommunityChecker) + ]); + await env.component.ngOnInit(); + + expect(env.component.syncableRows.length).toBe(1); + expect(env.component.syncableRows[0].projectId).toBe('proj1'); + }); + }); + + describe('syncProject', () => { + it('calls onlineSync and sets syncState to syncing', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + + env.component.syncProject(env.component.rows[0]); + + expect(env.component.rows[0].syncState).toBe('syncing'); + verify(env.mockedProjectService.onlineSync('proj1')).once(); + }); + + it('does nothing when row is already syncing', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + env.component.rows[0].syncState = 'syncing'; + when(env.mockedProjectService.onlineSync(anything())).thenResolve(); + + env.component.syncProject(env.component.rows[0]); + + verify(env.mockedProjectService.onlineSync(anything())).never(); + expect().nothing(); + }); + + it('does nothing when canSync is false', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.CommunityChecker)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync(anything())).thenResolve(); + + env.component.syncProject(env.component.rows[0]); + + verify(env.mockedProjectService.onlineSync(anything())).never(); + expect().nothing(); + }); + + it('sets syncState to failed when onlineSync rejects', fakeAsync(async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenReject(new Error('network error')); + + env.component.syncProject(env.component.rows[0]); + tick(); + + expect(env.component.rows[0].syncState).toBe('failed'); + })); + }); + + describe('retrySyncProject', () => { + it('resets state to pending and calls syncProject', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + env.component.rows[0].syncState = 'failed'; + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + + env.component.retrySyncProject(env.component.rows[0]); + + expect(env.component.rows[0].syncState).toBe('syncing'); + verify(env.mockedProjectService.onlineSync('proj1')).once(); + }); + }); + + describe('syncAll', () => { + it('calls syncProject for all syncable pending rows', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator), + makeProject('proj2', 'P2', SFProjectRole.ParatextTranslator), + makeProject('proj3', 'P3', SFProjectRole.CommunityChecker) + ]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync(anything())).thenResolve(); + + env.component.syncAll(); + + verify(env.mockedProjectService.onlineSync('proj1')).once(); + verify(env.mockedProjectService.onlineSync('proj2')).once(); + verify(env.mockedProjectService.onlineSync('proj3')).never(); + expect().nothing(); + }); + + it('skips rows already syncing', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator), + makeProject('proj2', 'P2', SFProjectRole.ParatextAdministrator) + ]); + await env.component.ngOnInit(); + env.component.rows[0].syncState = 'syncing'; + when(env.mockedProjectService.onlineSync(anything())).thenResolve(); + + env.component.syncAll(); + + verify(env.mockedProjectService.onlineSync('proj1')).never(); + verify(env.mockedProjectService.onlineSync('proj2')).once(); + expect().nothing(); + }); + }); + + describe('sync completion', () => { + it('resolves to synced on the queuedCount high->low edge when lastSyncSuccessful is true', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + env.component.syncProject(env.component.rows[0]); + + env.startSync('proj1'); + expect(env.component.rows[0].syncState).toBe('syncing'); + + env.completeSync('proj1', true); + expect(env.component.rows[0].syncState).toBe('synced'); + }); + + it('resolves to failed on completion when lastSyncSuccessful is false', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + env.component.syncProject(env.component.rows[0]); + + env.startSync('proj1'); + env.completeSync('proj1', false); + + expect(env.component.rows[0].syncState).toBe('failed'); + }); + + it('does not resolve prematurely on a queuedCount=0 change before the sync starts', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + env.component.syncProject(env.component.rows[0]); + + // A remote change arrives before the sync has been enqueued (queuedCount still 0). + env.emitRemoteChange('proj1'); + + expect(env.component.rows[0].syncState).toBe('syncing'); + }); + + it('resolves a row that is already syncing when the wizard opens', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + expect(env.component.rows[0].syncState).toBe('syncing'); + + env.completeSync('proj1', true); + + expect(env.component.rows[0].syncState).toBe('synced'); + }); + }); + + describe('Continue anyway gating', () => { + it('blocks continuing while a sync the user started is still running', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + expect(env.component.userSyncInProgress).toBeFalse(); + + env.component.syncProject(env.component.rows[0]); + + expect(env.component.userSyncInProgress).toBeTrue(); + }); + + it('still allows continuing when a project was already syncing on entry (not user-initiated)', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + + expect(env.component.rows[0].syncState).toBe('syncing'); + // A pre-existing (possibly stuck) sync must not trap the user, so continuing stays allowed. + expect(env.component.userSyncInProgress).toBeFalse(); + }); + + it('allows continuing again once the user-initiated sync finishes', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + env.component.syncProject(env.component.rows[0]); + env.startSync('proj1'); + expect(env.component.userSyncInProgress).toBeTrue(); + + env.completeSync('proj1', true); + + expect(env.component.userSyncInProgress).toBeFalse(); + }); + }); + + describe('continueAnyway', () => { + it('emits continue', async () => { + const env = new TestEnvironment([]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + env.component.continueAnyway(); + + expect(emitted).toBeTrue(); + }); + + it('emits the IDs of the projects that synced', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1), + makeProject('proj2', 'P2', SFProjectRole.ParatextAdministrator) + ]); + await env.component.ngOnInit(); + let emitted: string[] | undefined; + env.component.continue.subscribe(ids => (emitted = ids)); + + env.completeSync('proj1', true); // proj1 syncs; proj2 stays pending + + env.component.continueAnyway(); + + expect(emitted).toEqual(['proj1']); + }); + + it('emits an empty array when nothing was synced', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + let emitted: string[] | undefined; + env.component.continue.subscribe(ids => (emitted = ids)); + + env.component.continueAnyway(); + + expect(emitted).toEqual([]); + }); + }); + + describe('auto-advance', () => { + it('auto-advances after delay when all syncable rows are synced and no cant-sync rows', fakeAsync(async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + let emitted: string[] | undefined; + env.component.continue.subscribe(ids => (emitted = ids)); + + env.completeSync('proj1', true); + expect(emitted).toBeUndefined(); + + tick(1500); + expect(emitted).toEqual(['proj1']); + })); + + it('does not auto-advance when cant-sync rows exist', fakeAsync(async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1), + makeProject('proj2', 'P2', SFProjectRole.CommunityChecker) + ]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + env.completeSync('proj1', true); + tick(1500); + + expect(emitted).toBeFalse(); + })); + + it('does not auto-advance when some syncable rows are still pending', fakeAsync(async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1), + makeProject('proj2', 'P2', SFProjectRole.ParatextAdministrator) + ]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + // Only proj1 finishes; proj2 is still pending + env.completeSync('proj1', true); + tick(1500); + + expect(emitted).toBeFalse(); + })); + + it('does not auto-advance when a sync failed', fakeAsync(async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + env.completeSync('proj1', false); + tick(1500); + + expect(emitted).toBeFalse(); + })); + + it('cancels the pending auto-advance when the component is destroyed', fakeAsync(async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + env.completeSync('proj1', true); // schedules the auto-advance + env.destroyRef.destroy(); // component torn down before it fires + tick(1500); + + expect(emitted).toBeFalse(); + })); + }); +}); + +interface ProjectSpec { + projectId: string; + name: string; + role: SFProjectRole; + queuedCount: number; + paratextId?: string; +} + +function makeProject(projectId: string, name: string, role: SFProjectRole, queuedCount = 0): ProjectSpec { + return { projectId, name, role, queuedCount }; +} + +// A DBL resource is identified by a 16-character paratextId. +function makeResource(projectId: string, name: string, role: SFProjectRole, queuedCount = 0): ProjectSpec { + return { projectId, name, role, queuedCount, paratextId: 'resource16char01' }; +} + +/** A DestroyRef whose registered teardown callbacks can be fired on demand via destroy(). */ +class FakeDestroyRef implements DestroyRef { + destroyed = false; + private callbacks: (() => void)[] = []; + + onDestroy(callback: () => void): () => void { + this.callbacks.push(callback); + return () => (this.callbacks = this.callbacks.filter(cb => cb !== callback)); + } + + destroy(): void { + this.destroyed = true; + this.callbacks.forEach(cb => cb()); + this.callbacks = []; + } +} + +class TestEnvironment { + component: DraftPendingUpdatesComponent; + readonly mockedProjectService = mock(SFProjectService); + readonly destroyRef = new FakeDestroyRef(); + private readonly mockedUserService = mock(UserService); + private readonly projectDocs = new Map }>(); + + constructor(projects: ProjectSpec[]) { + when(this.mockedUserService.currentUserId).thenReturn(USER_ID); + + for (const spec of projects) { + const projectData = createTestProjectProfile({ + shortName: spec.name, + userRoles: { [USER_ID]: spec.role }, + sync: { queuedCount: spec.queuedCount }, + ...(spec.paratextId != null ? { paratextId: spec.paratextId } : {}) + }); + const doc = { data: projectData, remoteChanges$: new Subject() } as unknown as SFProjectDoc; + this.projectDocs.set(spec.projectId, doc as any); + when(this.mockedProjectService.get(spec.projectId)).thenResolve(doc); + } + + // Use a real PermissionsService so the role/resource permission logic is exercised, not stubbed. + const permissionsService = new PermissionsService( + instance(this.mockedUserService), + instance(this.mockedProjectService) + ); + this.component = new DraftPendingUpdatesComponent( + instance(this.mockedProjectService), + permissionsService, + this.destroyRef + ); + this.component.pendingProjects = projects.map(p => ({ projectId: p.projectId, name: p.name })); + } + + /** Simulate the project's sync becoming active (queuedCount > 0) and notify subscribers. */ + startSync(projectId: string): void { + const doc = this.projectDocs.get(projectId)!; + doc.data.sync = { ...doc.data.sync, queuedCount: 1 }; + doc.remoteChanges$.next(); + } + + /** Simulate the project's sync completing (queuedCount back to 0) and notify subscribers. */ + completeSync(projectId: string, successful: boolean): void { + const doc = this.projectDocs.get(projectId)!; + doc.data.sync = { ...doc.data.sync, queuedCount: 0, lastSyncSuccessful: successful }; + doc.remoteChanges$.next(); + } + + /** Emit a remote change without altering sync state (e.g. an unrelated doc edit). */ + emitRemoteChange(projectId: string): void { + this.projectDocs.get(projectId)!.remoteChanges$.next(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.stories.ts new file mode 100644 index 00000000000..377aa0b2fc5 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.stories.ts @@ -0,0 +1,204 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; +import { Subject } from 'rxjs'; +import userEvent from '@testing-library/user-event'; +import { expect, waitFor, within } from 'storybook/test'; +import { anything, instance, mock, reset, when } from 'ts-mockito'; +import { createTestFeatureFlag, FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { SFProjectDoc } from '../../../../core/models/sf-project-doc'; +import { PermissionsService } from '../../../../core/permissions.service'; +import { ProjectNotificationService } from '../../../../core/project-notification.service'; +import { SFProjectService } from '../../../../core/sf-project.service'; +import { DraftPendingUpdatesComponent } from './draft-pending-updates.component'; + +// Dependencies of the component (and of the SyncProgressComponent it renders while a project syncs). +const mockedSFProjectService = mock(SFProjectService); +const mockedPermissionsService = mock(PermissionsService); +const mockedProjectNotificationService = mock(ProjectNotificationService); +const mockedFeatureFlagService = mock(FeatureFlagService); +const mockedErrorReportingService = mock(ErrorReportingService); +const mockedOnlineStatusService = mock(OnlineStatusService); + +/** Describes one row of the interstitial. `queuedCount > 0` makes the project syncing on entry. */ +interface ProjectSpec { + projectId: string; + name: string; + canSync: boolean; + queuedCount?: number; + lastSyncSuccessful?: boolean; + /** The project doc never loads, so the component stays on its loading spinner. */ + neverLoad?: boolean; + /** When the user starts a sync for this project, immediately drive it to this outcome (for the succeeded/failed states). */ + completeOnSyncWith?: 'success' | 'failure'; +} + +// Crafted project docs keyed by id, so the play functions can drive a sync to completion after the user starts it. +const docs = new Map; data: any }>(); + +function setUpMocks(specs: ProjectSpec[]): void { + for (const m of [ + mockedSFProjectService, + mockedPermissionsService, + mockedProjectNotificationService, + mockedFeatureFlagService, + mockedErrorReportingService, + mockedOnlineStatusService + ]) { + reset(m); + } + docs.clear(); + + const specById = new Map(specs.map(s => [s.projectId, s])); + for (const spec of specs) { + const remoteChanges$ = new Subject(); + const data = createTestProjectProfile({ + shortName: spec.name, + sync: { queuedCount: spec.queuedCount ?? 0, lastSyncSuccessful: spec.lastSyncSuccessful ?? true } + }); + docs.set(spec.projectId, { + doc: { id: spec.projectId, data, remoteChanges$ } as unknown as SFProjectDoc, + remoteChanges$, + data + }); + } + + when(mockedSFProjectService.get(anything())).thenCall((id: string) => + specById.get(id)?.neverLoad ? new Promise(() => {}) : Promise.resolve(docs.get(id)!.doc) + ); + // Drive the sync to completion within the (zoned) sync call so change detection updates the row to its final state. + when(mockedSFProjectService.onlineSync(anything())).thenCall((id: string) => { + const outcome = specById.get(id)?.completeOnSyncWith; + if (outcome != null) completeSync(id, outcome === 'success'); + return Promise.resolve(); + }); + when(mockedPermissionsService.canSync(anything())).thenCall( + (doc: SFProjectDoc) => specById.get(doc?.id ?? '')?.canSync ?? false + ); + + // SyncProgressComponent dependencies (it's rendered for a project that is syncing in place). + when(mockedProjectNotificationService.start()).thenResolve(); + when(mockedProjectNotificationService.stop()).thenResolve(); + when(mockedProjectNotificationService.subscribeToProject(anything())).thenResolve(); + when(mockedFeatureFlagService.stillness).thenReturn(createTestFeatureFlag(false)); + when(mockedOnlineStatusService.isOnline).thenReturn(true); + when(mockedOnlineStatusService.isBrowserOnline).thenReturn(true); +} + +/** Drives a row's project to a completed sync (queuedCount 1 → 0) after the user has started it. */ +function completeSync(projectId: string, successful: boolean): void { + const entry = docs.get(projectId)!; + entry.data.sync.queuedCount = 1; + entry.remoteChanges$.next([]); // observed as "now syncing" + entry.data.sync.queuedCount = 0; + entry.data.sync.lastSyncSuccessful = successful; + entry.remoteChanges$.next([]); // observed as the syncing → done edge +} + +interface StoryArgs { + projects: ProjectSpec[]; +} + +const meta: Meta = { + title: 'Translate/NewDraft/PendingUpdates', + component: DraftPendingUpdatesComponent, + parameters: { controls: { disable: true } }, + render: args => { + setUpMocks(args.projects); + return { + props: { pendingProjects: args.projects.map(p => ({ projectId: p.projectId, name: p.name })) }, + moduleMetadata: { + imports: [DraftPendingUpdatesComponent], + providers: [ + { provide: SFProjectService, useValue: instance(mockedSFProjectService) }, + { provide: PermissionsService, useValue: instance(mockedPermissionsService) }, + { provide: ProjectNotificationService, useValue: instance(mockedProjectNotificationService) }, + { provide: FeatureFlagService, useValue: instance(mockedFeatureFlagService) }, + { provide: ErrorReportingService, useValue: instance(mockedErrorReportingService) }, + { provide: OnlineStatusService, useValue: instance(mockedOnlineStatusService) } + ] + } + }; + } +}; + +export default meta; + +type Story = StoryObj; + +/** A single syncable project: it uses its own row's Sync button (no "Sync all", which would imply more than one). */ +export const SingleSyncableProject: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true }] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Greek NT'); + expect(canvas.queryByRole('button', { name: /sync all/i })).toBeNull(); + } +}; + +/** Several syncable projects: each has its own Sync button, plus a "Sync all" primary action. */ +export const MultipleSyncableProjects: Story = { + args: { + projects: [ + { projectId: 'p1', name: 'Greek NT', canSync: true }, + { projectId: 'p2', name: 'Hebrew OT', canSync: true }, + { projectId: 'p3', name: 'Back Translation', canSync: true } + ] + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('button', { name: /sync all/i }); + } +}; + +/** The user lacks Texts.Edit, so it's informational only; they must ask an admin or continue anyway. */ +export const CannotSync: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: false }] } +}; + +/** A mix: one project the user can sync and one they can't. */ +export const MixedPermissions: Story = { + args: { + projects: [ + { projectId: 'p1', name: 'Greek NT', canSync: true }, + { projectId: 'p2', name: 'Hebrew OT', canSync: false } + ] + } +}; + +/** A project that was already syncing when the wizard opened shows live progress and no Sync button. */ +export const AlreadySyncing: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true, queuedCount: 1 }] } +}; + +/** Project docs haven't loaded yet. */ +export const Loading: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true, neverLoad: true }] }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.querySelector('.loading-indicator')).not.toBeNull()); + } +}; + +/** Start a sync, then let it finish successfully; the row shows "Up to date". */ +export const SyncSucceeded: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true, completeOnSyncWith: 'success' }] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Greek NT'); + await userEvent.click(await canvas.findByRole('button', { name: /sync/i })); + await waitFor(() => expect(canvas.getByText('Up to date')).not.toBeNull()); + } +}; + +/** Start a sync, then let it fail; the row shows "Sync failed" with a Retry button. */ +export const SyncFailed: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true, completeOnSyncWith: 'failure' }] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Greek NT'); + await userEvent.click(await canvas.findByRole('button', { name: /sync/i })); + await waitFor(() => expect(canvas.getByText('Sync failed')).not.toBeNull()); + await canvas.findByRole('button', { name: /retry/i }); + } +}; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.ts new file mode 100644 index 00000000000..c1369c5a45e --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.ts @@ -0,0 +1,162 @@ +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { TranslocoModule } from '@ngneat/transloco'; +import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { SFProjectDoc } from '../../../../core/models/sf-project-doc'; +import { PermissionsService } from '../../../../core/permissions.service'; +import { SFProjectService } from '../../../../core/sf-project.service'; +import { isSFProjectSyncing } from '../../../../sync/sync.component'; +import { SyncProgressComponent } from '../../../../sync/sync-progress/sync-progress.component'; + +/** How long the "All synced" state lingers before auto-advancing, so the transition isn't jarring. */ +const AUTO_ADVANCE_DELAY_MS = 1500; + +interface PendingProjectRow { + projectId: string; + name: string; + canSync: boolean; + projectDoc?: SFProjectDoc; + syncState: 'pending' | 'syncing' | 'synced' | 'failed'; + /** Latch tracking whether this project has been observed actively syncing, so completion is the high→low edge. */ + wasSyncing: boolean; + /** Whether a remoteChanges$ subscription is already watching this row's sync state. */ + monitoring: boolean; + /** + * Whether the user kicked off this row's sync from the pre-step (vs. it already syncing on entry). A user-initiated + * sync suppresses "Continue anyway" (they chose to wait); a pre-existing one does not (it may be stuck, and we must + * not trap the user). + */ + userInitiated: boolean; +} + +@Component({ + selector: 'app-draft-pending-updates', + templateUrl: './draft-pending-updates.component.html', + styleUrls: ['./draft-pending-updates.component.scss'], + imports: [MatButtonModule, MatIconModule, MatProgressSpinner, SyncProgressComponent, TranslocoModule] +}) +export class DraftPendingUpdatesComponent implements OnInit { + @Input() pendingProjects: { projectId: string; name: string }[] = []; + /** Emits when the user leaves the pre-step, carrying the IDs of the projects that were synced in place (if any). */ + @Output() continue = new EventEmitter(); + + rows: PendingProjectRow[] = []; + loading = true; + + private autoAdvanceTimeout?: ReturnType; + + constructor( + private readonly projectService: SFProjectService, + private readonly permissionsService: PermissionsService, + private readonly destroyRef: DestroyRef + ) { + this.destroyRef.onDestroy(() => { + if (this.autoAdvanceTimeout != null) clearTimeout(this.autoAdvanceTimeout); + }); + } + + async ngOnInit(): Promise { + // Load all the project docs concurrently, then build rows in the original order. + const projectDocs = await Promise.all(this.pendingProjects.map(p => this.projectService.get(p.projectId))); + for (const [i, { projectId, name }] of this.pendingProjects.entries()) { + const projectDoc = projectDocs[i]; + const canSync = this.permissionsService.canSync(projectDoc); + const syncing = projectDoc.data != null && isSFProjectSyncing(projectDoc.data); + const row: PendingProjectRow = { + projectId, + name, + canSync, + projectDoc, + syncState: syncing ? 'syncing' : 'pending', + wasSyncing: syncing, + monitoring: false, + userInitiated: false + }; + this.rows.push(row); + // A project already syncing when the wizard opens still needs its completion observed. + if (syncing) this.monitorSync(row); + } + this.loading = false; + } + + get syncableRows(): PendingProjectRow[] { + return this.rows.filter(r => r.canSync); + } + + /** + * Whether a sync the user started from this pre-step is still running. While true, "Continue anyway" is suppressed + * so the user can't bail out of a sync they chose to start. It clears automatically once that sync reaches a + * terminal state. A project that was already syncing on entry does not count, so a stuck project can't trap the user. + */ + get userSyncInProgress(): boolean { + return this.rows.some(r => r.userInitiated && r.syncState === 'syncing'); + } + + syncProject(row: PendingProjectRow): void { + if (!row.canSync || row.syncState === 'syncing') return; + row.userInitiated = true; + row.syncState = 'syncing'; + // Subscribe before the network round-trip so the queuedCount transition can't be missed. + this.monitorSync(row); + this.projectService.onlineSync(row.projectId).catch(() => { + // Failure to even enqueue the sync (e.g. RPC/network error). + row.syncState = 'failed'; + }); + } + + retrySyncProject(row: PendingProjectRow): void { + row.syncState = 'pending'; + this.syncProject(row); + } + + syncAll(): void { + for (const row of this.syncableRows) { + if (row.syncState === 'pending') this.syncProject(row); + } + } + + continueAnyway(): void { + this.continue.emit(this.syncedProjectIds()); + } + + /** IDs of the projects that completed a sync during this pre-step (so the wizard can re-derive from fresh data). */ + private syncedProjectIds(): string[] { + return this.rows.filter(r => r.syncState === 'synced').map(r => r.projectId); + } + + /** Watches a row's project doc for the sync to complete (queuedCount returning to 0). */ + private monitorSync(row: PendingProjectRow): void { + if (row.monitoring || row.projectDoc == null) return; + row.monitoring = true; + row.projectDoc.remoteChanges$ + .pipe(quietTakeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.checkSyncStatus(row)); + // Resolve immediately in case the sync already finished during the get()/subscribe gap. + this.checkSyncStatus(row); + } + + private checkSyncStatus(row: PendingProjectRow): void { + const data = row.projectDoc?.data; + if (data == null) return; + if (isSFProjectSyncing(data)) { + row.wasSyncing = true; + row.syncState = 'syncing'; + } else if (row.wasSyncing) { + // High→low edge of queuedCount: the sync that we observed running has now completed. + row.wasSyncing = false; + row.syncState = data.sync.lastSyncSuccessful === true ? 'synced' : 'failed'; + this.checkAutoAdvance(); + } + } + + private checkAutoAdvance(): void { + if (this.autoAdvanceTimeout != null || this.syncableRows.length === 0) return; + const hasCannotSyncRow = this.rows.some(r => !r.canSync); + const allSyncableSynced = this.syncableRows.every(r => r.syncState === 'synced'); + if (!hasCannotSyncRow && allSyncableSynced) { + this.autoAdvanceTimeout = setTimeout(() => this.continue.emit(this.syncedProjectIds()), AUTO_ADVANCE_DELAY_MS); + } + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts new file mode 100644 index 00000000000..c0e9f7457ca --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts @@ -0,0 +1,1147 @@ +import { DestroyRef } from '@angular/core'; +import { Canon } from '@sillsdev/scripture'; +import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; +import { ProjectScriptureRange } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; +import { BehaviorSubject, filter, firstValueFrom, of } from 'rxjs'; +import { anything, deepEqual, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { chapterCounts } from '../../../shared/progress-service/progress.service'; +import { VerboseScriptureRange } from '../../../shared/scripture-range'; +import { DraftSource, DraftSourcesAsArrays } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; +import { DraftProgressService, ExcludedDraftingBook, NewDraftLogicHandler } from './new-draft-logic-handler'; + +const mockDestroyRef = { onDestroy: () => () => {}, destroyed: false } as unknown as DestroyRef; + +function allBooksExcept(excludedBooks: string[]): string { + return new VerboseScriptureRange( + Canon.allBookIds + .filter(book => Canon.isBookOTNT(Canon.bookIdToNumber(book)) && !excludedBooks.includes(book)) + .map(book => `${book}1-${chapterCounts[book]}`) + .join(';') + ).toString(); +} + +const FULL_CANON_SCRIPTURE_RANGE = allBooksExcept([]); + +const ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET_ORIGINAL_VALUE = NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET; + +describe('NewDraftLogicHandler', () => { + const teamStartingToTranslateGenesis = { + lastSelectedTranslationScriptureRanges: [{ projectId: 'draft-source-1-id', scriptureRange: 'GEN' }], + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK;LUK;JHN' } + ], + draftingSourceBooksChapters: FULL_CANON_SCRIPTURE_RANGE, + targetProjectBooksChapters: 'GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + trainingSourcesBooksChapters: { + 'training-source-1-id': FULL_CANON_SCRIPTURE_RANGE + } + } as const satisfies TestState; + + const teamWithTwoTrainingSources = { + lastSelectedTranslationScriptureRanges: undefined, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK' }, + { projectId: 'training-source-2-id', scriptureRange: 'LUK;JHN' } + ], + draftingSourceBooksChapters: FULL_CANON_SCRIPTURE_RANGE, + targetProjectBooksChapters: 'GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + trainingSourcesBooksChapters: { + 'training-source-1-id': FULL_CANON_SCRIPTURE_RANGE, + 'training-source-2-id': FULL_CANON_SCRIPTURE_RANGE + } + } as const satisfies TestState; + + // The gate is shared static state; reset it after each test so a gate test doesn't leak into other specs. + afterEach(() => { + NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET_ORIGINAL_VALUE; + }); + + describe('initialization', () => { + it('aborts when a source project is inaccessible', async () => { + const env = new TestEnvironment({ ...teamStartingToTranslateGenesis, noAccessSources: true }); + await env.waitForAbort(); + + expect(env.logicHandler.status$.getValue()).toBe('abort'); + expect(env.logicHandler.abortMode).toBe('no_access'); + }); + + it('aborts with config_changed when sources change after initialization', async () => { + const sources$ = new BehaviorSubject({ + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'draft-source-1-id' } as DraftSource] + }); + const env = new TestEnvironment(teamStartingToTranslateGenesis, sources$); + await env.waitForInit(); + + sources$.next({ + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'different-source-id' } as DraftSource] + }); + + expect(env.logicHandler.status$.getValue()).toBe('abort'); + expect(env.logicHandler.abortMode).toBe('config_changed'); + }); + + it('does not abort when sources re-emit without changing', async () => { + const initialSources: DraftSourcesAsArrays = { + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'draft-source-1-id' } as DraftSource] + }; + const sources$ = new BehaviorSubject(initialSources); + const env = new TestEnvironment(teamStartingToTranslateGenesis, sources$); + await env.waitForInit(); + + sources$.next({ ...initialSources }); + + expect(env.logicHandler.status$.getValue()).toBe('input'); + }); + + it('does not abort when a non-config field changes but the source set is unchanged', async () => { + const sources$ = new BehaviorSubject({ + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'draft-source-1-id', name: 'Original' } as DraftSource] + }); + const env = new TestEnvironment(teamStartingToTranslateGenesis, sources$); + await env.waitForInit(); + + // Same project ref, but other fields (name, sync status, content) changed (e.g. after a sync). Source set is + // unchanged, so this must not abort (content/sync changes are handled separately). + sources$.next({ + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'draft-source-1-id', name: 'Renamed' } as DraftSource] + }); + + expect(env.logicHandler.status$.getValue()).toBe('input'); + }); + + it('initializes available ranges based on progress service', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // Ranges availble for selection should be set automatically upon init + expect(env.availableDraftingScriptureRange).toBe(testState.draftingSourceBooksChapters); + expect(env.availableTargetTrainingScriptureRange).toBe(testState.targetProjectBooksChapters); + + // Selections should be blank + expect(env.selectedDraftingScriptureRange).toBe(''); + expect(env.selectedTargetTrainingScriptureRange).toBe(''); + }); + }); + + describe('books offered for drafting', () => { + it('never offers extra-material (non-canonical) books, even when the source reports content for them', async () => { + // The drafting source has content for the front matter and a glossary, alongside Genesis. + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + draftingSourceBooksChapters: 'FRT1;GEN1-50;GLO1' + }); + await env.waitForInit(); + + expect(env.availableDraftingScriptureRange).toBe('GEN1-50'); + // Non-canonical exclusions are tracked but not surfaced to the user. + expect(env.excludedDraftingBooks).toEqual( + jasmine.arrayWithExactContents([ + { bookId: 'FRT', reason: 'non_canonical' }, + { bookId: 'GLO', reason: 'non_canonical' } + ]) + ); + }); + + it('does not offer source books missing from the target when drafting books not in the target is disallowed', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + allowDraftingBooksNotInTarget: false, + draftingSourceBooksChapters: 'GEN1-50;EXO1-40;MAT1-28', + targetTextBooks: ['GEN', 'MAT'] + }); + await env.waitForInit(); + + // EXO has source content but isn't in the target's text list, so it isn't offered. + expect(env.availableDraftingScriptureRange).toBe('GEN1-50;MAT1-28'); + expect(env.excludedDraftingBooks).toContain({ bookId: 'EXO', reason: 'not_in_target' }); + }); + + it('offers source books missing from the target when drafting books not in the target is allowed', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + allowDraftingBooksNotInTarget: true, + draftingSourceBooksChapters: 'GEN1-50;EXO1-40;MAT1-28', + targetTextBooks: ['GEN', 'MAT'] + }); + await env.waitForInit(); + + // The same EXO book is now offered, since target membership is no longer required. + expect(env.availableDraftingScriptureRange).toBe('GEN1-50;EXO1-40;MAT1-28'); + expect(env.excludedDraftingBooks).not.toContain({ bookId: 'EXO', reason: 'not_in_target' }); + }); + + it('reports target books the drafting source has no content for', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + allowDraftingBooksNotInTarget: false, + draftingSourceBooksChapters: 'GEN1-50', + targetTextBooks: ['GEN', 'EXO'] + }); + await env.waitForInit(); + + // EXO is in the target but the drafting source has no text for it. + expect(env.availableDraftingScriptureRange).toBe('GEN1-50'); + expect(env.excludedDraftingBooks).toContain({ bookId: 'EXO', reason: 'no_source_content' }); + }); + }); + + describe('books offered for training', () => { + it('excludes extra-material (non-canonical) books from the target and training-source lists', async () => { + // The target and a training source both report content for a glossary (extra-material). + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + targetProjectBooksChapters: 'GEN1-5;GLO1;MAT1-28', + trainingSourcesBooksChapters: { + 'training-source-1-id': 'GEN1-50;GLO1;MAT1-28' + } + }); + await env.waitForInit(); + + // The glossary is offered neither as a target training book nor as a training-source book. + expect(env.logicHandler.availableTargetTrainingScriptureRange.books.has('GLO')).toBe(false); + expect(env.logicHandler.trainingSourceBooks['training-source-1-id']).not.toContain('GLO'); + }); + + it('excludes target books absent from every training source and tracks them for the notice', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + targetProjectBooksChapters: 'GEN1-5;MAT1-28;LUK1-24', + // The training source has Genesis and Matthew, but not Luke. + trainingSourcesBooksChapters: { 'training-source-1-id': 'GEN1-50;MAT1-28' } + }); + await env.waitForInit(); + env.logicHandler.setInputMode('training_books'); + + const availableTargetTraining = env.logicHandler.availableTargetTrainingScriptureRange; + // Genesis and Matthew remain available; Luke is withheld because no training source contains it. + expect(availableTargetTraining.books.has('GEN')).toBe(true); + expect(availableTargetTraining.books.has('MAT')).toBe(true); + expect(availableTargetTraining.books.has('LUK')).toBe(false); + expect(env.logicHandler.targetTrainingBooksWithoutSource).toEqual(['LUK']); + }); + }); + + describe('selectDraftingBooks', () => { + it('allows selecting books and chapters within the available ranges', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // Select Genesis for drafting + env.logicHandler.selectDraftingBooks(['GEN']); + // Remaining undrafted chapters automatically selected + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + }); + + it('offers to draft a subset of chapters of a partially completed book and defaults to the remaining undrafted chapters', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + }); + + it('does not offer to draft a subset of chapters of a book that has not been started', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['EXO']); + + expect(env.selectedDraftingScriptureRange).toBe('EXO1-40'); + expect(env.booksOfferedForPartialDrafting).toEqual([]); + }); + + it('does not offer or silently apply partial drafting to a book that is not eligible for it', async () => { + // Ruth has 4 source chapters (below the 12-chapter partial-drafting threshold) and the target already has + // chapters 1-2. Ruth is therefore not eligible for partial drafting, so no chapter input is shown for it. + // Selecting it must default to the whole book, not silently to just the untranslated chapters (which the user + // would have no way to change back). + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + draftingSourceBooksChapters: 'GEN1-50;RUT1-4', + targetProjectBooksChapters: 'GEN1-5;RUT1-2' + }); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['RUT']); + + expect(env.booksOfferedForPartialDrafting).not.toContain('RUT'); + expect(env.selectedDraftingScriptureRange).toBe('RUT1-4'); + }); + + it('offers to draft a subset of chapters of a book that has been completed and defaults to all chapters', async () => { + const testState = { + ...teamStartingToTranslateGenesis, + targetProjectBooksChapters: teamStartingToTranslateGenesis.targetProjectBooksChapters.replace( + 'GEN1-5', + 'GEN1-50' + ) + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN1-50'); + }); + + it('allows selecting multiple books for drafting and only offers to draft subsets of books that have some progress', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // User selects GEN and then EXO + env.logicHandler.selectDraftingBooks(['GEN']); + env.logicHandler.selectDraftingBooks(['GEN', 'EXO']); + + // User should be able to select both GEN and EXO for drafting, but only GEN should be offered for partial drafting since EXO has no progress + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50;EXO1-40'); + }); + + it('defaults to remaining undrafted chapters when re-selecting a book after visiting the training step', async () => { + // Target has all 50 chapters of Genesis - nothing left to draft + const testState = { + ...teamStartingToTranslateGenesis, + targetProjectBooksChapters: 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21' + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // Selecting GEN defaults to all 50 chapters (source - target = 0 -> fallback to all) + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN1-50'); + + // User manually narrows the selection to just GEN1-30 + env.logicHandler.selectDraftingChapters('GEN', '1-30'); + expect(env.selectedDraftingScriptureRange).toBe('GEN1-30'); + + // Visit training step - GEN1-30 is subtracted from the available target training range + env.logicHandler.setInputMode('training_books'); + expect(env.availableTargetTrainingScriptureRange).toBe('GEN31-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + env.logicHandler.setInputMode('draft_books'); + + // Deselect then re-select GEN - default should be all 50 chapters since target has all of them + env.logicHandler.selectDraftingBooks([]); + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN1-50'); + }); + }); + + describe('selectDraftingChapters', () => { + it('allows selecting which chapters in a book should be drafted when offering partial drafting of a book', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // User selects GEN for drafting, which offers partial drafting since GEN1-5 is completed + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN']); + + // Expect the chapter range to be set by default to the chapters that are not completed + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + + // User selects to draft a different range of chapters in GEN + env.logicHandler.selectDraftingChapters('GEN', '7,20-50'); + expect(env.selectedDraftingScriptureRange).toBe('GEN7,20-50'); + }); + + it('throws for an invalid chapter range string', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + + expect(() => env.logicHandler.selectDraftingChapters('GEN', 'abc')).toThrow(); + }); + + it('throws when selected chapters are outside the available source range', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + + // GEN only has 50 chapters in the source + expect(() => env.logicHandler.selectDraftingChapters('GEN', '51-60')).toThrow(); + }); + + it('throws when the book is not eligible for partial drafting', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + // EXO has no target progress, so partial drafting is not offered + env.logicHandler.selectDraftingBooks(['EXO']); + expect(env.booksOfferedForPartialDrafting).toEqual([]); + + expect(() => env.logicHandler.selectDraftingChapters('EXO', '1-10')).toThrow(); + }); + }); + + describe('partial-training offering tracks the drafting selection', () => { + // A book is only offered for partial target training while it is itself being drafted; otherwise the whole book + // is available for training with no per-chapter restriction. This must still hold after the drafting selection + // changes and the user returns to the training step -- the offering can't be left over from the earlier selection. + it('stops offering a book for partial target training once it is no longer being drafted', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + // Draft GEN (partial: source GEN1-50, target GEN1-5) and MAT, so deselecting GEN later still leaves a valid + // drafting selection (the step can't be left with nothing selected). + env.logicHandler.selectDraftingBooks(['GEN', 'MAT']); + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN', 'MAT']); + + // On the training step, select GEN to train on (it is offered for partial target training). + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTargetTrainingBooks(['GEN']); + expect(env.booksOfferedForPartialTargetTraining).toEqual(['GEN']); + + // Go back and drop GEN from the drafting selection (MAT remains, so the step is still valid). + env.logicHandler.setInputMode('draft_books'); + env.logicHandler.selectDraftingBooks(['MAT']); + expect(env.booksOfferedForPartialDrafting).toEqual(['MAT']); + + // Return to the training step. GEN is no longer being drafted, so it must no longer be offered for partial + // target training. + env.logicHandler.setInputMode('training_books'); + expect(env.booksOfferedForPartialTargetTraining).not.toContain('GEN'); + }); + }); + + describe('training book selection', () => { + it('defaults to previous training data when going to the training step', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // Initially no training books selected + expect(env.selectedTargetTrainingScriptureRange).toBe(''); + expect(env.selectedTrainingSourceBooks).toEqual({}); + + // Previous training selections should be selected by default when going to the training step + env.logicHandler.setInputMode('training_books'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MAT', 'MRK', 'LUK', 'JHN'] }); + }); + + it('does not automatically select training data that was previously selected but is no longer available in the training sources', async () => { + const testState = { + ...teamStartingToTranslateGenesis, + trainingSourcesBooksChapters: { + 'training-source-1-id': allBooksExcept(['MAT']) + } + }; + expect(testState.trainingSourcesBooksChapters['training-source-1-id']).not.toContain('MAT'); + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + // The previously selected training data included MAT, but since that's now unavailable, it should not be selected + expect(env.availableTrainingSourceBooks).toEqual({ 'training-source-1-id': ['GEN', 'MRK', 'LUK', 'JHN'] }); + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MRK', 'LUK', 'JHN'] }); + }); + + it('does not automatically select training data that was previously selected but is now selected to be drafted', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['MAT']); + + env.logicHandler.setInputMode('training_books'); + + // The previously selected training data includes MAT, but since that's now selected as drafting material, it should + // not be selected as training material + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MRK', 'LUK', 'JHN'] }); + }); + + it('limits the selection of training data to what is available in the training sources and not selected for drafting', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // User selects MAT for drafting, which should remove it from the available training data since it's not possible to both draft and train on the same material + env.logicHandler.selectDraftingBooks(['MAT']); + + env.logicHandler.setInputMode('training_books'); + + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MRK', 'LUK', 'JHN'] }); + + // Go back and select Genesis instead + env.logicHandler.setInputMode('draft_books'); + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + env.logicHandler.setInputMode('training_books'); + + // Now GEN should be removed from the available training data instead of MAT + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MRK', 'LUK', 'JHN'] }); + }); + + it('reallows training data to be selected when it is deselected for drafting', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // User selects GEN and MRK for drafting, which should remove both from the available training data since it's not possible to both draft and train on the same material + env.logicHandler.selectDraftingBooks(['GEN']); + env.logicHandler.selectDraftingBooks(['GEN', 'MRK']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50;MRK1-16'); + + // Go to training step and see that MRK is not available for training + env.logicHandler.setInputMode('training_books'); + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MAT1-28;LUK1-24;JHN1-21'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MAT', 'LUK', 'JHN'] }); + + // Go back and deselect MRK for drafting + env.logicHandler.setInputMode('draft_books'); + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + + // Now MRK should be available again in the training data + env.logicHandler.setInputMode('training_books'); + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + }); + + it('restores previously selected books independently for each training source', async () => { + const env = new TestEnvironment(teamWithTwoTrainingSources); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + // Each source gets its own prior selection restored, with no cross-contamination + expect(env.selectedTrainingSourceBooks).toEqual({ + 'training-source-1-id': ['MAT', 'MRK'], + 'training-source-2-id': ['LUK', 'JHN'] + }); + }); + + it("restores the target training selection from the target project's own saved entry, not from the training sources", async () => { + const testState = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK;LUK;JHN' }, + // The target project's own previously selected training books, looked up by project ID + { projectId: 'testProjectId', scriptureRange: 'MAT;LUK' } + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + // Target training comes from the target's own entry (MAT, LUK), not the union of the source books + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;LUK1-24'); + }); + + it('ignores chapter detail in the saved target entry and re-derives chapter defaults from current project state', async () => { + const testState = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT' }, + // Chapter detail (LUK1-5) should be ignored; chapters default to all available in the current target + { projectId: 'testProjectId', scriptureRange: 'LUK1-5' } + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('LUK1-24'); + }); + + it('infers the target training selection from the source training ranges when no saved target entry exists', async () => { + // Older draft configs predate saving a target training entry, so the target selection must be inferred from the + // union of the source training books. + const testState = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK;LUK;JHN' } + // No entry for the target project ('testProjectId') + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + }); + + it("does not treat the target project's own training entry as a training source", async () => { + const testState = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT' }, + { projectId: 'testProjectId', scriptureRange: 'MAT;LUK' } + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTrainingSourceBooks['testProjectId']).toBeUndefined(); + }); + + it('a book selected for drafting is excluded from all training sources', async () => { + const testState = { + ...teamWithTwoTrainingSources, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK;LUK;JHN' }, + { projectId: 'training-source-2-id', scriptureRange: 'MAT;MRK;LUK;JHN' } + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['MAT']); + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTrainingSourceBooks['training-source-1-id']).not.toContain('MAT'); + expect(env.selectedTrainingSourceBooks['training-source-2-id']).not.toContain('MAT'); + }); + + it('filters a book from one source independently when it is absent from that source but present in another', async () => { + const testState = { + ...teamWithTwoTrainingSources, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT' }, + { projectId: 'training-source-2-id', scriptureRange: 'MAT' } + ], + trainingSourcesBooksChapters: { + 'training-source-1-id': FULL_CANON_SCRIPTURE_RANGE, + 'training-source-2-id': allBooksExcept(['MAT']) + } + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + // Source 1 still has MAT available and selected; source 2 does not + expect(env.availableTrainingSourceBooks['training-source-1-id']).toContain('MAT'); + expect(env.availableTrainingSourceBooks['training-source-2-id']).not.toContain('MAT'); + expect(env.selectedTrainingSourceBooks['training-source-1-id']).toContain('MAT'); + expect(env.selectedTrainingSourceBooks['training-source-2-id']).not.toContain('MAT'); + }); + }); + + describe('auto-selecting training books on first visit', () => { + // A team generating their first draft: no previously saved training selection, so training books are auto-selected. + const teamWithNoPriorTrainingSelection = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: undefined + } as const satisfies TestState; + + it('pre-selects only books that appear complete, and pairs them in the training sources', async () => { + const env = new TestEnvironment({ + ...teamWithNoPriorTrainingSelection, + // MAT and MRK look complete; LUK and JHN do not (e.g. still in progress). + completeTargetBooks: ['MAT', 'MRK'] + }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;MRK1-16'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MAT', 'MRK'] }); + expect(env.trainingBooksWereAutoSelected).toBe(true); + }); + + it('never pre-selects a book that is being drafted, even when it appears complete', async () => { + const env = new TestEnvironment({ + ...teamWithNoPriorTrainingSelection, + completeTargetBooks: ['GEN', 'MAT'] + }); + await env.waitForInit(); + + // Partially draft GEN (GEN6-50), leaving GEN1-5 available for training. GEN appears complete, but since the user + // is drafting it, it is a lower-conviction case and must not be auto-selected. MAT is complete and not drafted. + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MAT'] }); + expect(env.trainingBooksWereAutoSelected).toBe(true); + }); + + it('selects nothing and shows no notice when no book appears complete', async () => { + const env = new TestEnvironment({ ...teamWithNoPriorTrainingSelection, completeTargetBooks: [] }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe(''); + expect(env.trainingBooksWereAutoSelected).toBe(false); + }); + + it('pairs auto-selected books across every training source that contains them', async () => { + const env = new TestEnvironment({ + ...teamWithTwoTrainingSources, + previouslySelectedTrainingScriptureRanges: undefined, + completeTargetBooks: ['MAT', 'LUK'] + }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;LUK1-24'); + expect(env.selectedTrainingSourceBooks).toEqual({ + 'training-source-1-id': ['MAT', 'LUK'], + 'training-source-2-id': ['MAT', 'LUK'] + }); + expect(env.trainingBooksWereAutoSelected).toBe(true); + }); + + it('does not auto-select when a previous training selection exists (restore takes precedence)', async () => { + // teamStartingToTranslateGenesis has a saved selection (MAT;MRK;LUK;JHN); the complete-book set should be ignored. + const env = new TestEnvironment({ ...teamStartingToTranslateGenesis, completeTargetBooks: ['GEN'] }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + expect(env.trainingBooksWereAutoSelected).toBe(false); + }); + + it('clears the auto-selected notice once the user deselects every target training book', async () => { + const env = new TestEnvironment({ ...teamWithNoPriorTrainingSelection, completeTargetBooks: ['MAT', 'MRK'] }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + expect(env.trainingBooksWereAutoSelected).toBe(true); + + // Deselecting down to a non-empty selection keeps the review notice. + env.logicHandler.selectTargetTrainingBooks(['MAT']); + env.logicHandler.dismissAutoSelectNoticeIfSelectionEmpty(); + expect(env.trainingBooksWereAutoSelected).toBe(true); + + // Deselecting everything clears it. + env.logicHandler.selectTargetTrainingBooks([]); + env.logicHandler.dismissAutoSelectNoticeIfSelectionEmpty(); + expect(env.trainingBooksWereAutoSelected).toBe(false); + }); + }); + + describe('reload (after in-place sync)', () => { + it('re-derives availability from freshly fetched progress', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + draftingSourceBooksChapters: 'GEN1-50', + targetProjectBooksChapters: 'GEN1-5', + targetProjectBooksChaptersAfterReload: 'GEN1-10' + }); + await env.waitForInit(); + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5'); + + await env.logicHandler.reload(['testProjectId']); + + // The post-sync target content is now reflected in what's available. + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-10'); + }); + + it('defaults the draft to chapters still untranslated after the sync, not before', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + draftingSourceBooksChapters: 'GEN1-50', + targetProjectBooksChapters: 'GEN1-5', + // The user synced their translation of GEN 6-10 in place. + targetProjectBooksChaptersAfterReload: 'GEN1-10', + trainingSourcesBooksChapters: { 'training-source-1-id': 'GEN1-50' } + }); + await env.waitForInit(); + + await env.logicHandler.reload(['testProjectId']); + + // Default = source - target. Pre-sync this would have been GEN6-50; with the synced chapters it must be GEN11-50, + // so the chapters the user just translated aren't defaulted back into the draft. + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN11-50'); + }); + + it('forces a fresh fetch only for the synced projects', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + resetCalls(mockedDraftProgressService); + + await env.logicHandler.reload(['testProjectId']); // only the target synced + + // Target (synced) is re-fetched with no staleness tolerance... + verify( + mockedDraftProgressService.getProgressForProject('testProjectId', deepEqual({ maxStalenessMs: 0 })) + ).once(); + // ...while the unsynced drafting source uses the default staleness (served from cache by the real service). + verify(mockedDraftProgressService.getProgressForProject('draft-source-1-id', deepEqual({}))).once(); + expect().nothing(); + }); + + it('resets any selections the user had made', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).not.toBe(''); + + await env.logicHandler.reload(['testProjectId']); + + expect(env.selectedDraftingScriptureRange).toBe(''); + expect(env.logicHandler.inputMode).toBe('draft_books'); + }); + }); + + describe('selectTargetTrainingChapters', () => { + it('updates the selected target training scripture range', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); // defaults to GEN6-50 + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTargetTrainingBooks(['GEN']); // GEN1-5 available for training + + env.logicHandler.selectTargetTrainingChapters('GEN', '1-3'); + expect(env.selectedTargetTrainingScriptureRange).toBe('GEN1-3'); + }); + + it('throws when called in draft_books mode', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + // still in draft_books mode + expect(() => env.logicHandler.selectTargetTrainingChapters('GEN', '1-3')).toThrow(); + }); + + it('throws when selected chapters are outside the available target training range', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); // GEN6-50 drafted + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTargetTrainingBooks(['GEN']); // only GEN1-5 available for training + + // GEN6 is being drafted, so it's not available for target training + expect(() => env.logicHandler.selectTargetTrainingChapters('GEN', '1-10')).toThrow(); + }); + + it('throws when the book is not eligible for partial target training', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + env.logicHandler.setInputMode('training_books'); + // MAT is available for training but was not offered for partial drafting + env.logicHandler.selectTargetTrainingBooks(['MAT']); + expect(env.booksOfferedForPartialTargetTraining).toEqual([]); + + expect(() => env.logicHandler.selectTargetTrainingChapters('MAT', '1-10')).toThrow(); + }); + }); + + describe('selectTargetTrainingBooks', () => { + it('updates the selected target training range', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTargetTrainingBooks(['MRK', 'LUK']); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24'); + }); + + it('throws when called in draft_books mode', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + expect(() => env.logicHandler.selectTargetTrainingBooks(['MAT'])).toThrow(); + }); + + it('offers partial target training for a book that was partially drafted with target chapters remaining', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + // GEN: source has 50 chapters (>= 12), target has GEN1-5 (>= 1) - eligible for partial drafting + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + + env.logicHandler.setInputMode('training_books'); + // GEN1-5 remain available for target training since only GEN6-50 is being drafted + env.logicHandler.selectTargetTrainingBooks(['GEN']); + + expect(env.booksOfferedForPartialTargetTraining).toEqual(['GEN']); + }); + + it('does not offer partial target training for books not offered for partial drafting', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + env.logicHandler.setInputMode('training_books'); + // MAT is available for target training but was not offered for partial drafting + env.logicHandler.selectTargetTrainingBooks(['MAT']); + + expect(env.booksOfferedForPartialTargetTraining).toEqual([]); + }); + }); + + describe('selectTrainingSourceBooks', () => { + it('updates the selected training source books for a project', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['MAT', 'MRK']); + + expect(env.logicHandler.selectedTrainingSourceBooks['training-source-1-id']).toEqual(['MAT', 'MRK']); + }); + + it('throws when called in draft_books mode', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + expect(() => env.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['MAT'])).toThrow(); + }); + + it('throws when selecting a book not in the available set', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + // REV is not in the target project, so it can never be in the available training source books + expect(() => env.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['REV'])).toThrow(); + }); + + it('preserves selections for other projects when updating one', async () => { + const env = new TestEnvironment(teamWithTwoTrainingSources); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['MAT']); + env.logicHandler.selectTrainingSourceBooks('training-source-2-id', ['LUK']); + + expect(env.logicHandler.selectedTrainingSourceBooks['training-source-1-id']).toEqual(['MAT']); + expect(env.logicHandler.selectedTrainingSourceBooks['training-source-2-id']).toEqual(['LUK']); + }); + }); +}); + +const mockedActivatedProjectService = mock(ActivatedProjectService); +const mockedSFProjectService = mock(SFProjectService); +const mockedDraftSourcesService = mock(DraftSourcesService); +const mockedDraftProgressService = mock(DraftProgressService); + +interface TestState { + lastSelectedTranslationScriptureRanges: ProjectScriptureRange[] | undefined; + previouslySelectedTrainingScriptureRanges: ProjectScriptureRange[] | undefined; + + /** A scripture range specifying what books and chapters exist in the drafting source */ + draftingSourceBooksChapters: string; + /** A scripture range specifying what books and chapters exist in the target project */ + targetProjectBooksChapters: string; + /** + * If set, the target progress returned by a second fetch (i.e. after `reload()`), simulating an in-place sync that + * changed the target. The first fetch still returns `targetProjectBooksChapters`. + */ + targetProjectBooksChaptersAfterReload?: string; + + /** + * A mapping of project ID to scripture range specifying what books and chapters exist in each training source, keyed + * by project ID. + */ + trainingSourcesBooksChapters: { [key: string]: string }; + noAccessSources?: boolean; + + /** + * The target books that appear complete enough to be auto-selected as training data on a first draft (what + * getCompleteBookIds reports). Independent of the chapter-level ranges above, since completeness is a segment-level + * judgment. Defaults to none. + */ + completeTargetBooks?: string[]; + + /** + * The books that exist in the target project's text list (membership, independent of content). Only consulted when + * the target-membership gate is enforced (allowDraftingBooksNotInTarget === false). Defaults to undefined (no texts). + */ + targetTextBooks?: string[]; + + /** + * Whether to allow drafting books not present in the target project. Defaults to true in tests so that the + * target-membership gate doesn't interfere with tests that aren't exercising it; tests for the gate set it + * explicitly. + */ + allowDraftingBooksNotInTarget?: boolean; +} + +class TestEnvironment { + logicHandler: NewDraftLogicHandler; + + activatedProjectService = instance(mockedActivatedProjectService); + sfProjectService = instance(mockedSFProjectService); + draftSourcesService = instance(mockedDraftSourcesService); + draftProgressService = instance(mockedDraftProgressService); + + constructor(state: TestState, sources$?: BehaviorSubject) { + // Default to allowing books not in the target so the membership gate stays out of the way of tests that aren't + // exercising it. Gate tests set this explicitly. + NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = state.allowDraftingBooksNotInTarget ?? true; + + const project = createTestProjectProfile({ + texts: (state.targetTextBooks ?? []).map(bookId => ({ + bookNum: Canon.bookIdToNumber(bookId), + hasSource: false, + chapters: [], + permissions: {} + })), + translateConfig: { + preTranslate: true, + draftConfig: { + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: 'draft-source-1-id', + name: 'Draft Source 1', + shortName: 'DS1', + writingSystem: { script: 'Latn', tag: 'es' } + } + ], + trainingSources: Object.entries(state.trainingSourcesBooksChapters).map(([projectId, _booksChapters]) => ({ + paratextId: `${projectId}-pt-id`, + projectRef: projectId, + name: `Training Source for ${projectId}`, + shortName: `TS-${projectId}`, + writingSystem: { script: 'Latn', tag: 'es' } + })), + lastSelectedTrainingScriptureRanges: state.previouslySelectedTrainingScriptureRanges, + lastSelectedTrainingDataFiles: [], + lastSelectedTranslationScriptureRanges: state.lastSelectedTranslationScriptureRanges + } + } + }); + + const projectId = 'testProjectId'; + when(mockedActivatedProjectService.projectId).thenReturn(projectId); + when(mockedActivatedProjectService.projectId$).thenReturn(of(projectId)); + when(mockedActivatedProjectService.projectDoc).thenReturn({ data: project } as SFProjectProfileDoc); + when(mockedActivatedProjectService.projectDoc$).thenReturn(of({ data: project } as SFProjectProfileDoc)); + when(mockedSFProjectService.getProfile(projectId)).thenResolve({ data: project } as SFProjectProfileDoc); + + when(mockedDraftSourcesService.getDraftProjectSources()).thenReturn( + sources$ ?? + of({ + trainingSources: state.noAccessSources ? ([{ noAccess: true }] as unknown as DraftSource[]) : [], + trainingTargets: [], + draftingSources: [] + }) + ); + + // Set up the progress service to return the specified scripture ranges for the project and sources. The second + // argument (staleness options) is matched with anything() since callers pass per-project staleness overrides. + if (state.targetProjectBooksChaptersAfterReload != null) { + // First load returns the pre-sync target; a subsequent fetch (after reload) returns the post-sync target. + when(mockedDraftProgressService.getProgressForProject(projectId, anything())) + .thenResolve(new VerboseScriptureRange(state.targetProjectBooksChapters)) + .thenResolve(new VerboseScriptureRange(state.targetProjectBooksChaptersAfterReload)); + } else { + when(mockedDraftProgressService.getProgressForProject(projectId, anything())).thenResolve( + new VerboseScriptureRange(state.targetProjectBooksChapters) + ); + } + when(mockedDraftProgressService.getProgressForProject('draft-source-1-id', anything())).thenResolve( + new VerboseScriptureRange(state.draftingSourceBooksChapters) + ); + for (const [trainingSourceProjectId, booksChapters] of Object.entries(state.trainingSourcesBooksChapters)) { + when(mockedDraftProgressService.getProgressForProject(trainingSourceProjectId, anything())).thenResolve( + new VerboseScriptureRange(booksChapters) + ); + } + when(mockedDraftProgressService.getCompleteBookIds(projectId, anything())).thenResolve( + new Set(state.completeTargetBooks ?? []) + ); + + this.logicHandler = new NewDraftLogicHandler( + this.activatedProjectService, + this.draftSourcesService, + this.draftProgressService, + mockDestroyRef + ); + } + + async waitForInit(): Promise { + await firstValueFrom(this.logicHandler.status$.pipe(filter(status => status === 'input'))); + } + + async waitForAbort(): Promise { + await firstValueFrom(this.logicHandler.status$.pipe(filter(status => status === 'abort'))); + } + + // Aliases + + get availableTrainingSourceBooks(): { [projectId: string]: string[] } { + return this.logicHandler.availableTrainingSourceBooks; + } + + get selectedTrainingSourceBooks(): { [projectId: string]: string[] } { + return this.logicHandler.selectedTrainingSourceBooks; + } + + get availableTargetTrainingScriptureRange(): string { + return this.logicHandler.availableTargetTrainingScriptureRange.toString(); + } + + get selectedTargetTrainingScriptureRange(): string { + return this.logicHandler.selectedTargetTrainingScriptureRange.toString(); + } + + get trainingBooksWereAutoSelected(): boolean { + return this.logicHandler.trainingBooksWereAutoSelected; + } + + get availableDraftingScriptureRange(): string { + return this.logicHandler.availableDraftingScriptureRange.toString(); + } + + get excludedDraftingBooks(): ExcludedDraftingBook[] { + return this.logicHandler.excludedDraftingBooks; + } + + get selectedDraftingScriptureRange(): string { + return this.logicHandler.selectedDraftingScriptureRange.toString(); + } + + get booksOfferedForPartialDrafting(): string[] { + return this.logicHandler.booksOfferedForPartialDrafting; + } + + get booksOfferedForPartialTargetTraining(): string[] { + return this.logicHandler.booksOfferedForPartialTargetTraining; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts new file mode 100644 index 00000000000..8f2e4af8428 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts @@ -0,0 +1,699 @@ +import { DestroyRef, Injectable } from '@angular/core'; +import { Canon } from '@sillsdev/scripture'; +import { mapValues } from 'lodash-es'; +import { BehaviorSubject, firstValueFrom, skip } from 'rxjs'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { + bookAppearsCompleteForTrainingAutoSelection, + ProgressService +} from '../../../shared/progress-service/progress.service'; +import { ChapterSet, VerboseScriptureRange } from '../../../shared/scripture-range'; +import { projectLabel } from '../../../shared/utils'; +import { DraftSourcesAsArrays } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; + +/** + * Minimum number of chapters a source book must have before it is offered for partial (chapter-level) drafting. + * Books smaller than this are only ever drafted in full. + */ +const MIN_SOURCE_CHAPTERS_FOR_PARTIAL_DRAFTING = 12; + +/** + * Minimum fraction of a chapter's verse segments that must be non-blank for the chapter to count as having content. + * Chapters at or below this ratio are treated as untranslated, which drives three decisions off the same policy: + * whether a source chapter is offered as material to draft from, whether a target chapter counts toward existing + * content (and so is excluded from the default drafting selection), and whether a book is eligible for partial + * drafting. Kept in one place, behind `chapterHasContent`, so those uses can't drift apart. + */ +const MIN_CHAPTER_COMPLETION_RATIO_FOR_CONTENT = 0.1; + +/** Whether a chapter has enough non-blank verse segments to count as having content (see the constant above). */ +function chapterHasContent(chapter: { verseSegments: number; blankVerseSegments: number }): boolean { + if (chapter.verseSegments === 0) { + return false; + } + const completionRatio = (chapter.verseSegments - chapter.blankVerseSegments) / chapter.verseSegments; + return completionRatio > MIN_CHAPTER_COMPLETION_RATIO_FOR_CONTENT; +} + +/** + * Why a book that a user might expect to see was left out of the list offered for drafting. Every excluded book is + * recorded with its reason so the UI can explain the omission. Not every reason is surfaced to the user: 'non_canonical' + * books (front/back matter, glossaries, etc.) are excluded silently, since users don't expect them to be draftable. + */ +export type DraftingBookExclusionReason = 'non_canonical' | 'no_source_content' | 'not_in_target'; + +/** + * Default freshness window for progress lookups. Progress data older than this is re-fetched. Callers that must have + * up-to-the-moment data (e.g. just after an in-place sync) pass `maxStalenessMs: 0` to force a fresh fetch. + */ +const DEFAULT_PROGRESS_STALENESS_MS = 1000 * 60; + +export interface ExcludedDraftingBook { + bookId: string; + reason: DraftingBookExclusionReason; +} + +@Injectable({ providedIn: 'root' }) +/** Like ProgressService, but provides a VerboseScriptureRange instead of raw progress data */ +export class DraftProgressService { + constructor(private readonly progressService: ProgressService) {} + + async getProgressForProject( + projectId: string, + options: { maxStalenessMs?: number } = {} + ): Promise { + const progress = await this.progressService.getProgressWithChapterProgress(projectId, { + maxStalenessMs: options.maxStalenessMs ?? DEFAULT_PROGRESS_STALENESS_MS + }); + const scriptureRange = new VerboseScriptureRange(); + for (const bookProgress of progress.books) { + const chapters = new ChapterSet([]); + for (const chapterProgress of bookProgress.chapters) { + if (chapterHasContent(chapterProgress)) { + chapters.chapters.add(chapterProgress.chapterNumber); + } + } + // Only include books with content; empty books shouldn't be offered for selection. + if (chapters.count() > 0) { + scriptureRange.books.set(bookProgress.bookId, chapters); + } + } + return scriptureRange; + } + + /** + * Returns the IDs of the books in a project that appear complete enough to be auto-selected as training data (see + * bookAppearsCompleteForTrainingAutoSelection). Derived from the segment-level progress counts that getProgressFor + * Project discards, which is why this is computed separately. Reuses the cached progress, so calling it alongside + * getProgressForProject for the same project (with the same staleness) costs no extra request. + */ + async getCompleteBookIds(projectId: string, options: { maxStalenessMs?: number } = {}): Promise> { + const progress = await this.progressService.getProgressWithChapterProgress(projectId, { + maxStalenessMs: options.maxStalenessMs ?? DEFAULT_PROGRESS_STALENESS_MS + }); + const completeBookIds = new Set(); + for (const bookProgress of progress.books) { + if (bookAppearsCompleteForTrainingAutoSelection(bookProgress)) { + completeBookIds.add(bookProgress.bookId); + } + } + return completeBookIds; + } +} + +export type NewDraftAbortMode = 'config_changed' | 'project_syncing' | 'no_access' | 'init_failure' | null; + +/** + * Returns the book IDs in a ScriptureRange, dropping the chapter-level detail. Useful when only the set of books + * matters, such as determining which books users can select. Books with no chapters are already pruned when ranges are + * built (see getProgressForProject and VerboseScriptureRange.removeEmptyBooks), so every returned book is selectable. + */ +export function scriptureRangeToBookListWithoutChapterDetail(range: VerboseScriptureRange): string[] { + return Array.from(range.books.keys()); +} + +/** + * Returns a copy of the range with extra-material (non-canonical) books removed. Such books (front/back matter, + * glossaries, etc.) are never drafted or used as training data, so they should not be offered for selection. + */ +function withoutExtraMaterialBooks(range: VerboseScriptureRange): VerboseScriptureRange { + const result = range.clone(); + for (const bookId of result.books.keys()) { + if (Canon.isExtraMaterial(bookId)) { + result.books.delete(bookId); + } + } + return result; +} + +/** + * Implements business logic for creating a new draft. Intended to be used in conjunction with a component that handles + * UI interaction. + * + * Basic flow is: + * 1. Initilization: Loads project and progress data, determines which books and chapters are available for drafting and + * training, and sets up subscriptions to watch background changes that would necessitate forcing the user to start + * over. The draft source and target projects are tracked at a chapter level, while the training sources are only + * tracked at a book level. + * + */ +export class NewDraftLogicHandler { + /** + * When false (current behavior, matching the legacy stepper), a book is only offered for drafting if it also exists + * in the target project's text list. This is a temporary restriction: the current UI doesn't handle drafting a book + * that isn't already in the target. SF-3822 is intended to lift this soon, at which point this can be changed to true + * (or deleted), and any canonical book with source content is offered regardless of target membership. + * + * Overridable on the class (NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET) so tests can exercise both branches. + */ + static ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = false; + + status$ = new BehaviorSubject<'init' | 'input' | 'abort'>('init'); + abortMode: NewDraftAbortMode = null; + + /** Names of the projects that could not be accessed, populated when aborting with mode 'no_access'. */ + inaccessibleProjectNames: string[] = []; + /** The error that caused an 'init_failure' abort, retained so the component can report it. */ + initError?: unknown; + + // A book can be present (in a project), available (logic rules do not forbit selecting it, and it is therefore + // offered in the UI), and selected (user action, or default values selected the ) + availableDraftingScriptureRange: VerboseScriptureRange = new VerboseScriptureRange(); + selectedDraftingScriptureRange: VerboseScriptureRange = new VerboseScriptureRange(); + + /** Books left out of the drafting list, with the reason for each (see DraftingBookExclusionReason). */ + excludedDraftingBooks: ExcludedDraftingBook[] = []; + + targetProjectScriptureRange = new VerboseScriptureRange(); + availableTargetTrainingScriptureRange: VerboseScriptureRange = new VerboseScriptureRange(); + selectedTargetTrainingScriptureRange: VerboseScriptureRange = new VerboseScriptureRange(); + + /** + * Whether training books were automatically selected on this project's first draft (no previously saved training + * selection). Used to show the "review the pre-selected books" notice on the training step. + */ + trainingBooksWereAutoSelected: boolean = false; + + /** Target books that appear complete enough to auto-select as training data (see getCompleteBookIds). */ + private completeTargetBookIds = new Set(); + + /** + * Target books that have content available for training but are not offered, because no training source contains + * the book (so it could never be paired with a source). Populated when the training step is entered; used to explain + * why those books are missing from the target training list. + */ + targetTrainingBooksWithoutSource: string[] = []; + + /** Books that exist in the training sources, by project ID */ + trainingSourceBooks: { [projectId: string]: string[] } = {}; + availableTrainingSourceBooks: { [projectId: string]: string[] } = {}; + selectedTrainingSourceBooks: { [projectId: string]: string[] } = {}; + + get booksOfferedForPartialDrafting(): string[] { + return Array.from(this.selectedDraftingScriptureRange.books.keys()).filter(bookId => + this.isBookEligibleForPartialDrafting(bookId) + ); + } + + get booksOfferedForPartialTargetTraining(): string[] { + // Only books offered for partial drafting can have their target training chapters selected individually, and at + // least one target chapter must remain available for training. + const booksOfferedForPartialDrafting = new Set(this.booksOfferedForPartialDrafting); + return Array.from(this.selectedTargetTrainingScriptureRange.books.keys()).filter(bookId => { + if (!booksOfferedForPartialDrafting.has(bookId)) return false; + const chaptersAvailableForTraining = this.availableTargetTrainingScriptureRange.books.get(bookId); + return chaptersAvailableForTraining != null && chaptersAvailableForTraining.count() >= 1; + }); + } + + /** + * SPecifies what input mode the user is using. When a book is selected for use as drafting, it must be automatically + * removed from being used as training. However, if a user selects and unselects a book while selecting books to + * draft, that book shouldn't be automatically removed from being used as training data. Tracking the input state + * allows update rules to be enforced at the right point in time. + */ + inputMode: 'draft_books' | 'training_books' = 'draft_books'; + + sources?: DraftSourcesAsArrays; + + constructor( + private readonly activatedProjectService: ActivatedProjectService, + private readonly draftSourcesService: DraftSourcesService, + private readonly progressService: DraftProgressService, + private readonly destroyRef: DestroyRef + ) { + void this.init(); + } + + /** + * Sets up the state by loading the project, checking for changes in Paratext that haven't synced to SF yet, loading + * progress data, and setting up subscripts that watch for changes that should result in bailing out (forcing the + * user to restart the process). Automatically sets training books to most recently selected training books. + */ + async init(): Promise { + try { + const [bundle, sources] = await Promise.all([ + this.fetchProgressBundle(), + firstValueFrom(this.draftSourcesService.getDraftProjectSources()) + ]); + this.sources = sources; + + const sourcesWithNoAccess = [ + ...this.sources.trainingSources, + ...this.sources.trainingTargets, + ...this.sources.draftingSources + ].filter(source => source.noAccess); + if (sourcesWithNoAccess.length > 0) { + this.inaccessibleProjectNames = sourcesWithNoAccess + .map(source => projectLabel(source)) + .filter(label => label !== ''); + this.abort('no_access'); + return; + } + + this.applyDerivedRanges(bundle); + + this.status$.next('input'); + + // Watch for changes to which projects are configured (not their content). A source-identity change means + // the wizard was operating on a stale premise and must abort. Skip the initial emission (baseline). + const baselineSignature = this.sourceConfigSignature(this.sources); + this.draftSourcesService + .getDraftProjectSources() + .pipe(skip(1), quietTakeUntilDestroyed(this.destroyRef)) + .subscribe(newSources => { + if (this.sourceConfigSignature(newSources) !== baselineSignature) { + this.abort('config_changed'); + } + }); + } catch (error) { + // Any unanticipated failure while loading project/progress data (network errors, missing config, etc.) aborts + // into a generic failure state rather than leaving the wizard stuck on its loading spinner. The error is + // retained so the component can route it to the global error handler. + this.initError = error; + this.abort('init_failure'); + } + } + + /** + * Loads the progress data needed to derive book/chapter availability: the drafting source's content, the target's + * content, each training source's content, and the set of target books that appear complete (for auto-selection). + * Projects in `freshProjectIds` are fetched with no staleness tolerance (forcing a fresh fetch — used after an + * in-place sync); all others use the default freshness window, so unchanged projects are served from cache. + */ + private async fetchProgressBundle(freshProjectIds: Set = new Set()): Promise<{ + draftSourceProgress: VerboseScriptureRange; + targetProjectProgress: VerboseScriptureRange; + trainingSourcesProgress: { projectId: string; range: VerboseScriptureRange }[]; + completeTargetBookIds: Set; + }> { + const projectId = await firstValueFrom(this.activatedProjectService.projectId$.pipe(filterNullish())); + const projectDoc = await firstValueFrom(this.activatedProjectService.projectDoc$.pipe(filterNullish())); + + if (projectId == null) throw new Error('No project selected'); + if (projectDoc?.data == null) throw new Error('Project data not loaded'); + + const draftConfig = projectDoc.data.translateConfig?.draftConfig; + if (draftConfig == null) throw new Error('Draft config not found in project data'); + + // The UI never allows configuring more than one drafting source, so this is treated as an impossible state. + if (draftConfig.draftingSources.length !== 1) { + throw new Error(`Expected exactly one drafting source; found ${draftConfig.draftingSources.length}`); + } + const draftingSource = draftConfig.draftingSources[0]; + + const staleness = (id: string): { maxStalenessMs?: number } => + freshProjectIds.has(id) ? { maxStalenessMs: 0 } : {}; + + const trainingSourcesProgressPromise = Promise.all( + draftConfig.trainingSources.map(async trainingSource => { + const range = await this.progressService.getProgressForProject( + trainingSource.projectRef, + staleness(trainingSource.projectRef) + ); + return { projectId: trainingSource.projectRef, range }; + }) + ); + + // Order matters: the target's range (getProgressForProject) is invoked before its complete-book set + // (getCompleteBookIds) so the two reads of the same project coalesce onto a single in-flight request. + const [draftSourceProgress, targetProjectProgress, trainingSourcesProgress, completeTargetBookIds] = + await Promise.all([ + this.progressService.getProgressForProject(draftingSource.projectRef, staleness(draftingSource.projectRef)), + this.progressService.getProgressForProject(projectId, staleness(projectId)), + trainingSourcesProgressPromise, + this.progressService.getCompleteBookIds(projectId, staleness(projectId)) + ]); + + return { draftSourceProgress, targetProjectProgress, trainingSourcesProgress, completeTargetBookIds }; + } + + /** + * Derives the offered drafting books, available ranges, and training-source book lists from a freshly loaded progress + * bundle and publishes them on the relevant subjects. Reads the target's text list from the (live) project doc for + * membership, so a reload after an in-place sync picks up newly synced books. Does not touch the user's selections. + */ + private applyDerivedRanges(bundle: { + draftSourceProgress: VerboseScriptureRange; + targetProjectProgress: VerboseScriptureRange; + trainingSourcesProgress: { projectId: string; range: VerboseScriptureRange }[]; + completeTargetBookIds: Set; + }): void { + const texts = this.activatedProjectService.projectDoc?.data?.texts ?? []; + const targetTextBookIds = new Set(texts.map(text => Canon.bookNumberToId(text.bookNum))); + const { available, excluded } = this.computeOfferedDraftingBooks(bundle.draftSourceProgress, targetTextBookIds); + + // Extra-material books are never offered for training (drafting handles them separately). + const canonicalTargetProgress = withoutExtraMaterialBooks(bundle.targetProjectProgress); + + this.completeTargetBookIds = bundle.completeTargetBookIds; + this.targetProjectScriptureRange = canonicalTargetProgress; + this.availableDraftingScriptureRange = available; + this.excludedDraftingBooks = excluded; + // Clone: this range is narrowed in limitAvailableTrainingRangeBasedOnSelectedDraftingRange, so it must not alias + // targetProjectScriptureRange. + this.availableTargetTrainingScriptureRange = canonicalTargetProgress.clone(); + this.trainingSourceBooks = Object.fromEntries( + bundle.trainingSourcesProgress.map(source => [ + source.projectId, + scriptureRangeToBookListWithoutChapterDetail(withoutExtraMaterialBooks(source.range)) + ]) + ); + } + + /** + * Re-derives book/chapter availability after the user syncs stale projects in place via the pending-updates + * pre-step. Only the projects in `syncedProjectIds` are re-fetched fresh; unchanged projects are served from cache. + * Valid only before the user has made any selection (the pre-step precedes Step 1), so it returns the selection + * state to its post-init baseline rather than trying to preserve stale selections. + */ + async reload(syncedProjectIds: string[]): Promise { + const bundle = await this.fetchProgressBundle(new Set(syncedProjectIds)); + this.applyDerivedRanges(bundle); + this.resetSelectionState(); + } + + /** Returns all selection-derived state to its post-init baseline. */ + private resetSelectionState(): void { + this.selectedDraftingScriptureRange = new VerboseScriptureRange(); + this.selectedTargetTrainingScriptureRange = new VerboseScriptureRange(); + this.selectedTrainingSourceBooks = {}; + this.availableTrainingSourceBooks = {}; + this.targetTrainingBooksWithoutSource = []; + this.trainingBooksWereAutoSelected = false; + this.hasVisitedTrainingBooksInputMode = false; + this.inputMode = 'draft_books'; + } + + private hasVisitedTrainingBooksInputMode = false; + setInputMode(newMode: 'draft_books' | 'training_books'): void { + const priorMode = this.inputMode; + // Switch the mode first so that loadPreviouslySelectedTrainingBooks() can use the normal training-book selection + // path (e.g. selectTargetTrainingBooks), which requires being in training_books mode. + this.inputMode = newMode; + if (priorMode === 'draft_books' && newMode === 'training_books') { + this.limitAvailableTrainingRangeBasedOnSelectedDraftingRange(); + if (!this.hasVisitedTrainingBooksInputMode) { + this.loadPreviouslySelectedTrainingBooks(); + this.hasVisitedTrainingBooksInputMode = true; + } + } + } + + selectDraftingBooks(books: string[]): void { + if (this.inputMode !== 'draft_books') { + throw new Error('Cannot update draft books when not in draft_books input mode'); + } + + const newDraftingScriptureRange = new VerboseScriptureRange(); + const newlySelectedBooks = books.filter(book => !this.selectedDraftingScriptureRange.books.has(book)); + + for (const book of books) { + if (!this.availableDraftingScriptureRange.books.has(book)) { + throw new Error(`Selected book ${book} not in available drafting scripture range`); + } + if (newlySelectedBooks.includes(book)) { + const chaptersInTarget = this.targetProjectScriptureRange.books.get(book); + const chaptersInSource = this.availableDraftingScriptureRange.books.get(book); + if (chaptersInSource == null) + throw new Error(`Selected book ${book} not in available drafting scripture range`); + // Only books eligible for partial drafting get a chapter input, so only they may default to a subset. + // Default an eligible book to the untranslated chapters (those in the source but not the target), falling + // back to the whole book when none remain untranslated. A book that is not eligible has no input, so it must + // default to the whole book; defaulting it to a subset would silently drop chapters the user couldn't add + // back. + if (this.isBookEligibleForPartialDrafting(book) && chaptersInTarget != null) { + const newChaptersToDraft = chaptersInSource.difference(chaptersInTarget); + newDraftingScriptureRange.books.set( + book, + newChaptersToDraft.count() > 0 ? newChaptersToDraft : chaptersInSource.clone() + ); + } else { + newDraftingScriptureRange.books.set(book, chaptersInSource.clone()); + } + } else { + const alreadySelectedChapters = this.selectedDraftingScriptureRange.books.get(book); + if (alreadySelectedChapters == null) throw new Error('This should be unreachable'); + newDraftingScriptureRange.books.set(book, alreadySelectedChapters); + } + } + this.selectedDraftingScriptureRange = newDraftingScriptureRange; + } + + selectDraftingChapters(bookId: string, chapters: string): void { + if (this.inputMode !== 'draft_books') { + throw new Error('Cannot update draft books when not in draft_books input mode'); + } + + const selectedChapters = ChapterSet.fromUserInput(chapters); + + if (!this.booksOfferedForPartialDrafting.includes(bookId)) { + throw new Error(`Book ${bookId} is not eligible for partial drafting`); + } + const chaptersInSource = this.availableDraftingScriptureRange.books.get(bookId); + if (chaptersInSource == null) throw new Error(`Book ${bookId} not in available drafting scripture range`); + const selectedChaptersNotInSource = selectedChapters.difference(chaptersInSource); + if (selectedChaptersNotInSource.count() > 0) { + throw new Error( + `Selected chapters ${selectedChaptersNotInSource.toString()} are not in the available drafting scripture range for book ${bookId}` + ); + } + + const newDraftingScriptureRange = this.selectedDraftingScriptureRange.clone(); + newDraftingScriptureRange.books.set(bookId, selectedChapters); + this.selectedDraftingScriptureRange = newDraftingScriptureRange; + } + + /** + * Determines which books from the drafting source are offered for drafting, and records why each book the user might + * expect to see was left out. A book is offered only if it is canonical, has content in the drafting source, and + * (unless allowDraftingBooksNotInTarget is set) exists in the target project's text list. + * + * The books considered are those with content in the drafting source plus those present in the target project. This + * lets the UI explain both books the target contains but the source has no text for ('no_source_content') and books + * the source has but the target lacks ('not_in_target'). Books that are excluded purely for being non-canonical are + * recorded as 'non_canonical' but are not surfaced to the user. + */ + private computeOfferedDraftingBooks( + draftSourceProgress: VerboseScriptureRange, + targetTextBookIds: Set + ): { available: VerboseScriptureRange; excluded: ExcludedDraftingBook[] } { + const available = new VerboseScriptureRange(); + const excluded: ExcludedDraftingBook[] = []; + + const booksToConsider = new Set([...draftSourceProgress.books.keys(), ...targetTextBookIds]); + // Evaluate in canonical order so the excluded list (and any notice built from it) reads naturally. + const orderedBooks = Array.from(booksToConsider).sort((a, b) => Canon.bookIdToNumber(a) - Canon.bookIdToNumber(b)); + + for (const bookId of orderedBooks) { + if (Canon.isExtraMaterial(bookId)) { + excluded.push({ bookId, reason: 'non_canonical' }); + } else if (!draftSourceProgress.books.has(bookId)) { + excluded.push({ bookId, reason: 'no_source_content' }); + } else if (!NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET && !targetTextBookIds.has(bookId)) { + excluded.push({ bookId, reason: 'not_in_target' }); + } else { + available.books.set(bookId, draftSourceProgress.books.get(bookId)!.clone()); + } + } + + return { available, excluded }; + } + + private isBookEligibleForPartialDrafting(bookId: string): boolean { + const sourceChapterCount = this.availableDraftingScriptureRange.books.get(bookId)?.count(); + const targetChaptersWithContent = this.targetProjectScriptureRange.books.get(bookId)?.count(); + + return ( + sourceChapterCount != null && + sourceChapterCount >= MIN_SOURCE_CHAPTERS_FOR_PARTIAL_DRAFTING && + targetChaptersWithContent != null && + targetChaptersWithContent >= 1 + ); + } + + selectTargetTrainingChapters(bookId: string, chapters: string): void { + if (this.inputMode !== 'training_books') { + throw new Error('Cannot update training chapters when not in training_books input mode'); + } + + const selectedChapters = ChapterSet.fromUserInput(chapters); + + if (!this.booksOfferedForPartialTargetTraining.includes(bookId)) { + throw new Error(`Book ${bookId} is not eligible for partial target training`); + } + const chaptersAvailableForTraining = this.availableTargetTrainingScriptureRange.books.get(bookId); + if (chaptersAvailableForTraining == null) + throw new Error(`Book ${bookId} not in available target training scripture range`); + const selectedChaptersNotAvailable = selectedChapters.difference(chaptersAvailableForTraining); + if (selectedChaptersNotAvailable.count() > 0) { + throw new Error( + `Selected chapters ${selectedChaptersNotAvailable.toString()} are not available for target training for book ${bookId}` + ); + } + + const newTargetTrainingScriptureRange = this.selectedTargetTrainingScriptureRange.clone(); + newTargetTrainingScriptureRange.books.set(bookId, selectedChapters); + this.selectedTargetTrainingScriptureRange = newTargetTrainingScriptureRange; + } + + selectTrainingSourceBooks(projectId: string, bookIds: string[]): void { + if (this.inputMode !== 'training_books') { + throw new Error('Cannot update training source books when not in training_books input mode'); + } + const available = this.availableTrainingSourceBooks[projectId] ?? []; + for (const bookId of bookIds) { + if (!available.includes(bookId)) { + throw new Error(`Selected book ${bookId} is not available for training source project ${projectId}`); + } + } + const current = { ...this.selectedTrainingSourceBooks }; + current[projectId] = bookIds; + this.selectedTrainingSourceBooks = current; + } + + selectTargetTrainingBooks(books: string[]): void { + if (this.inputMode !== 'training_books') { + throw new Error('Cannot update training books when not in training_books input mode'); + } + const newTargetTrainingScriptureRange = new VerboseScriptureRange(); + for (const bookId of books) { + const bookRange = this.availableTargetTrainingScriptureRange.books.get(bookId); + if (bookRange == null) { + throw new Error(`Selected book ${bookId} not in available target training scripture range`); + } + newTargetTrainingScriptureRange.books.set(bookId, bookRange.clone()); + } + this.selectedTargetTrainingScriptureRange = newTargetTrainingScriptureRange; + } + + /** + * A stable fingerprint of which projects are configured as drafting/training sources, used to detect mid-flow + * reconfiguration. Only the set of project refs matters (order-independent); the target is excluded since it is + * fixed (it is the activated project). Content and sync changes deliberately do not affect this signature. + */ + private sourceConfigSignature(sources: DraftSourcesAsArrays): string { + const refs = [...sources.draftingSources, ...sources.trainingSources].map(source => source.projectRef); + return JSON.stringify([...new Set(refs)].sort()); + } + + abort(mode: NewDraftAbortMode): void { + this.abortMode = mode; + this.status$.next('abort'); + } + + private loadPreviouslySelectedTrainingBooks(): void { + const draftConfig = this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig; + if (draftConfig == null) throw new Error('Draft config not found in project data'); + const targetProjectId = this.activatedProjectService.projectId; + const lastSelectedTrainingScriptureRanges = draftConfig.lastSelectedTrainingScriptureRanges ?? []; + + // On a project's first draft there is nothing to restore; auto-select a default instead. + if (lastSelectedTrainingScriptureRanges.length === 0) { + this.autoSelectTrainingBooks(); + return; + } + + // Restore the previously selected books for each training source, ignoring the target project's own entry (handled + // separately below). Only keep books that are still available for training in that source. + const selectedTrainingSourceBooksByProjectId: { [key: string]: string[] } = {}; + for (const sourceScriptureRange of lastSelectedTrainingScriptureRanges) { + if (sourceScriptureRange.projectId === targetProjectId) continue; + const previouslySelectedBooks = scriptureRangeToBookListWithoutChapterDetail( + new VerboseScriptureRange(sourceScriptureRange.scriptureRange) + ); + const booksAvailableForTraining = this.availableTrainingSourceBooks[sourceScriptureRange.projectId] ?? []; + selectedTrainingSourceBooksByProjectId[sourceScriptureRange.projectId] = previouslySelectedBooks.filter(bookId => + booksAvailableForTraining.includes(bookId) + ); + } + this.selectedTrainingSourceBooks = selectedTrainingSourceBooksByProjectId; + + // Determine the previously selected target training books. Prefer the target project's own saved entry (looked up + // by project ID); its chapter detail is ignored so that chapter defaults are re-derived from current project + // state. Older draft configs predate saving a target entry, so when none exists fall back to inferring it from + // the union of the selected source training books (the previous behavior). + const savedTargetTrainingRange = lastSelectedTrainingScriptureRanges.find( + range => range.projectId === targetProjectId + ); + const previouslySelectedTargetBooks = + savedTargetTrainingRange != null + ? scriptureRangeToBookListWithoutChapterDetail( + new VerboseScriptureRange(savedTargetTrainingRange.scriptureRange) + ) + : Array.from(new Set(Object.values(selectedTrainingSourceBooksByProjectId).flat())); + + // Run the books through the normal book-selection path so chapter defaults match a manual selection. Filter to + // books still available for target training first, since selectTargetTrainingBooks requires available books. + const availableTargetTrainingScriptureRange = this.availableTargetTrainingScriptureRange; + const availableTargetBooks = previouslySelectedTargetBooks.filter(bookId => + availableTargetTrainingScriptureRange.books.has(bookId) + ); + this.selectTargetTrainingBooks(availableTargetBooks); + } + + /** + * Auto-selects training books on a project's first draft. Picks only books that appear fully translated (see + * getCompleteBookIds) and are not being drafted, then pairs each with its source books. Uses a high bar because the + * selection is persisted and reused and a wrong pick silently degrades future drafts. + */ + private autoSelectTrainingBooks(): void { + const availableTargetTrainingRange = this.availableTargetTrainingScriptureRange; + const draftedBooks = this.selectedDraftingScriptureRange.books; + const booksToAutoSelect = Array.from(availableTargetTrainingRange.books.keys()).filter( + bookId => this.completeTargetBookIds.has(bookId) && !draftedBooks.has(bookId) + ); + + this.selectTargetTrainingBooks(booksToAutoSelect); + + // Pair each auto-selected book with matching source books, same as a manual selection would. + const autoSelected = new Set(booksToAutoSelect); + this.selectedTrainingSourceBooks = mapValues(this.availableTrainingSourceBooks, bookIds => + bookIds.filter(bookId => autoSelected.has(bookId)) + ); + + this.trainingBooksWereAutoSelected = booksToAutoSelect.length > 0; + } + + /** Clears the auto-selected notice once the user has deselected all target training books. */ + dismissAutoSelectNoticeIfSelectionEmpty(): void { + if (this.trainingBooksWereAutoSelected && this.selectedTargetTrainingScriptureRange.books.size === 0) { + this.trainingBooksWereAutoSelected = false; + } + } + + private limitAvailableTrainingRangeBasedOnSelectedDraftingRange(): void { + // Available target training books are the target's content minus what's being drafted, further limited to books + // that exist in at least one training source: a target book can only be used as training data if a source + // provides the matching book to pair it with. Books with no such source are recorded + // (targetTrainingBooksWithoutSource) so the UI can explain why they aren't offered. + const targetTrainingRange = this.targetProjectScriptureRange.difference(this.selectedDraftingScriptureRange); + const booksInAnyTrainingSource = new Set(Object.values(this.trainingSourceBooks).flat()); + + const availableTargetTrainingRange = new VerboseScriptureRange(); + const booksWithoutSource: string[] = []; + for (const [bookId, chapters] of targetTrainingRange.books) { + if (booksInAnyTrainingSource.has(bookId)) { + availableTargetTrainingRange.books.set(bookId, chapters); + } else { + booksWithoutSource.push(bookId); + } + } + this.availableTargetTrainingScriptureRange = availableTargetTrainingRange; + this.targetTrainingBooksWithoutSource = booksWithoutSource; + this.selectedTargetTrainingScriptureRange = this.selectedTargetTrainingScriptureRange.difference( + this.selectedDraftingScriptureRange + ); + + // Limit available and selected training source books to not exceed available target training scripture range + const availableTargetRange = this.availableTargetTrainingScriptureRange; + this.availableTrainingSourceBooks = mapValues(this.trainingSourceBooks, bookIds => + bookIds.filter(bookId => availableTargetRange.books.has(bookId)) + ); + this.selectedTrainingSourceBooks = mapValues(this.selectedTrainingSourceBooks, (bookIds, projectId) => + bookIds.filter(bookId => this.availableTrainingSourceBooks[projectId]?.includes(bookId)) + ); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html new file mode 100644 index 00000000000..b606fab1b92 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html @@ -0,0 +1,367 @@ +
+ @if (page === "abort") { +
+ info + @if (abortMode === "config_changed") { +

{{ t("abort.config_changed") }}

+ } @else if (abortMode === "project_syncing") { +

{{ t("abort.project_syncing") }}

+ } @else if (inaccessibleProjectNames.length > 0) { +

{{ t("abort.no_access_list_heading") }}

+
    + @for (project of inaccessibleProjectNames; track project) { +
  • {{ project }}
  • + } +
+

{{ t("abort.no_access_instruction") }}

+ } @else { +

{{ t("abort.no_access") }}

+ } + +
+ } @else if (initData == null || page === "loading") { + +

{{ t("loading") }}

+ } @else { + @if (stepNumber != null) { +

{{ t("step_indicator", { step: stepNumber, total: totalStepCount }) }}

+ } + @if (page === "pending_updates") { + + } @else if (page === "preface") { + + @for (msg of copyrightMessages; track msg.banner) { + + } + + } @else if (page === "draft_books") { +

{{ t("draft_books.title") }}

+

+ @if (availableDraftingBooks.length === 0) { + {{ t("draft_books.no_available_books") }} + } @else { + + } + @if (draftingExclusionNotices.length > 0) { + +
+ + @if (draftingExclusionsExpanded) { + @for (notice of draftingExclusionNotices; track notice.key) { +

{{ t(notice.key, notice.params) }}

+ } + } +
+
+ } + @if (booksOfferedForPartialDrafting.length > 0) { +

{{ t("draft_books.which_chapters") }}

+

{{ t("draft_books.partial_explanation") }}

+
+ @for (bookId of booksOfferedForPartialDrafting; track bookId) { + {{ i18n.localizeBook(bookId) }} +
+ + + {{ t("chapter_input.available", { range: draftingChapterHint(bookId) }) }} + + @if (draftingChapterErrors.get(bookId); as error) { + {{ t(error.key, error.params) }} + } +
+ } +
+ } + } @else if (page === "training_books") { +

{{ t("training_books.title") }}

+

+ @if (availableTargetTrainingBooks.length === 0) { + {{ t("training_books.no_target_books") }} + } @else { + + } + @if (trainingBooksWereAutoSelected) { + {{ t("training_books.auto_selected") }} + } + @if (hasTargetTrainingBooksWithoutSource) { + +
+ + @if (trainingExclusionsExpanded) { +

+ {{ t("training_books.excluded_not_in_any_source", { books: targetTrainingBooksWithoutSourceNames }) }} +

+ } +
+
+ } + @if (booksOfferedForPartialTargetTraining.length > 0) { +

{{ t("training_books.which_chapters") }}

+

{{ t("training_books.partial_explanation") }}

+
+ @for (bookId of booksOfferedForPartialTargetTraining; track bookId) { + {{ i18n.localizeBook(bookId) }} +
+ + + {{ t("chapter_input.available", { range: targetTrainingChapterHint(bookId) }) }} + + @if (targetTrainingChapterErrors.get(bookId); as error) { + {{ t(error.key, error.params) }} + } +
+ } +
+ } + @if (trainingSources.length > 0) { +

{{ t("reference_books") }}

+ @for (source of trainingSources; track source.projectRef) { + @let availableSourceBooks = availableTrainingSourceBooksForProject(source.projectRef); + + @if (availableSourceBooks.length === 0) { +

{{ t("training_books.reference_books_will_appear") }}

+ } + } + } + @if (trainingDataFiles.length > 0) { +

{{ t("training_files") }}

+
+ @for (file of trainingDataFiles; track file.dataId) { + + {{ file.title }} + + } +
+ } + } @else if (page === "suffix") { +

{{ t("summary.draft_title", { books: draftingBookNamesFormatted }) }}

+ + + +
+
+

{{ t("summary.draft_books") }}

+

+
+ +
+
    + @for (item of draftingBookListItems; track item) { +
  • {{ item }}
  • + } +
+
+
+ + + +
+
+

{{ t("summary.example_translations") }}

+

+
+ +
+ @if (sourceTrainingSections.length === 0) { +

{{ t("summary.no_training_books") }}

+ } @else { + + + + + + + + + + @for (source of sourceTrainingSections; track source.projectRef) { + + + + + + } + +
{{ t("summary.training_books") }}{{ sourceTrainingLanguageName }}{{ targetLanguageName }}
{{ formatTrainingBooks(source.bookNumbers) }}{{ source.shortName }}{{ targetShortName }}
+ } + @if (selectedTrainingDataFileTitles.length > 0) { + + + + + + + + @for (title of selectedTrainingDataFileTitles; track title) { + + + + } + +
{{ t("training_files") }}
{{ title }}
+ } +
+
+ + + +

{{ t("summary.settings") }}

+ @if (currentUserEmail != null) { + + {{ t("summary.email_me", { email: currentUserEmail }) }} + + } +
+
+ + @if (isCustomConfigSet) { + {{ t("summary.custom_configurations_apply") }} + } + + @if (featureFlags.showDeveloperTools.enabled) { + + +

Developer options

+
+ + Fast Training - saves time but greatly reduces accuracy + +
+
+ + Echo Translation Engine - echoes source text instead of translating + +
+
+
+ } + + @if (!onlineStatusService.isOnline) { + {{ t("offline_message") }} + } + } + + @if (page !== "pending_updates") { + @if (stepError) { +

{{ t(stepError) }}

+ } +
+ + @if (page === "suffix") { +
+ @if (submitting) { +
+ } + +
+ } @else { + + } +
+ } + } +
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss new file mode 100644 index 00000000000..a7c1e8ee5e0 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss @@ -0,0 +1,193 @@ +@use '@angular/material' as mat; +@use 'src/variables'; + +.wrapper { + max-width: 120em; +} + +.loading-indicator { + margin: 0 auto; + top: 10em; +} + +.loading-text { + text-align: center; + color: variables.$lighterTextColor; +} + +.empty-state { + color: variables.$lighterTextColor; +} + +.abort-screen { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 1em; + max-width: 40em; + margin: 6em auto 0; + + .abort-icon { + font-size: 3em; + width: auto; + height: auto; + } + + .inaccessible-projects { + margin: 0; + text-align: start; + + li { + font-weight: 600; + } + } +} + +.partial-book-drafting-table { + display: inline-grid; + gap: 16px; + align-items: baseline; + .book-name { + grid-column-start: 1; + font-weight: 500; + } + .chapter-input-wrapper { + grid-column-start: 2; + } +} + +.configure-sources-link { + margin-top: 1em; +} + +app-confirm-sources { + --confirm-sources-h1-margin-top: 0; +} + +app-copyright-banner { + display: block; + margin-top: 1em; +} + +.step-indicator { + color: variables.$lighterTextColor; + font-size: 0.85em; + margin: 0 0 1em; +} + +.step-buttons { + display: flex; + gap: 1em; + margin-top: 1em; +} + +.chapter-input { + @include mat.form-field-density(-5); +} + +.chapter-error { + color: var(--mat-form-field-error-text-color); + padding-left: 16px; +} + +.step-error { + color: var(--mat-form-field-error-text-color); +} + +.summary-card { + margin-bottom: 1.5em; + + h1 { + font-size: 1.5em; + font-weight: 400; + margin: 0 0 0.75em; + line-height: 1.3; + } +} + +.summary-subtitle { + color: variables.$lighterTextColor; + margin: 0; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1em; + + h1 { + margin-bottom: 0.25em; + } +} + +.card-header-action { + flex-shrink: 0; +} + +.draft-books-list { + margin: 1em 0 0; +} + +.summary-table { + width: 100%; + border-collapse: collapse; + margin-top: 1.5em; + + th, + td { + text-align: start; + padding: 0.75em 0.5em; + border-bottom: 1px solid variables.$border-color; + } + + th { + font-weight: 500; + } +} + +.summary-empty { + color: variables.$greyLight; + font-style: italic; +} + +.training-data-files { + display: flex; + flex-direction: column; + gap: 0.5em; + margin-bottom: 0.875em; +} + +.save-container { + display: flex; + align-items: center; + gap: 1em; +} + +.books-hidden-container { + display: flex; + flex-direction: column; + row-gap: 0.5em; +} + +.books-hidden-message { + cursor: pointer; + + ::ng-deep u { + &:hover { + color: variables.$theme-secondary; + } + } +} + +.books-hidden-explanation { + margin: 0; +} + +:host ::ng-deep { + strong.semi-bold, + .semi-bold strong { + font-weight: 500; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts new file mode 100644 index 00000000000..c4f59932f41 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts @@ -0,0 +1,1157 @@ +import { DestroyRef, ErrorHandler } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { Canon } from '@sillsdev/scripture'; +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 { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; +import { BehaviorSubject, filter, firstValueFrom, Observable, of, Subject } from 'rxjs'; +import { anything, capture, deepEqual, instance, mock, reset, resetCalls, verify, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { createTestFeatureFlag, FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; +import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; +import { UserService } from 'xforge-common/user.service'; +import { ParatextProject } from '../../../core/models/paratext-project'; +import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; +import { ParatextService } from '../../../core/paratext.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { VerboseScriptureRange } from '../../../shared/scripture-range'; +import { NllbLanguageService } from '../../nllb-language.service'; +import { DraftGenerationService } from '../draft-generation.service'; +import { DraftSource } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; +import { TrainingDataService } from '../training-data/training-data.service'; +import { DraftProgressService, NewDraftLogicHandler } from './new-draft-logic-handler'; +import { NewDraftComponent } from './new-draft.component'; + +const SOURCE_SHORT_NAME = 'DS1'; +const TARGET_SHORT_NAME = 'TP1'; + +const ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET_ORIGINAL_VALUE = NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET; + +describe('NewDraftComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ providers: [TestOnlineStatusService, provideTestOnlineStatus()] }); + // These tests aren't exercising the target-membership gate, and the test project has no text list, so allow + // drafting books regardless of target membership. Reset afterwards so it doesn't leak into other specs. + NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = true; + }); + + afterEach(() => { + NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET_ORIGINAL_VALUE; + }); + + // GEN: source has 50 chapters, target has GEN1-5 -> eligible for partial drafting + const testState = { + draftingSourceBooksChapters: 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + targetProjectBooksChapters: 'GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + trainingSourcesBooksChapters: { 'training-source-1-id': 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21' } + }; + + describe('onDraftingChaptersBlurred', () => { + it('sets an invalid_range error for unparseable input', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + env.component.onDraftingChaptersBlurred('GEN', 'abc'); + + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.invalid_range'); + }); + + it('rejects empty or whitespace-only input without changing the drafting selection', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + const defaultRange = env.selectedDraftingScriptureRange; + + env.component.onDraftingChaptersBlurred('GEN', ''); + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + + env.component.onDraftingChaptersBlurred('GEN', ' '); + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + + // The book keeps its prior (non-empty) range rather than being stored with zero chapters. + expect(env.selectedDraftingScriptureRange).toBe(defaultRange); + expect(env.selectedDraftingScriptureRange).not.toBe('GEN'); + }); + + // A lone separator (',' or the Arabic comma '،') is non-empty but normalizes to no chapters; it must be rejected + // like empty input rather than committed as a zero-chapter (whole-book) selection. + it('rejects separator-only input that normalizes to no chapters', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + const defaultRange = env.selectedDraftingScriptureRange; + + env.component.onDraftingChaptersBlurred('GEN', ','); + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + + // The Arabic comma is normalized to a list separator too, so it has the same empty result. + env.component.onDraftingChaptersBlurred('GEN', '،'); + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + + // The book must keep its prior range rather than collapsing to a whole-book 'GEN' selection. + expect(env.selectedDraftingScriptureRange).toBe(defaultRange); + expect(env.selectedDraftingScriptureRange).not.toBe('GEN'); + }); + + it('sets a chapters_not_in_source error with source name for out-of-range chapters', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + // GEN has only 50 chapters in the source + env.component.onDraftingChaptersBlurred('GEN', '51-60'); + + const error = env.component.draftingChapterErrors.get('GEN'); + expect(error?.key).toBe('chapter_input.chapters_not_in_source'); + expect(error?.params).toEqual(jasmine.objectContaining({ chapters: '51-60', sourceName: SOURCE_SHORT_NAME })); + }); + + it('clears the error and updates state for valid input', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + // First introduce an error + env.component.onDraftingChaptersBlurred('GEN', 'abc'); + expect(env.component.draftingChapterErrors.has('GEN')).toBeTrue(); + + // Then provide valid input + env.component.onDraftingChaptersBlurred('GEN', '6-30'); + + expect(env.component.draftingChapterErrors.has('GEN')).toBeFalse(); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-30'); + }); + }); + + describe('onTargetTrainingChaptersBlurred', () => { + it('sets an invalid_range error for unparseable input', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + await env.selectGENForTraining(); + + env.component.onTargetTrainingChaptersBlurred('GEN', 'xyz'); + + expect(env.component.targetTrainingChapterErrors.get('GEN')?.key).toBe('chapter_input.invalid_range'); + }); + + it('rejects empty or whitespace-only input without changing the target training selection', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + await env.selectGENForTraining(); + const defaultRange = env.selectedTargetTrainingScriptureRange; + + env.component.onTargetTrainingChaptersBlurred('GEN', ''); + expect(env.component.targetTrainingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_training'); + + env.component.onTargetTrainingChaptersBlurred('GEN', ' '); + expect(env.component.targetTrainingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_training'); + + expect(env.selectedTargetTrainingScriptureRange).toBe(defaultRange); + expect(env.selectedTargetTrainingScriptureRange).not.toBe('GEN'); + }); + + it('sets a chapters_will_be_translated error when selected chapters are being drafted', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + // GEN6-50 is being drafted; GEN1-5 is available for training + await env.selectGENForTraining(); + + // Trying to include GEN6 (which is being drafted) in training + env.component.onTargetTrainingChaptersBlurred('GEN', '1-10'); + + const error = env.component.targetTrainingChapterErrors.get('GEN'); + expect(error?.key).toBe('chapter_input.chapters_will_be_translated'); + expect(error?.params).toEqual(jasmine.objectContaining({ chapters: '6-10' })); + }); + + it('sets a chapters_not_in_target error when selected chapters are absent from the target project', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + // Narrow the drafted range to GEN6-10, leaving GEN11-50 neither drafted nor in target + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.logicHandler.selectDraftingChapters('GEN', '6-10'); + env.component.logicHandler.setInputMode('training_books'); + env.component.logicHandler.selectTargetTrainingBooks(['GEN']); // GEN1-5 available + + // GEN11-15 are not in the target project and not being drafted + env.component.onTargetTrainingChaptersBlurred('GEN', '11-15'); + + const error = env.component.targetTrainingChapterErrors.get('GEN'); + expect(error?.key).toBe('chapter_input.chapters_not_in_target'); + expect(error?.params).toEqual(jasmine.objectContaining({ chapters: '11-15', targetName: TARGET_SHORT_NAME })); + }); + + it('clears the error and updates state for valid input', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + await env.selectGENForTraining(); + + // First introduce an error + env.component.onTargetTrainingChaptersBlurred('GEN', 'xyz'); + expect(env.component.targetTrainingChapterErrors.has('GEN')).toBeTrue(); + + // Then provide valid input - GEN1-3 is within the available training range GEN1-5 + env.component.onTargetTrainingChaptersBlurred('GEN', '1-3'); + + expect(env.component.targetTrainingChapterErrors.has('GEN')).toBeFalse(); + expect(env.selectedTargetTrainingScriptureRange).toBe('GEN1-3'); + }); + }); + + describe('training source book sync', () => { + it('shows no source books when no target training books are selected', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + expect(env.component.availableTrainingSourceBooksForProject('training-source-1-id')).toEqual([]); + }); + + it('limits available source books to those selected in the target', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + + expect(env.availableTrainingSourceBookIds('training-source-1-id')).toEqual(['MAT', 'MRK']); + }); + + it('auto-selects source books when they are selected in the target', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + + const available = env.component.availableTrainingSourceBooksForProject('training-source-1-id'); + expect(available.every(b => b.selected)).toBeTrue(); + }); + + it('pairs every book into the reference sources when many are selected at once (bulk select)', async () => { + // The OT/NT/DC "select all" controls (rich mode) emit the whole set of book numbers in one event; the pairing + // must handle a multi-book selection, not just one book at a time. + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + const allTargetBookNumbers = env.component.availableTargetTrainingBooks.map(b => b.number); + expect(allTargetBookNumbers.length).toBeGreaterThan(1); + env.component.onTargetTrainingBookSelect(allTargetBookNumbers); + + // Every selected target book that exists in the source is now selected there too. + const expectedIds = allTargetBookNumbers.map(n => Canon.bookNumberToId(n)).sort(); + expect(env.selectedTrainingSourceBookIds('training-source-1-id').sort()).toEqual(expectedIds); + }); + + it('does not offer a target book that is not in any training source', async () => { + const stateWithoutGEN = { + ...testState, + trainingSourcesBooksChapters: { 'training-source-1-id': 'MAT1-28;MRK1-16;LUK1-24;JHN1-21' } + }; + const env = new TestEnvironment(stateWithoutGEN); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + // GEN is in the target project but not in the training source, so it isn't offered as a target training book. + // Instead it's recorded so the UI can explain why it's missing. + expect(env.component.availableTargetTrainingBooks.map(book => book.number)).not.toContain( + Canon.bookIdToNumber('GEN') + ); + expect(env.component.logicHandler.targetTrainingBooksWithoutSource).toContain('GEN'); + }); + + it('removes a book from source when it is deselected in the target', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT')]); + + expect(env.availableTrainingSourceBookIds('training-source-1-id')).not.toContain('MRK'); + expect(env.selectedTrainingSourceBookIds('training-source-1-id')).not.toContain('MRK'); + }); + + it('preserves a manual source deselection when other target books change', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + // Select MAT and MRK - both auto-selected in source + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + // User manually deselects MRK from the source + env.component.onTrainingSourceBookSelect([Canon.bookIdToNumber('MAT')], 'training-source-1-id'); + + // Add LUK to target - LUK should be auto-selected, MRK should stay deselected + env.component.onTargetTrainingBookSelect([ + Canon.bookIdToNumber('MAT'), + Canon.bookIdToNumber('MRK'), + Canon.bookIdToNumber('LUK') + ]); + + expect(env.selectedTrainingSourceBookIds('training-source-1-id')).not.toContain('MRK'); + expect(env.selectedTrainingSourceBookIds('training-source-1-id')).toContain('MAT'); + expect(env.selectedTrainingSourceBookIds('training-source-1-id')).toContain('LUK'); + }); + }); + + describe('training-pair forward gate', () => { + // Select MAT and MRK as target training books (auto-selected in the source), then manually deselect MRK from the + // source. MRK is now a selected training book with no matching reference selected. Position the wizard on the + // training step so next() exercises the forward gate. + async function setUpOrphanedTrainingBook(env: TestEnvironment): Promise { + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + env.component.onTrainingSourceBookSelect([Canon.bookIdToNumber('MAT')], 'training-source-1-id'); + env.component.page = 'training_books'; + } + + it('blocks advancing when a selected training book has no matching reference book selected', async () => { + const env = new TestEnvironment(testState); + await setUpOrphanedTrainingBook(env); + + env.component.next(); + + expect(env.component.stepError).toBe('no_training_pair_selected'); + expect(env.component.page).toBe('training_books'); + }); + + it('clears the error and advances once a matching reference book is selected', async () => { + const env = new TestEnvironment(testState); + await setUpOrphanedTrainingBook(env); + env.component.next(); + expect(env.component.stepError).toBe('no_training_pair_selected'); + + // Re-select MRK in the source, pairing it again. + env.component.onTrainingSourceBookSelect( + [Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')], + 'training-source-1-id' + ); + env.component.next(); + + expect(env.component.stepError).toBeNull(); + expect(env.component.page).toBe('suffix'); + }); + + it('blocks an unpaired training book even when training is optional', async () => { + const env = new TestEnvironment(testState, { trainingOptional: true }); + await setUpOrphanedTrainingBook(env); + + env.component.next(); + + expect(env.component.stepError).toBe('no_training_pair_selected'); + expect(env.component.page).toBe('training_books'); + }); + }); + + describe('onDraftingBookSelect', () => { + it('removes stale errors for books no longer offered for partial drafting', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.onDraftingChaptersBlurred('GEN', 'abc'); + expect(env.component.draftingChapterErrors.has('GEN')).toBeTrue(); + + // Deselect GEN - switch to MAT only, removing GEN from booksOfferedForPartialDrafting + env.component.onDraftingBookSelect([Canon.bookIdToNumber('MAT')]); + + expect(env.component.draftingChapterErrors.has('GEN')).toBeFalse(); + }); + + it('keeps errors for books still offered for partial drafting', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.onDraftingChaptersBlurred('GEN', 'abc'); + + // Re-select GEN along with another book - GEN remains offered for partial drafting + env.component.onDraftingBookSelect([Canon.bookIdToNumber('GEN'), Canon.bookIdToNumber('MAT')]); + + expect(env.component.draftingChapterErrors.has('GEN')).toBeTrue(); + }); + }); + + describe('copyrightMessages', () => { + it('returns empty when no sources have copyright banners', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + + expect(env.component.copyrightMessages).toEqual([]); + }); + + it('returns messages from both drafting and training sources', async () => { + const env = new TestEnvironment({ + ...testState, + draftingSourceCopyrightBanner: 'Drafting source copyright', + trainingSourceCopyrightBanners: { 'training-source-1-id': 'Training source copyright' } + }); + await env.waitForInit(); + + const banners = env.component.copyrightMessages.map(m => m.banner); + expect(banners).toContain('Drafting source copyright'); + expect(banners).toContain('Training source copyright'); + }); + + it('deduplicates sources that share the same banner text', async () => { + const sharedBanner = 'Shared copyright notice'; + const env = new TestEnvironment({ + ...testState, + draftingSourceCopyrightBanner: sharedBanner, + trainingSourceCopyrightBanners: { 'training-source-1-id': sharedBanner } + }); + await env.waitForInit(); + + expect(env.component.copyrightMessages.length).toBe(1); + expect(env.component.copyrightMessages[0].banner).toBe(sharedBanner); + }); + }); + + describe('generateDraftClicked', () => { + beforeEach(() => { + reset(mockedDraftGenerationService); + reset(mockedRouter); + when(mockedDraftGenerationService.startBuildOrGetActiveBuild(anything())).thenReturn(of(undefined)); + }); + + it('sends chapter-level translation range for a partially drafted book', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); // runs logicHandler.init() and component.init() to completion (sets initData) + // GEN: source has chapters 1-50, target has 1-5 -> default draft selection is 6-50 + env.component.logicHandler.selectDraftingBooks(['GEN']); + + env.component.generateDraftClicked(); + tick(); + + verify( + mockedDraftGenerationService.startBuildOrGetActiveBuild( + deepEqual({ + projectId: 'testProjectId', + translationScriptureRanges: [{ projectId: 'draft-source-1-id', scriptureRange: 'GEN6-50' }], + trainingScriptureRanges: [{ projectId: 'testProjectId', scriptureRange: '' }], + trainingDataFiles: [], + availableTrainingDataFiles: [], + fastTraining: false, + useEcho: false, + sendEmailOnBuildFinished: false + }) + ) + ).once(); + expect().nothing(); + })); + + it('includes chapter-level target training range and book-level training source ranges', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + // Draft GEN 6-50; target training defaults to non-drafted chapters GEN 1-5 + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.logicHandler.setInputMode('training_books'); + env.component.logicHandler.selectTargetTrainingBooks(['GEN']); + env.component.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['GEN']); + + env.component.generateDraftClicked(); + tick(); + + verify( + mockedDraftGenerationService.startBuildOrGetActiveBuild( + deepEqual({ + projectId: 'testProjectId', + translationScriptureRanges: [{ projectId: 'draft-source-1-id', scriptureRange: 'GEN6-50' }], + trainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'GEN' }, + { projectId: 'testProjectId', scriptureRange: 'GEN1-5' } + ], + trainingDataFiles: [], + availableTrainingDataFiles: [], + fastTraining: false, + useEcho: false, + sendEmailOnBuildFinished: false + }) + ) + ).once(); + expect().nothing(); + })); + + it('navigates to draft-generation after submitting the build', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + env.component.generateDraftClicked(); + tick(); + + verify(mockedRouter.navigate(deepEqual(['/projects', 'testProjectId', 'draft-generation']))).once(); + expect().nothing(); + })); + + it('does not call the backend when offline', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + env.onlineStatusService.setIsOnline(false); + + env.component.generateDraftClicked(); + tick(); + + verify(mockedDraftGenerationService.startBuildOrGetActiveBuild(anything())).never(); + expect().nothing(); + })); + + it('sends the selected files and the full available set of training data files', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [makeTrainingData('a'), makeTrainingData('b'), makeTrainingData('c')], + // 'a' was used last time; 'b' was offered but deselected; 'c' is newly added + lastSelectedTrainingDataFiles: ['a'], + lastAvailableTrainingDataFiles: ['a', 'b'] + }); + tick(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + env.component.generateDraftClicked(); + tick(); + + const [config] = capture(mockedDraftGenerationService.startBuildOrGetActiveBuild).last(); + // 'a' (used last time) and 'c' (new) default selected; 'b' (deselected last time) stays off + expect(config.trainingDataFiles).toEqual(['a', 'c']); + // The full set offered is always reported so a later build can detect new vs deselected files + expect(config.availableTrainingDataFiles).toEqual(['a', 'b', 'c']); + })); + }); + + describe('training data file selection', () => { + it('defaults selection from the last build, keeping used and newly added files', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [makeTrainingData('a'), makeTrainingData('b'), makeTrainingData('c')], + lastSelectedTrainingDataFiles: ['a'], + lastAvailableTrainingDataFiles: ['a', 'b'] + }); + tick(); + + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); // used last time + expect(env.component.isTrainingDataFileSelected('b')).toBe(false); // deselected last time + expect(env.component.isTrainingDataFileSelected('c')).toBe(true); // newly added + })); + + it('selects all files for a legacy config with no recorded available set', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [makeTrainingData('a'), makeTrainingData('b')], + lastSelectedTrainingDataFiles: [], + lastAvailableTrainingDataFiles: undefined + }); + tick(); + + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); + expect(env.component.isTrainingDataFileSelected('b')).toBe(true); + })); + + it('exposes the titles of the selected files for the summary recap', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [ + makeTrainingData('a', 'Alpha'), + makeTrainingData('b', 'Beta'), + makeTrainingData('c', 'Gamma') + ], + lastSelectedTrainingDataFiles: ['a'], + lastAvailableTrainingDataFiles: ['a', 'b'] + }); + tick(); + + // 'a' (used last time) and 'c' (new) default selected; 'b' (deselected last time) stays off + expect(env.component.selectedTrainingDataFileTitles).toEqual(['Alpha', 'Gamma']); + + env.component.onTrainingDataFileToggled('b', true); + expect(env.component.selectedTrainingDataFileTitles).toEqual(['Alpha', 'Beta', 'Gamma']); + })); + + it('toggles a file selection on and off', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [makeTrainingData('a')], + lastSelectedTrainingDataFiles: ['a'], + lastAvailableTrainingDataFiles: ['a'] + }); + tick(); + + env.component.onTrainingDataFileToggled('a', false); + expect(env.component.isTrainingDataFileSelected('a')).toBe(false); + env.component.onTrainingDataFileToggled('a', true); + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); + })); + + it('prunes the selection when a file is removed, without re-running the defaults', fakeAsync(() => { + const trainingData$ = new BehaviorSubject([makeTrainingData('a'), makeTrainingData('b')]); + const env = new TestEnvironment( + { ...testState, lastSelectedTrainingDataFiles: ['a', 'b'], lastAvailableTrainingDataFiles: ['a', 'b'] }, + { trainingData$ } + ); + tick(); + + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); + expect(env.component.isTrainingDataFileSelected('b')).toBe(true); + + // 'b' is deleted from the project after the wizard has already loaded + trainingData$.next([makeTrainingData('a')]); + tick(); + + expect(env.component.trainingDataFiles.map(f => f.dataId)).toEqual(['a']); + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); + // The removed file is dropped from the selection rather than lingering + expect(env.component.isTrainingDataFileSelected('b')).toBe(false); + })); + }); + + describe('detectPendingUpdates', () => { + // Reset call history so the per-test verify() counts aren't inflated by other tests' init flows. + beforeEach(() => { + reset(mockedParatextService); + reset(mockedErrorReportingService); + }); + + it('shows the pending-updates page for an involved, connected project with an update', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [makeParatextProject({ projectId: 'draft-source-1-id', name: 'Draft Source 1', hasUpdate: true })] + }); + tick(); + + expect(env.component.page).toEqual('pending_updates'); + expect(env.component.pendingProjects).toEqual([{ projectId: 'draft-source-1-id', name: 'Draft Source 1' }]); + })); + + it('excludes projects that are not involved, not connected, or have no update', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [ + makeParatextProject({ projectId: 'unrelated-id', hasUpdate: true }), // not involved + makeParatextProject({ projectId: 'draft-source-1-id', isConnected: false, hasUpdate: true }), // not connected + makeParatextProject({ projectId: 'training-source-1-id', hasUpdate: false }), // no update + makeParatextProject({ projectId: null, hasUpdate: true }) // no SF project id + ] + }); + tick(); + + expect(env.component.pendingProjects).toEqual([]); + expect(env.component.page).toEqual('preface'); + })); + + it('falls back to shortName when the project name is empty', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [makeParatextProject({ projectId: 'testProjectId', name: '', shortName: 'TGT', hasUpdate: true })] + }); + tick(); + + expect(env.component.pendingProjects).toEqual([{ projectId: 'testProjectId', name: 'TGT' }]); + })); + + it('skips detection entirely when offline', fakeAsync(() => { + const env = new TestEnvironment(testState, { + offline: true, + projects: [makeParatextProject({ projectId: 'draft-source-1-id', hasUpdate: true })] + }); + tick(); + + expect(env.component.pendingProjects).toEqual([]); + expect(env.component.page).toEqual('preface'); + verify(mockedParatextService.getProjects()).never(); + })); + + it('proceeds to the preface page when getProjects fails', fakeAsync(() => { + const env = new TestEnvironment(testState, { getProjectsError: true }); + tick(); + + expect(env.component.page).toEqual('preface'); + verify(mockedErrorReportingService.silentError(anything(), anything())).once(); + })); + }); + + describe('abort handling', () => { + // Reset call history so the per-test verify() counts aren't inflated by other tests' flows. + beforeEach(() => { + reset(mockedRouter); + reset(mockedErrorHandler); + }); + + it('shows the abort screen with no_access mode when a project is inaccessible', fakeAsync(() => { + const env = new TestEnvironment(testState, { noAccessSources: true }); + tick(); + + expect(env.component.page).toEqual('abort'); + expect(env.component.abortMode).toEqual('no_access'); + })); + + it('exposes the inaccessible project names for the abort screen', fakeAsync(() => { + const env = new TestEnvironment(testState, { noAccessSources: true }); + tick(); + + expect(env.component.inaccessibleProjectNames).toEqual(['IS1 - Inaccessible Source']); + })); + + it('navigates back to draft generation from the no_access abort screen', fakeAsync(() => { + const env = new TestEnvironment(testState, { noAccessSources: true }); + tick(); + + env.component.goBack(); + + verify(mockedRouter.navigate(deepEqual(['/projects', 'testProjectId', 'draft-generation']))).once(); + expect().nothing(); + })); + + it('delegates to the global error handler and navigates back when initialization fails', fakeAsync(() => { + const env = new TestEnvironment(testState, { progressError: true }); + tick(); + + // Fatal/unanticipated failures go to the app-wide error dialog rather than a bespoke abort screen. + verify(mockedErrorHandler.handleError(anything())).once(); + verify(mockedRouter.navigate(deepEqual(['/projects', 'testProjectId', 'draft-generation']))).once(); + expect(env.component.page).not.toEqual('abort'); + })); + }); + + describe('onPendingUpdatesComplete', () => { + beforeEach(() => { + reset(mockedRouter); + reset(mockedErrorHandler); + }); + + it('re-derives progress fresh for the synced projects, then shows the preface', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + resetCalls(mockedProgressService); // ignore the calls made during init + + void env.component.onPendingUpdatesComplete(['draft-source-1-id']); + tick(); + + // The reload re-fetches and forces fresh data (maxStalenessMs: 0) for the synced project. + verify(mockedProgressService.getProgressForProject('draft-source-1-id', deepEqual({ maxStalenessMs: 0 }))).once(); + expect(env.component.page).toEqual('preface'); + })); + + it('skips the reload and goes straight to the preface when nothing was synced', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + resetCalls(mockedProgressService); + + void env.component.onPendingUpdatesComplete([]); + tick(); + + verify(mockedProgressService.getProgressForProject(anything(), anything())).never(); + expect(env.component.page).toEqual('preface'); + })); + + it('routes a reload failure to the global error handler and navigates back', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + // Init has already succeeded; make the reload's progress fetch fail. + when(mockedProgressService.getProgressForProject('draft-source-1-id', anything())).thenReject( + new Error('reload failed') + ); + + void env.component.onPendingUpdatesComplete(['draft-source-1-id']); + tick(); + + verify(mockedErrorHandler.handleError(anything())).once(); + verify(mockedRouter.navigate(deepEqual(['/projects', 'testProjectId', 'draft-generation']))).once(); + expect().nothing(); + })); + }); + + describe('sync watcher', () => { + it('does not abort when a project starts syncing during the pending-updates pre-step', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [makeParatextProject({ projectId: 'draft-source-1-id', name: 'Draft Source 1', hasUpdate: true })] + }); + tick(); + expect(env.component.page).toEqual('pending_updates'); + + // The pre-step is designed to absorb syncs (via reload), so the watcher must not be armed yet. + env.startSyncFor('draft-source-1-id'); + tick(); + + expect(env.component.page).toEqual('pending_updates'); + })); + + it('aborts with project_syncing when an involved project starts syncing after lock-in', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + expect(env.component.page).toEqual('preface'); + + env.startSyncFor('draft-source-1-id'); + tick(); + + expect(env.component.page).toEqual('abort'); + expect(env.component.abortMode).toEqual('project_syncing'); + })); + + it('aborts when the target project starts syncing after lock-in', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + + env.startSyncFor('testProjectId'); + tick(); + + expect(env.component.page).toEqual('abort'); + expect(env.component.abortMode).toEqual('project_syncing'); + })); + + it('does not abort when a project was already syncing at lock-in', fakeAsync(() => { + // The user continued past a pre-existing (possibly stuck) sync; that in-flight sync is the baseline, not an edge. + const env = new TestEnvironment(testState, { targetSyncing: true }); + tick(); + + expect(env.component.page).toEqual('preface'); + + // A further change while it is still syncing is not a not-syncing→syncing edge, so it still must not abort. + env.startSyncFor('testProjectId'); + tick(); + + expect(env.component.page).toEqual('preface'); + })); + + it('arms the watcher after the user leaves the pending-updates pre-step', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [makeParatextProject({ projectId: 'draft-source-1-id', name: 'Draft Source 1', hasUpdate: true })] + }); + tick(); + expect(env.component.page).toEqual('pending_updates'); + + void env.component.onPendingUpdatesComplete([]); + tick(); + expect(env.component.page).toEqual('preface'); + + env.startSyncFor('draft-source-1-id'); + tick(); + + expect(env.component.page).toEqual('abort'); + expect(env.component.abortMode).toEqual('project_syncing'); + })); + }); + + describe('empty states', () => { + it('reports no available drafting books when the source offers none', async () => { + const env = new TestEnvironment({ + draftingSourceBooksChapters: '', + targetProjectBooksChapters: 'GEN1-5', + trainingSourcesBooksChapters: { 'training-source-1-id': 'GEN1-50' } + }); + await env.waitForInit(); + + // Drives the "no books available to draft" empty state on Step 2. + expect(env.component.availableDraftingBooks).toEqual([]); + }); + + it('reports no target training books when none are in any reference project', async () => { + const env = new TestEnvironment({ + draftingSourceBooksChapters: 'GEN1-50', + targetProjectBooksChapters: 'GEN1-5', + trainingSourcesBooksChapters: { 'training-source-1-id': 'EXO1-40' } + }); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.logicHandler.setInputMode('training_books'); + + // GEN is the only target book and it isn't in the reference project, so it's withheld → empty state on Step 3. + expect(env.component.availableTargetTrainingBooks).toEqual([]); + }); + }); + + describe('hidden-books notices', () => { + it('counts only the surfaced drafting-exclusion reasons (not non-canonical)', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + env.component.logicHandler.excludedDraftingBooks = [ + { bookId: 'GEN', reason: 'no_source_content' }, + { bookId: 'EXO', reason: 'not_in_target' }, + { bookId: 'FRT', reason: 'non_canonical' } // tracked but never surfaced + ]; + + expect(env.component.draftingHiddenBookCount).toBe(2); + expect(env.component.draftingExclusionNotices.length).toBe(2); + })); + + it('counts target training books hidden for lacking a matching source', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + env.component.logicHandler.targetTrainingBooksWithoutSource = ['LEV', 'NUM']; + + expect(env.component.targetTrainingHiddenBookCount).toBe(2); + expect(env.component.hasTargetTrainingBooksWithoutSource).toBeTrue(); + })); + + it('starts with the explanations collapsed on both steps', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + + expect(env.component.draftingExclusionsExpanded).toBeFalse(); + expect(env.component.trainingExclusionsExpanded).toBeFalse(); + })); + }); +}); + +const mockedActivatedProjectService = mock(ActivatedProjectService); +const mockedDraftSourcesService = mock(DraftSourcesService); +const mockedDraftGenerationService = mock(DraftGenerationService); +const mockedProgressService = mock(DraftProgressService); +const mockedI18nService = mock(I18nService); +const mockedFeatureFlagService = mock(FeatureFlagService); +const mockedUserService = mock(UserService); +const mockedRouter = mock(Router); +const mockedNllbLanguageService = mock(NllbLanguageService); +const mockedParatextService = mock(ParatextService); +const mockedErrorReportingService = mock(ErrorReportingService); +const mockedErrorHandler = mock(ErrorHandler); +const mockedTrainingDataService = mock(TrainingDataService); +const mockedSFProjectService = mock(SFProjectService); + +interface TestState { + draftingSourceBooksChapters: string; + targetProjectBooksChapters: string; + trainingSourcesBooksChapters: { [key: string]: string }; + draftingSourceCopyrightBanner?: string; + trainingSourceCopyrightBanners?: { [key: string]: string }; + /** Training data files currently available for the project. */ + trainingDataFiles?: TrainingData[]; + lastSelectedTrainingDataFiles?: string[]; + lastAvailableTrainingDataFiles?: string[]; + /** Target books getCompleteBookIds should report as complete (auto-selectable on first draft). Defaults to none. */ + completeTargetBooks?: string[]; +} + +function makeTrainingData(dataId: string, title: string = dataId): TrainingData { + return { + dataId, + title, + projectRef: 'testProjectId', + ownerRef: 'user01', + fileUrl: `https://example.com/${dataId}.csv`, + mimeType: 'text/csv', + skipRows: 0 + }; +} + +// `hasUpdate` is required so each call states explicitly whether the project has a pending update. +function makeParatextProject( + overrides: Partial & Pick +): ParatextProject { + return { + paratextId: 'pt-id', + name: 'A Project', + shortName: 'PRJ', + languageTag: 'en', + projectId: 'sf-project-id', + isConnectable: true, + isConnected: true, + hasUserRoleChanged: false, + role: SFProjectRole.ParatextAdministrator, + ...overrides + }; +} + +class TestEnvironment { + component: NewDraftComponent; + readonly onlineStatusService = TestBed.inject(TestOnlineStatusService); + + /** Mutable sync-state profile docs (target + sources) the sync watcher subscribes to, keyed by project id. */ + private readonly syncDocs = new Map }>(); + + private makeSyncDoc(projectId: string, data: { sync: { queuedCount: number } }): SFProjectProfileDoc { + const changes$ = new Subject(); + this.syncDocs.set(projectId, { data, changes$ }); + return { data, remoteChanges$: changes$ } as unknown as SFProjectProfileDoc; + } + + /** Simulates a project starting to sync (queuedCount 0 → 1) and notifying watchers. */ + startSyncFor(projectId: string): void { + const entry = this.syncDocs.get(projectId); + if (entry == null) throw new Error(`No sync doc registered for ${projectId}`); + entry.data.sync.queuedCount = 1; + entry.changes$.next(); + } + + constructor( + state: TestState, + options: { + getProjectsError?: boolean; + projects?: ParatextProject[]; + offline?: boolean; + noAccessSources?: boolean; + progressError?: boolean; + /** When true, both languages are treated as NLLB languages so that training is optional. */ + trainingOptional?: boolean; + /** Overrides the training-data stream so a test can emit changes over time (e.g. a file being removed). */ + trainingData$?: Observable; + /** When true, the target project is already syncing on entry (baseline for the sync watcher). */ + targetSyncing?: boolean; + } = {} + ) { + const project = createTestProjectProfile({ + shortName: TARGET_SHORT_NAME, + translateConfig: { + preTranslate: true, + draftConfig: { + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: 'draft-source-1-id', + name: 'Draft Source 1', + shortName: SOURCE_SHORT_NAME, + writingSystem: { script: 'Latn', tag: 'es' } + } + ], + trainingSources: Object.keys(state.trainingSourcesBooksChapters).map(projectId => ({ + paratextId: `${projectId}-pt-id`, + projectRef: projectId, + name: `Training Source for ${projectId}`, + shortName: `TS-${projectId}`, + writingSystem: { script: 'Latn', tag: 'es' } + })), + lastSelectedTrainingScriptureRanges: [], + lastSelectedTrainingDataFiles: state.lastSelectedTrainingDataFiles ?? [], + lastAvailableTrainingDataFiles: state.lastAvailableTrainingDataFiles, + lastSelectedTranslationScriptureRanges: undefined + } + } + }); + + if (options.targetSyncing) project.sync.queuedCount = 1; + + const projectId = 'testProjectId'; + // The target doc doubles as a sync-watcher doc so tests can simulate the target starting to sync. + const targetDoc = this.makeSyncDoc(projectId, project as unknown as { sync: { queuedCount: number } }); + when(mockedActivatedProjectService.projectId).thenReturn(projectId); + when(mockedActivatedProjectService.projectId$).thenReturn(of(projectId)); + when(mockedActivatedProjectService.projectDoc).thenReturn(targetDoc); + when(mockedActivatedProjectService.projectDoc$).thenReturn(of(targetDoc)); + + // Sync-watcher profile docs for the source projects (start not-syncing). + for (const sourceId of ['draft-source-1-id', ...Object.keys(state.trainingSourcesBooksChapters)]) { + when(mockedSFProjectService.getProfile(sourceId)).thenResolve( + this.makeSyncDoc(sourceId, { sync: { queuedCount: 0 } }) + ); + } + + if (options.noAccessSources) { + when(mockedDraftSourcesService.getDraftProjectSources()).thenReturn( + of({ + trainingSources: [ + { noAccess: true, shortName: 'IS1', name: 'Inaccessible Source' } as unknown as DraftSource + ], + trainingTargets: [], + draftingSources: [] + }) + ); + } else { + when(mockedDraftSourcesService.getDraftProjectSources()).thenReturn( + of({ + trainingSources: Object.keys(state.trainingSourcesBooksChapters).map(projectId => ({ + paratextId: `${projectId}-pt-id`, + projectRef: projectId, + name: `Training Source for ${projectId}`, + shortName: `TS-${projectId}`, + writingSystem: { script: 'Latn', tag: 'es' }, + texts: [], + copyrightBanner: state.trainingSourceCopyrightBanners?.[projectId] + })) as DraftSource[], + trainingTargets: [], + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: 'draft-source-1-id', + name: 'Draft Source 1', + shortName: SOURCE_SHORT_NAME, + writingSystem: { script: 'Latn', tag: 'es' }, + texts: [], + copyrightBanner: state.draftingSourceCopyrightBanner + } as DraftSource + ] + }) + ); + } + + if (options.progressError) { + when(mockedProgressService.getProgressForProject(projectId, anything())).thenReject(new Error('progress failed')); + } else { + when(mockedProgressService.getProgressForProject(projectId, anything())).thenResolve( + new VerboseScriptureRange(state.targetProjectBooksChapters) + ); + } + when(mockedProgressService.getProgressForProject('draft-source-1-id', anything())).thenResolve( + new VerboseScriptureRange(state.draftingSourceBooksChapters) + ); + for (const [trainingSourceId, booksChapters] of Object.entries(state.trainingSourcesBooksChapters)) { + when(mockedProgressService.getProgressForProject(trainingSourceId, anything())).thenResolve( + new VerboseScriptureRange(booksChapters) + ); + } + when(mockedProgressService.getCompleteBookIds(projectId, anything())).thenResolve( + new Set(state.completeTargetBooks ?? []) + ); + + when(mockedTrainingDataService.getTrainingData(anything(), anything())).thenReturn( + options.trainingData$ ?? of(state.trainingDataFiles ?? []) + ); + + when(mockedFeatureFlagService.showDeveloperTools).thenReturn(createTestFeatureFlag(false)); + when(mockedUserService.getCurrentUser()).thenResolve(undefined as any); + when(mockedNllbLanguageService.isNllbLanguageAsync(anything())).thenResolve(options.trainingOptional === true); + // Set the online state before the component is constructed so init()'s online check sees it. + this.onlineStatusService.setIsOnline(!options.offline); + if (options.getProjectsError) { + when(mockedParatextService.getProjects()).thenReject(new Error('network error')); + } else { + when(mockedParatextService.getProjects()).thenResolve(options.projects); + } + + this.component = new NewDraftComponent( + instance(mockedActivatedProjectService), + instance(mockedDraftSourcesService), + instance(mockedDraftGenerationService), + instance(mockedProgressService), + instance(mockedI18nService), + instance(mockedFeatureFlagService), + this.onlineStatusService, + instance(mockedUserService), + instance(mockedRouter), + instance(mockedNllbLanguageService), + instance(mockedParatextService), + instance(mockedErrorReportingService), + instance(mockedErrorHandler), + instance(mockedTrainingDataService), + instance(mockedSFProjectService), + { onDestroy: () => () => {} } as unknown as DestroyRef + ); + } + + async waitForInit(): Promise { + await firstValueFrom(this.component.logicHandler.status$.pipe(filter(s => s === 'input'))); + } + + /** Selects GEN for drafting (GEN6-50) and GEN for training (GEN1-5 available). */ + async selectGENForTraining(): Promise { + this.component.logicHandler.selectDraftingBooks(['GEN']); + this.component.logicHandler.setInputMode('training_books'); + this.component.logicHandler.selectTargetTrainingBooks(['GEN']); + } + + availableTrainingSourceBookIds(projectId: string): string[] { + return this.component.availableTrainingSourceBooksForProject(projectId).map(b => Canon.bookNumberToId(b.number)); + } + + selectedTrainingSourceBookIds(projectId: string): string[] { + return this.component.selectedTrainingSourceBooksForProject(projectId).map(b => Canon.bookNumberToId(b.number)); + } + + get selectedDraftingScriptureRange(): string { + return this.component.logicHandler.selectedDraftingScriptureRange.toString(); + } + + get selectedTargetTrainingScriptureRange(): string { + return this.component.logicHandler.selectedTargetTrainingScriptureRange.toString(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts new file mode 100644 index 00000000000..66e8929c497 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts @@ -0,0 +1,505 @@ +import { ErrorHandler } from '@angular/core'; +import { Router } from '@angular/router'; +import { Canon } from '@sillsdev/scripture'; +import { Meta, StoryObj } from '@storybook/angular'; +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 { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; +import { of } from 'rxjs'; +import userEvent from '@testing-library/user-event'; +import { expect, waitFor, within } from 'storybook/test'; +import { anything, instance, mock, reset, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; +import { DialogService } from 'xforge-common/dialog.service'; +import { createTestFeatureFlag, FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { UserDoc } from 'xforge-common/models/user-doc'; +import { UserService } from 'xforge-common/user.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { ParatextProject } from '../../../core/models/paratext-project'; +import { SFProjectDoc } from '../../../core/models/sf-project-doc'; +import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; +import { ParatextService } from '../../../core/paratext.service'; +import { PermissionsService } from '../../../core/permissions.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { ProgressService } from '../../../shared/progress-service/progress.service'; +import { NllbLanguageService } from '../../nllb-language.service'; +import { DraftGenerationService } from '../draft-generation.service'; +import { DraftSource, DraftSourcesAsArrays } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; +import { TrainingDataService } from '../training-data/training-data.service'; +import { DraftProgressService } from './new-draft-logic-handler'; +import { NewDraftComponent } from './new-draft.component'; +import { VerboseScriptureRange } from '../../../shared/scripture-range'; + +// Project IDs used throughout the stories. +const TARGET_ID = 'target-project-id'; +const DRAFT_SOURCE_ID = 'draft-source-1-id'; +const TRAINING_SOURCE_ID = 'training-source-1-id'; + +// Books available in each project. GEN is the interesting case: the source has all 50 chapters but the target only +// has GEN1-5, so GEN6-50 is offered for drafting and GEN1-5 remains available for training (partial-book drafting). +// PHM is in both the source and the target (1 chapter), so selecting it produces a clean whole-book selection with no +// chapter table. JUD exists only in the source, so it is not offered for drafting and instead demonstrates the +// "not in the project" exclusion notice (see the ExcludedBooks story). +const TARGET_BOOKS = 'GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21;PHM1'; +const DRAFT_SOURCE_BOOKS = 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21;PHM1;JUD1'; +const TRAINING_SOURCE_BOOKS = 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21;PHM1'; + +const mockedActivatedProjectService = mock(ActivatedProjectService); +const mockedDraftSourcesService = mock(DraftSourcesService); +const mockedDraftGenerationService = mock(DraftGenerationService); +const mockedDraftProgressService = mock(DraftProgressService); +const mockedFeatureFlagService = mock(FeatureFlagService); +const mockedOnlineStatusService = mock(OnlineStatusService); +const mockedUserService = mock(UserService); +const mockedRouter = mock(Router); +const mockedNllbLanguageService = mock(NllbLanguageService); +const mockedParatextService = mock(ParatextService); +const mockedErrorReportingService = mock(ErrorReportingService); +const mockedErrorHandler = mock(ErrorHandler); +const mockedTrainingDataService = mock(TrainingDataService); +// Dependencies of the child components rendered inside the wizard. +const mockedProgressService = mock(ProgressService); +const mockedSFProjectService = mock(SFProjectService); +const mockedPermissionsService = mock(PermissionsService); +const mockedDialogService = mock(DialogService); +const mockedAuthService = mock(AuthService); + +interface StoryArgs { + /** Application online state. Offline disables Generate and skips the pending-updates pre-step. */ + online: boolean; + /** Enables the developer-tools feature flag (adds the Fast Training / Echo cards on the summary). */ + developerTools: boolean; + /** Gives the sources copyright banners, shown on the preface. */ + withCopyright: boolean; + /** Provides a couple of training-data files for the training step. */ + withTrainingDataFiles: boolean; + /** An administrator has applied a custom Serval config, shown as a notice on the summary. */ + withCustomConfig: boolean; + /** A source/target project is inaccessible, so the wizard aborts with the no-access screen. */ + noAccess: boolean; + /** An involved project has un-synced Paratext changes, so the sync interstitial is shown first. */ + hasPendingUpdates: boolean; + /** Never resolves project/progress loading, leaving the wizard on its loading spinner. */ + neverLoad: boolean; +} + +const defaultArgs: StoryArgs = { + online: true, + developerTools: false, + withCopyright: false, + withTrainingDataFiles: false, + withCustomConfig: false, + noAccess: false, + hasPendingUpdates: false, + neverLoad: false +}; + +function makeTrainingData(dataId: string, title: string): TrainingData { + return { + dataId, + title, + projectRef: TARGET_ID, + ownerRef: 'user01', + fileUrl: `https://example.com/${dataId}.csv`, + mimeType: 'text/csv', + skipRows: 0 + }; +} + +function buildProjectProfile(args: StoryArgs): SFProjectProfile { + return createTestProjectProfile({ + name: 'NTV - Nueva Traducción Viviente', + shortName: 'TP1', + writingSystem: { tag: 'en' }, + // The target's text list (book membership) gates which source books are offered for drafting. Include the books + // the target contains; the drafting source's extra book (JUD) is intentionally absent so the story shows the + // "not in the project" notice. + texts: Array.from(new VerboseScriptureRange(TARGET_BOOKS).books.keys()).map(bookId => ({ + bookNum: Canon.bookIdToNumber(bookId), + hasSource: false, + chapters: [], + permissions: {} + })), + translateConfig: { + preTranslate: true, + draftConfig: { + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: DRAFT_SOURCE_ID, + name: 'Spanish Draft Source', + shortName: 'DS1', + writingSystem: { script: 'Latn', tag: 'es' } + } + ], + trainingSources: [ + { + paratextId: 'training-source-1-pt-id', + projectRef: TRAINING_SOURCE_ID, + name: 'Reference Project', + shortName: 'RP1', + writingSystem: { script: 'Latn', tag: 'es' } + } + ], + // Restoring GEN for both the target and the reference source means entering the training step lands with a + // representative selection already made, so the summary and training steps show real data. + lastSelectedTrainingScriptureRanges: [ + { projectId: TARGET_ID, scriptureRange: 'GEN' }, + { projectId: TRAINING_SOURCE_ID, scriptureRange: 'GEN' } + ], + lastSelectedTrainingDataFiles: args.withTrainingDataFiles ? ['glossary'] : [], + lastAvailableTrainingDataFiles: args.withTrainingDataFiles ? ['glossary'] : [], + servalConfig: args.withCustomConfig ? '{ "custom": "value" }' : undefined, + sendEmailOnBuildFinished: false, + fastTraining: false, + useEcho: false + } + } + }); +} + +function draftSources(args: StoryArgs): DraftSourcesAsArrays { + if (args.noAccess) { + return { + trainingSources: [ + { noAccess: true, shortName: 'RP1', name: 'Reference Project' } as unknown as DraftSource, + { noAccess: true, shortName: 'DS1', name: 'Spanish Draft Source' } as unknown as DraftSource + ], + trainingTargets: [], + draftingSources: [] + }; + } + return { + trainingSources: [ + { + paratextId: 'training-source-1-pt-id', + projectRef: TRAINING_SOURCE_ID, + name: 'Reference Project', + shortName: 'RP1', + writingSystem: { script: 'Latn', tag: 'es' }, + texts: [], + copyrightBanner: args.withCopyright ? 'Reference text © Example Bible Society' : undefined + } as DraftSource + ], + trainingTargets: [], + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: DRAFT_SOURCE_ID, + name: 'Spanish Draft Source', + shortName: 'DS1', + writingSystem: { script: 'Latn', tag: 'es' }, + texts: [], + copyrightBanner: args.withCopyright ? 'Source text © Example Bible Society' : undefined + } as DraftSource + ] + }; +} + +function makeParatextProject(overrides: Partial): ParatextProject { + return { + paratextId: 'pt-id', + name: 'A Project', + shortName: 'PRJ', + languageTag: 'en', + projectId: 'sf-project-id', + isConnectable: true, + isConnected: true, + hasUpdate: false, + hasUserRoleChanged: false, + role: 'pt_administrator', + ...overrides + } as ParatextProject; +} + +function setUpMocks(args: StoryArgs): void { + for (const m of [ + mockedActivatedProjectService, + mockedDraftSourcesService, + mockedDraftGenerationService, + mockedDraftProgressService, + mockedFeatureFlagService, + mockedOnlineStatusService, + mockedUserService, + mockedRouter, + mockedNllbLanguageService, + mockedParatextService, + mockedErrorReportingService, + mockedErrorHandler, + mockedTrainingDataService, + mockedProgressService, + mockedSFProjectService, + mockedPermissionsService, + mockedDialogService, + mockedAuthService + ]) { + reset(m); + } + + const project = buildProjectProfile(args); + const projectDoc = { id: TARGET_ID, data: project } as SFProjectProfileDoc; + + when(mockedActivatedProjectService.projectId).thenReturn(TARGET_ID); + when(mockedActivatedProjectService.projectId$).thenReturn(of(TARGET_ID)); + when(mockedActivatedProjectService.projectDoc).thenReturn(projectDoc); + when(mockedActivatedProjectService.projectDoc$).thenReturn(of(projectDoc)); + when(mockedActivatedProjectService.changes$).thenReturn(of(projectDoc)); + + when(mockedDraftSourcesService.getDraftProjectSources()).thenReturn(of(draftSources(args))); + + // The logic handler loads progress for the target, the drafting source and the training source. When `neverLoad` is + // set the progress promises never resolve, leaving the wizard stuck on its loading spinner. + const progressFor = (range: string): Promise => + args.neverLoad ? new Promise(() => {}) : Promise.resolve(new VerboseScriptureRange(range)); + when(mockedDraftProgressService.getProgressForProject(TARGET_ID, anything())).thenCall(() => + progressFor(TARGET_BOOKS) + ); + when(mockedDraftProgressService.getProgressForProject(DRAFT_SOURCE_ID, anything())).thenCall(() => + progressFor(DRAFT_SOURCE_BOOKS) + ); + when(mockedDraftProgressService.getProgressForProject(TRAINING_SOURCE_ID, anything())).thenCall(() => + progressFor(TRAINING_SOURCE_BOOKS) + ); + when(mockedDraftProgressService.getCompleteBookIds(TARGET_ID, anything())).thenResolve(new Set()); + + when(mockedFeatureFlagService.showDeveloperTools).thenReturn(createTestFeatureFlag(args.developerTools)); + + when(mockedOnlineStatusService.isOnline).thenReturn(args.online); + when(mockedOnlineStatusService.onlineStatus$).thenReturn(of(args.online)); + + when(mockedUserService.getCurrentUser()).thenResolve({ + data: { email: 'translator@example.com' } + } as UserDoc); + + when(mockedNllbLanguageService.isNllbLanguageAsync(anything())).thenResolve(false); + + const pendingProjects = args.hasPendingUpdates + ? [makeParatextProject({ projectId: DRAFT_SOURCE_ID, name: 'Spanish Draft Source', hasUpdate: true })] + : []; + when(mockedParatextService.getProjects()).thenResolve(pendingProjects); + + const files = args.withTrainingDataFiles + ? [makeTrainingData('glossary', 'Glossary terms'), makeTrainingData('back-translation', 'Back translation notes')] + : []; + when(mockedTrainingDataService.getTrainingData(anything(), anything())).thenReturn(of(files)); + + when(mockedDraftGenerationService.startBuildOrGetActiveBuild(anything())).thenReturn(of(undefined)); + + // For the pending-updates interstitial: each involved project doc loads with no active sync, so the row is shown as + // "syncable" with a Sync button (no live SyncProgressComponent). + when(mockedSFProjectService.get(anything())).thenResolve({ + id: DRAFT_SOURCE_ID, + data: createTestProjectProfile({ shortName: 'DS1', sync: { queuedCount: 0, lastSyncSuccessful: true } }), + remoteChanges$: of() + } as unknown as SFProjectDoc); + when(mockedPermissionsService.canSync(anything())).thenReturn(true); +} + +const meta: Meta = { + title: 'Translate/NewDraft', + component: NewDraftComponent, + args: defaultArgs, + parameters: { controls: { include: Object.keys(defaultArgs) } }, + render: args => { + setUpMocks(args); + return { + props: args, + moduleMetadata: { + imports: [NewDraftComponent], + providers: [ + { provide: ActivatedProjectService, useValue: instance(mockedActivatedProjectService) }, + { provide: DraftSourcesService, useValue: instance(mockedDraftSourcesService) }, + { provide: DraftGenerationService, useValue: instance(mockedDraftGenerationService) }, + { provide: DraftProgressService, useValue: instance(mockedDraftProgressService) }, + { provide: FeatureFlagService, useValue: instance(mockedFeatureFlagService) }, + { provide: OnlineStatusService, useValue: instance(mockedOnlineStatusService) }, + { provide: UserService, useValue: instance(mockedUserService) }, + { provide: Router, useValue: instance(mockedRouter) }, + { provide: NllbLanguageService, useValue: instance(mockedNllbLanguageService) }, + { provide: ParatextService, useValue: instance(mockedParatextService) }, + { provide: ErrorReportingService, useValue: instance(mockedErrorReportingService) }, + { provide: ErrorHandler, useValue: instance(mockedErrorHandler) }, + { provide: TrainingDataService, useValue: instance(mockedTrainingDataService) }, + { provide: ProgressService, useValue: instance(mockedProgressService) }, + { provide: SFProjectService, useValue: instance(mockedSFProjectService) }, + { provide: PermissionsService, useValue: instance(mockedPermissionsService) }, + { provide: DialogService, useValue: instance(mockedDialogService) }, + { provide: AuthService, useValue: instance(mockedAuthService) } + ] + } + }; + } +}; + +export default meta; + +type Story = StoryObj; + +// --- Navigation helpers for the play functions --- + +type Canvas = ReturnType; + +/** Advance to the next wizard step. */ +async function clickNext(canvas: Canvas): Promise { + await userEvent.click(await canvas.findByRole('button', { name: /next/i })); +} + +/** Toggle a book chip (e.g. "Genesis") in the visible book multi-select. */ +async function toggleBook(canvas: Canvas, bookName: string): Promise { + await userEvent.click(await canvas.findByText(bookName)); +} + +/** preface -> draft_books, selecting GEN (the first chip) so the step's validation passes. Selecting by position + * rather than by name keeps this working under any locale (book names are localized; the chrome is not). */ +async function gotoDraftBooksWithGen(canvas: Canvas): Promise { + await clickNext(canvas); // preface -> draft_books + await canvas.findByText('Select books to draft'); + const books = await canvas.findAllByRole('option'); // ordered by book number, so GEN is first + await userEvent.click(books[0]); +} + +// --- Stories --- + +/** The wizard before its project and progress data have loaded. */ +export const Loading: Story = { + args: { neverLoad: true }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.querySelector('.loading-indicator')).not.toBeNull()); + } +}; + +/** A source or reference project is inaccessible, so drafting cannot proceed. */ +export const Abort: Story = { + args: { noAccess: true }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.querySelector('.abort-screen')).not.toBeNull()); + } +}; + +/** Pre-step interstitial: an involved project has un-synced Paratext changes. */ +export const PendingUpdates: Story = { + args: { hasPendingUpdates: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Spanish Draft Source'); + // The continue-anyway action is always offered; the wizard's own step buttons are hidden here. + expect(canvas.queryByRole('button', { name: /next/i })).toBeNull(); + } +}; + +/** First wizard step: confirm the configured sources before drafting. */ +export const Preface: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => expect(canvasElement.querySelector('.configure-sources-link')).not.toBeNull()); + await canvas.findByRole('button', { name: /next/i }); + } +}; + +/** Preface with copyright banners contributed by the sources. */ +export const PrefaceWithCopyright: Story = { + args: { withCopyright: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Source text © Example Bible Society'); + await canvas.findByText('Reference text © Example Bible Society'); + } +}; + +/** Book selection step with a whole-book selection (PHM is in both projects but has no partial-chapter table). */ +export const SelectBooksToDraft: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await clickNext(canvas); // preface -> draft_books + await canvas.findByText('Select books to draft'); + await toggleBook(canvas, 'Philemon'); + // PHM has only one chapter, so it isn't eligible for partial drafting and no chapter inputs appear. + await waitFor(() => expect(canvasElement.querySelector('.partial-book-drafting-table')).toBeNull()); + } +}; + +/** A source-only book (JUD) isn't offered for drafting; expanding the collapsed notice explains why it's hidden. */ +export const ExcludedBooks: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await clickNext(canvas); // preface -> draft_books + await canvas.findByText('Select books to draft'); + // The exclusion notice starts collapsed; expand it to reveal which books were left out and why. + await userEvent.click(await canvas.findByText(/books are hidden/i)); + // JUD is in the source but not the target, so it is named in the "not in the project" notice. + await canvas.findByText(/Jude/); + } +}; + +/** Book selection with partial-book drafting: GEN exposes a chapter input, and an out-of-range value shows an error. */ +export const SelectChaptersToDraft: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + // GEN is eligible for partial drafting, so a chapter input appears. + const input = await canvas.findByRole('textbox'); + await userEvent.clear(input); + await userEvent.type(input, '51-60'); // GEN only has 50 chapters in the source + await userEvent.tab(); // blur triggers validation + await waitFor(() => expect(canvasElement.querySelector('.chapter-error')).not.toBeNull()); + } +}; + +/** Training step: choose which of your own books, reference books, and training files to train on. */ +export const SelectTrainingBooks: Story = { + args: { withTrainingDataFiles: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + await clickNext(canvas); // draft_books -> training_books + await canvas.findByText('Select books to train on'); + // The reference source and the training-data files both render. + await canvas.findByText('RP1'); + await canvas.findByText('Glossary terms'); + } +}; + +/** Final summary step, ready to generate the draft. A custom Serval config is set, so the summary also shows the + * "custom draft configurations have been applied" notice. */ +export const Summary: Story = { + args: { withCustomConfig: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + await clickNext(canvas); // draft_books -> training_books + await canvas.findByText('Select books to train on'); + await clickNext(canvas); // training_books -> suffix + await canvas.findByRole('button', { name: /generate draft/i }); + await canvas.findByText('Custom draft configurations have been applied by an administrator.'); + } +}; + +/** Summary while offline and with developer tools enabled: Generate is disabled and the dev cards are shown. */ +export const SummaryOfflineDevTools: Story = { + args: { online: false, developerTools: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + await clickNext(canvas); // draft_books -> training_books + await canvas.findByText('Select books to train on'); + await clickNext(canvas); // training_books -> suffix + await canvas.findByText('Developer options'); + const generate = await canvas.findByRole('button', { name: /generate draft/i }); + expect(generate).toBeDisabled(); + } +}; + +/** Right-to-left layout of the training step. */ +export const RTL: Story = { + args: { withTrainingDataFiles: true }, + parameters: { locale: 'ar' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + await clickNext(canvas); // draft_books -> training_books + await canvas.findByText('RP1'); + } +}; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts new file mode 100644 index 00000000000..5aeb8d16ecd --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts @@ -0,0 +1,916 @@ +import { CommonModule } from '@angular/common'; +import { Component, DestroyRef, ErrorHandler } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { Router } from '@angular/router'; +import { TranslocoModule } from '@ngneat/transloco'; +import { Canon } from '@sillsdev/scripture'; +import { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; +import { ProjectScriptureRange } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; +import { filter, firstValueFrom, take } from 'rxjs'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; +import { I18nKeyForComponent, I18nService } from 'xforge-common/i18n.service'; +import { UserDoc } from 'xforge-common/models/user-doc'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { UserService } from 'xforge-common/user.service'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { hasStringProp } from '../../../../type-utils'; +import { ParatextProject } from '../../../core/models/paratext-project'; +import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; +import { ParatextService } from '../../../core/paratext.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { Book } from '../../../shared/book-multi-select/book-multi-select'; +import { BookMultiSelectComponent } from '../../../shared/book-multi-select/book-multi-select.component'; +import { CopyrightBannerComponent } from '../../../shared/copyright-banner/copyright-banner.component'; +import { NoticeComponent } from '../../../shared/notice/notice.component'; +import { ChapterSet, VerboseScriptureRange } from '../../../shared/scripture-range'; +import { projectLabel } from '../../../shared/utils'; +import { isSFProjectSyncing } from '../../../sync/sync.component'; +import { NllbLanguageService } from '../../nllb-language.service'; +import { ConfirmSourcesComponent } from '../confirm-sources/confirm-sources.component'; +import { BuildConfig } from '../draft-generation'; +import { DraftGenerationService } from '../draft-generation.service'; +import { CopyrightMessage, DraftSource, getCopyrightMessages } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; +import { TrainingDataService } from '../training-data/training-data.service'; +import { DraftPendingUpdatesComponent } from './draft-pending-updates/draft-pending-updates.component'; +import { + DraftingBookExclusionReason, + DraftProgressService, + NewDraftAbortMode, + NewDraftLogicHandler, + scriptureRangeToBookListWithoutChapterDetail +} from './new-draft-logic-handler'; +import { defaultSelectedTrainingDataFiles } from './training-data-file-selection'; +import { formatTrainingBooksSummary } from './training-data-summary'; + +interface ChapterInputError { + key: I18nKeyForComponent<'new_draft'>; + params?: object; +} + +const PAGES_BY_ORDER = [ + { page: 'preface' }, + { page: 'draft_books', inputState: 'draft_books' }, + { page: 'training_books', inputState: 'training_books' }, + { page: 'suffix' } +] as const; + +@Component({ + selector: 'app-new-draft', + templateUrl: './new-draft.component.html', + styleUrls: ['./new-draft.component.scss'], + imports: [ + MatProgressSpinner, + ConfirmSourcesComponent, + CopyrightBannerComponent, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatIconModule, + BookMultiSelectComponent, + MatFormFieldModule, + MatInputModule, + NoticeComponent, + DraftPendingUpdatesComponent, + FormsModule, + TranslocoModule, + CommonModule + ] +}) +export class NewDraftComponent { + logicHandler: NewDraftLogicHandler; + + page: (typeof PAGES_BY_ORDER)[number]['page'] | 'loading' | 'pending_updates' | 'abort' = 'loading'; + + pendingProjects: { projectId: string; name: string }[] = []; + + draftingChapterErrors = new Map(); + targetTrainingChapterErrors = new Map(); + stepError: I18nKeyForComponent<'new_draft'> | null = null; + + draftingExclusionsExpanded = false; + trainingExclusionsExpanded = false; + + sendEmailOnBuildFinished: boolean = false; + fastTraining: boolean = false; + useEcho: boolean = false; + isTrainingOptional: boolean = false; + + /** All training data files currently available for the project. */ + trainingDataFiles: TrainingData[] = []; + /** DataIds of the training data files the user has chosen to include in this build. */ + selectedTrainingDataFileIds = new Set(); + private hasInitializedTrainingDataSelection = false; + + // Data that is guarnateed to be loaded post init + initData?: { projectId: string }; + + private currentUserDoc?: UserDoc; + + constructor( + private readonly activatedProjectService: ActivatedProjectService, + private readonly draftSourcesService: DraftSourcesService, + private readonly draftGenerationService: DraftGenerationService, + private readonly progressService: DraftProgressService, + readonly i18n: I18nService, + readonly featureFlags: FeatureFlagService, + protected readonly onlineStatusService: OnlineStatusService, + private readonly userService: UserService, + private readonly router: Router, + private readonly nllbLanguageService: NllbLanguageService, + private readonly paratextService: ParatextService, + private readonly errorReportingService: ErrorReportingService, + private readonly errorHandler: ErrorHandler, + private readonly trainingDataService: TrainingDataService, + private readonly projectService: SFProjectService, + private readonly destroyRef: DestroyRef + ) { + this.logicHandler = new NewDraftLogicHandler( + this.activatedProjectService, + this.draftSourcesService, + this.progressService, + this.destroyRef + ); + + void this.init(); + } + + async init(): Promise { + const [, status] = await Promise.all([ + this.userService.getCurrentUser().then(doc => (this.currentUserDoc = doc)), + firstValueFrom(this.logicHandler.status$.pipe(filter(status => status === 'input' || status === 'abort'))) + ]); + if (status === 'abort') { + this.handleAbort(); + return; + } + this.initData = { + projectId: await firstValueFrom(this.activatedProjectService.projectId$.pipe(filterNullish())) + }; + + this.initTrainingDataFiles(this.initData.projectId); + + const sources = this.logicHandler.sources; + const targetTag = this.activatedProjectService.projectDoc?.data?.writingSystem.tag; + const draftingSourceTag = sources?.draftingSources[0]?.writingSystem?.tag; + if (targetTag != null && draftingSourceTag != null) { + this.isTrainingOptional = + (await this.nllbLanguageService.isNllbLanguageAsync(targetTag)) && + (await this.nllbLanguageService.isNllbLanguageAsync(draftingSourceTag)); + } + + const draftConfig = this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig; + this.sendEmailOnBuildFinished = draftConfig?.sendEmailOnBuildFinished ?? false; + if (this.featureFlags.showDeveloperTools.enabled) { + this.fastTraining = draftConfig?.fastTraining ?? false; + this.useEcho = draftConfig?.useEcho ?? false; + } + + if (this.onlineStatusService.isOnline) { + await this.detectPendingUpdates(); + } + if (this.pendingProjects.length > 0) { + this.page = 'pending_updates'; + } else { + this.page = 'preface'; + this.armSyncWatcher(); + } + + this.logicHandler.status$ + .pipe( + filter(s => s === 'abort'), + take(1), + quietTakeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.handleAbort()); + } + + /** + * Anticipated failures (no_access, config_changed, project_syncing) show a blocking abort screen. + * Unanticipated failures go to the app-wide error handler and navigate back so the user isn't stranded. + */ + private handleAbort(): void { + const mode = this.logicHandler.abortMode; + if (mode === 'no_access' || mode === 'config_changed' || mode === 'project_syncing') { + this.page = 'abort'; + return; + } + this.errorHandler.handleError(this.logicHandler.initError); + this.goBack(); + } + + get abortMode(): NewDraftAbortMode { + return this.logicHandler.abortMode; + } + + get inaccessibleProjectNames(): string[] { + return this.logicHandler.inaccessibleProjectNames; + } + + goBack(): void { + void this.router.navigate(['/projects', this.activatedProjectService.projectId, 'draft-generation']); + } + + /** + * Watches training data files. On first load, computes the default selection from the previous build's selection + * and available files (see {@link defaultSelectedTrainingDataFiles}). Later changes keep the list current and + * prune missing files, without clobbering in-session choices. + */ + private initTrainingDataFiles(projectId: string): void { + this.trainingDataService + .getTrainingData(projectId, this.destroyRef) + .pipe(quietTakeUntilDestroyed(this.destroyRef, { logWarnings: false })) + .subscribe(files => { + this.trainingDataFiles = files; + const currentFileIds = files.map(f => f.dataId); + if (!this.hasInitializedTrainingDataSelection) { + const draftConfig = this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig; + this.selectedTrainingDataFileIds = new Set( + defaultSelectedTrainingDataFiles( + currentFileIds, + draftConfig?.lastSelectedTrainingDataFiles, + draftConfig?.lastAvailableTrainingDataFiles + ) + ); + this.hasInitializedTrainingDataSelection = true; + } else { + // Prune selections for files that have since been removed. + const stillPresent = new Set(currentFileIds); + this.selectedTrainingDataFileIds = new Set( + [...this.selectedTrainingDataFileIds].filter(id => stillPresent.has(id)) + ); + } + }); + } + + isTrainingDataFileSelected(dataId: string): boolean { + return this.selectedTrainingDataFileIds.has(dataId); + } + + /** Titles of the training data files the user has selected, for the summary recap. */ + get selectedTrainingDataFileTitles(): string[] { + return this.trainingDataFiles.filter(f => this.selectedTrainingDataFileIds.has(f.dataId)).map(f => f.title); + } + + onTrainingDataFileToggled(dataId: string, selected: boolean): void { + // Replace the Set so the template's change detection picks up the change. + const updated = new Set(this.selectedTrainingDataFileIds); + if (selected) { + updated.add(dataId); + } else { + updated.delete(dataId); + } + this.selectedTrainingDataFileIds = updated; + } + + private async detectPendingUpdates(): Promise { + const sources = this.logicHandler.sources; + const projectId = this.initData!.projectId; + const involvedIds = new Set([ + projectId, + ...(sources?.draftingSources.map(s => s.projectRef) ?? []), + ...(sources?.trainingSources.map(s => s.projectRef) ?? []) + ]); + + let projects: ParatextProject[] | undefined; + try { + projects = await this.paratextService.getProjects(); + } catch (error) { + // Detection is advisory; if we can't reach Paratext, proceed rather than stranding the user. + this.errorReportingService.silentError( + 'Failed to check for pending Paratext updates before drafting', + ErrorReportingService.normalizeError(error) + ); + return; + } + this.pendingProjects = (projects ?? []) + .filter(p => p.projectId != null && involvedIds.has(p.projectId) && p.isConnected && p.hasUpdate) + .map(p => ({ + projectId: p.projectId!, + name: p.name?.length ? p.name : p.shortName + })); + } + + /** + * Leaves the pending-updates pre-step. If projects were synced in place, re-derives book/chapter availability + * from fresh progress before showing Step 1 to avoid stale selection UI. + */ + async onPendingUpdatesComplete(syncedProjectIds: string[]): Promise { + if (syncedProjectIds.length === 0) { + this.page = 'preface'; + this.armSyncWatcher(); + return; + } + this.page = 'loading'; + try { + await this.logicHandler.reload(syncedProjectIds); + this.page = 'preface'; + this.armSyncWatcher(); + } catch (error) { + // Mirror init-failure handling: route to the app-wide error handler and navigate back rather than stranding the + // user on the spinner with stale data. + this.errorHandler.handleError(error); + this.goBack(); + } + } + + private syncWatcherArmed = false; + + /** + * Begins watching involved projects for a sync that starts after this point; aborts if one does. Called at + * "lock-in" when the user leaves the pre-step (or immediately if there was none). A project already syncing + * at lock-in is treated as the baseline and does not trigger an abort. + */ + private armSyncWatcher(): void { + if (this.syncWatcherArmed) return; + this.syncWatcherArmed = true; + + const sources = this.logicHandler.sources; + const involvedIds = new Set([ + this.initData!.projectId, + ...(sources?.draftingSources.map(s => s.projectRef) ?? []), + ...(sources?.trainingSources.map(s => s.projectRef) ?? []) + ]); + + for (const projectId of involvedIds) { + void this.watchProjectForSync(projectId); + } + } + + private async watchProjectForSync(projectId: string): Promise { + try { + // The target is already loaded; sources are fetched as profile docs, which also carry sync status. + const projectDoc: SFProjectProfileDoc = + projectId === this.initData?.projectId && this.activatedProjectService.projectDoc != null + ? this.activatedProjectService.projectDoc + : await this.projectService.getProfile(projectId); + + let wasSyncing = projectDoc.data != null && isSFProjectSyncing(projectDoc.data); + projectDoc.remoteChanges$.pipe(quietTakeUntilDestroyed(this.destroyRef)).subscribe(() => { + const data = projectDoc.data; + if (data == null) return; + const syncing = isSFProjectSyncing(data); + // Abort only on the not-syncing → syncing edge; a project already syncing at arm time is the baseline. + if (syncing && !wasSyncing) this.logicHandler.abort('project_syncing'); + wasSyncing = syncing; + }); + } catch (error) { + this.errorReportingService.silentError( + 'Failed to watch a project for sync during drafting', + ErrorReportingService.normalizeError(error) + ); + } + } + + get currentUserEmail(): string | undefined { + return this.currentUserDoc?.data?.email; + } + + get copyrightMessages(): CopyrightMessage[] { + const sources = this.logicHandler.sources; + if (sources == null) return []; + return getCopyrightMessages([...sources.trainingSources, ...sources.draftingSources]); + } + + /** Index of the current page in PAGES_BY_ORDER, or -1 on non-step pages (loading, pending updates, abort). */ + private get currentPageIndex(): number { + return PAGES_BY_ORDER.findIndex(p => p.page === this.page); + } + + /** The 1-based number of the current wizard step, or null on non-step pages (loading, pending updates, abort). */ + get stepNumber(): number | null { + const index = this.currentPageIndex; + return index >= 0 ? index + 1 : null; + } + + /** Total number of wizard steps shown in the step indicator. */ + get totalStepCount(): number { + return PAGES_BY_ORDER.length; + } + + back(): void { + this.step(-1); + } + + next(): void { + this.step(1); + } + + private getForwardError(): I18nKeyForComponent<'new_draft'> | null { + if (this.page === 'draft_books') { + if (this.logicHandler.selectedDraftingScriptureRange.books.size === 0) return 'no_drafting_books_selected'; + if (this.draftingChapterErrors.size > 0) return 'fix_chapter_errors'; + } + if (this.page === 'training_books') { + if (!this.isTrainingOptional && !this.hasTrainingBooksSelected) return 'no_training_books_selected'; + // Unlike the "select something" requirement above, an unpaired book is an inconsistent state, not a missing + // optional choice: the user chose to train on a book but then deselected the matching reference book from every + // source, so their selection leaves nothing to pair it with. Block it regardless of whether training is optional. + if (this.unpairedTargetTrainingBooks.length > 0) return 'no_training_pair_selected'; + if (this.targetTrainingChapterErrors.size > 0) return 'fix_chapter_errors'; + } + return null; + } + + /** + * Selected target training books with no matching book in any training source. This is only non-empty after + * the user manually deselects a previously auto-paired book from all sources. + */ + private get unpairedTargetTrainingBooks(): string[] { + const targetBooks = Array.from(this.logicHandler.selectedTargetTrainingScriptureRange.books.keys()); + const selectedSourceBooks = new Set(Object.values(this.logicHandler.selectedTrainingSourceBooks).flat()); + return targetBooks.filter(bookId => !selectedSourceBooks.has(bookId)); + } + + private get hasTrainingBooksSelected(): boolean { + const hasTargetBooks = this.logicHandler.selectedTargetTrainingScriptureRange.books.size > 0; + if (!hasTargetBooks) return false; + if (this.trainingSources.length === 0) return true; + const selected = this.logicHandler.selectedTrainingSourceBooks; + return Object.values(selected).some(books => books.length > 0); + } + + private clearStepErrorIfResolved(): void { + if (this.stepError != null && this.getForwardError() !== this.stepError) this.stepError = null; + } + + private step(count: 1 | -1): void { + if (count === 1) { + const error = this.getForwardError(); + if (error != null) { + this.stepError = error; + return; + } + } + this.stepError = null; + const newIndex = this.currentPageIndex + count; + if (newIndex < 0) { + void this.router.navigate(['/projects', this.initData?.projectId, 'draft-generation']); + } else if (newIndex < PAGES_BY_ORDER.length) { + const newPage = PAGES_BY_ORDER[newIndex]; + this.page = newPage.page; + if (hasStringProp(newPage, 'inputState')) { + this.draftingChapterErrors.clear(); + this.targetTrainingChapterErrors.clear(); + this.logicHandler.setInputMode(newPage.inputState as 'draft_books' | 'training_books'); + } + } else throw new Error(`Cannot navigate from page ${this.page} to index ${newIndex}`); + } + + submitting = false; + async generateDraftClicked(): Promise { + if (!this.onlineStatusService.isOnline || this.initData == null) return; + + this.submitting = true; + + await Promise.resolve(); + + try { + const projectId = this.initData.projectId; + const draftingSource = this.logicHandler.sources?.draftingSources[0]; + if (draftingSource == null) return; + + const translationScriptureRanges: ProjectScriptureRange[] = [ + { + projectId: draftingSource.projectRef, + scriptureRange: this.logicHandler.selectedDraftingScriptureRange.toString() + } + ]; + + const trainingScriptureRanges: ProjectScriptureRange[] = []; + const selectedSrcBooks = this.logicHandler.selectedTrainingSourceBooks; + for (const source of this.logicHandler.sources?.trainingSources ?? []) { + const bookIds = selectedSrcBooks[source.projectRef] ?? []; + if (bookIds.length > 0) { + trainingScriptureRanges.push({ projectId: source.projectRef, scriptureRange: bookIds.join(';') }); + } + } + // Include target project entry at chapter-level: persists training selection and drives backend filter + trainingScriptureRanges.push({ + projectId, + scriptureRange: this.logicHandler.selectedTargetTrainingScriptureRange.toString() + }); + + // Record available files alongside the selection so later builds can distinguish new files from deliberately deselected ones. + const availableTrainingDataFiles = this.trainingDataFiles.map(f => f.dataId); + const trainingDataFiles = availableTrainingDataFiles.filter(id => this.selectedTrainingDataFileIds.has(id)); + + const buildConfig: BuildConfig = { + projectId, + translationScriptureRanges, + trainingScriptureRanges, + trainingDataFiles, + availableTrainingDataFiles, + fastTraining: this.fastTraining, + useEcho: this.useEcho, + sendEmailOnBuildFinished: this.sendEmailOnBuildFinished + }; + + await firstValueFrom(this.draftGenerationService.startBuildOrGetActiveBuild(buildConfig)); + + void this.router.navigate(['/projects', projectId, 'draft-generation']); + } finally { + this.submitting = false; + } + } + + /** + * Maps a range's books to the `Book[]` shape the multi-select expects. With no `selectedBookIds`, every book is + * marked selected (the range itself is the selection); otherwise a book is selected iff its id is in `selectedBookIds`. + */ + private toBookList(range: VerboseScriptureRange, selectedBookIds?: Set): Book[] { + return scriptureRangeToBookListWithoutChapterDetail(range).map(id => ({ + number: Canon.bookIdToNumber(id), + selected: selectedBookIds == null || selectedBookIds.has(id) + })); + } + + // Section: Drafting books selection + + get availableDraftingBooks(): Book[] { + return this.toBookList( + this.logicHandler.availableDraftingScriptureRange, + new Set(this.logicHandler.selectedDraftingScriptureRange.books.keys()) + ); + } + + get selectedDraftingBooks(): Book[] { + return this.toBookList(this.logicHandler.selectedDraftingScriptureRange); + } + + get booksOfferedForPartialDrafting(): string[] { + return this.logicHandler.booksOfferedForPartialDrafting; + } + + /** Drafting-exclusion reasons shown to the user, in display order. Non-canonical exclusions are never surfaced. */ + private readonly surfacedDraftingExclusionReasons: DraftingBookExclusionReason[] = [ + 'no_source_content', + 'not_in_target' + ]; + + /** How many books are hidden from the drafting list for a surfaced reason (used by the "N books are hidden" toggle). */ + get draftingHiddenBookCount(): number { + return this.logicHandler.excludedDraftingBooks.filter(book => + this.surfacedDraftingExclusionReasons.includes(book.reason) + ).length; + } + + /** + * Notices explaining books the user might expect that were left out of the drafting list, one per surfaced reason. + * Non-canonical exclusions are intentionally tracked but not surfaced. Each entry carries the i18n key for its + * reason and the parameters that message needs. + */ + get draftingExclusionNotices(): { key: I18nKeyForComponent<'new_draft'>; params: Record }[] { + const excluded = this.logicHandler.excludedDraftingBooks; + return this.surfacedDraftingExclusionReasons + .map(reason => { + const bookNames = excluded + .filter(book => book.reason === reason) + .map(book => this.i18n.localizeBook(book.bookId)); + return { reason, bookNames }; + }) + .filter(group => group.bookNames.length > 0) + .map(group => ({ + key: `draft_books.excluded_${group.reason}` as I18nKeyForComponent<'new_draft'>, + params: { + books: this.i18n.enumerateList(group.bookNames), + source: this.draftingSourceName, + project: this.targetProjectDisplayName + } + })); + } + + /** Drops chapter-input errors for books that are no longer offered for partial selection (so have no input). */ + private pruneChapterErrors(errors: Map, offeredBookIds: string[]): void { + for (const bookId of errors.keys()) { + if (!offeredBookIds.includes(bookId)) errors.delete(bookId); + } + } + + onDraftingBookSelect(books: number[]): void { + const selectedBookIds = books.map(b => Canon.bookNumberToId(b)); + this.logicHandler.selectDraftingBooks(selectedBookIds); + this.pruneChapterErrors(this.draftingChapterErrors, this.logicHandler.booksOfferedForPartialDrafting); + this.clearStepErrorIfResolved(); + } + + /** + * Parses a chapter-range input, recording an error and returning null on failure. An empty input is rejected + * with `emptyErrorKey` because clearing the input is not how a book is removed. The emptyErrorKey allows the caller + * to specify which I18nKey to return when the input is empty. + */ + private parseChapterInput( + bookId: string, + value: string, + errors: Map, + emptyErrorKey: I18nKeyForComponent<'new_draft'> + ): ChapterSet | null { + let parsed: ChapterSet; + try { + parsed = ChapterSet.fromUserInput(value); + } catch { + errors.set(bookId, { key: 'chapter_input.invalid_range' }); + return null; + } + // Checked after parsing: fromUserInput normalizes whitespace and separators away, so empty, whitespace-only, and + // separator-only input all resolve to an empty set here. + if (parsed.count() === 0) { + errors.set(bookId, { key: emptyErrorKey }); + return null; + } + return parsed; + } + + onDraftingChaptersBlurred(bookId: string, value: string): void { + const parsed = this.parseChapterInput(bookId, value, this.draftingChapterErrors, 'chapter_input.empty_draft'); + if (parsed == null) return; + + const available = this.logicHandler.availableDraftingScriptureRange.books.get(bookId); + const badChapters = available != null ? parsed.difference(available) : parsed; + if (badChapters.count() > 0) { + this.draftingChapterErrors.set(bookId, { + key: 'chapter_input.chapters_not_in_source', + params: { + chapters: badChapters.toString(), + sourceName: this.logicHandler.sources?.draftingSources[0]?.shortName ?? '' + } + }); + return; + } + + this.draftingChapterErrors.delete(bookId); + this.logicHandler.selectDraftingChapters(bookId, value); + } + + draftingRangeForBook(bookId: string): string { + const range = this.logicHandler.selectedDraftingScriptureRange; + return range.books.get(bookId)?.toString() ?? ''; + } + + draftingChapterHint(bookId: string): string { + return this.logicHandler.availableDraftingScriptureRange.books.get(bookId)?.toString() ?? ''; + } + + // Section: Target training books selection + + get availableTargetTrainingBooks(): Book[] { + return this.toBookList( + this.logicHandler.availableTargetTrainingScriptureRange, + new Set(this.logicHandler.selectedTargetTrainingScriptureRange.books.keys()) + ); + } + + get selectedTargetTrainingBooks(): Book[] { + return this.toBookList(this.logicHandler.selectedTargetTrainingScriptureRange); + } + + get booksOfferedForPartialTargetTraining(): string[] { + return this.logicHandler.booksOfferedForPartialTargetTraining; + } + + /** Whether any target book is hidden from the training list for lacking a matching book in any training source. */ + get hasTargetTrainingBooksWithoutSource(): boolean { + return this.logicHandler.targetTrainingBooksWithoutSource.length > 0; + } + + /** How many target books are hidden from the training list (used by the "N books are hidden" toggle). */ + get targetTrainingHiddenBookCount(): number { + return this.logicHandler.targetTrainingBooksWithoutSource.length; + } + + /** Whether training books were pre-selected on this project's first draft (shows the "review these" notice). */ + get trainingBooksWereAutoSelected(): boolean { + return this.logicHandler.trainingBooksWereAutoSelected; + } + + /** Localized, comma-joined names of the target books hidden from the training list for lacking a training source. */ + get targetTrainingBooksWithoutSourceNames(): string { + return this.i18n.enumerateList( + this.logicHandler.targetTrainingBooksWithoutSource.map(bookId => this.i18n.localizeBook(bookId)) + ); + } + + onTargetTrainingBookSelect(books: number[]): void { + const previousSelectedTargetIds = new Set( + scriptureRangeToBookListWithoutChapterDetail(this.logicHandler.selectedTargetTrainingScriptureRange) + ); + const newSelectedIds = new Set(books.map(n => Canon.bookNumberToId(n))); + const addedIds = [...newSelectedIds].filter(id => !previousSelectedTargetIds.has(id)); + + this.logicHandler.selectTargetTrainingBooks([...newSelectedIds]); + this.pruneChapterErrors(this.targetTrainingChapterErrors, this.logicHandler.booksOfferedForPartialTargetTraining); + + // Auto-select newly added target books in each training source; drop removed books + for (const source of this.trainingSources) { + const available = this.logicHandler.availableTrainingSourceBooks[source.projectRef] ?? []; + const currentSelected = this.logicHandler.selectedTrainingSourceBooks[source.projectRef] ?? []; + const stillValid = currentSelected.filter(id => newSelectedIds.has(id)); + const autoAdded = addedIds.filter(id => available.includes(id)); + this.logicHandler.selectTrainingSourceBooks(source.projectRef, [...new Set([...stillValid, ...autoAdded])]); + } + this.logicHandler.dismissAutoSelectNoticeIfSelectionEmpty(); + this.clearStepErrorIfResolved(); + } + + onTargetTrainingChaptersBlurred(bookId: string, value: string): void { + const parsed = this.parseChapterInput( + bookId, + value, + this.targetTrainingChapterErrors, + 'chapter_input.empty_training' + ); + if (parsed == null) return; + + const available = this.logicHandler.availableTargetTrainingScriptureRange.books.get(bookId); + const unavailable = available != null ? parsed.difference(available) : parsed; + + if (unavailable.count() > 0) { + const drafted = this.logicHandler.selectedDraftingScriptureRange.books.get(bookId); + const draftedUnavailable = drafted != null ? unavailable.intersection(drafted) : new ChapterSet([]); + if (draftedUnavailable.count() > 0) { + this.targetTrainingChapterErrors.set(bookId, { + key: 'chapter_input.chapters_will_be_translated', + params: { chapters: draftedUnavailable.toString() } + }); + } else { + const targetName = this.activatedProjectService.projectDoc?.data?.shortName ?? ''; + this.targetTrainingChapterErrors.set(bookId, { + key: 'chapter_input.chapters_not_in_target', + params: { chapters: unavailable.toString(), targetName } + }); + } + return; + } + + this.targetTrainingChapterErrors.delete(bookId); + this.logicHandler.selectTargetTrainingChapters(bookId, value); + } + + targetTrainingRangeForBook(bookId: string): string { + const range = this.logicHandler.selectedTargetTrainingScriptureRange; + return range.books.get(bookId)?.toString() ?? ''; + } + + targetTrainingChapterHint(bookId: string): string { + return this.logicHandler.availableTargetTrainingScriptureRange.books.get(bookId)?.toString() ?? ''; + } + + // Section: Training source book selection + + get trainingSources(): DraftSource[] { + return this.logicHandler.sources?.trainingSources ?? []; + } + + onTrainingSourceBookSelect(books: number[], projectId: string): void { + const bookIds = books.map(b => Canon.bookNumberToId(b)); + this.logicHandler.selectTrainingSourceBooks(projectId, bookIds); + this.clearStepErrorIfResolved(); + } + + availableTrainingSourceBooksForProject(projectId: string): Book[] { + const bookIds = this.logicHandler.availableTrainingSourceBooks[projectId] ?? []; + const selectedTargetIds = new Set( + scriptureRangeToBookListWithoutChapterDetail(this.logicHandler.selectedTargetTrainingScriptureRange) + ); + const selectedIds = this.logicHandler.selectedTrainingSourceBooks[projectId] ?? []; + return bookIds + .filter(id => selectedTargetIds.has(id)) + .map(id => ({ number: Canon.bookIdToNumber(id), selected: selectedIds.includes(id) })); + } + + selectedTrainingSourceBooksForProject(projectId: string): Book[] { + const bookIds = this.logicHandler.selectedTrainingSourceBooks[projectId] ?? []; + return bookIds.map(id => ({ number: Canon.bookIdToNumber(id), selected: true })); + } + + // Section: Summary (Step 4) + + get draftingItems(): { bookId: string; chapterRange: string | null }[] { + const selectedRange = this.logicHandler.selectedDraftingScriptureRange; + const availableRange = this.logicHandler.availableDraftingScriptureRange; + return Array.from(selectedRange.books.entries()) + .sort(([a], [b]) => Canon.bookIdToNumber(a) - Canon.bookIdToNumber(b)) + .map(([bookId, selected]) => { + const available = availableRange.books.get(bookId); + const isPartial = available != null && available.difference(selected).count() > 0; + return { bookId, chapterRange: isPartial ? selected.toStringForDisplay() : null }; + }); + } + + /** Selected drafting book names with chapter ranges (for partially-drafted books), as bulleted-list items. */ + get draftingBookListItems(): string[] { + return this.draftingItems.map(item => { + const bookName = this.i18n.localizeBook(item.bookId); + return item.chapterRange != null ? `${bookName} (${item.chapterRange})` : bookName; + }); + } + + /** Selected drafting book names without chapter detail, as a locale-aware conjunction list (for the page title). */ + get draftingBookNamesFormatted(): string { + // draftingItems is already canon-sorted, so reuse it rather than re-sorting the selected range here. + return this.i18n.enumerateList(this.draftingItems.map(item => this.i18n.localizeBook(item.bookId))); + } + + /** One row per training source that has selected books, for the summary's training-books table. */ + get sourceTrainingSections(): { projectRef: string; shortName: string; bookNumbers: number[] }[] { + return this.trainingSources + .map(source => { + const bookIds = this.logicHandler.selectedTrainingSourceBooks[source.projectRef] ?? []; + return { + projectRef: source.projectRef, + shortName: source.shortName, + bookNumbers: bookIds.map(id => Canon.bookIdToNumber(id)).sort((a, b) => a - b) + }; + }) + .filter(s => s.bookNumbers.length > 0); + } + + /** + * Localized training-book list for a source row in the summary table. Fully-used books collapse into ranges; + * a book that is only partly used as training data is shown with its chapter range (and broken out of any range). + */ + formatTrainingBooks(bookNumbers: number[]): string { + return formatTrainingBooksSummary( + bookNumbers, + this.logicHandler.selectedTargetTrainingScriptureRange, + this.logicHandler.availableTargetTrainingScriptureRange, + this.logicHandler.selectedDraftingScriptureRange, + this.i18n + ); + } + + get draftingSourceName(): string { + return this.logicHandler.sources?.draftingSources[0]?.shortName ?? ''; + } + + /** The drafting source's "shortName - name" label (shown in the draft-books summary subtitle). */ + get draftingSourceLabel(): string { + const draftingSource = this.logicHandler.sources?.draftingSources[0]; + return draftingSource != null ? projectLabel(draftingSource) : ''; + } + + get sourceLanguageDisplay(): string { + const tag = this.logicHandler.sources?.draftingSources[0]?.writingSystem?.tag; + return tag != null ? this.languageDisplay(tag) : ''; + } + + get targetLanguageDisplay(): string { + const tag = this.activatedProjectService.projectDoc?.data?.writingSystem?.tag; + return tag != null ? this.languageDisplay(tag) : ''; + } + + private languageDisplay(tag: string): string { + const name = this.i18n.getLanguageDisplayName(tag); + return name === tag ? tag : `${name} (${tag})`; + } + + get targetProjectDisplayName(): string { + const projectData = this.activatedProjectService.projectDoc?.data; + return projectData != null ? projectLabel(projectData) : ''; + } + + get targetShortName(): string { + return this.activatedProjectService.projectDoc?.data?.shortName ?? ''; + } + + /** Plain language name (no tag) for the training-source column header. Matches the legacy stepper's summary. */ + get sourceTrainingLanguageName(): string { + const tag = this.trainingSources[0]?.writingSystem?.tag; + return tag != null ? (this.i18n.getLanguageDisplayName(tag) ?? tag) : ''; + } + + /** Plain language name (no tag) for the target column header. */ + get targetLanguageName(): string { + const tag = this.activatedProjectService.projectDoc?.data?.writingSystem?.tag; + return tag != null ? (this.i18n.getLanguageDisplayName(tag) ?? tag) : ''; + } + + /** Whether an administrator has applied a custom Serval config to this project */ + get isCustomConfigSet(): boolean { + return this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig?.servalConfig != null; + } + + goToConfigureSources(): void { + const projectId = this.initData?.projectId; + if (projectId != null) { + void this.router.navigate(['/projects', projectId, 'draft-generation', 'configure-sources']); + } + } + + goToPage(page: 'draft_books' | 'training_books'): void { + this.stepError = null; + if (page === 'draft_books') { + this.logicHandler.setInputMode('draft_books'); + } + this.page = page; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.spec.ts new file mode 100644 index 00000000000..bc4b2950790 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.spec.ts @@ -0,0 +1,65 @@ +import { defaultSelectedTrainingDataFiles } from './training-data-file-selection'; + +describe('defaultSelectedTrainingDataFiles', () => { + describe('when the available set was recorded last time (new format)', () => { + it('keeps files that were used last time and are still present', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b', 'c'], ['a', 'c'], ['a', 'b', 'c']); + expect(result).toEqual(['a', 'c']); + }); + + it('selects newly added files (present now, not offered last time)', () => { + // 'd' is new (was not available at the last build), so it defaults to selected alongside the prior selection + const result = defaultSelectedTrainingDataFiles(['a', 'b', 'c', 'd'], ['a'], ['a', 'b', 'c']); + expect(result).toEqual(['a', 'd']); + }); + + it('leaves files that were offered but not used last time deselected', () => { + // 'b' was available last time but not selected → it was deliberately deselected, so it stays off + const result = defaultSelectedTrainingDataFiles(['a', 'b'], ['a'], ['a', 'b']); + expect(result).toEqual(['a']); + }); + + it('drops previously selected files that no longer exist', () => { + const result = defaultSelectedTrainingDataFiles(['a'], ['a', 'z'], ['a', 'z']); + expect(result).toEqual(['a']); + }); + + it('treats an empty recorded available set as "everything is new" (all selected)', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b'], [], []); + expect(result).toEqual(['a', 'b']); + }); + + it('preserves the current-file ordering', () => { + const result = defaultSelectedTrainingDataFiles(['c', 'a', 'b'], ['a', 'b', 'c'], ['a', 'b', 'c']); + expect(result).toEqual(['c', 'a', 'b']); + }); + }); + + describe('legacy fallback (available set not recorded)', () => { + it('follows the last selection when one exists', () => { + // lastAvailable undefined → cannot detect new files; respect the last selection as-is + const result = defaultSelectedTrainingDataFiles(['a', 'b', 'c'], ['a', 'c'], undefined); + expect(result).toEqual(['a', 'c']); + }); + + it('does not auto-select newly added files when there is a prior selection', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b', 'c'], ['a'], undefined); + expect(result).toEqual(['a']); + }); + + it('selects everything when there is no prior selection at all', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b'], [], undefined); + expect(result).toEqual(['a', 'b']); + }); + + it('selects everything when the prior selection is also undefined', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b'], undefined, undefined); + expect(result).toEqual(['a', 'b']); + }); + }); + + it('returns an empty list when there are no current files', () => { + expect(defaultSelectedTrainingDataFiles([], ['a'], ['a'])).toEqual([]); + expect(defaultSelectedTrainingDataFiles([], undefined, undefined)).toEqual([]); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.ts new file mode 100644 index 00000000000..dea384377e8 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.ts @@ -0,0 +1,33 @@ +/** + * Computes which training data files should be selected by default when the new draft wizard opens. + * + * The intent: + * - Files used at the last build (and still present) start selected. + * - Newly added files (present now, but not offered at the last build) start selected. + * - Files that were offered at the last build but not used start deselected (the user deliberately excluded them). + * + * Distinguishing a newly added file from a deliberately deselected one requires knowing which files were available + * at the last build (`lastAvailableFileIds`). Builds made before that was recorded won't have it, so we fall back to + * following the last selection if one exists, or selecting everything when there is no prior selection at all. + * + * The returned list preserves the order of `currentFileIds`. + */ +export function defaultSelectedTrainingDataFiles( + currentFileIds: string[], + lastSelectedFileIds: string[] | undefined, + lastAvailableFileIds: string[] | undefined +): string[] { + const lastSelected = new Set(lastSelectedFileIds ?? []); + + if (lastAvailableFileIds != null) { + // New format: we know what was offered last time, so we can tell newly added files apart from deselected ones. + const lastAvailable = new Set(lastAvailableFileIds); + return currentFileIds.filter(id => lastSelected.has(id) || !lastAvailable.has(id)); + } + + // Legacy fallback: no record of what was offered last time. + if (lastSelected.size > 0) { + return currentFileIds.filter(id => lastSelected.has(id)); + } + return [...currentFileIds]; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts new file mode 100644 index 00000000000..b3a2c33d771 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts @@ -0,0 +1,103 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslocoService } from '@ngneat/transloco'; +import { Canon } from '@sillsdev/scripture'; +import { CookieService } from 'ngx-cookie-service'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { BugsnagService } from 'xforge-common/bugsnag.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { LocationService } from 'xforge-common/location.service'; +import { getTestTranslocoModule } from 'xforge-common/test-utils'; +import { VerboseScriptureRange } from '../../../shared/scripture-range'; +import { formatTrainingBooksSummary } from './training-data-summary'; + +const mockedLocationService = mock(LocationService); +const mockedBugsnagService = mock(BugsnagService); +const mockedCookieService = mock(CookieService); +const mockedErrorReportingService = mock(ErrorReportingService); +const mockedDocument = mock(Document); + +describe('formatTrainingBooksSummary', () => { + const GEN = Canon.bookIdToNumber('GEN'); + const EXO = Canon.bookIdToNumber('EXO'); + const LEV = Canon.bookIdToNumber('LEV'); + const NUM = Canon.bookIdToNumber('NUM'); + + let i18n: I18nService; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [getTestTranslocoModule()] }); + when(mockedLocationService.search).thenReturn(''); + when(mockedCookieService.get(anything())).thenReturn(''); + // A genuine I18nService backed by the test transloco module (real 'en' translations, including book names). + // ignoreCookieLocale=true keeps the constructor from reading the cookie locale, so the dependencies are only + // present to satisfy construction. + i18n = new I18nService( + instance(mockedLocationService), + instance(mockedBugsnagService), + TestBed.inject(TranslocoService), + instance(mockedCookieService), + instance(mockedErrorReportingService), + instance(mockedDocument), + true + ); + }); + + function format(bookNumbers: number[], selected: string, available: string, drafting = ''): string { + return formatTrainingBooksSummary( + bookNumbers, + new VerboseScriptureRange(selected), + new VerboseScriptureRange(available), + new VerboseScriptureRange(drafting), + i18n + ); + } + + it('collapses fully-used books into a single range', () => { + expect(format([GEN, EXO, LEV], 'GEN1-50;EXO1-40;LEV1-27', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( + 'Genesis - Leviticus' + ); + }); + + it('renders a single fully-used book as its name', () => { + expect(format([EXO], 'EXO1-40', 'EXO1-40')).toEqual('Exodus'); + }); + + it('breaks a partial book in the middle out of the range and shows its chapters', () => { + expect(format([GEN, EXO, LEV], 'GEN1-50;EXO1-10;LEV1-27', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( + 'Genesis, Exodus (1-10), and Leviticus' + ); + }); + + it('handles a partial book at the start', () => { + expect(format([GEN, EXO, LEV], 'GEN1-10;EXO1-40;LEV1-27', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( + 'Genesis (1-10) and Exodus - Leviticus' + ); + }); + + it('handles a partial book at the end', () => { + expect(format([GEN, EXO, LEV], 'GEN1-50;EXO1-40;LEV1-10', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( + 'Genesis - Exodus and Leviticus (1-10)' + ); + }); + + it('does not merge full books across a partial book', () => { + expect(format([GEN, EXO, LEV, NUM], 'GEN1-50;EXO1-5;LEV1-27;NUM1-36', 'GEN1-50;EXO1-40;LEV1-27;NUM1-36')).toEqual( + 'Genesis, Exodus (1-5), and Leviticus - Numbers' + ); + }); + + it('shows the training chapter range for a partially-drafted book whose remaining chapters are all selected', () => { + // Draft the latter two-thirds (GEN18-50); the complete first third (GEN1-17) is available and fully selected. + expect(format([GEN], 'GEN1-17', 'GEN1-17', 'GEN18-50')).toEqual('Genesis (1-17)'); + }); + + it('formats non-contiguous selected chapters with spaces after commas', () => { + expect(format([GEN], 'GEN1-3,7', 'GEN1-50')).toEqual('Genesis (1-3, 7)'); + }); + + it('treats a book missing from the target training selection as full (defensive)', () => { + // EXO has no entry in the selection; it should not crash and should render as a full book. + expect(format([GEN, EXO], 'GEN1-50', 'GEN1-50;EXO1-40')).toEqual('Genesis - Exodus'); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts new file mode 100644 index 00000000000..16a4d998dfa --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts @@ -0,0 +1,82 @@ +import { Canon } from '@sillsdev/scripture'; +import { I18nService } from 'xforge-common/i18n.service'; +import { ChapterSet, VerboseScriptureRange } from '../../../shared/scripture-range'; + +/** + * A contiguous run of fully-used training books (rendered as a book range), or a single book that is only partly + * used as training data (rendered with its chapter range). Partial books are never absorbed into a range. + */ +export type TrainingBookSegment = + | { kind: 'range'; bookNumbers: number[] } + | { kind: 'partial'; bookId: string; chapterRange: string }; + +const EMPTY_CHAPTER_SET = new ChapterSet(''); + +/** + * Splits a list of training book numbers into display segments. + * + * A book counts as "full" (name only) when its training selection covers the whole book: every chapter that is + * either available for training *or* being drafted. Adjacent full books are grouped into a single `range` segment. + * A book whose selection is only part of the book is a `partial` segment carrying its chapter range, and it breaks + * any surrounding range (so a partial book in the middle of otherwise-full books is never collapsed into a range). + * + * Including the drafted chapters in the "whole book" baseline means a partially-drafted book always shows its + * training chapter range: if you draft the latter two-thirds of a book and train on the complete first third, the + * book is not fully used as training data, so its chapter range is shown. + */ +export function segmentTrainingBooks( + bookNumbers: number[], + selectedTargetTraining: VerboseScriptureRange, + availableTargetTraining: VerboseScriptureRange, + selectedDrafting: VerboseScriptureRange +): TrainingBookSegment[] { + const segments: TrainingBookSegment[] = []; + let pendingFullBooks: number[] = []; + const flushFullBooks = (): void => { + if (pendingFullBooks.length > 0) { + segments.push({ kind: 'range', bookNumbers: pendingFullBooks }); + pendingFullBooks = []; + } + }; + + for (const bookNumber of [...bookNumbers].sort((a, b) => a - b)) { + const bookId = Canon.bookNumberToId(bookNumber); + const selected = selectedTargetTraining.books.get(bookId); + const available = availableTargetTraining.books.get(bookId) ?? EMPTY_CHAPTER_SET; + const drafted = selectedDrafting.books.get(bookId) ?? EMPTY_CHAPTER_SET; + const wholeBook = available.union(drafted); + const isPartial = selected != null && wholeBook.difference(selected).count() > 0; + if (isPartial) { + flushFullBooks(); + segments.push({ kind: 'partial', bookId, chapterRange: selected!.toStringForDisplay() }); + } else { + pendingFullBooks.push(bookNumber); + } + } + flushFullBooks(); + return segments; +} + +/** + * Localized, conjunction-joined training-book list for the summary table (e.g. "Genesis, Exodus (1-10), and + * Leviticus"). Full books collapse into ranges; partial books show their chapters. + */ +export function formatTrainingBooksSummary( + bookNumbers: number[], + selectedTargetTraining: VerboseScriptureRange, + availableTargetTraining: VerboseScriptureRange, + selectedDrafting: VerboseScriptureRange, + i18n: I18nService +): string { + const labels = segmentTrainingBooks( + bookNumbers, + selectedTargetTraining, + availableTargetTraining, + selectedDrafting + ).map(segment => + segment.kind === 'range' + ? i18n.formatAndLocalizeBookRange(segment.bookNumbers) + : `${i18n.localizeBook(segment.bookId)} (${segment.chapterRange})` + ); + return i18n.enumerateList(labels); +} 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 813c9ebec19..6d8f5eef34b 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 @@ -230,6 +230,91 @@ "warning_generation_faulted": "Last generation attempt failed. Click [em]\"{{ generateButtonText }}\"[/em] below to try again.", "click_book_to_preview": "Click a book below to preview the draft and add it to your project." }, + "new_draft": { + "offline_message": "Draft generation is not available offline.", + "loading": "Loading…", + "step_indicator": "Step {{ step }} of {{ total }}", + "abort": { + "config_changed": "The draft configuration has changed. Please go back and try again.", + "project_syncing": "A project started syncing, which can change the available books and chapters. Please go back and try again.", + "no_access_list_heading": "The following projects could not be accessed:", + "no_access_instruction": "Check that you have access and try again.", + "no_access": "Some projects could not be accessed. Check that you have access and try again.", + "go_back": "Go back" + }, + "chapter_input": { + "available": "Available chapters: {{ range }}", + "invalid_range": "Invalid chapter range.", + "empty_draft": "Enter the chapters to draft, or deselect this book if you don't want to draft it.", + "empty_training": "Enter the chapters to use for training, or deselect this book if you don't want to train on it.", + "chapters_not_in_source": "Chapters {{ chapters }} cannot be generated because they are not in the source project, {{ sourceName }}.", + "chapters_not_in_target": "Chapters {{ chapters }} cannot be used for training because they are not in the main project, {{ targetName }}.", + "chapters_will_be_translated": "Chapters {{ chapters }} cannot be used for training because they are going to be translated." + }, + "change_source_configuration": "Change source configuration", + "books_hidden": { + "show_why": "{{ numBooks }} books are hidden. {{ underlineStart }}Show why{{ underlineEnd }}", + "hide": "{{ numBooks }} books are hidden. {{ underlineStart }}Hide explanation{{ underlineEnd }}" + }, + "training_files": "Training data files", + "fix_chapter_errors": "Please fix the chapter range errors above before continuing.", + "no_drafting_books_selected": "Select at least one book to draft before continuing.", + "no_training_books_selected": "Select training books from your project and at least one source project before continuing.", + "no_training_pair_selected": "Some of the books you selected to train on don't have a matching reference book selected. Select a matching reference book for each.", + "reference_books": "Reference books", + "summary": { + "example_translations": "Language training data", + "no_training_books": "No training books selected.", + "custom_configurations_apply": "Custom draft configurations have been applied by an administrator.", + "settings": "Settings", + "choose_different_books": "Choose different books", + "choose_different_training_data": "Choose different training data", + "example_translations_subtitle": "Scripture Forge uses training data (example translations) to learn how to translate from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} into {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}.", + "email_me": "Email me at {{ email }} when the draft is finished.", + "draft_title": "Draft {{ books }}", + "draft_books": "Draft books", + "training_books": "Training books", + "draft_books_subtitle": "The following books in {{ boldStart }}{{ source }}{{ boldEnd }} will be translated from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} to {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}" + }, + "pending_updates": { + "heading": "Sync before drafting", + "body": "These projects have changes in Paratext that aren't reflected here yet. Drafting without syncing may reduce draft quality.", + "sync": "Sync", + "sync_all": "Sync all", + "continue_anyway": "Continue anyway", + "synced": "Up to date", + "sync_failed": "Sync failed", + "retry": "Retry", + "cant_sync": "Has changes — ask a project admin to sync", + "wait_for_sync": "Wait for the sync to finish before continuing." + }, + "draft_books": { + "title": "Select books to draft", + "subtitle": "Which books in {{ boldStart }}{{ project }}{{ boldEnd }} do you want to translate from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} to {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}?", + "which_chapters": "Which chapters of each book do you want to draft?", + "partial_explanation": "Some of the chapters in the books you selected already have text.", + "learn_more": "Learn more about drafting part of a book.", + "excluded_no_source_content": "These books aren't available to draft because {{ source }} has no text for them: {{ books }}.", + "excluded_not_in_target": "These books aren't available to draft because they aren't in {{ project }} yet: {{ books }}.", + "no_available_books": "You have no books available for drafting." + }, + "training_books": { + "title": "Select books to train on", + "subtitle": "Which books in {{ boldStart }}{{ project }}{{ boldEnd }} are good enough to use to train the model to translate from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} to {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}?", + "which_chapters": "Which chapters of each book do you want to use to train the model?", + "partial_explanation": "Some of the chapters in the books you selected will be translated and cannot be used as training data.", + "learn_more": "Learn more about training on part of a book.", + "excluded_not_in_any_source": "These books aren't available to train on because they aren't in any of the reference projects: {{ books }}.", + "auto_selected": "Books that appear to be complete have been pre-selected. Please review and update the training data that should be included.", + "no_target_books": "No books from your project are available for training.", + "reference_books_will_appear": "Reference books will appear here as you select books from your project." + }, + "navigation": { + "back": "Back", + "next": "Next", + "generate_draft": "Generate Draft" + } + }, "draft_generation_steps": { "back": "Back", "books_are_hidden_hide_explanation": "{{ numBooks }} books are hidden. {{ underlineStart }}Hide explanation{{ underlineEnd }}", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.html b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.html new file mode 100644 index 00000000000..3be39aa72b3 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.html @@ -0,0 +1,24 @@ + +

+ science {{ t("title") }} + +

+ +

{{ t("description") }}

+ + @for (feature of availableExperimentalFeatures; track feature.name; let isLast = $last) { + +
{{ feature.name }}
+ @if (feature.description != null && feature.description !== "") { +
{{ feature.description }}
+ } +
+ } @empty { +

No experimental features are currently available.

+ } +
+
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.scss new file mode 100644 index 00000000000..68ca39a1196 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.scss @@ -0,0 +1,40 @@ +.mat-mdc-dialog-title { + display: flex; + align-items: center; + justify-content: space-between; +} + +.title-left { + display: flex; + align-items: center; + column-gap: 0.5em; +} + +.mat-mdc-dialog-content { + width: 40em; + max-width: 100%; +} + +.feature-checkbox { + padding-block: 0.75em; + width: 100%; + white-space: normal; + + ::ng-deep .mdc-form-field { + align-items: flex-start; + } + + ::ng-deep .mdc-label { + display: block; + padding-top: 10px; + } +} + +.feature-name { + font-size: 1.1em; + font-weight: 500; +} + +.feature-description { + margin-top: 0.25em; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.spec.ts new file mode 100644 index 00000000000..3eb34c7a5c7 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.spec.ts @@ -0,0 +1,142 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { DebugElement } from '@angular/core'; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; +import { By } from '@angular/platform-browser'; +import { mock, when } from 'ts-mockito'; +import { ChildViewContainerComponent, configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; +import { FeatureFlag } from '../feature-flags/feature-flag.service'; +import { ExperimentalFeaturesDialogComponent } from './experimental-features-dialog.component'; +import { ExperimentalFeature, ExperimentalFeaturesService } from './experimental-features.service'; + +const mockedExperimentalFeaturesService = mock(ExperimentalFeaturesService); + +describe('ExperimentalFeaturesDialogComponent', () => { + configureTestingModule(() => ({ + imports: [ExperimentalFeaturesDialogComponent, getTestTranslocoModule()], + providers: [{ provide: ExperimentalFeaturesService, useMock: mockedExperimentalFeaturesService }] + })); + + let overlayContainer: OverlayContainer; + + beforeEach(() => { + overlayContainer = TestBed.inject(OverlayContainer); + }); + + afterEach(() => { + // Prevents 'Error: Test did not clean up its overlay container content.' + overlayContainer.ngOnDestroy(); + }); + + it('Shows available experimental features and whether they are editable', fakeAsync(() => { + const env = new TestEnvironment(); + + expect(env.getFeatureNameText(0)).toBe('Feature A'); + expect(env.getMatCheckbox(0).disabled).toBeFalsy(); + expect(env.getMatCheckbox(0).checked).toBeTruthy(); + + expect(env.getFeatureNameText(1)).toBe('Feature B'); + expect(env.getMatCheckbox(1).disabled).toBeTruthy(); + expect(env.getMatCheckbox(1).checked).toBeFalsy(); + + expect(env.getDescriptionText(0)).toBe('Description A'); + expect(env.getDescriptionText(1)).toBe('Description B'); + })); + + it('Updates a feature flag when its checkbox is checked or unchecked', fakeAsync(() => { + const env = new TestEnvironment(); + expect(env.getFeatureEnabled(0)).toBeTruthy(); + + // SUT + env.clickMatCheckbox(0); + env.wait(); + + expect(env.getFeatureEnabled(0)).toBeFalsy(); + })); +}); + +class TestEnvironment { + private readonly fixture: ComponentFixture; + private readonly features: ExperimentalFeature[]; + + constructor() { + this.features = [ + { + name: 'Feature A', + description: 'Description A', + available: () => true, + featureFlag: { + key: 'FEATURE_A', + description: 'Feature A', + position: 0, + readonly: false, + enabled: true + } as FeatureFlag + }, + { + name: 'Feature B', + description: 'Description B', + available: () => true, + featureFlag: { + key: 'FEATURE_B', + description: 'Feature B', + position: 1, + readonly: true, + enabled: false + } as FeatureFlag + } + ]; + + when(mockedExperimentalFeaturesService.availableExperimentalFeatures).thenReturn(this.features); + + this.fixture = TestBed.createComponent(ChildViewContainerComponent); + const config: MatDialogConfig = { + viewContainerRef: this.fixture.componentInstance.childViewContainer + }; + + // SUT + TestBed.inject(MatDialog).open(ExperimentalFeaturesDialogComponent, config); + + this.wait(); + } + + get checkboxes(): DebugElement[] { + return this.fixture.debugElement.queryAll(By.directive(MatCheckbox)); + } + + get featureNames(): DebugElement[] { + return this.fixture.debugElement.queryAll(By.css('.feature-name')); + } + + get descriptions(): DebugElement[] { + return this.fixture.debugElement.queryAll(By.css('.feature-description')); + } + + getFeatureNameText(i: number): string { + return this.featureNames[i].nativeElement.textContent?.trim(); + } + + getMatCheckbox(i: number): MatCheckbox { + return this.checkboxes[i].injector.get(MatCheckbox); + } + + getDescriptionText(i: number): string { + return this.descriptions[i].nativeElement.textContent?.trim(); + } + + getFeatureEnabled(i: number): boolean { + return this.features[i].featureFlag.enabled; + } + + clickMatCheckbox(i: number): void { + const input: HTMLInputElement | null = this.checkboxes[i].nativeElement.querySelector('input'); + input!.click(); + } + + wait(): void { + tick(); + this.fixture.detectChanges(); + flush(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.ts new file mode 100644 index 00000000000..88522a81226 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.ts @@ -0,0 +1,34 @@ +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatIconButton } from '@angular/material/button'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatDialogClose, MatDialogContent, MatDialogTitle } from '@angular/material/dialog'; +import { MatDivider } from '@angular/material/divider'; +import { MatIcon } from '@angular/material/icon'; +import { TranslocoModule } from '@ngneat/transloco'; +import { ExperimentalFeature, ExperimentalFeaturesService } from './experimental-features.service'; + +@Component({ + templateUrl: './experimental-features-dialog.component.html', + styleUrls: ['./experimental-features-dialog.component.scss'], + imports: [ + TranslocoModule, + MatDialogTitle, + MatIcon, + MatIconButton, + CdkScrollable, + MatDialogContent, + MatDialogClose, + MatCheckbox, + MatDivider, + FormsModule + ] +}) +export class ExperimentalFeaturesDialogComponent { + constructor(readonly experimentalFeatures: ExperimentalFeaturesService) {} + + get availableExperimentalFeatures(): ExperimentalFeature[] { + return this.experimentalFeatures.availableExperimentalFeatures; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features.service.ts new file mode 100644 index 00000000000..c7b77548738 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; +import { FeatureFlag, FeatureFlagService } from '../feature-flags/feature-flag.service'; +import { SFUserProjectsService } from '../user-projects.service'; +import { UserService } from '../user.service'; + +/** Wraps a feature flag as an experimental feature, giving it a name, description, and availability check */ +export interface ExperimentalFeature { + name: string; + description: string; + available: () => boolean; + featureFlag: FeatureFlag; +} + +@Injectable({ providedIn: 'root' }) +export class ExperimentalFeaturesService { + constructor( + private readonly featureFlagService: FeatureFlagService, + private readonly userService: UserService, + private readonly userProjectsService: SFUserProjectsService + ) {} + + public experimentalFeatures: ExperimentalFeature[] = [ + { + name: 'Enable chapter-level drafting & training', + description: + 'Choose which chapters to generate, so that your existing translations of other chapters in the same book can be used to train the language model and improve draft quality.', + available: () => + this.doesUserHaveAnyOfRolesOnAnyProject([ + SFProjectRole.ParatextAdministrator, + SFProjectRole.ParatextTranslator + ]), + featureFlag: this.featureFlagService.partialBookDrafting + } + ]; + + public get availableExperimentalFeatures(): ExperimentalFeature[] { + return this.experimentalFeatures.filter(feature => feature.available()); + } + + public get showExperimentalFeaturesInMenu(): boolean { + return this.availableExperimentalFeatures.length > 0; + } + + /** Helper method for experimental features, since many of them will be limited to particular roles */ + private doesUserHaveAnyOfRolesOnAnyProject(roles: SFProjectRole[]): boolean { + const projectDocs = this.userProjectsService.projectDocs ?? []; + return projectDocs.some(projectDoc => { + const userRoleOnProject = projectDoc.data?.userRoles[this.userService.currentUserId]; + return roles.some(role => role === userRoleOnProject); + }); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts index 2813a0288da..61175518a29 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts @@ -379,6 +379,13 @@ export class FeatureFlagService { this.featureFlagStore ); + readonly partialBookDrafting: FeatureFlag = new FeatureFlagFromStorage( + 'PartialBookDrafting', + 'Partial book drafting', + 20, + this.featureFlagStore + ); + get featureFlags(): FeatureFlag[] { return Object.values(this).filter(value => value instanceof FeatureFlagFromStorage); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts index 8036dde8473..91dd65bf2df 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts @@ -124,6 +124,7 @@ export class I18nService { } private currentLocale$ = new BehaviorSubject(defaultLocale); + private conjunctionListFormatter: Intl.ListFormat | undefined; constructor( locationService: LocationService, @@ -204,6 +205,7 @@ export class I18nService { } this.currentLocale$.next(locale); + this.conjunctionListFormatter = undefined; this.transloco.setActiveLang(locale.canonicalTag); const date = new Date(); date.setFullYear(date.getFullYear() + 1); @@ -348,7 +350,16 @@ export class I18nService { } enumerateList(list: string[]): string { - return new (Intl as any).ListFormat(this.localeCode, { style: 'long', type: 'conjunction' }).format(list); + return this.getConjunctionListFormatter().format(list); + } + + enumerateListParts(list: string[]): { type: 'element' | 'literal'; value: string }[] { + return this.getConjunctionListFormatter().formatToParts(list); + } + + private getConjunctionListFormatter(): Intl.ListFormat { + this.conjunctionListFormatter ??= new Intl.ListFormat(this.localeCode, { style: 'long', type: 'conjunction' }); + return this.conjunctionListFormatter; } translateTextAroundTemplateTags(key: I18nKey, params: object = {}): TextAroundTemplate | undefined { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.spec.ts new file mode 100644 index 00000000000..e0bbaffa6f8 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.spec.ts @@ -0,0 +1,53 @@ +import { difference, intersection, union } from './set-util'; + +describe('set-util', () => { + describe('intersection', () => { + it('returns elements present in both sets', () => { + expect([...intersection(new Set([1, 2, 3]), new Set([2, 3, 4]))]).toEqual([2, 3]); + }); + + it('returns an empty set when there is no overlap', () => { + expect(intersection(new Set([1, 2]), new Set([3, 4])).size).toBe(0); + }); + + it('does not mutate the inputs', () => { + const a = new Set([1, 2, 3]); + const b = new Set([2, 3, 4]); + intersection(a, b); + expect([...a]).toEqual([1, 2, 3]); + expect([...b]).toEqual([2, 3, 4]); + }); + }); + + describe('union', () => { + it('returns elements present in either set, without duplicates', () => { + expect([...union(new Set([1, 2]), new Set([2, 3]))]).toEqual([1, 2, 3]); + }); + + it('does not mutate the inputs', () => { + const a = new Set([1, 2]); + const b = new Set([2, 3]); + union(a, b); + expect([...a]).toEqual([1, 2]); + expect([...b]).toEqual([2, 3]); + }); + }); + + describe('difference', () => { + it('returns elements in the first set but not the second', () => { + expect([...difference(new Set([1, 2, 3]), new Set([2, 3, 4]))]).toEqual([1]); + }); + + it('returns a copy of the first set when there is no overlap', () => { + expect([...difference(new Set([1, 2]), new Set([3, 4]))]).toEqual([1, 2]); + }); + + it('does not mutate the inputs', () => { + const a = new Set([1, 2, 3]); + const b = new Set([2, 3]); + difference(a, b); + expect([...a]).toEqual([1, 2, 3]); + expect([...b]).toEqual([2, 3]); + }); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.ts new file mode 100644 index 00000000000..f35015edc92 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.ts @@ -0,0 +1,34 @@ +/** + * Set helpers that mirror the ES2024 `Set.prototype` composition methods (intersection/union/difference), implemented + * without relying on those methods so the app keeps working on browsers that don't yet support them. Each returns a new + * Set and leaves the inputs unmodified. + */ + +/** Returns a new Set containing the elements present in both `a` and `b`. */ +export function intersection(a: ReadonlySet, b: ReadonlySet): Set { + // Iterate the smaller set for fewer membership checks; the result is the same either way. + const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a]; + const result = new Set(); + for (const value of smaller) { + if (larger.has(value)) result.add(value); + } + return result; +} + +/** Returns a new Set containing the elements present in either `a` or `b`. */ +export function union(a: ReadonlySet, b: ReadonlySet): Set { + const result = new Set(a); + for (const value of b) { + result.add(value); + } + return result; +} + +/** Returns a new Set containing the elements present in `a` but not in `b`. */ +export function difference(a: ReadonlySet, b: ReadonlySet): Set { + const result = new Set(a); + for (const value of b) { + result.delete(value); + } + return result; +} diff --git a/src/SIL.XForge.Scripture/Models/BookProgress.cs b/src/SIL.XForge.Scripture/Models/BookProgress.cs index 34e6bda2adc..5823a03c77d 100644 --- a/src/SIL.XForge.Scripture/Models/BookProgress.cs +++ b/src/SIL.XForge.Scripture/Models/BookProgress.cs @@ -19,4 +19,24 @@ public class BookProgress /// The number of blank verse segments in this book. /// public int BlankVerseSegments { get; set; } + + /// + /// The progress data for each chapter in this book. + /// + public ChapterProgress[] Chapters { get; set; } = []; +} + +public class ChapterProgress +{ + public int ChapterNumber { get; set; } + + /// + /// The total number of verse segments in this chapter. + /// + public int VerseSegments { get; set; } + + /// + /// The number of blank verse segments in this chapter. + /// + public int BlankVerseSegments { get; set; } } diff --git a/src/SIL.XForge.Scripture/Models/BuildConfig.cs b/src/SIL.XForge.Scripture/Models/BuildConfig.cs index a4d30451cce..a856a626cfd 100644 --- a/src/SIL.XForge.Scripture/Models/BuildConfig.cs +++ b/src/SIL.XForge.Scripture/Models/BuildConfig.cs @@ -20,6 +20,17 @@ public class BuildConfig /// The DataIds of the files to use as for training. public HashSet TrainingDataFiles { get; set; } = []; + /// + /// Gets or sets the DataIds of all the training data files that were available to choose from for this build. + /// + /// The DataIds of the files that were offered to the user, of which is the + /// selected subset. null if the client did not report the available files. + /// + /// When provided, this is persisted to so that a later + /// build can tell newly added files apart from files the user deliberately deselected. + /// + public HashSet? AvailableTrainingDataFiles { get; set; } + /// /// Gets or sets the per-project books and chapters to use for training. /// diff --git a/src/SIL.XForge.Scripture/Models/DraftConfig.cs b/src/SIL.XForge.Scripture/Models/DraftConfig.cs index 9fe143f6918..e1b60c2006a 100644 --- a/src/SIL.XForge.Scripture/Models/DraftConfig.cs +++ b/src/SIL.XForge.Scripture/Models/DraftConfig.cs @@ -8,6 +8,13 @@ public class DraftConfig public IList TrainingSources { get; set; } = []; public IList LastSelectedTrainingScriptureRanges { get; set; } = []; public IList LastSelectedTrainingDataFiles { get; set; } = []; + + /// + /// The training data files that were available to choose from at the time of the last build. Used to distinguish + /// newly added files (which default to selected) from files the user deliberately deselected. null for + /// builds made before this was recorded (an empty list means a build recorded that zero files were available). + /// + public IList? LastAvailableTrainingDataFiles { get; set; } public IList LastSelectedTranslationScriptureRanges { get; set; } = []; public bool? FastTraining { get; set; } public bool? UseEcho { get; set; } diff --git a/src/SIL.XForge.Scripture/Services/BuildConfigJsonConverter.cs b/src/SIL.XForge.Scripture/Services/BuildConfigJsonConverter.cs index f1299c95455..1895c6f8f05 100644 --- a/src/SIL.XForge.Scripture/Services/BuildConfigJsonConverter.cs +++ b/src/SIL.XForge.Scripture/Services/BuildConfigJsonConverter.cs @@ -31,6 +31,12 @@ public override void WriteJson(JsonWriter writer, BuildConfig? value, JsonSerial serializer.Serialize(writer, value.TrainingDataFiles); } + if (value.AvailableTrainingDataFiles is not null) + { + writer.WritePropertyName(nameof(value.AvailableTrainingDataFiles)); + serializer.Serialize(writer, value.AvailableTrainingDataFiles); + } + if (value.TrainingScriptureRanges.Count > 0) { writer.WritePropertyName(nameof(value.TrainingScriptureRanges)); diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index beb380977ed..502528e8d1a 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -2859,6 +2859,16 @@ await projectDoc.SubmitJson0OpAsync(op => [.. buildConfig.TrainingDataFiles], _listStringComparer ); + // Only record the available files when the client reported them, so that a null value continues to mean + // "no record" (i.e. a build made before this was tracked) rather than "zero files were available". + // No equality comparer here: the field starts null, and _listStringComparer cannot compare against null. + if (buildConfig.AvailableTrainingDataFiles is not null) + { + op.Set?>( + p => p.TranslateConfig.DraftConfig.LastAvailableTrainingDataFiles, + [.. buildConfig.AvailableTrainingDataFiles] + ); + } op.Set( p => p.TranslateConfig.DraftConfig.LastSelectedTrainingScriptureRanges, [.. buildConfig.TrainingScriptureRanges], diff --git a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs index 89b28bd5fa7..bf9a3884a3c 100644 --- a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs @@ -1160,6 +1160,16 @@ IList corporaSyncInfo options["max_steps"] = 20; } + string? targetProjectId = corporaSyncInfo + .FirstOrDefault(s => !s.IsSource && s.ParallelCorpusId == servalData.ParallelCorpusIdForTrainOn) + ?.ProjectId; + + // Only present when submitted by the new partial book drafting component, which includes the target + // project in TrainingScriptureRanges so that drafted chapters can be excluded from training. + string? explicitTargetRange = buildConfig + .TrainingScriptureRanges.FirstOrDefault(t => t.ProjectId == targetProjectId) + ?.ScriptureRange; + // Create the build configuration var translationBuildConfig = new TranslationBuildConfig { @@ -1211,50 +1221,43 @@ .. corporaSyncInfo .Select(s => new ParallelCorpusFilterConfig { CorpusId = s.CorpusId, - // NOTE: The scripture range will be set to match the matching source filter's scripture range below + ScriptureRange = explicitTargetRange, }), ], }, ], }; - // Set the training target filter scripture ranges to the same as the source filter scripture ranges - foreach (TrainingCorpusConfig trainingCorpusConfig in translationBuildConfig.TrainOn) + // The legacy draft UI does not specify an explicit target training range, so it is derived here from the + // union of the source training ranges. The new draft component always specifies a target training range, + // though it may be an empty string, which is NOT the same as not specifying one at all (an empty range means + // "train on no target books", so it must skip this fallback; hence `== null`, not IsNullOrEmpty). Once the + // legacy stepper is gone every build will specify a target range, so this fallback can be removed and the + // presence of a target range enforced instead. + if (explicitTargetRange == null) { - for (int j = 0; j < trainingCorpusConfig.SourceFilters?.Count; j++) + foreach (TrainingCorpusConfig trainingCorpusConfig in translationBuildConfig.TrainOn) { - if (trainingCorpusConfig.TargetFilters is not null) + if (trainingCorpusConfig.TargetFilters is null || trainingCorpusConfig.SourceFilters is null) + continue; + + for (int j = 0; j < trainingCorpusConfig.SourceFilters.Count; j++) { - if ( - trainingCorpusConfig.TargetFilters.Count > j - && trainingCorpusConfig.TargetFilters[j] is not null - ) + string sourceRange = trainingCorpusConfig.SourceFilters[j].ScriptureRange ?? string.Empty; + + if (j < trainingCorpusConfig.TargetFilters.Count) { - // Set the scripture range for the matching target filter - trainingCorpusConfig.TargetFilters[j].ScriptureRange = - trainingCorpusConfig.SourceFilters[j].ScriptureRange ?? string.Empty; + trainingCorpusConfig.TargetFilters[j].ScriptureRange = sourceRange; } - else if (trainingCorpusConfig.TargetFilters[0] is not null) + else { - // There is no matching target filter, so update the first target filter. - // Merge the two scripture ranges by getting the distinct book names. - // This will also preserve any chapter ranges that are specified. - trainingCorpusConfig.TargetFilters[0].ScriptureRange = string.Join( - ';', - string.Join( - ';', - trainingCorpusConfig.TargetFilters[0].ScriptureRange ?? string.Empty, - trainingCorpusConfig.SourceFilters[j].ScriptureRange ?? string.Empty - ) - .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct() - ); - - // Ensure that the scripture range is empty if it is null, so that no books will be trained on - if (string.IsNullOrWhiteSpace(trainingCorpusConfig.TargetFilters[0].ScriptureRange)) - { - trainingCorpusConfig.TargetFilters[0].ScriptureRange = string.Empty; - } + // There are more source filters than target filters; accumulate the overflow + // source ranges into TargetFilters[0] by merging and deduplicating. + string existingRange = trainingCorpusConfig.TargetFilters[0].ScriptureRange ?? string.Empty; + IEnumerable mergedEntries = $"{existingRange};{sourceRange}" + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(); + trainingCorpusConfig.TargetFilters[0].ScriptureRange = string.Join(';', mergedEntries); } } } diff --git a/src/SIL.XForge.Scripture/Services/SFProjectService.cs b/src/SIL.XForge.Scripture/Services/SFProjectService.cs index 3978b30ddc3..68ddad7f7ba 100644 --- a/src/SIL.XForge.Scripture/Services/SFProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/SFProjectService.cs @@ -2326,6 +2326,24 @@ public async Task GetProjectProgressAsync(string curUserId, stri { "_id", "$book" }, { "verseSegments", new BsonDocument("$sum", "$verseSegments") }, { "blankVerseSegments", new BsonDocument("$sum", "$blankVerseSegments") }, + { + "chapters", + new BsonDocument( + "$push", + new BsonDocument + { + { + "chapterNumber", + new BsonDocument( + "$arrayElemAt", + new BsonArray { new BsonDocument("$split", new BsonArray { "$_id", ":" }), 2 } + ) + }, + { "verseSegments", "$verseSegments" }, + { "blankVerseSegments", "$blankVerseSegments" }, + } + ) + }, } ) .ToListAsync(); @@ -2336,6 +2354,16 @@ public async Task GetProjectProgressAsync(string curUserId, stri BookId = doc["_id"].AsString, VerseSegments = doc["verseSegments"].AsInt32, BlankVerseSegments = doc["blankVerseSegments"].AsInt32, + Chapters = + [ + .. doc["chapters"] + .AsBsonArray.Select(chapterDoc => new ChapterProgress + { + ChapterNumber = int.Parse(chapterDoc["chapterNumber"].AsString), + VerseSegments = chapterDoc["verseSegments"].AsInt32, + BlankVerseSegments = chapterDoc["blankVerseSegments"].AsInt32, + }), + ], }) .ToArray(); diff --git a/test/SIL.XForge.Scripture.Tests/Services/BuildConfigJsonConverterTests.cs b/test/SIL.XForge.Scripture.Tests/Services/BuildConfigJsonConverterTests.cs index 5884aed237e..1c985750705 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/BuildConfigJsonConverterTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/BuildConfigJsonConverterTests.cs @@ -80,6 +80,37 @@ public void WriteJson_Serializes_BuildConfig_TrainingDataFiles() writer.Received().WriteEndObject(); } + [Test] + public void WriteJson_Serializes_BuildConfig_AvailableTrainingDataFiles() + { + var converter = new BuildConfigJsonConverter(); + var writer = Substitute.For(); + var serializer = Substitute.For(); + var buildConfig = new BuildConfig { AvailableTrainingDataFiles = [Data01, Data02] }; + + // SUT + converter.WriteJson(writer, buildConfig, serializer); + + writer.Received().WriteStartObject(); + writer.Received().WritePropertyName(nameof(buildConfig.AvailableTrainingDataFiles)); + serializer.Received().Serialize(writer, buildConfig.AvailableTrainingDataFiles); + writer.Received().WriteEndObject(); + } + + [Test] + public void WriteJson_OmitsAvailableTrainingDataFiles_WhenNull() + { + var converter = new BuildConfigJsonConverter(); + var writer = Substitute.For(); + var serializer = Substitute.For(); + var buildConfig = new BuildConfig { ProjectId = Project01 }; + + // SUT + converter.WriteJson(writer, buildConfig, serializer); + + writer.DidNotReceive().WritePropertyName(nameof(buildConfig.AvailableTrainingDataFiles)); + } + [Test] public void WriteJson_Serializes_BuildConfig_TrainingScriptureRanges() { @@ -230,6 +261,49 @@ public void ReadJson_Deserializes_JSON_Object_TrainingDataFiles() Assert.AreEqual(Project01, buildConfig.ProjectId); } + [Test] + public void ReadJson_Deserializes_JSON_Object_AvailableTrainingDataFiles() + { + var converter = new BuildConfigJsonConverter(); + const string jsonString = $$""" + { + "{{nameof(BuildConfig.ProjectId)}}":"{{Project01}}", + "{{nameof(BuildConfig.AvailableTrainingDataFiles)}}":["{{Data01}}","{{Data02}}"] + } + """; + using var stringReader = new StringReader(jsonString); + using var reader = new JsonTextReader(stringReader); + var serializer = new JsonSerializer(); + + // SUT + var buildConfig = converter.ReadJson(reader, typeof(BuildConfig), null, false, serializer); + + Assert.IsNotNull(buildConfig); + Assert.IsNotNull(buildConfig!.AvailableTrainingDataFiles); + CollectionAssert.AreEqual(new List { Data01, Data02 }, buildConfig.AvailableTrainingDataFiles!); + Assert.AreEqual(Project01, buildConfig.ProjectId); + } + + [Test] + public void ReadJson_LeavesAvailableTrainingDataFilesNull_WhenAbsent() + { + var converter = new BuildConfigJsonConverter(); + const string jsonString = $$""" + { + "{{nameof(BuildConfig.ProjectId)}}":"{{Project01}}" + } + """; + using var stringReader = new StringReader(jsonString); + using var reader = new JsonTextReader(stringReader); + var serializer = new JsonSerializer(); + + // SUT + var buildConfig = converter.ReadJson(reader, typeof(BuildConfig), null, false, serializer); + + Assert.IsNotNull(buildConfig); + Assert.IsNull(buildConfig!.AvailableTrainingDataFiles); + } + [Test] public void ReadJson_Deserializes_JSON_Object_TrainingScriptureRanges() { diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 3996e9fdd80..d8a64a25f93 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -4771,6 +4771,32 @@ await env.Service.StartPreTranslationBuildAsync( Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTrainingScriptureRanges); Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTrainingDataFiles); Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTranslationScriptureRanges); + // The client did not report the available files, so the record stays null (rather than an empty list) + Assert.IsNull(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastAvailableTrainingDataFiles); + } + + [Test] + public async Task StartPreTranslationBuildAsync_RecordsAvailableTrainingDataFilesWhenReported() + { + // Set up test environment + var env = new TestEnvironment(); + const string data02 = "data02"; + + // SUT + await env.Service.StartPreTranslationBuildAsync( + User01, + new BuildConfig + { + ProjectId = Project01, + TrainingDataFiles = { Data01 }, + AvailableTrainingDataFiles = [Data01, data02], + }, + CancellationToken.None + ); + + DraftConfig draftConfig = env.Projects.Get(Project01).TranslateConfig.DraftConfig; + Assert.AreEqual(new[] { Data01 }, draftConfig.LastSelectedTrainingDataFiles); + Assert.AreEqual(new[] { Data01, data02 }, draftConfig.LastAvailableTrainingDataFiles); } [Test] diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs index f26c7c53634..83ad38b6625 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs @@ -2275,6 +2275,69 @@ public void GetTranslationBuildConfig_TranslationScriptureRangesAndTrainingScrip ); } + [Test] + public void GetTranslationBuildConfig_UsesExplicitTargetTrainingScriptureRange() + { + // Set up test environment + var env = new TestEnvironment(); + var servalData = new ServalData + { + ParallelCorpusIdForPreTranslate = ParallelCorpus01, + ParallelCorpusIdForTrainOn = ParallelCorpus02, + }; + const string project03ScriptureRange = "LUK;JHN"; + const string project04ScriptureRange = "ACT;ROM"; + const string targetScriptureRange = "GEN;EXO"; + var buildConfig = new BuildConfig + { + TrainingScriptureRanges = + [ + new ProjectScriptureRange { ProjectId = Project03, ScriptureRange = project03ScriptureRange }, + new ProjectScriptureRange { ProjectId = Project04, ScriptureRange = project04ScriptureRange }, + new ProjectScriptureRange { ProjectId = Project02, ScriptureRange = targetScriptureRange }, + ], + }; + List corporaSyncInfo = + [ + new ServalCorpusSyncInfo + { + CorpusId = Corpus03, + IsSource = true, + ParallelCorpusId = ParallelCorpus02, + ProjectId = Project03, + }, + new ServalCorpusSyncInfo + { + CorpusId = Corpus04, + IsSource = true, + ParallelCorpusId = ParallelCorpus02, + ProjectId = Project04, + }, + new ServalCorpusSyncInfo + { + CorpusId = Corpus05, + IsSource = false, + ParallelCorpusId = ParallelCorpus02, + ProjectId = Project02, + }, + ]; + + // SUT + TranslationBuildConfig actual = env.Service.GetTranslationBuildConfig( + servalData, + servalConfig: null, + buildConfig, + corporaSyncInfo + ); + Assert.AreEqual( + targetScriptureRange, + actual + .TrainOn!.Single(c => c.ParallelCorpusId == ParallelCorpus02) + .TargetFilters!.Single(f => f.CorpusId == Corpus05) + .ScriptureRange + ); + } + [TestCase(false, false, MachineProjectService.SmtTransfer)] [TestCase(false, true, MachineProjectService.SmtTransfer)] [TestCase(true, false, MachineProjectService.Nmt)] From 0d88a916740ab3606dc48fc1e85ed9a00382be87 Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Thu, 25 Jun 2026 15:11:23 -0400 Subject: [PATCH 02/17] Code review: harden book-multi-select against changing project IDs --- .../book-multi-select/book-multi-select.component.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts index 0afcc560f3a..e370e312d98 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts @@ -102,9 +102,13 @@ export class BookMultiSelectComponent implements OnChanges { this.loaded = false; return; } - if (this.projectId !== this.loadedProgressProjectId) { - this.cachedProgress = await this.progressService.getProgress(this.projectId, { maxStalenessMs: 30_000 }); - this.loadedProgressProjectId = this.projectId; + const projectId = this.projectId; + if (projectId !== this.loadedProgressProjectId) { + const fetchedProgress = await this.progressService.getProgress(projectId, { maxStalenessMs: 30_000 }); + // Inputs may have changed while the fetch was in flight; drop stale results to avoid out-of-order writes. + if (projectId !== this.projectId) return; + this.cachedProgress = fetchedProgress; + this.loadedProgressProjectId = projectId; } progress = this.cachedProgress; } From ba6b5387c00d8812033c03f7c59ccf506f1a90e7 Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Thu, 25 Jun 2026 17:24:37 -0400 Subject: [PATCH 03/17] Fix from testing: improve parallelization of loading draft component --- .../new-draft/new-draft.component.ts | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts index 5aeb8d16ecd..a329bbb4875 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts @@ -145,6 +145,11 @@ export class NewDraftComponent { } async init(): Promise { + // Fetching the Paratext project list (for pending-update detection) doesn't depend on the project doc, so start it + // now to overlap with the init work below. The fetch is best-effort: offline (or any failure) resolves to + // undefined and detection is simply skipped. + const projectsPromise = this.fetchProjectsForPendingUpdates(); + const [, status] = await Promise.all([ this.userService.getCurrentUser().then(doc => (this.currentUserDoc = doc)), firstValueFrom(this.logicHandler.status$.pipe(filter(status => status === 'input' || status === 'abort'))) @@ -163,11 +168,12 @@ export class NewDraftComponent { const targetTag = this.activatedProjectService.projectDoc?.data?.writingSystem.tag; const draftingSourceTag = sources?.draftingSources[0]?.writingSystem?.tag; if (targetTag != null && draftingSourceTag != null) { - this.isTrainingOptional = - (await this.nllbLanguageService.isNllbLanguageAsync(targetTag)) && - (await this.nllbLanguageService.isNllbLanguageAsync(draftingSourceTag)); + const [targetIsNllb, sourceIsNllb] = await Promise.all([ + this.nllbLanguageService.isNllbLanguageAsync(targetTag), + this.nllbLanguageService.isNllbLanguageAsync(draftingSourceTag) + ]); + this.isTrainingOptional = targetIsNllb && sourceIsNllb; } - const draftConfig = this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig; this.sendEmailOnBuildFinished = draftConfig?.sendEmailOnBuildFinished ?? false; if (this.featureFlags.showDeveloperTools.enabled) { @@ -175,16 +181,14 @@ export class NewDraftComponent { this.useEcho = draftConfig?.useEcho ?? false; } - if (this.onlineStatusService.isOnline) { - await this.detectPendingUpdates(); - } + const projects = await projectsPromise; + if (projects != null) this.detectPendingUpdates(projects); if (this.pendingProjects.length > 0) { this.page = 'pending_updates'; } else { this.page = 'preface'; this.armSyncWatcher(); } - this.logicHandler.status$ .pipe( filter(s => s === 'abort'), @@ -272,7 +276,23 @@ export class NewDraftComponent { this.selectedTrainingDataFileIds = updated; } - private async detectPendingUpdates(): Promise { + /** + * Fetches the user's Paratext projects for pending-update detection. Detection is advisory: on failure, log and + * resolve to undefined so the caller proceeds rather than stranding the user. The catch is attached here (at the + * call site) so kicking this off eagerly can never leave a rejection unhandled on an early-return path. + */ + private fetchProjectsForPendingUpdates(): Promise { + if (!this.onlineStatusService.isOnline) return Promise.resolve(undefined); + return this.paratextService.getProjects().catch(error => { + this.errorReportingService.silentError( + 'Failed to check for pending Paratext updates before drafting', + ErrorReportingService.normalizeError(error) + ); + return undefined; + }); + } + + private detectPendingUpdates(projects: ParatextProject[]): void { const sources = this.logicHandler.sources; const projectId = this.initData!.projectId; const involvedIds = new Set([ @@ -280,19 +300,7 @@ export class NewDraftComponent { ...(sources?.draftingSources.map(s => s.projectRef) ?? []), ...(sources?.trainingSources.map(s => s.projectRef) ?? []) ]); - - let projects: ParatextProject[] | undefined; - try { - projects = await this.paratextService.getProjects(); - } catch (error) { - // Detection is advisory; if we can't reach Paratext, proceed rather than stranding the user. - this.errorReportingService.silentError( - 'Failed to check for pending Paratext updates before drafting', - ErrorReportingService.normalizeError(error) - ); - return; - } - this.pendingProjects = (projects ?? []) + this.pendingProjects = projects .filter(p => p.projectId != null && involvedIds.has(p.projectId) && p.isConnected && p.hasUpdate) .map(p => ({ projectId: p.projectId!, From 02c7206751747f5abf03a7753619e40a1949ec80 Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Thu, 25 Jun 2026 22:46:46 -0400 Subject: [PATCH 04/17] Fix book-multi-select erroring when offline Test team noted that when going to the next step while offline an error was shown due to book-multi-select fetching progress. Since progress is not critical to the functionality, I've changed it to wait until the user is online to fetch it. --- .../book-multi-select.component.html | 12 +- .../book-multi-select.component.scss | 7 - .../book-multi-select.component.spec.ts | 142 ++++++++++--- .../book-multi-select.component.ts | 195 +++++++++--------- .../draft-generation-steps.component.html | 4 +- .../draft-generation-steps.component.spec.ts | 1 + .../draft-import-wizard.component.html | 1 - .../draft-onboarding-form.component.html | 4 +- .../new-draft/new-draft.component.html | 4 +- .../src/assets/i18n/non_checking_en.json | 1 - 10 files changed, 219 insertions(+), 152 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html index afa2291cb7a..b59cc07bbdc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html @@ -1,10 +1,10 @@ - @if (!readonly && (projectName != null || (!basicMode && availableBooks.length > 0))) { + @if (!readonly && (projectName != null || (bulkBookSelection && availableBooks.length > 0))) {
@if (projectName != null) { {{ projectName }} } - @if (!basicMode && availableBooks.length > 0) { + @if (bulkBookSelection && availableBooks.length > 0) {
- @if (book.progress != null && basicMode === false) { + @if (book.progress != null && showProgress) { }
- @if (!loaded) { -
- - {{ t("loading_message") }} -
- } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss index 6751ac2faa2..e8beb9feab3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss @@ -28,10 +28,3 @@ background: var(--sf-book-border-fill); } } - -.loading-message { - display: flex; - gap: 0.5em; - align-items: center; - margin-block: 1em; -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts index 01a6758ae86..0ea51884f78 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +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 { anything, mock, verify, when } from 'ts-mockito'; import { I18nService } from 'xforge-common/i18n.service'; import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; @@ -12,6 +15,7 @@ const mockedI18nService = mock(I18nService); describe('BookMultiSelectComponent', () => { let component: BookMultiSelectComponent; let fixture: ComponentFixture; + let onlineStatus: TestOnlineStatusService; let mockBooks: Book[]; let mockSelectedBooks: Book[]; @@ -19,6 +23,8 @@ describe('BookMultiSelectComponent', () => { configureTestingModule(() => ({ imports: [getTestTranslocoModule()], providers: [ + provideTestOnlineStatus(), + { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: ProgressService, useMock: mockedProgressService }, { provide: I18nService, useMock: mockedI18nService } ] @@ -28,7 +34,14 @@ describe('BookMultiSelectComponent', () => { return { number: bookNum, selected: false }; } - beforeEach(() => { + // Progress is fetched asynchronously; wait for the pipeline to settle and re-run change detection. + async function settle(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + beforeEach(async () => { mockBooks = [book(1), book(2), book(3), book(40), book(42), book(67), book(70)]; mockSelectedBooks = [ { number: 1, selected: true }, @@ -49,22 +62,26 @@ describe('BookMultiSelectComponent', () => { fixture = TestBed.createComponent(BookMultiSelectComponent); component = fixture.componentInstance; + onlineStatus = TestBed.inject(OnlineStatusService) as TestOnlineStatusService; component.availableBooks = mockBooks; component.selectedBooks = mockSelectedBooks; component.projectId = 'test-project-id'; - fixture.detectChanges(); + // Mirror the "full" usage: fetch/show progress and offer the testament select-all checkboxes. + component.showProgress = true; + component.bulkBookSelection = true; + // Inputs are set imperatively on the root fixture, so Angular won't call ngOnChanges for us; push them ourselves. + component.ngOnChanges(); + await settle(); }); it('supports providing project name', async () => { - await component.ngOnChanges(); expect(fixture.nativeElement.querySelector('.project-name')).toBeNull(); component.projectName = 'Test Project'; - fixture.detectChanges(); + await settle(); expect(fixture.nativeElement.querySelector('.project-name')).not.toBeNull(); }); - it('should initialize book options on ngOnChanges', async () => { - await component.ngOnChanges(); + it('should initialize book options', () => { expect(component.bookOptions).toEqual([ { bookNum: 1, bookId: 'GEN', selected: true, progress: 0.0 }, { bookNum: 2, bookId: 'EXO', selected: false, progress: 0.15 }, @@ -80,7 +97,8 @@ describe('BookMultiSelectComponent', () => { for (let i = 0; i < 5; i++) { component.availableBooks = [...mockBooks]; component.selectedBooks = [...mockSelectedBooks]; - await component.ngOnChanges(); + component.ngOnChanges(); + await settle(); } verify(mockedProgressService.getProgress('test-project-id', anything())).once(); @@ -89,15 +107,70 @@ describe('BookMultiSelectComponent', () => { it('re-fetches progress when the project changes', async () => { component.projectId = 'a-different-project-id'; - await component.ngOnChanges(); + component.ngOnChanges(); + await settle(); verify(mockedProgressService.getProgress('a-different-project-id', anything())).once(); expect().nothing(); }); + it('does not fetch progress when showProgress is false', async () => { + fixture = TestBed.createComponent(BookMultiSelectComponent); + component = fixture.componentInstance; + component.availableBooks = mockBooks; + component.selectedBooks = mockSelectedBooks; + component.projectId = 'no-progress-project'; + component.showProgress = false; + component.ngOnChanges(); + await settle(); + + verify(mockedProgressService.getProgress('no-progress-project', anything())).never(); + expect(component.bookOptions.length).toBe(mockBooks.length); + expect(component.bookOptions.every(b => b.progress == null)).toBe(true); + }); + + it('does not crash or fetch when offline, and still renders the books', async () => { + onlineStatus.setIsOnline(false); + component.projectId = 'offline-project'; + component.ngOnChanges(); + await settle(); + + verify(mockedProgressService.getProgress('offline-project', anything())).never(); + expect(component.bookOptions.length).toBe(mockBooks.length); + }); + + it('fetches progress once the connection returns', async () => { + onlineStatus.setIsOnline(false); + component.projectId = 'reconnect-project'; + component.ngOnChanges(); + await settle(); + verify(mockedProgressService.getProgress('reconnect-project', anything())).never(); + + onlineStatus.setIsOnline(true); + await settle(); + verify(mockedProgressService.getProgress('reconnect-project', anything())).once(); + expect().nothing(); + }); + + it('does not re-fetch progress when the connection drops and returns after it has loaded', async () => { + // beforeEach already fetched progress for 'test-project-id' while online. + verify(mockedProgressService.getProgress('test-project-id', anything())).once(); + + onlineStatus.setIsOnline(false); + await settle(); + onlineStatus.setIsOnline(true); + await settle(); + + // Online status is only a gate for the initial fetch; toggling it must not trigger another fetch. + verify(mockedProgressService.getProgress('test-project-id', anything())).once(); + expect().nothing(); + }); + it('should not crash when texts have not yet loaded', async () => { when(mockedProgressService.getProgress(anything(), anything())).thenResolve(new ProjectProgress([])); - await component.ngOnChanges(); + component.projectId = 'empty-progress-project'; + component.ngOnChanges(); + await settle(); expect(component.bookOptions).toEqual([ { bookNum: 1, bookId: 'GEN', selected: true, progress: null }, @@ -110,73 +183,76 @@ describe('BookMultiSelectComponent', () => { ]); }); - it('can select all OT books and clear all OT books', async () => { + it('can select all OT books and clear all OT books', () => { expect(component.selectedBooks.length).toEqual(2); - await component.select('OT', true); + component.select('OT', true); expect(component.selectedBooks.length).toEqual(3); - await component.select('OT', false); + component.select('OT', false); expect(component.selectedBooks.length).toEqual(0); }); - it('can select all NT books and clear all NT books', async () => { + it('can select all NT books and clear all NT books', () => { expect(component.selectedBooks.length).toEqual(2); - await component.select('NT', true); + component.select('NT', true); expect(component.selectedBooks.length).toEqual(4); - await component.select('NT', false); + component.select('NT', false); expect(component.selectedBooks.length).toEqual(2); }); - it('can select all DC books and clear all DC books', async () => { + it('can select all DC books and clear all DC books', () => { expect(component.selectedBooks.length).toEqual(2); - await component.select('DC', true); + component.select('DC', true); expect(component.selectedBooks.length).toEqual(4); - await component.select('DC', false); + component.select('DC', false); expect(component.selectedBooks.length).toEqual(2); }); it('should show checkboxes for OT, NT, and DC as indeterminate when only some books from that category are selected', async () => { - await component.select('OT', false); + component.select('OT', false); component.selectedBooks = [{ number: 1, selected: true }]; - await component.ngOnChanges(); - fixture.detectChanges(); + component.ngOnChanges(); + await settle(); expect(component.partialOT).toBe(true); expect(component.partialNT).toBe(false); expect(component.partialDC).toBe(false); - await component.select('OT', false); + component.select('OT', false); component.selectedBooks = [{ number: 40, selected: true }]; - await component.ngOnChanges(); - fixture.detectChanges(); + component.ngOnChanges(); + await settle(); expect(component.partialOT).toBe(false); expect(component.partialNT).toBe(true); expect(component.partialDC).toBe(false); - await component.select('NT', false); + component.select('NT', false); component.selectedBooks = [{ number: 67, selected: true }]; - await component.ngOnChanges(); - fixture.detectChanges(); + component.ngOnChanges(); + await settle(); expect(component.partialOT).toBe(false); expect(component.partialOT).toBe(false); expect(component.partialDC).toBe(true); }); - it('can hide checkboxes and progress in basic mode', async () => { - await component.ngOnChanges(); - fixture.detectChanges(); + it('hides the progress bars when showProgress is false', async () => { expect(fixture.nativeElement.querySelector('.book-multi-select .border-fill')).not.toBeNull(); - expect(fixture.nativeElement.querySelector('.scope-selection')).not.toBeNull(); - component.basicMode = true; - fixture.detectChanges(); + component.showProgress = false; + await settle(); expect(fixture.nativeElement.querySelector('.book-multi-select .border-fill')).toBeNull(); + }); + + it('hides the testament checkboxes when bulkBookSelection is false', async () => { + expect(fixture.nativeElement.querySelector('.scope-selection')).not.toBeNull(); + component.bulkBookSelection = false; + await settle(); expect(fixture.nativeElement.querySelector('.scope-selection')).toBeNull(); }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts index e370e312d98..0ef68c9b6ab 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts @@ -1,12 +1,15 @@ -import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { Component, DestroyRef, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatChipListbox, MatChipOption } from '@angular/material/chips'; -import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslocoModule } from '@ngneat/transloco'; import { Canon } from '@sillsdev/scripture'; import { cloneDeep, isEqual } from 'lodash-es'; +import { BehaviorSubject, combineLatest, from, Observable, of } from 'rxjs'; +import { catchError, distinctUntilChanged, filter, map, startWith, switchMap, take } from 'rxjs/operators'; import { L10nPercentPipe } from 'xforge-common/l10n-percent.pipe'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { estimatedActualBookProgress, ProgressService, ProjectProgress } from '../progress-service/progress.service'; import { Book } from './book-multi-select'; @@ -20,47 +23,33 @@ export interface BookOption { type Scope = 'OT' | 'NT' | 'DC'; +/** The inputs that, when changed, require the rendered view model to be rebuilt. */ +interface RenderInputs { + availableBooks: Book[]; + selectedBooks: Book[]; + projectId?: string; + showProgress: boolean; +} + @Component({ selector: 'app-book-multi-select', templateUrl: './book-multi-select.component.html', - imports: [ - MatCheckbox, - MatChipListbox, - MatChipOption, - MatTooltip, - MatProgressSpinner, - TranslocoModule, - L10nPercentPipe - ], + imports: [MatCheckbox, MatChipListbox, MatChipOption, MatTooltip, TranslocoModule, L10nPercentPipe], styleUrls: ['./book-multi-select.component.scss'] }) -export class BookMultiSelectComponent implements OnChanges { +export class BookMultiSelectComponent implements OnInit, OnChanges { @Input() availableBooks: Book[] = []; @Input() selectedBooks: Book[] = []; @Input() readonly: boolean = false; - /** The ID of the project to get the progress. */ + /** The ID of the project to get the progress. Required for {@link showProgress} to have any effect. */ @Input() projectId?: string; @Input() projectName?: string; - @Input() basicMode: boolean = false; + /** Whether to fetch and display per-book translation progress. Requires {@link projectId}. */ + @Input() showProgress: boolean = false; + /** Whether to show the OT/NT/DC "select whole testament" checkboxes. */ + @Input() bulkBookSelection: boolean = false; @Output() bookSelect = new EventEmitter(); - protected loaded = false; - - // Fetch progress only when projectId changes, not on every ngOnChanges. If a consumer binds inputs to getters that - // return new array references each change-detection pass, the awaited fetch would reschedule change detection on - // every pass and loop forever (browser freeze). - private cachedProgress?: ProjectProgress; - private loadedProgressProjectId?: string; - - /** Deep copy of the inputs initBookOptions last ran on, used to skip redundant rebuilds (see ngOnChanges). */ - private previousInputs?: { - availableBooks: Book[]; - selectedBooks: Book[]; - projectId?: string; - basicMode: boolean; - readonly: boolean; - }; - bookOptions: BookOption[] = []; booksOT: Book[] = []; @@ -77,69 +66,46 @@ export class BookMultiSelectComponent implements OnChanges { selectedAllNT: boolean = false; selectedAllDC: boolean = false; - constructor(private readonly progressService: ProgressService) {} - - ngOnChanges(): void { - const inputs = { - availableBooks: this.availableBooks, - selectedBooks: this.selectedBooks, - projectId: this.projectId, - basicMode: this.basicMode, - readonly: this.readonly - }; - // Compare by content: new array refs with identical content don't trigger a rebuild. Deep copy so in-place mutations are still detected. - if (isEqual(inputs, this.previousInputs)) return; - this.previousInputs = cloneDeep(inputs); - void this.initBookOptions(); + // Inputs and internal selection changes are pushed here; the ngOnInit pipeline reacts to them. + private readonly renderInputs$ = new BehaviorSubject(this.currentInputs()); + + constructor( + private readonly progressService: ProgressService, + private readonly onlineStatusService: OnlineStatusService, + private readonly destroyRef: DestroyRef + ) {} + + ngOnInit(): void { + // Compare by content, not reference: consumers may bind getters that return a new array each CD pass, which would + // otherwise rebuild (and re-fetch) every pass. + const distinctInputs$ = this.renderInputs$.pipe(distinctUntilChanged(isEqual)); + + // Progress depends only on the project, so isolate it from other input changes. + const progress$: Observable = distinctInputs$.pipe( + map(inputs => (inputs.showProgress ? inputs.projectId : undefined)), + distinctUntilChanged(), + switchMap(projectId => { + if (projectId == null) return of(undefined); + // Wait until online, then fetch once. take(1) makes online status a one-time gate, not a re-fetch trigger + // startWith renders the book list now instead of blocking on the fetch (matters when offline); a failed fetch + // falls back to no progress. + return this.onlineStatusService.onlineStatus$.pipe( + filter(isOnline => isOnline), + take(1), + switchMap(() => from(this.progressService.getProgress(projectId, { maxStalenessMs: 30_000 }))), + catchError(() => of(undefined)), + startWith(undefined) + ); + }) + ); + + combineLatest([distinctInputs$, progress$]) + .pipe(quietTakeUntilDestroyed(this.destroyRef)) + .subscribe(([inputs, progress]) => this.rebuild(inputs, progress)); } - async initBookOptions(): Promise { - // Only load progress if not in basic mode - let progress: ProjectProgress | undefined; - if (this.basicMode === false) { - // projectId may arrive asynchronously; show loading state until it arrives. - if (this.projectId == null) { - this.loaded = false; - return; - } - const projectId = this.projectId; - if (projectId !== this.loadedProgressProjectId) { - const fetchedProgress = await this.progressService.getProgress(projectId, { maxStalenessMs: 30_000 }); - // Inputs may have changed while the fetch was in flight; drop stale results to avoid out-of-order writes. - if (projectId !== this.projectId) return; - this.cachedProgress = fetchedProgress; - this.loadedProgressProjectId = projectId; - } - progress = this.cachedProgress; - } - this.loaded = true; - - const progressByBookNum = (progress?.books ?? []).map(b => ({ - bookNum: Canon.bookIdToNumber(b.bookId), - progress: estimatedActualBookProgress(b) - })); - - this.bookOptions = this.availableBooks.map((book: Book) => ({ - bookNum: book.number, - bookId: Canon.bookNumberToId(book.number), - selected: this.selectedBooks.find(b => book.number === b.number) !== undefined, - progress: progressByBookNum.find(p => p.bookNum === book.number)?.progress ?? null - })); - - this.booksOT = this.selectedBooks.filter(n => Canon.isBookOT(n.number)); - this.availableBooksOT = this.availableBooks.filter(n => Canon.isBookOT(n.number)); - this.booksNT = this.selectedBooks.filter(n => Canon.isBookNT(n.number)); - this.availableBooksNT = this.availableBooks.filter(n => Canon.isBookNT(n.number)); - this.booksDC = this.selectedBooks.filter(n => Canon.isBookDC(n.number)); - this.availableBooksDC = this.availableBooks.filter(n => Canon.isBookDC(n.number)); - - this.selectedAllOT = this.booksOT.length > 0 && this.booksOT.length === this.availableBooksOT.length; - this.selectedAllNT = this.booksNT.length > 0 && this.booksNT.length === this.availableBooksNT.length; - this.selectedAllDC = this.booksDC.length > 0 && this.booksDC.length === this.availableBooksDC.length; - - this.partialOT = !this.selectedAllOT && this.booksOT.length > 0; - this.partialNT = !this.selectedAllNT && this.booksNT.length > 0; - this.partialDC = !this.selectedAllDC && this.booksDC.length > 0; + ngOnChanges(): void { + this.renderInputs$.next(this.currentInputs()); } onChipListChange(book: BookOption): void { @@ -149,7 +115,7 @@ export class BookMultiSelectComponent implements OnChanges { .filter(n => n.selected) .map(n => ({ number: n.bookNum, selected: this.bookOptions[bookIndex].selected })); this.bookSelect.emit(this.selectedBooks.map(b => b.number)); - void this.initBookOptions(); + this.renderInputs$.next(this.currentInputs()); } isBookInScope(bookNum: number, scope: Scope): boolean { @@ -159,7 +125,7 @@ export class BookMultiSelectComponent implements OnChanges { throw new Error('Invalid scope'); } - async select(scope: Scope, value: boolean): Promise { + select(scope: Scope, value: boolean): void { if (value) { this.selectedBooks.push( ...this.availableBooks.filter( @@ -169,7 +135,7 @@ export class BookMultiSelectComponent implements OnChanges { } else { this.selectedBooks = this.selectedBooks.filter(n => !this.isBookInScope(n.number, scope)); } - await this.initBookOptions(); + this.renderInputs$.next(this.currentInputs()); this.bookSelect.emit(this.selectedBooks.map(b => b.number)); } @@ -192,4 +158,43 @@ export class BookMultiSelectComponent implements OnChanges { normalizeRatioForDisplay(ratio: number): number { return ratio > 0.99 && ratio < 1 ? 0.99 : ratio; } + + private currentInputs(): RenderInputs { + // Deep copy so distinctUntilChanged still detects a later in-place mutation of an input array. + return cloneDeep({ + availableBooks: this.availableBooks, + selectedBooks: this.selectedBooks, + projectId: this.projectId, + showProgress: this.showProgress + }); + } + + private rebuild(inputs: RenderInputs, progress: ProjectProgress | undefined): void { + const progressByBookNum = (progress?.books ?? []).map(b => ({ + bookNum: Canon.bookIdToNumber(b.bookId), + progress: estimatedActualBookProgress(b) + })); + + this.bookOptions = inputs.availableBooks.map((book: Book) => ({ + bookNum: book.number, + bookId: Canon.bookNumberToId(book.number), + selected: inputs.selectedBooks.find(b => book.number === b.number) !== undefined, + progress: progressByBookNum.find(p => p.bookNum === book.number)?.progress ?? null + })); + + this.booksOT = inputs.selectedBooks.filter(n => Canon.isBookOT(n.number)); + this.availableBooksOT = inputs.availableBooks.filter(n => Canon.isBookOT(n.number)); + this.booksNT = inputs.selectedBooks.filter(n => Canon.isBookNT(n.number)); + this.availableBooksNT = inputs.availableBooks.filter(n => Canon.isBookNT(n.number)); + this.booksDC = inputs.selectedBooks.filter(n => Canon.isBookDC(n.number)); + this.availableBooksDC = inputs.availableBooks.filter(n => Canon.isBookDC(n.number)); + + this.selectedAllOT = this.booksOT.length > 0 && this.booksOT.length === this.availableBooksOT.length; + this.selectedAllNT = this.booksNT.length > 0 && this.booksNT.length === this.availableBooksNT.length; + this.selectedAllDC = this.booksDC.length > 0 && this.booksDC.length === this.availableBooksDC.length; + + this.partialOT = !this.selectedAllOT && this.booksOT.length > 0; + this.partialNT = !this.selectedAllNT && this.booksNT.length > 0; + this.partialDC = !this.selectedAllDC && this.booksDC.length > 0; + } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html index 7091a05cdf9..df0a5e21209 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.html @@ -53,7 +53,6 @@

{{ t("choose_books_to_translate_title") }}

@@ -138,6 +137,8 @@

{{ t("translated_books") }}

[selectedBooks]="selectedTrainingBooksByProj(activatedProject.projectId)" [projectName]="targetProjectName" [projectId]="activatedProject.projectId" + [showProgress]="true" + [bulkBookSelection]="true" (bookSelect)="onTranslatedBookSelect($event)" data-test-id="draft-stepper-training-books" > @@ -196,7 +197,6 @@

{{ t("reference_books") }}

} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts index 381e2457e05..a277b26ebd8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts @@ -87,6 +87,7 @@ describe('DraftGenerationStepsComponent', () => { ]) ); when(mockOnlineStatusService.isOnline).thenReturn(true); + when(mockOnlineStatusService.onlineStatus$).thenReturn(of(true)); })); describe('ngOnInit', () => { 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 e0b30eeacd4..d3eea10ed6b 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 @@ -194,7 +194,6 @@

{{ t("confirm_books_to_import") }}

diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html index cec35d856e4..32530a5dd1c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html @@ -99,7 +99,8 @@

{{ t("title") }}

[availableBooks]="projectBooks" [selectedBooks]="selectedCompletedBooks" (bookSelect)="onCompletedBooksSelect($event)" - [basicMode]="false" + [showProgress]="true" + [bulkBookSelection]="true" [projectName]="currentProjectDisplayName" [projectId]="currentProjectId" > @@ -168,7 +169,6 @@

{{ t("title") }}

[availableBooks]="allCanonicalBooks" [selectedBooks]="selectedSubmittedBooks" (bookSelect)="onSubmittedBooksSelect($event)" - [basicMode]="true" > @if (showPlannedBooksRequiredError) { {{ t("planned_books_required") }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html index b606fab1b92..b3b2e51021b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html @@ -60,7 +60,6 @@

{{ t("draft_books.title") }}

} @@ -127,6 +126,8 @@

{{ t("training_books.title") }}

[selectedBooks]="selectedTargetTrainingBooks" [projectId]="initData.projectId" [projectName]="targetProjectDisplayName" + [showProgress]="true" + [bulkBookSelection]="true" (bookSelect)="onTargetTrainingBookSelect($event)" > } @@ -181,7 +182,6 @@

{{ t("reference_books") }}

@for (source of trainingSources; track source.projectRef) { @let availableSourceBooks = availableTrainingSourceBooksForProject(source.projectRef); Date: Thu, 25 Jun 2026 22:54:16 -0400 Subject: [PATCH 05/17] Move generate draft submit spinner to right of button --- .../draft-generation/new-draft/new-draft.component.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html index b3b2e51021b..bdc7bd33f08 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html @@ -344,9 +344,6 @@

Developer options

@if (page === "suffix") {
- @if (submitting) { -
- } + @if (submitting) { +
+ }
} @else { } @else if (page === "draft_books") {

{{ t("draft_books.title") }}

@@ -205,7 +205,7 @@

{{ t("training_files") }}

}
} - } @else if (page === "suffix") { + } @else if (page === "summary") {

{{ t("summary.draft_title", { books: draftingBookNamesFormatted }) }}

@@ -342,7 +342,7 @@

Developer options

- @if (page === "suffix") { + @if (page === "summary") {
@@ -170,7 +170,7 @@

{{ t("training_books.which_chapters") }}

/> {{ t("chapter_input.available", { range: targetTrainingChapterHint(bookId) }) }} - @if (targetTrainingChapterErrors.get(bookId); as error) { + @for (error of targetTrainingChapterErrors.get(bookId) ?? []; track error.key) { {{ t(error.key, error.params) }} } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss index 27733e437d9..61f45c3542b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss @@ -91,6 +91,7 @@ app-copyright-banner { .chapter-error { color: var(--mat-form-field-error-text-color); padding-left: 16px; + font-size: 0.75rem; } .step-error { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts index e4a84bc5a27..505c18f9256 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts @@ -50,7 +50,7 @@ describe('NewDraftComponent', () => { env.component.onDraftingChaptersBlurred('GEN', 'abc'); - expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.invalid_range'); + expect(env.component.draftingChapterErrors.get('GEN')?.[0]?.key).toBe('chapter_input.invalid_range'); }); it('rejects empty or whitespace-only input without changing the drafting selection', async () => { @@ -60,10 +60,10 @@ describe('NewDraftComponent', () => { const defaultRange = env.selectedDraftingScriptureRange; env.component.onDraftingChaptersBlurred('GEN', ''); - expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + expect(env.component.draftingChapterErrors.get('GEN')?.[0]?.key).toBe('chapter_input.empty_draft'); env.component.onDraftingChaptersBlurred('GEN', ' '); - expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + expect(env.component.draftingChapterErrors.get('GEN')?.[0]?.key).toBe('chapter_input.empty_draft'); // The book keeps its prior (non-empty) range rather than being stored with zero chapters. expect(env.selectedDraftingScriptureRange).toBe(defaultRange); @@ -79,11 +79,11 @@ describe('NewDraftComponent', () => { const defaultRange = env.selectedDraftingScriptureRange; env.component.onDraftingChaptersBlurred('GEN', ','); - expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + expect(env.component.draftingChapterErrors.get('GEN')?.[0]?.key).toBe('chapter_input.empty_draft'); // The Arabic comma is normalized to a list separator too, so it has the same empty result. env.component.onDraftingChaptersBlurred('GEN', '،'); - expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + expect(env.component.draftingChapterErrors.get('GEN')?.[0]?.key).toBe('chapter_input.empty_draft'); // The book must keep its prior range rather than collapsing to a whole-book 'GEN' selection. expect(env.selectedDraftingScriptureRange).toBe(defaultRange); @@ -98,9 +98,58 @@ describe('NewDraftComponent', () => { // GEN has only 50 chapters in the source env.component.onDraftingChaptersBlurred('GEN', '51-60'); - const error = env.component.draftingChapterErrors.get('GEN'); - expect(error?.key).toBe('chapter_input.chapters_not_in_source'); - expect(error?.params).toEqual(jasmine.objectContaining({ chapters: '51-60', sourceName: SOURCE_SHORT_NAME })); + const errors = env.component.draftingChapterErrors.get('GEN'); + expect(errors?.length).toBe(1); + expect(errors?.[0]?.key).toBe('chapter_input.chapters_not_in_source'); + expect(errors?.[0]?.params).toEqual( + jasmine.objectContaining({ chapters: '51-60', sourceName: SOURCE_SHORT_NAME }) + ); + }); + + it('sets a chapters_empty_in_source error when selected chapters are in the source project but blank', async () => { + // The source contains GEN1-50, but only GEN1-40 have content + const env = new TestEnvironment({ + ...testState, + draftingSourceBooksChapters: 'GEN1-40;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + draftingSourcePresentChapters: 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21' + }); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + // GEN41-45 exist in the source project but have no content + env.component.onDraftingChaptersBlurred('GEN', '41-45'); + + const errors = env.component.draftingChapterErrors.get('GEN'); + expect(errors?.length).toBe(1); + expect(errors?.[0]?.key).toBe('chapter_input.chapters_empty_in_source'); + expect(errors?.[0]?.params).toEqual( + jasmine.objectContaining({ chapters: '41-45', sourceName: SOURCE_SHORT_NAME }) + ); + }); + + it('lists a separate error for missing and blank chapters when the selection includes both', async () => { + // The source contains GEN1-50, but only GEN1-40 have content; GEN51+ does not exist + const env = new TestEnvironment({ + ...testState, + draftingSourceBooksChapters: 'GEN1-40;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + draftingSourcePresentChapters: 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21' + }); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + // GEN41-50 are blank and GEN51-55 don't exist; each error must name only its own chapters + env.component.onDraftingChaptersBlurred('GEN', '41-55'); + + const errors = env.component.draftingChapterErrors.get('GEN'); + expect(errors?.length).toBe(2); + expect(errors?.[0]?.key).toBe('chapter_input.chapters_not_in_source'); + expect(errors?.[0]?.params).toEqual( + jasmine.objectContaining({ chapters: '51-55', sourceName: SOURCE_SHORT_NAME }) + ); + expect(errors?.[1]?.key).toBe('chapter_input.chapters_empty_in_source'); + expect(errors?.[1]?.params).toEqual( + jasmine.objectContaining({ chapters: '41-50', sourceName: SOURCE_SHORT_NAME }) + ); }); it('clears the error and updates state for valid input', async () => { @@ -128,7 +177,7 @@ describe('NewDraftComponent', () => { env.component.onTargetTrainingChaptersBlurred('GEN', 'xyz'); - expect(env.component.targetTrainingChapterErrors.get('GEN')?.key).toBe('chapter_input.invalid_range'); + expect(env.component.targetTrainingChapterErrors.get('GEN')?.[0]?.key).toBe('chapter_input.invalid_range'); }); it('rejects empty or whitespace-only input without changing the target training selection', async () => { @@ -138,10 +187,10 @@ describe('NewDraftComponent', () => { const defaultRange = env.selectedTargetTrainingScriptureRange; env.component.onTargetTrainingChaptersBlurred('GEN', ''); - expect(env.component.targetTrainingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_training'); + expect(env.component.targetTrainingChapterErrors.get('GEN')?.[0]?.key).toBe('chapter_input.empty_training'); env.component.onTargetTrainingChaptersBlurred('GEN', ' '); - expect(env.component.targetTrainingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_training'); + expect(env.component.targetTrainingChapterErrors.get('GEN')?.[0]?.key).toBe('chapter_input.empty_training'); expect(env.selectedTargetTrainingScriptureRange).toBe(defaultRange); expect(env.selectedTargetTrainingScriptureRange).not.toBe('GEN'); @@ -156,9 +205,10 @@ describe('NewDraftComponent', () => { // Trying to include GEN6 (which is being drafted) in training env.component.onTargetTrainingChaptersBlurred('GEN', '1-10'); - const error = env.component.targetTrainingChapterErrors.get('GEN'); - expect(error?.key).toBe('chapter_input.chapters_will_be_translated'); - expect(error?.params).toEqual(jasmine.objectContaining({ chapters: '6-10' })); + const errors = env.component.targetTrainingChapterErrors.get('GEN'); + expect(errors?.length).toBe(1); + expect(errors?.[0]?.key).toBe('chapter_input.chapters_will_be_translated'); + expect(errors?.[0]?.params).toEqual(jasmine.objectContaining({ chapters: '6-10' })); }); it('sets a chapters_not_in_target error when selected chapters are absent from the target project', async () => { @@ -173,9 +223,64 @@ describe('NewDraftComponent', () => { // GEN11-15 are not in the target project and not being drafted env.component.onTargetTrainingChaptersBlurred('GEN', '11-15'); - const error = env.component.targetTrainingChapterErrors.get('GEN'); - expect(error?.key).toBe('chapter_input.chapters_not_in_target'); - expect(error?.params).toEqual(jasmine.objectContaining({ chapters: '11-15', targetName: TARGET_SHORT_NAME })); + const errors = env.component.targetTrainingChapterErrors.get('GEN'); + expect(errors?.length).toBe(1); + expect(errors?.[0]?.key).toBe('chapter_input.chapters_not_in_target'); + expect(errors?.[0]?.params).toEqual( + jasmine.objectContaining({ chapters: '11-15', targetName: TARGET_SHORT_NAME }) + ); + }); + + it('sets a chapters_empty_in_target error when selected chapters are in the target project but blank', async () => { + // The target contains GEN1-10, but only GEN1-5 have content + const env = new TestEnvironment({ + ...testState, + targetPresentChapters: 'GEN1-10;MAT1-28;MRK1-16;LUK1-24;JHN1-21' + }); + await env.waitForInit(); + // Narrow the drafted range to GEN11-50 so GEN6-10 are neither drafted nor available for training + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.logicHandler.selectDraftingChapters('GEN', '11-50'); + env.component.logicHandler.setInputMode('training_books'); + env.component.logicHandler.selectTargetTrainingBooks(['GEN']); // GEN1-5 available + + // GEN6-10 exist in the target project but have no content + env.component.onTargetTrainingChaptersBlurred('GEN', '6-10'); + + const errors = env.component.targetTrainingChapterErrors.get('GEN'); + expect(errors?.length).toBe(1); + expect(errors?.[0]?.key).toBe('chapter_input.chapters_empty_in_target'); + expect(errors?.[0]?.params).toEqual( + jasmine.objectContaining({ chapters: '6-10', targetName: TARGET_SHORT_NAME }) + ); + }); + + it('lists a separate error for missing and blank chapters when the selection includes both', async () => { + // The target contains GEN1-10, but only GEN1-5 have content; GEN11+ does not exist + const env = new TestEnvironment({ + ...testState, + targetPresentChapters: 'GEN1-10;MAT1-28;MRK1-16;LUK1-24;JHN1-21' + }); + await env.waitForInit(); + // Draft GEN16-50 so that GEN6-15 are neither drafted nor available for training + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.logicHandler.selectDraftingChapters('GEN', '16-50'); + env.component.logicHandler.setInputMode('training_books'); + env.component.logicHandler.selectTargetTrainingBooks(['GEN']); // GEN1-5 available + + // GEN6-10 are blank and GEN11-15 don't exist; each error must name only its own chapters + env.component.onTargetTrainingChaptersBlurred('GEN', '6-15'); + + const errors = env.component.targetTrainingChapterErrors.get('GEN'); + expect(errors?.length).toBe(2); + expect(errors?.[0]?.key).toBe('chapter_input.chapters_not_in_target'); + expect(errors?.[0]?.params).toEqual( + jasmine.objectContaining({ chapters: '11-15', targetName: TARGET_SHORT_NAME }) + ); + expect(errors?.[1]?.key).toBe('chapter_input.chapters_empty_in_target'); + expect(errors?.[1]?.params).toEqual( + jasmine.objectContaining({ chapters: '6-10', targetName: TARGET_SHORT_NAME }) + ); }); it('clears the error and updates state for valid input', async () => { @@ -720,7 +825,9 @@ describe('NewDraftComponent', () => { tick(); // The reload re-fetches and forces fresh data (maxStalenessMs: 0) for the synced project. - verify(mockedProgressService.getProgressForProject('draft-source-1-id', deepEqual({ maxStalenessMs: 0 }))).once(); + verify( + mockedProgressService.getChaptersWithContent('draft-source-1-id', deepEqual({ maxStalenessMs: 0 })) + ).once(); expect(env.component.page).toEqual('preface'); })); @@ -732,7 +839,7 @@ describe('NewDraftComponent', () => { void env.component.onPendingUpdatesComplete([]); tick(); - verify(mockedProgressService.getProgressForProject(anything(), anything())).never(); + verify(mockedProgressService.getChaptersWithContent(anything(), anything())).never(); expect(env.component.page).toEqual('preface'); })); @@ -740,7 +847,7 @@ describe('NewDraftComponent', () => { const env = new TestEnvironment(testState); tick(); // Init has already succeeded; make the reload's progress fetch fail. - when(mockedProgressService.getProgressForProject('draft-source-1-id', anything())).thenReject( + when(mockedProgressService.getChaptersWithContent('draft-source-1-id', anything())).thenReject( new Error('reload failed') ); @@ -937,6 +1044,13 @@ interface TestState { lastAvailableTrainingDataFiles?: string[]; /** Target books getCompleteBookIds should report as complete (auto-selectable on first draft). Defaults to none. */ completeTargetBooks?: string[]; + /** + * The chapters the target project contains, including blank ones (unlike targetProjectBooksChapters, which lists + * only chapters with content). Defaults to targetProjectBooksChapters, i.e. no blank chapters. + */ + targetPresentChapters?: string; + /** Like targetPresentChapters, but for the drafting source. Defaults to draftingSourceBooksChapters. */ + draftingSourcePresentChapters?: string; } function makeTrainingData(dataId: string, title: string = dataId): TrainingData { @@ -1091,17 +1205,25 @@ class TestEnvironment { } if (options.progressError) { - when(mockedProgressService.getProgressForProject(projectId, anything())).thenReject(new Error('progress failed')); + when(mockedProgressService.getChaptersWithContent(projectId, anything())).thenReject( + new Error('progress failed') + ); } else { - when(mockedProgressService.getProgressForProject(projectId, anything())).thenResolve( + when(mockedProgressService.getChaptersWithContent(projectId, anything())).thenResolve( new VerboseScriptureRange(state.targetProjectBooksChapters) ); } - when(mockedProgressService.getProgressForProject('draft-source-1-id', anything())).thenResolve( + when(mockedProgressService.getPresentChapters(projectId, anything())).thenResolve( + new VerboseScriptureRange(state.targetPresentChapters ?? state.targetProjectBooksChapters) + ); + when(mockedProgressService.getChaptersWithContent('draft-source-1-id', anything())).thenResolve( new VerboseScriptureRange(state.draftingSourceBooksChapters) ); + when(mockedProgressService.getPresentChapters('draft-source-1-id', anything())).thenResolve( + new VerboseScriptureRange(state.draftingSourcePresentChapters ?? state.draftingSourceBooksChapters) + ); for (const [trainingSourceId, booksChapters] of Object.entries(state.trainingSourcesBooksChapters)) { - when(mockedProgressService.getProgressForProject(trainingSourceId, anything())).thenResolve( + when(mockedProgressService.getChaptersWithContent(trainingSourceId, anything())).thenResolve( new VerboseScriptureRange(booksChapters) ); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts index 376df66abe1..4a8e2df394e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts @@ -253,13 +253,17 @@ function setUpMocks(args: StoryArgs): void { // set the progress promises never resolve, leaving the wizard stuck on its loading spinner. const progressFor = (range: string): Promise => args.neverLoad ? new Promise(() => {}) : Promise.resolve(new VerboseScriptureRange(range)); - when(mockedDraftProgressService.getProgressForProject(TARGET_ID, anything())).thenCall(() => + when(mockedDraftProgressService.getChaptersWithContent(TARGET_ID, anything())).thenCall(() => progressFor(TARGET_BOOKS) ); - when(mockedDraftProgressService.getProgressForProject(DRAFT_SOURCE_ID, anything())).thenCall(() => + when(mockedDraftProgressService.getPresentChapters(TARGET_ID, anything())).thenCall(() => progressFor(TARGET_BOOKS)); + when(mockedDraftProgressService.getChaptersWithContent(DRAFT_SOURCE_ID, anything())).thenCall(() => progressFor(DRAFT_SOURCE_BOOKS) ); - when(mockedDraftProgressService.getProgressForProject(TRAINING_SOURCE_ID, anything())).thenCall(() => + when(mockedDraftProgressService.getPresentChapters(DRAFT_SOURCE_ID, anything())).thenCall(() => + progressFor(DRAFT_SOURCE_BOOKS) + ); + when(mockedDraftProgressService.getChaptersWithContent(TRAINING_SOURCE_ID, anything())).thenCall(() => progressFor(TRAINING_SOURCE_BOOKS) ); when(mockedDraftProgressService.getCompleteBookIds(TARGET_ID, anything())).thenResolve(new Set()); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts index 2413ac6e5f6..1b54a784125 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts @@ -93,8 +93,10 @@ export class NewDraftComponent { pendingProjects: { projectId: string; name: string }[] = []; - draftingChapterErrors = new Map(); - targetTrainingChapterErrors = new Map(); + // Chapter-input errors per book. Every applicable error is listed at once (one per reason), so a user whose input + // has several problems doesn't have to discover them one blur at a time. + draftingChapterErrors = new Map(); + targetTrainingChapterErrors = new Map(); stepError: I18nKeyForComponent<'new_draft'> | null = null; draftingExclusionsExpanded = false; @@ -598,7 +600,7 @@ export class NewDraftComponent { } /** Drops chapter-input errors for books that are no longer offered for partial selection (so have no input). */ - private pruneChapterErrors(errors: Map, offeredBookIds: string[]): void { + private pruneChapterErrors(errors: Map, offeredBookIds: string[]): void { for (const bookId of errors.keys()) { if (!offeredBookIds.includes(bookId)) errors.delete(bookId); } @@ -619,20 +621,20 @@ export class NewDraftComponent { private parseChapterInput( bookId: string, value: string, - errors: Map, + errors: Map, emptyErrorKey: I18nKeyForComponent<'new_draft'> ): ChapterSet | null { let parsed: ChapterSet; try { parsed = ChapterSet.fromUserInput(value); } catch { - errors.set(bookId, { key: 'chapter_input.invalid_range' }); + errors.set(bookId, [{ key: 'chapter_input.invalid_range' }]); return null; } // Checked after parsing: fromUserInput normalizes whitespace and separators away, so empty, whitespace-only, and // separator-only input all resolve to an empty set here. if (parsed.count() === 0) { - errors.set(bookId, { key: emptyErrorKey }); + errors.set(bookId, [{ key: emptyErrorKey }]); return null; } return parsed; @@ -645,13 +647,26 @@ export class NewDraftComponent { const available = this.logicHandler.availableDraftingScriptureRange.books.get(bookId); const badChapters = available != null ? parsed.difference(available) : parsed; if (badChapters.count() > 0) { - this.draftingChapterErrors.set(bookId, { - key: 'chapter_input.chapters_not_in_source', - params: { - chapters: badChapters.toString(), - sourceName: this.logicHandler.sources?.draftingSources[0]?.shortName ?? '' - } - }); + // A chapter can be unavailable because the source doesn't contain it at all, or because it contains it blank + // (available chapters are content-based). One error per applicable reason, each naming only its own chapters. + const present = this.logicHandler.draftingSourcePresentChapters.books.get(bookId) ?? new ChapterSet([]); + const missing = badChapters.difference(present); + const blank = badChapters.intersection(present); + const sourceName = this.logicHandler.sources?.draftingSources[0]?.shortName ?? ''; + const errors: ChapterInputError[] = []; + if (missing.count() > 0) { + errors.push({ + key: 'chapter_input.chapters_not_in_source', + params: { chapters: missing.toString(), sourceName } + }); + } + if (blank.count() > 0) { + errors.push({ + key: 'chapter_input.chapters_empty_in_source', + params: { chapters: blank.toString(), sourceName } + }); + } + this.draftingChapterErrors.set(bookId, errors); return; } @@ -742,20 +757,36 @@ export class NewDraftComponent { const unavailable = available != null ? parsed.difference(available) : parsed; if (unavailable.count() > 0) { + // A chapter can be unavailable because it is selected for drafting, because the target doesn't contain it at + // all, or because the target contains it blank (available chapters are content-based). One error per applicable + // reason, each naming only its own chapters. const drafted = this.logicHandler.selectedDraftingScriptureRange.books.get(bookId); const draftedUnavailable = drafted != null ? unavailable.intersection(drafted) : new ChapterSet([]); + const notDrafted = unavailable.difference(draftedUnavailable); + const present = this.logicHandler.targetPresentChapters.books.get(bookId) ?? new ChapterSet([]); + const missing = notDrafted.difference(present); + const blank = notDrafted.intersection(present); + const targetName = this.activatedProjectService.projectDoc?.data?.shortName ?? ''; + const errors: ChapterInputError[] = []; if (draftedUnavailable.count() > 0) { - this.targetTrainingChapterErrors.set(bookId, { + errors.push({ key: 'chapter_input.chapters_will_be_translated', params: { chapters: draftedUnavailable.toString() } }); - } else { - const targetName = this.activatedProjectService.projectDoc?.data?.shortName ?? ''; - this.targetTrainingChapterErrors.set(bookId, { + } + if (missing.count() > 0) { + errors.push({ key: 'chapter_input.chapters_not_in_target', - params: { chapters: unavailable.toString(), targetName } + params: { chapters: missing.toString(), targetName } + }); + } + if (blank.count() > 0) { + errors.push({ + key: 'chapter_input.chapters_empty_in_target', + params: { chapters: blank.toString(), targetName } }); } + this.targetTrainingChapterErrors.set(bookId, errors); return; } 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 5c582e26207..da8f129b96d 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 @@ -247,7 +247,9 @@ "empty_draft": "Enter the chapters to draft, or deselect this book if you don't want to draft it.", "empty_training": "Enter the chapters to use for training, or deselect this book if you don't want to train on it.", "chapters_not_in_source": "Chapters {{ chapters }} cannot be generated because they are not in the source project, {{ sourceName }}.", + "chapters_empty_in_source": "Chapters {{ chapters }} cannot be generated because they are blank in the source project, {{ sourceName }}.", "chapters_not_in_target": "Chapters {{ chapters }} cannot be used for training because they are not in the main project, {{ targetName }}.", + "chapters_empty_in_target": "Chapters {{ chapters }} cannot be used for training because they are blank in the main project, {{ targetName }}.", "chapters_will_be_translated": "Chapters {{ chapters }} cannot be used for training because they are going to be translated." }, "change_source_configuration": "Change source configuration", From 9e6eae01318173b5cca5c5d1b04830a22562d0df Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Tue, 7 Jul 2026 16:12:52 +0000 Subject: [PATCH 12/17] Show training data on the draft history entry the way the new draft component shows it Also fix the showing of training and drafting ranges elsewhere throughout the application --- .../src/app/core/permissions.service.ts | 7 + .../draft-jobs.component.html | 8 +- .../draft-jobs.component.ts | 36 ++-- .../serval-builds.component.spec.ts | 38 +++- .../serval-builds.component.ts | 36 ++-- .../serval-project.component.spec.ts | 34 ++-- .../serval-project.component.ts | 31 ++-- .../shared/scripture-range-display.spec.ts | 161 +++++++++++++++++ .../src/app/shared/scripture-range-display.ts | 169 ++++++++++++++++++ .../src/app/shared/scripture-range.spec.ts | 72 +++++++- .../src/app/shared/scripture-range.ts | 43 +++++ .../draft-generation-steps.component.spec.ts | 4 +- .../draft-generation.component.ts | 13 +- .../draft-history-entry.component.html | 6 +- .../draft-history-entry.component.spec.ts | 61 ++++++- .../draft-history-entry.component.ts | 34 ++-- .../draft-preview-books.component.html | 5 +- .../draft-preview-books.component.spec.ts | 4 +- .../draft-preview-books.component.ts | 4 +- .../new-draft/new-draft.component.html | 8 +- .../new-draft/new-draft.component.spec.ts | 4 + .../new-draft/new-draft.component.stories.ts | 1 + .../new-draft/new-draft.component.ts | 16 +- .../new-draft/training-data-summary.spec.ts | 12 +- .../new-draft/training-data-summary.ts | 51 ++---- .../src/assets/i18n/checking_en.json | 4 +- .../src/xforge-common/i18n.service.spec.ts | 4 +- .../src/xforge-common/i18n.service.ts | 54 +++--- 28 files changed, 750 insertions(+), 170 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range-display.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range-display.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts index 3b832fb4eb8..92c14a27586 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/permissions.service.ts @@ -120,6 +120,13 @@ export class PermissionsService { return role === SFProjectRole.ParatextAdministrator || role === SFProjectRole.ParatextTranslator; } + /** Whether the user is allowed to configure drafting sources for the project. */ + canConfigureSources(projectDoc?: SFProjectProfileDoc, userId?: string): boolean { + if (projectDoc?.data == null) return false; + const role = projectDoc.data.userRoles[userId ?? this.userService.currentUserId]; + return role === SFProjectRole.ParatextAdministrator; + } + canAccessBiblicalTerms(projectDoc: SFProjectProfileDoc): boolean { if (projectDoc?.data?.biblicalTermsConfig?.biblicalTermsEnabled !== true) return false; return SF_PROJECT_RIGHTS.hasRight( diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html index ce49dfa5dfc..3baccb8d196 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html @@ -128,9 +128,9 @@ }}: @if (projectBook.books.length <= 2) { - {{ projectBook.books.join("; ") }} + {{ projectBook.booksDisplay }} } @else { - + {{ projectBook.books.length }} books } @@ -150,9 +150,9 @@ }}: @if (projectBook.books.length <= 2) { - {{ projectBook.books.join("; ") }} + {{ projectBook.booksDisplay }} } @else { - + {{ projectBook.books.length }} books } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts index 3932a32d9c1..8ab5a65cf62 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts @@ -34,6 +34,8 @@ import { SFProjectService } from '../core/sf-project.service'; import { EventMetric } from '../event-metrics/event-metric'; import { InfoComponent } from '../shared/info/info.component'; import { NoticeComponent } from '../shared/notice/notice.component'; +import { trainingSourceRangesWithTargetDetail } from '../shared/scripture-range'; +import { formatScriptureRangeTokensCompact } from '../shared/scripture-range-display'; import { projectLabel } from '../shared/utils'; import { DateRangePickerComponent, NormalizedDateRange } from './date-range-picker.component'; import { DraftJobsExportService, SpreadsheetRow } from './draft-jobs-export.service'; @@ -94,6 +96,8 @@ export interface DraftJobsProjectBooks { /** Short name of the project if available. */ shortName?: string; books: string[]; + /** Compact display of `books`, e.g. "GEN-LEV; NUM 1-3" (full books collapse into ranges). */ + booksDisplay: string; } /** Names of events that are relevant for pre-translation draft generation requests and processing. */ @@ -829,20 +833,22 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { if (event.payload != null) { const buildConfig = event.payload.buildConfig; if (buildConfig != null) { - // Extract training books + // Extract training books. The entry for the event's own project (the draft target) is not a training + // source; it is folded into the source ranges as chapter detail. if (Array.isArray(buildConfig.TrainingScriptureRanges)) { - for (const range of buildConfig.TrainingScriptureRanges) { - if (range.ScriptureRange != null) { - // Use the project ID from the range if available, otherwise use the event's project ID - const projectId = range.ProjectId || event.projectId; - if (projectId) { - // Split semicolon-separated books and add them to the project's books - const books = range.ScriptureRange.split(';').filter((book: string) => book.trim().length > 0); - if (!trainingProjects.has(projectId)) { - trainingProjects.set(projectId, []); - } - trainingProjects.get(projectId)!.push(...books); + const ranges: { projectId?: string; scriptureRange?: string }[] = buildConfig.TrainingScriptureRanges.map( + (range: any) => ({ projectId: range.ProjectId, scriptureRange: range.ScriptureRange ?? undefined }) + ); + for (const range of trainingSourceRangesWithTargetDetail(ranges, r => r.projectId, event.projectId)) { + // Use the project ID from the range if available, otherwise use the event's project ID + const projectId = range.projectId || event.projectId; + if (projectId) { + // Split semicolon-separated books and add them to the project's books + const books = range.scriptureRange!.split(';').filter((book: string) => book.trim().length > 0); + if (!trainingProjects.has(projectId)) { + trainingProjects.set(projectId, []); } + trainingProjects.get(projectId)!.push(...books); } } } @@ -874,14 +880,16 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { const trainingBooks: DraftJobsProjectBooks[] = Array.from(trainingProjects.entries()).map(([projectId, books]) => ({ sfProjectId: projectId, projectDisplayName: '', - books: books + books: books, + booksDisplay: formatScriptureRangeTokensCompact(books) })); const translationBooks: DraftJobsProjectBooks[] = Array.from(translationProjects.entries()).map( ([projectId, books]) => ({ sfProjectId: projectId, projectDisplayName: '', - books: books + books: books, + booksDisplay: formatScriptureRangeTokensCompact(books) }) ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts index 1d09cf9cde1..53994549af6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts @@ -1726,11 +1726,43 @@ describe('ServalBuildsComponent', () => { expect(result).toBe('112233 BSB: GEN 10-11, 16-19; EXO'); }); - it('compactRangeNotation de-duplicates duplicate chapter numbers', () => { + it('collapses contiguous full books into an ID range', () => { + const projectBooks: ProjectBooks[] = [ + { + sfProjectId: '112233', + projectDisplayName: 'BSB', + shortName: 'BSB', + booksAndChapters: [ + { bookId: 'GEN' }, + { bookId: 'EXO' }, + { bookId: 'LEV' }, + { bookId: 'NUM', chapters: [1, 2, 3] } + ] + } + ]; + + // SUT + const result: string = ServalBuildsComponent.formatProjectBooks(projectBooks); + + expect(result).toBe('112233 BSB: GEN-LEV; NUM 1-3'); + }); + + it('treats a book whose chapters reach the canonical count as a full book', () => { + const projectBooks: ProjectBooks[] = [ + { + sfProjectId: '112233', + projectDisplayName: 'BSB', + shortName: 'BSB', + booksAndChapters: [{ bookId: 'GEN', chapters: [1, 2, 3] }, { bookId: 'EXO' }] + } + ]; + // All 40 chapters of Exodus, which is the same as the whole book + projectBooks[0].booksAndChapters[1].chapters = Array.from({ length: 40 }, (_, i) => i + 1); + // SUT - const result: string = ServalBuildsComponent.compactRangeNotation([10, 10, 11, 10, 16, 16, 17]); + const result: string = ServalBuildsComponent.formatProjectBooks(projectBooks); - expect(result).toBe('10-11, 16-17'); + expect(result).toBe('112233 BSB: GEN 1-3; EXO'); }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts index f1d58718405..d81d26adc6c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts @@ -53,7 +53,8 @@ import { UserService } from 'xforge-common/user.service'; import { isPopulatedString, isString, notNull } from '../../type-utils'; import { InfoComponent } from '../shared/info/info.component'; import { NoticeComponent } from '../shared/notice/notice.component'; -import { ChapterSet } from '../shared/scripture-range'; +import { ChapterSet, trainingSourceRangesWithTargetDetail } from '../shared/scripture-range'; +import { formatScriptureRangeTokensCompact } from '../shared/scripture-range-display'; import { BookConfidence, ChapterConfidence } from '../translate/draft-generation/build-confidences/build-confidences'; import { DisplayConfidenceComponent } from '../translate/draft-generation/build-confidences/display-confidence.component'; import { DraftGenerationService } from '../translate/draft-generation/draft-generation.service'; @@ -591,7 +592,14 @@ export class ServalBuildsComponent extends DataLoadingComponent implements OnIni sfProjectId ); const projectLink: string | undefined = ServalBuildsComponent.servalAdminProjectLinkFor(sfProjectId); - const trainingBooks: ProjectBooks[] = toProjectBooks(report.config.trainingScriptureRanges); + // The target project's entry among the training ranges is not a source; fold it in as chapter detail. + const trainingBooks: ProjectBooks[] = toProjectBooks( + trainingSourceRangesWithTargetDetail( + report.config.trainingScriptureRanges ?? [], + r => r.sfProjectId, + sfProjectId + ) + ); const translationBooks: ProjectBooks[] = toProjectBooks(report.config.translationScriptureRanges); return { @@ -1025,21 +1033,17 @@ export class ServalBuildsComponent extends DataLoadingComponent implements OnIni return result; } - /** Formats a BookAndChapters array into a display string, e.g. "GEN 10-11, 16-19; EXO". */ + /** + * Formats a BookAndChapters array into a compact display string, e.g. "GEN-LEV; NUM 10-11, 16-19". Contiguous + * full books (including those whose chapters reach the canonical count) collapse into an ID range. + */ static formatBookEntries(booksAndChapters: BookAndChapters[]): string { - return booksAndChapters - .map(entry => { - if (entry.chapters == null || entry.chapters.length === 0) { - return entry.bookId; - } - return `${entry.bookId} ${ServalBuildsComponent.compactRangeNotation(entry.chapters)}`; - }) - .join('; '); - } - - /** Formats numbers into compact range notation, e.g. [7, 1, 3, 2] → "1-3, 7" (sorted, de-duplicated). */ - static compactRangeNotation(nums: number[]): string { - return new ChapterSet(nums).toStringForDisplay(); + const tokens = booksAndChapters.map(entry => + entry.chapters == null || entry.chapters.length === 0 + ? entry.bookId + : `${entry.bookId}${new ChapterSet(entry.chapters).toString()}` + ); + return formatScriptureRangeTokensCompact(tokens); } /** (The return type is string if the input is a type string (at compile time), or undefined diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts index b4a2e961449..88165fdf8ab 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts @@ -14,13 +14,12 @@ import { ActivatedProjectService } from 'xforge-common/activated-project.service import { AuthService } from 'xforge-common/auth.service'; import { CommandError, CommandErrorCode } from 'xforge-common/command.service'; import { FileService } from 'xforge-common/file.service'; -import { I18nService } from 'xforge-common/i18n.service'; import { FileType } from 'xforge-common/models/file-offline-data'; import { NoticeService } from 'xforge-common/notice.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 } from 'xforge-common/test-utils'; +import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc'; import { SFProjectService } from '../core/sf-project.service'; import { BuildDto } from '../machine-api/build-dto'; @@ -41,7 +40,6 @@ const mockActivatedRoute = mock(ActivatedRoute); const mockAuthService = mock(AuthService); const mockDraftGenerationService = mock(DraftGenerationService); const mockFileService = mock(FileService); -const mockedI18nService = mock(I18nService); const mockNoticeService = mock(NoticeService); const mockSFProjectService = mock(SFProjectService); const mockServalAdministrationService = mock(ServalAdministrationService); @@ -49,6 +47,7 @@ const mockTrainingDataService = mock(TrainingDataService); describe('ServalProjectComponent', () => { configureTestingModule(() => ({ + imports: [getTestTranslocoModule()], providers: [ provideTestOnlineStatus(), { provide: ActivatedProjectService, useMock: mockActivatedProjectService }, @@ -56,7 +55,6 @@ describe('ServalProjectComponent', () => { { provide: AuthService, useMock: mockAuthService }, { provide: DraftGenerationService, useMock: mockDraftGenerationService }, { provide: FileService, useMock: mockFileService }, - { provide: I18nService, useMock: mockedI18nService }, { provide: NoticeService, useMock: mockNoticeService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: ServalAdministrationService, useMock: mockServalAdministrationService }, @@ -264,11 +262,31 @@ describe('ServalProjectComponent', () => { }); const trainingSources = env.trainingSources; expect(trainingSources.length).toEqual(2); - expect(env.getTrainingSourceBookNames(trainingSources[0])).toEqual('Genesis - Exodus'); + expect(env.getTrainingSourceBookNames(trainingSources[0])).toEqual('Genesis and Exodus'); expect(env.getTrainingSourceBookNames(trainingSources[1])).toEqual('Genesis'); const translationSources = env.translationSources; expect(translationSources.length).toEqual(1); - expect(env.getTranslationBookNames(translationSources[0])).toEqual('Leviticus - Numbers'); + expect(env.getTranslationBookNames(translationSources[0])).toEqual('Leviticus and Numbers'); + })); + + it('folds the target training entry into the sources as chapter detail instead of listing it', fakeAsync(() => { + const env = new TestEnvironment({ + preTranslate: true, + draftConfig: { + lastSelectedTrainingScriptureRanges: [ + { projectId: 'project04', scriptureRange: 'GEN;EXO' }, + // The entry for the target project itself carries the chapter-level selection + { projectId: 'project01', scriptureRange: 'GEN1-3;EXO1-40' } + ], + lastSelectedTranslationScriptureRanges: [{ projectId: 'project03', scriptureRange: 'LEV2-5' }] + } as DraftConfig + }); + const trainingSources = env.trainingSources; + expect(trainingSources.length).toEqual(1); + expect(env.getTrainingSourceBookNames(trainingSources[0])).toEqual('Genesis 1-3 and Exodus'); + const translationSources = env.translationSources; + expect(translationSources.length).toEqual(1); + expect(env.getTranslationBookNames(translationSources[0])).toEqual('Leviticus 2-5'); })); describe('serval configuration', () => { @@ -496,10 +514,6 @@ describe('ServalProjectComponent', () => { when(mockActivatedProjectService.projectDoc).thenReturn(mockProjectDoc); when(mockActivatedProjectService.projectDoc$).thenReturn(mockProjectDoc$); - when(mockedI18nService.formatAndLocalizeScriptureRange('GEN')).thenReturn('Genesis'); - when(mockedI18nService.formatAndLocalizeScriptureRange('GEN;EXO')).thenReturn('Genesis - Exodus'); - when(mockedI18nService.formatAndLocalizeScriptureRange('LEV;NUM')).thenReturn('Leviticus - Numbers'); - when(mockDraftGenerationService.getLastCompletedBuild(this.mockProjectId)).thenReturn( of(args.lastCompletedBuild) ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts index d083696d6bf..3ed718adc4f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts @@ -50,6 +50,8 @@ import { InfoComponent } from '../shared/info/info.component'; import { JsonViewerComponent } from '../shared/json-viewer/json-viewer.component'; import { MobileNotSupportedComponent } from '../shared/mobile-not-supported/mobile-not-supported.component'; import { NoticeComponent } from '../shared/notice/notice.component'; +import { trainingSourceRangesWithTargetDetail, VerboseScriptureRange } from '../shared/scripture-range'; +import { formatScriptureRangeWithChapters } from '../shared/scripture-range-display'; import { projectLabel } from '../shared/utils'; import { DraftZipProgress } from '../translate/draft-generation/draft-generation'; import { DraftGenerationService } from '../translate/draft-generation/draft-generation.service'; @@ -227,16 +229,22 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn // We have to set the rows this way to trigger the update this.rows = rows; - // Setup the books + // Setup the books. The target project's entry is not a training source; it is folded into the source + // ranges as chapter detail. this.trainingBooksByProject = []; - if (draftConfig.lastSelectedTrainingScriptureRanges != null) { - let sourceCount = 1; - for (const range of draftConfig.lastSelectedTrainingScriptureRanges) { - this.trainingBooksByProject.push({ - source: `Source ${sourceCount++}`, - scriptureRange: this.i18n.formatAndLocalizeScriptureRange(range.scriptureRange) - }); - } + let trainingSourceCount = 1; + for (const range of trainingSourceRangesWithTargetDetail( + draftConfig.lastSelectedTrainingScriptureRanges ?? [], + r => r.projectId, + projectDoc.id + )) { + this.trainingBooksByProject.push({ + source: `Source ${trainingSourceCount++}`, + scriptureRange: formatScriptureRangeWithChapters( + new VerboseScriptureRange(range.scriptureRange), + this.i18n + ) + }); } this.trainingFiles = draftConfig.lastSelectedTrainingDataFiles; this.translationBooksByProject = []; @@ -245,7 +253,10 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn for (const range of draftConfig.lastSelectedTranslationScriptureRanges) { this.translationBooksByProject.push({ source: `Source ${sourceCount++}`, - scriptureRange: this.i18n.formatAndLocalizeScriptureRange(range.scriptureRange) + scriptureRange: formatScriptureRangeWithChapters( + new VerboseScriptureRange(range.scriptureRange), + this.i18n + ) }); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range-display.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range-display.spec.ts new file mode 100644 index 00000000000..61a31cbbdda --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range-display.spec.ts @@ -0,0 +1,161 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslocoService } from '@ngneat/transloco'; +import { CookieService } from 'ngx-cookie-service'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { BugsnagService } from 'xforge-common/bugsnag.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { LocationService } from 'xforge-common/location.service'; +import { getTestTranslocoModule } from 'xforge-common/test-utils'; +import { VerboseScriptureRange } from './scripture-range'; +import { + formatScriptureRangeCompact, + formatScriptureRangeWithChapters, + groupContiguousBookNumbers +} from './scripture-range-display'; + +const mockedLocationService = mock(LocationService); +const mockedBugsnagService = mock(BugsnagService); +const mockedCookieService = mock(CookieService); +const mockedErrorReportingService = mock(ErrorReportingService); +const mockedDocument = mock(Document); + +describe('groupContiguousBookNumbers', () => { + it('groups a run of 3 or more into a range', () => { + expect(groupContiguousBookNumbers([1, 2, 3])).toEqual([{ kind: 'range', start: 1, end: 3 }]); + }); + + it('keeps a run of 2 as individual books rather than a range', () => { + expect(groupContiguousBookNumbers([1, 2])).toEqual([ + { kind: 'single', bookNumber: 1 }, + { kind: 'single', bookNumber: 2 } + ]); + }); + + it('keeps a single book as an individual book', () => { + expect(groupContiguousBookNumbers([5])).toEqual([{ kind: 'single', bookNumber: 5 }]); + }); + + it('handles multiple runs of different lengths', () => { + expect(groupContiguousBookNumbers([1, 2, 5, 6, 7, 10])).toEqual([ + { kind: 'single', bookNumber: 1 }, + { kind: 'single', bookNumber: 2 }, + { kind: 'range', start: 5, end: 7 }, + { kind: 'single', bookNumber: 10 } + ]); + }); + + it('returns an empty array for no book numbers', () => { + expect(groupContiguousBookNumbers([])).toEqual([]); + }); +}); + +describe('formatScriptureRangeWithChapters', () => { + let i18n: I18nService; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [getTestTranslocoModule()] }); + when(mockedLocationService.search).thenReturn(''); + when(mockedCookieService.get(anything())).thenReturn(''); + // A genuine I18nService backed by the test transloco module (real 'en' translations, including book names). + // ignoreCookieLocale=true keeps the constructor from reading the cookie locale, so the dependencies are only + // present to satisfy construction. + i18n = new I18nService( + instance(mockedLocationService), + instance(mockedBugsnagService), + TestBed.inject(TranslocoService), + instance(mockedCookieService), + instance(mockedErrorReportingService), + instance(mockedDocument), + true + ); + }); + + function format(range: string, options?: { collapseFullBookRuns?: boolean }): string { + return formatScriptureRangeWithChapters(new VerboseScriptureRange(range), i18n, options); + } + + it('renders a chapter-less book as its name', () => { + expect(format('GEN')).toEqual('Genesis'); + }); + + it('renders a book whose chapters reach the canonical count as its name', () => { + expect(format('GEN1-50')).toEqual('Genesis'); + }); + + it('renders a book with a partial chapter selection with its chapter range', () => { + expect(format('GEN1-3')).toEqual('Genesis 1-3'); + }); + + it('parenthesizes only a multi-part chapter selection, with spaces after commas', () => { + expect(format('GEN1-3,7')).toEqual('Genesis (1-3, 7)'); + }); + + it('collapses adjacent full books into a range', () => { + expect(format('GEN;EXO;LEV')).toEqual('Genesis - Leviticus'); + }); + + it('lists two adjacent full books individually rather than as a range', () => { + expect(format('GEN;EXO')).toEqual('Genesis and Exodus'); + }); + + it('breaks a partial book out of a range of full books', () => { + expect(format('GEN;EXO1-10;LEV')).toEqual('Genesis, Exodus 1-10, and Leviticus'); + }); + + it('sorts books into canonical order', () => { + expect(format('LEV;GEN;EXO1-10')).toEqual('Genesis, Exodus 1-10, and Leviticus'); + }); + + it('lists full books individually when collapsing is disabled', () => { + expect(format('GEN;EXO;LEV3', { collapseFullBookRuns: false })).toEqual('Genesis, Exodus, and Leviticus 3'); + }); + + it('skips unknown book ids', () => { + expect(format('XXX;GEN')).toEqual('Genesis'); + }); + + it('returns an empty string for an empty range', () => { + expect(format('')).toEqual(''); + }); +}); + +describe('formatScriptureRangeCompact', () => { + function format(range: string): string { + return formatScriptureRangeCompact(new VerboseScriptureRange(range)); + } + + it('renders a full book as its ID', () => { + expect(format('GEN')).toEqual('GEN'); + expect(format('GEN1-50')).toEqual('GEN'); + }); + + it('renders a partial book with a space before its chapters', () => { + expect(format('GEN2-3')).toEqual('GEN 2-3'); + expect(format('GEN1-3,7')).toEqual('GEN 1-3, 7'); + }); + + it('collapses contiguous full books into an ID range', () => { + expect(format('GEN;EXO;LEV')).toEqual('GEN-LEV'); + }); + + it('lists two contiguous full books individually rather than as a range', () => { + expect(format('GEN;EXO')).toEqual('GEN; EXO'); + }); + + it('does not collapse non-contiguous full books', () => { + expect(format('GEN;LEV')).toEqual('GEN; LEV'); + }); + + it('breaks a partial book out of a range of full books', () => { + expect(format('GEN;EXO2-3;LEV;NUM;DEU')).toEqual('GEN; EXO 2-3; LEV-DEU'); + }); + + it('sorts books into canonical order', () => { + expect(format('LEV;GEN;EXO')).toEqual('GEN-LEV'); + }); + + it('returns an empty string for an empty range', () => { + expect(format('')).toEqual(''); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range-display.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range-display.ts new file mode 100644 index 00000000000..42c8404d909 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range-display.ts @@ -0,0 +1,169 @@ +import { Canon } from '@sillsdev/scripture'; +import { I18nService } from 'xforge-common/i18n.service'; +import { expectedBookChapters } from './progress-service/progress.service'; +import { VerboseScriptureRange } from './scripture-range'; + +/** + * One run of consecutive book numbers. A run of 3 or more books becomes a `range` group with a `start` and `end` + * book number. A run of only 1 or 2 books becomes that many `single` groups instead, since a 2-book range (e.g. + * "Genesis - Exodus") reads no better than just listing both books by name. + */ +export type BookNumberGroup = { kind: 'single'; bookNumber: number } | { kind: 'range'; start: number; end: number }; + +/** Splits sorted book numbers into consecutive runs. Returns one {@link BookNumberGroup} per run. */ +export function groupContiguousBookNumbers(bookNumbers: number[]): BookNumberGroup[] { + const groups: BookNumberGroup[] = []; + let start: number | null = null; + let end: number | null = null; + const flush = (): void => { + if (start == null || end == null) return; + if (end - start + 1 >= 3) { + groups.push({ kind: 'range', start, end }); + } else { + for (let bookNumber = start; bookNumber <= end; bookNumber++) { + groups.push({ kind: 'single', bookNumber }); + } + } + }; + for (const bookNumber of bookNumbers) { + if (start == null || end == null) { + start = bookNumber; + end = bookNumber; + } else if (bookNumber === end + 1) { + end = bookNumber; + } else { + flush(); + start = bookNumber; + end = bookNumber; + } + } + flush(); + return groups; +} + +/** + * A contiguous run of full books (rendered as a book range), or a single book that is only partly covered + * (rendered with its chapter range). Partial books are never absorbed into a range. + */ +export type BookDisplaySegment = + | { kind: 'range'; bookNumbers: number[] } + | { kind: 'partial'; bookId: string; chapterRange: string }; + +/** + * Sorts books canonically and groups them for display: consecutive full books (null chapterRange) merge into one + * `range` segment; each partial book becomes its own `partial` segment, splitting the run of full books around it. + */ +export function segmentBooksForDisplay( + books: { bookNumber: number; chapterRange: string | null }[] +): BookDisplaySegment[] { + const segments: BookDisplaySegment[] = []; + let pendingFullBooks: number[] = []; + const flushFullBooks = (): void => { + if (pendingFullBooks.length > 0) { + segments.push({ kind: 'range', bookNumbers: pendingFullBooks }); + pendingFullBooks = []; + } + }; + + for (const book of [...books].sort((a, b) => a.bookNumber - b.bookNumber)) { + if (book.chapterRange != null) { + flushFullBooks(); + segments.push({ + kind: 'partial', + bookId: Canon.bookNumberToId(book.bookNumber), + chapterRange: book.chapterRange + }); + } else { + pendingFullBooks.push(book.bookNumber); + } + } + flushFullBooks(); + return segments; +} + +/** + * Returns one localized string with all segments joined into a conjunction list, e.g. "Genesis, Exodus 1-10, and + * Leviticus". + */ +export function formatBookDisplaySegments(segments: BookDisplaySegment[], i18n: I18nService): string { + const labels = segments.flatMap(segment => + segment.kind === 'range' + ? i18n.localizeBookRangeLabels(segment.bookNumbers) + : // Parenthesize only a multi-part chapter range, so "Genesis 1-3, 7" doesn't read as separate list items. + [ + i18n.localizeBookWithChapters(segment.bookId, segment.chapterRange, { + grouped: segment.chapterRange.includes(',') + }) + ] + ); + return i18n.enumerateList(labels); +} + +/** + * Formats a scripture range string (e.g. "GEN1-3;EXO") for display, keeping chapter detail: + * - a partly-covered book renders with its chapters: "Genesis 1-3" + * - a full book renders by name, and adjacent full books collapse into "Genesis - Leviticus" + * (pass `collapseFullBookRuns: false` to list each full book by name instead) + * + * A book counts as full when it names no chapters, or when its chapters reach the canonical count + * (`expectedBookChapters`). Caveat: a non-standard versification can make that count off by one at the end of a + * book (same limitation as the draft preview buttons). Unknown book ids are skipped. + */ +export function formatScriptureRangeWithChapters( + range: VerboseScriptureRange, + i18n: I18nService, + { collapseFullBookRuns = true }: { collapseFullBookRuns?: boolean } = {} +): string { + const books = booksWithChapterRanges(range); + const segments = collapseFullBookRuns + ? segmentBooksForDisplay(books) + : [...books].sort((a, b) => a.bookNumber - b.bookNumber).flatMap(book => segmentBooksForDisplay([book])); + return formatBookDisplaySegments(segments, i18n); +} + +/** + * Compact, non-localized form of a scripture range for admin pages, e.g. "GEN-LEV; NUM 1-3, 7". Applies the same + * full-book rules as {@link formatScriptureRangeWithChapters}: a book whose chapters reach the canonical count + * renders as just its ID, and contiguous full books collapse into an ID range. + */ +export function formatScriptureRangeCompact(range: VerboseScriptureRange): string { + return segmentBooksForDisplay(booksWithChapterRanges(range)) + .flatMap(segment => + segment.kind === 'range' + ? groupContiguousBookNumbers(segment.bookNumbers).map(group => + group.kind === 'single' + ? Canon.bookNumberToId(group.bookNumber) + : `${Canon.bookNumberToId(group.start)}-${Canon.bookNumberToId(group.end)}` + ) + : [`${segment.bookId} ${segment.chapterRange}`] + ) + .join('; '); +} + +/** + * Compact display of raw scripture-range tokens (e.g. `["GEN", "EXO1-3"]`), tolerating tokens that don't parse + * cleanly as a range. Book ids can come from legacy/free-form event-metric payloads, so a parse failure falls back + * to the raw tokens joined with "; " instead of throwing. + */ +export function formatScriptureRangeTokensCompact(tokens: string[]): string { + try { + const formatted = formatScriptureRangeCompact(new VerboseScriptureRange(tokens.join(';'))); + if (formatted !== '' || tokens.length === 0) return formatted; + } catch {} + return tokens.join('; '); +} + +/** + * The books of a range with each book's chapter range for display, or null for a full book (one that names no + * chapters, or whose chapters reach the canonical count from `expectedBookChapters`). Unknown book ids are skipped. + */ +function booksWithChapterRanges(range: VerboseScriptureRange): { bookNumber: number; chapterRange: string | null }[] { + const books: { bookNumber: number; chapterRange: string | null }[] = []; + for (const [bookId, chapters] of range.books) { + const bookNumber = Canon.bookIdToNumber(bookId); + if (bookNumber <= 0) continue; + const isPartial = chapters.count() > 0 && chapters.count() < expectedBookChapters(bookId); + books.push({ bookNumber, chapterRange: isPartial ? chapters.toStringForDisplay() : null }); + } + return books; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.spec.ts index a356882c170..6374826cf18 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.spec.ts @@ -1,4 +1,9 @@ -import { ChapterSet, VerboseScriptureRange } from './scripture-range'; +import { + ChapterSet, + overlayChapterDetail, + trainingSourceRangesWithTargetDetail, + VerboseScriptureRange +} from './scripture-range'; describe('ScriptureRange', () => { describe('ChapterSet', () => { @@ -184,5 +189,70 @@ describe('ScriptureRange', () => { it('combines an empty list into an empty range', () => { expect(VerboseScriptureRange.fromCombinedRanges([]).toString()).toBe(''); }); + + it('overlays chapter detail onto whole-book tokens only', () => { + // GEN and EXO are whole-book tokens (no chapters); LEV already names chapters and is left alone. + const range = new VerboseScriptureRange('GEN;EXO;LEV1-2'); + const detail = new VerboseScriptureRange('GEN1-3;LEV5-10'); + expect(overlayChapterDetail(range, detail).toString()).toBe('GEN1-3;EXO;LEV1-2'); + }); + + it('leaves a whole-book token as-is when detail has nothing for it', () => { + const range = new VerboseScriptureRange('GEN'); + expect(overlayChapterDetail(range, new VerboseScriptureRange('EXO1-3')).toString()).toBe('GEN'); + }); + + it('does not mutate the original range', () => { + const range = new VerboseScriptureRange('GEN'); + overlayChapterDetail(range, new VerboseScriptureRange('GEN1-3')); + expect(range.toString()).toBe('GEN'); + }); + }); + + describe('trainingSourceRangesWithTargetDetail', () => { + function sourceRanges( + ranges: { projectId?: string; scriptureRange?: string }[], + targetProjectId: string | undefined + ): { projectId?: string; scriptureRange?: string }[] { + return trainingSourceRangesWithTargetDetail(ranges, r => r.projectId, targetProjectId); + } + + it('drops the target entry and overlays its chapter detail onto the source entries', () => { + expect( + sourceRanges( + [ + { projectId: 'source01', scriptureRange: 'GEN;EXO' }, + { projectId: 'target01', scriptureRange: 'GEN1-3;EXO1-40' } + ], + 'target01' + ) + ).toEqual([{ projectId: 'source01', scriptureRange: 'GEN1-3;EXO1-40' }]); + }); + + it('passes legacy ranges through unchanged when there is no target entry', () => { + const legacy = [ + { projectId: 'source01', scriptureRange: 'GEN;EXO' }, + { projectId: 'source02', scriptureRange: 'LEV' } + ]; + expect(sourceRanges(legacy, 'target01')).toEqual(legacy); + }); + + it('drops entries with an empty range, such as the target entry of a build with no training books', () => { + expect( + sourceRanges( + [ + { projectId: 'target01', scriptureRange: '' }, + { projectId: 'source01', scriptureRange: '' } + ], + 'target01' + ) + ).toEqual([]); + }); + + it('does not treat an entry with an unknown project as the target when the target ID is unknown', () => { + expect(sourceRanges([{ projectId: undefined, scriptureRange: 'GEN' }], undefined)).toEqual([ + { projectId: undefined, scriptureRange: 'GEN' } + ]); + }); }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.ts index 224d1a1f74c..4243108a787 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.ts @@ -253,3 +253,46 @@ export class VerboseScriptureRange { } } } + +/** + * Returns a copy of `range` where each book listed without chapters takes its chapter list from `detail` + * (when `detail` has chapters for it). Books that already specify chapters are unchanged, and books that + * appear only in `detail` are not added. + */ +export function overlayChapterDetail( + range: VerboseScriptureRange, + detail: VerboseScriptureRange +): VerboseScriptureRange { + const result = range.clone(); + for (const [bookId, chapters] of result.books) { + if (chapters.count() > 0) continue; + const detailChapters = detail.books.get(bookId); + if (detailChapters != null && detailChapters.count() > 0) { + result.books.set(bookId, detailChapters.clone()); + } + } + return result; +} + +/** + * Prepares a build's training scripture ranges for display as training sources. The target project's entry is not + * a source: it records the chapter-level training selection, so it is removed and its chapter detail is overlaid + * onto each source entry instead. Entries with an empty range are dropped (a build with no training books still + * stores an empty entry for the target). Ranges from the legacy stepper have no target entry and pass through + * unchanged. + */ +export function trainingSourceRangesWithTargetDetail( + ranges: readonly T[], + projectIdOf: (range: T) => string | undefined, + targetProjectId: string | undefined +): T[] { + // Guard against a null target ID matching entries whose own project ID is unknown + const isTargetEntry = (r: T): boolean => targetProjectId != null && projectIdOf(r) === targetProjectId; + const targetDetail = new VerboseScriptureRange(ranges.find(isTargetEntry)?.scriptureRange ?? ''); + return ranges + .filter(r => !isTargetEntry(r) && r.scriptureRange != null && r.scriptureRange !== '') + .map(r => ({ + ...r, + scriptureRange: overlayChapterDetail(new VerboseScriptureRange(r.scriptureRange!), targetDetail).toString() + })); +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts index a277b26ebd8..41b220b8ef4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts @@ -1370,8 +1370,8 @@ describe('DraftGenerationStepsComponent', () => { it('should localize, group, and collapse the books to use in training', () => { const trainingGroups = component.selectedTrainingBooksCollapsed(); expect(trainingGroups.length).toEqual(2); - expect(trainingGroups[0].ranges).toEqual('Genesis - Exodus'); - expect(trainingGroups[1].ranges).toEqual('Genesis - Exodus and Numbers'); + expect(trainingGroups[0].ranges).toEqual('Genesis and Exodus'); + expect(trainingGroups[1].ranges).toEqual('Genesis, Exodus, and Numbers'); }); it('should show that the training books was empty', () => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.ts index 042235dbdc2..a49eb3359c3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.ts @@ -14,7 +14,6 @@ import { NgCircleProgressModule } from 'ng-circle-progress'; import { TranslocoMarkupModule } from 'ngx-transloco-markup'; import { RouterLink } from 'ngx-transloco-markup-router-link'; import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role'; -import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { ProjectType } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; import { asyncScheduler, combineLatest, of, Subscription } from 'rxjs'; import { catchError, filter, switchMap, tap, throttleTime } from 'rxjs/operators'; @@ -34,6 +33,7 @@ import { issuesEmailTemplate } from 'xforge-common/utils'; import { environment } from '../../../environments/environment'; import { SelectableProject } from '../../core/models/selectable-project'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; +import { PermissionsService } from '../../core/permissions.service'; import { SFProjectService } from '../../core/sf-project.service'; import { BuildDto } from '../../machine-api/build-dto'; import { BuildStates } from '../../machine-api/build-states'; @@ -161,6 +161,7 @@ export class DraftGenerationComponent extends DataLoadingComponent implements On protected readonly draftOptionsService: DraftOptionsService, private readonly projectService: SFProjectService, private readonly featureFlags: FeatureFlagService, + private readonly permissions: PermissionsService, private destroyRef: DestroyRef ) { super(noticeService, 'DraftGenerationComponent'); @@ -210,15 +211,7 @@ export class DraftGenerationComponent extends DataLoadingComponent implements On } get hasConfigureSourcePermission(): boolean { - return this.isProjectAdmin; - } - - private get isProjectAdmin(): boolean { - const userId = this.authService.currentUserId; - if (userId != null) { - return this.activatedProject.projectDoc?.data?.userRoles[userId] === SFProjectRole.ParatextAdministrator; - } - return false; + return this.permissions.canConfigureSources(this.activatedProject.projectDoc); } ngOnInit(): void { 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 57ed19adf95..75305dbf2e6 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 @@ -115,20 +115,20 @@ {{ t("training_books") }} - {{ i18n.formatAndLocalizeScriptureRange(element.scriptureRange) }} + {{ getTrainingRangeDisplay(element.scriptureRange) }} {{ i18n.getLanguageDisplayName(sourceLanguage) }} - {{ element.source }} + {{ element.source ?? t("draft_unknown") }} {{ i18n.getLanguageDisplayName(targetLanguage) }} - {{ element.target }} + {{ element.target ?? t("draft_unknown") }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts index 5d8da196326..9f7ddde786c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts @@ -182,6 +182,60 @@ describe('DraftHistoryEntryComponent', () => { expect(fixture.nativeElement.querySelector('.requested-label')).not.toBeNull(); })); + it('should not treat an empty target project training entry as training configuration', fakeAsync(() => { + // A build with no training books still stores an empty entry for the target project (the target side of the + // training pairs). It is not training configuration. + const entry = getStandardBuildDto({ trainingBooks: [], trainingDataFiles: [] }); + entry.additionalInfo!.trainingScriptureRanges = [{ projectId: 'project01', scriptureRange: '' }]; + + // SUT + component.entry = entry; + tick(); + fixture.detectChanges(); + + expect(component.hasTrainingConfiguration).toBe(false); + expect(fixture.nativeElement.querySelector('.no-training-configuration')).not.toBeNull(); + })); + + it('should show the target training entry as chapter detail on source rows, not as its own row', fakeAsync(() => { + const entry = getStandardBuildDto({ trainingBooks: ['GEN', 'EXO'], trainingDataFiles: [] }); + entry.additionalInfo!.trainingScriptureRanges = [ + { projectId: 'project02', scriptureRange: 'GEN;EXO' }, + // The target project entry carries the chapter-level selection: part of Genesis, all of Exodus + { projectId: 'project01', scriptureRange: 'GEN1-3;EXO1-40' } + ]; + // Leave the draft unavailable so the training configuration auto-expands + entry.additionalInfo!.dateGenerated = undefined; + component.draftIsAvailable = false; + + // SUT + component.entry = entry; + component.isLatestBuild = true; + tick(); + fixture.detectChanges(); + + expect(component.trainingConfiguration).toEqual([ + { + scriptureRange: 'GEN1-3;EXO1-40', + source: 'src', + target: 'tar' + } + ]); + expect(fixture.nativeElement.querySelector('.scriptureRange').innerText).toBe('Genesis 1-3 and Exodus'); + })); + + it('should show chapter detail in the title for a partial-book draft', fakeAsync(() => { + const entry = getStandardBuildDto({ translateBooks: ['GEN3-5', 'EXO'] }); + + // SUT + component.entry = entry; + tick(); + fixture.detectChanges(); + + expect(component.scriptureRange).toEqual('GEN3-5;EXO'); + expect(fixture.nativeElement.querySelector('.title').innerText).toBe('Genesis 3-5 and Exodus'); + })); + it('should show the USFM format option when the project is the latest draft', fakeAsync(() => { when(mockedDraftOptionsService.areFormattingOptionsAvailableButUnselected(anything())).thenReturn(false); when(mockedDraftOptionsService.areFormattingOptionsSupportedForBuild(anything())).thenReturn(true); @@ -239,12 +293,15 @@ describe('DraftHistoryEntryComponent', () => { expect(component.trainingConfiguration).toEqual([ { scriptureRange: 'EXO', - source: 'Unknown', - target: 'Unknown' + source: undefined, + target: undefined } ]); expect(component.trainingConfigurationOpen).toBe(true); expect(fixture.nativeElement.querySelector('.scriptureRange').innerText).toBe('Exodus'); + // An unknown source or target renders as a localized "Unknown" + expect(fixture.nativeElement.querySelector('td.mat-column-source').innerText).toBe('Unknown'); + expect(fixture.nativeElement.querySelector('td.mat-column-target').innerText).toBe('Unknown'); })); it('should handle builds with additional info referencing a deleted user', fakeAsync(() => { 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 1ef11e8929f..0df0fe4fb06 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 @@ -35,7 +35,8 @@ import { SFProjectService } from '../../../../core/sf-project.service'; import { BuildDto } from '../../../../machine-api/build-dto'; import { BuildStates } from '../../../../machine-api/build-states'; import { NoticeComponent } from '../../../../shared/notice/notice.component'; -import { booksFromScriptureRange } from '../../../../shared/utils'; +import { trainingSourceRangesWithTargetDetail, VerboseScriptureRange } from '../../../../shared/scripture-range'; +import { formatScriptureRangeWithChapters } from '../../../../shared/scripture-range-display'; import { RIGHT_TO_LEFT_MARK } from '../../../../shared/verse-utils'; import { DraftDownloadButtonComponent } from '../../draft-download-button/draft-download-button.component'; import { DraftImportWizardComponent } from '../../draft-import-wizard/draft-import-wizard.component'; @@ -61,8 +62,9 @@ interface BuildFaultedRow { interface TrainingConfigurationRow { scriptureRange: string; - source: string; - target: string; + /** Project short name; undefined renders as the localized "Unknown". */ + source?: string; + target?: string; } interface SourceInfo { @@ -129,12 +131,17 @@ export class DraftHistoryEntryComponent { this._targetLanguage = undefined; this._trainingConfiguration = []; - // Get the books used in the training configuration - const trainingScriptureRanges = this._entry?.additionalInfo?.trainingScriptureRanges ?? []; + // A build's engine ID is the ID of the project the draft is for (the DTO has no other field carrying it). + const targetProjectId: string | undefined = value?.engine?.id; + // One table row per training source, with the target project's entry folded in as chapter detail. + const trainingScriptureRanges = trainingSourceRangesWithTargetDetail( + this._entry?.additionalInfo?.trainingScriptureRanges ?? [], + r => r.projectId, + targetProjectId + ); void Promise.all( trainingScriptureRanges.map(async r => { - // The engine ID is the target project ID - const { target, source } = await this.getProjectSourceInfo(value?.engine.id, r.projectId, 'training'); + const { target, source } = await this.getProjectSourceInfo(targetProjectId, r.projectId, 'training'); // Get the target language, if it is not already set this._targetLanguage ??= target?.data?.writingSystem?.tag; @@ -145,8 +152,8 @@ export class DraftHistoryEntryComponent { // Return the data for this training range return { scriptureRange: r.scriptureRange, - source: source?.shortName ?? this.i18n.translateStatic('draft_history_entry.draft_unknown'), - target: target?.data?.shortName ?? this.i18n.translateStatic('draft_history_entry.draft_unknown') + source: source?.shortName, + target: target?.data?.shortName } as TrainingConfigurationRow; }) ).then(trainingConfiguration => { @@ -365,7 +372,14 @@ export class DraftHistoryEntryComponent { } getScriptureRangeAsLocalizedBooks(scriptureRange: string): string { - return this.i18n.enumerateList(booksFromScriptureRange(scriptureRange).map(b => this.i18n.localizeBook(b))); + return formatScriptureRangeWithChapters(new VerboseScriptureRange(scriptureRange), this.i18n, { + collapseFullBookRuns: false + }); + } + + // Called from the template (rather than precomputed) so the text re-localizes when the user switches languages. + getTrainingRangeDisplay(scriptureRange: string): string { + return formatScriptureRangeWithChapters(new VerboseScriptureRange(scriptureRange), this.i18n); } openImportWizard(): void { 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 bfbd3160f94..83f2fdf862c 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,9 +1,10 @@ @for (book of booksWithDrafts$ | async; track book.bookId) { } @empty { 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 3d45a5b8cfc..1a0bac6669e 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 @@ -85,7 +85,7 @@ describe('DraftPreviewBooks', () => { } as BuildDto }); - expect(env.getBookButtonAtIndex(0).textContent).toContain('2-3'); + expect(env.getBookButtonAtIndex(0).textContent!.trim()).toBe('Genesis 2-3'); env.getBookButtonAtIndex(0).click(); tick(); @@ -103,7 +103,7 @@ describe('DraftPreviewBooks', () => { } as BuildDto }); - expect(env.getBookButtonAtIndex(0).textContent).not.toContain('('); + expect(env.getBookButtonAtIndex(0).textContent!.trim()).toBe('Genesis'); })); }); 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 827b63dd72f..b1e74cc77e8 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 @@ -7,6 +7,7 @@ import { Canon } from '@sillsdev/scripture'; import { TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { map, Observable, tap } from 'rxjs'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { I18nService } from 'xforge-common/i18n.service'; import { filterNullish } from 'xforge-common/util/rxjs-util'; import { BuildDto } from '../../../machine-api/build-dto'; import { expectedBookChapters } from '../../../shared/progress-service/progress.service'; @@ -55,7 +56,8 @@ export class DraftPreviewBooksComponent { constructor( private readonly activatedProjectService: ActivatedProjectService, - private readonly router: Router + private readonly router: Router, + readonly i18n: I18nService ) {} linkForBookAndChapter(bookId: string, chapterNumber: number): string[] { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html index ac6c62be3aa..7eea04b237d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html @@ -39,9 +39,11 @@ @for (msg of copyrightMessages; track msg.banner) { } - + @if (canConfigureSources) { + + } } @else if (page === "draft_books") {

{{ t("draft_books.title") }}

(ErrorHandler); const mockedTrainingDataService = mock(TrainingDataService); const mockedSFProjectService = mock(SFProjectService); +const mockedPermissionsService = mock(PermissionsService); interface TestState { draftingSourceBooksChapters: string; @@ -1237,6 +1239,7 @@ class TestEnvironment { when(mockedFeatureFlagService.showDeveloperTools).thenReturn(createTestFeatureFlag(false)); when(mockedUserService.getCurrentUser()).thenResolve(undefined as any); + when(mockedPermissionsService.canConfigureSources(anything())).thenReturn(true); when(mockedNllbLanguageService.isNllbLanguageAsync(anything())).thenResolve(options.trainingOptional === true); // Set the online state before the component is constructed so init()'s online check sees it. this.onlineStatusService.setIsOnline(!options.offline); @@ -1262,6 +1265,7 @@ class TestEnvironment { instance(mockedErrorHandler), instance(mockedTrainingDataService), instance(mockedSFProjectService), + instance(mockedPermissionsService), { onDestroy: () => () => {} } as unknown as DestroyRef ); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts index 4a8e2df394e..a2273ccabfb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts @@ -299,6 +299,7 @@ function setUpMocks(args: StoryArgs): void { remoteChanges$: of() } as unknown as SFProjectDoc); when(mockedPermissionsService.canSync(anything())).thenReturn(true); + when(mockedPermissionsService.canConfigureSources(anything())).thenReturn(true); } const meta: Meta = { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts index 1b54a784125..8db2588a8c4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts @@ -26,6 +26,7 @@ import { hasStringProp } from '../../../../type-utils'; import { ParatextProject } from '../../../core/models/paratext-project'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { ParatextService } from '../../../core/paratext.service'; +import { PermissionsService } from '../../../core/permissions.service'; import { SFProjectService } from '../../../core/sf-project.service'; import { Book } from '../../../shared/book-multi-select/book-multi-select'; import { BookMultiSelectComponent } from '../../../shared/book-multi-select/book-multi-select.component'; @@ -134,6 +135,7 @@ export class NewDraftComponent { private readonly errorHandler: ErrorHandler, private readonly trainingDataService: TrainingDataService, private readonly projectService: SFProjectService, + private readonly permissions: PermissionsService, private readonly destroyRef: DestroyRef ) { this.logicHandler = new NewDraftLogicHandler( @@ -852,10 +854,12 @@ export class NewDraftComponent { /** Selected drafting book names with chapter ranges (for partially-drafted books), as bulleted-list items. */ get draftingBookListItems(): string[] { - return this.draftingItems.map(item => { - const bookName = this.i18n.localizeBook(item.bookId); - return item.chapterRange != null ? `${bookName} (${item.chapterRange})` : bookName; - }); + // Each book is on its own line, so the chapter range never needs parentheses to set it apart. + return this.draftingItems.map(item => + item.chapterRange != null + ? this.i18n.localizeBookWithChapters(item.bookId, item.chapterRange) + : this.i18n.localizeBook(item.bookId) + ); } /** Selected drafting book names without chapter detail, as a locale-aware conjunction list (for the page title). */ @@ -943,6 +947,10 @@ export class NewDraftComponent { return this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig?.servalConfig != null; } + get canConfigureSources(): boolean { + return this.permissions.canConfigureSources(this.activatedProjectService.projectDoc); + } + goToConfigureSources(): void { const projectId = this.initData?.projectId; if (projectId != null) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts index b3a2c33d771..47ba6db8345 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts @@ -65,31 +65,31 @@ describe('formatTrainingBooksSummary', () => { it('breaks a partial book in the middle out of the range and shows its chapters', () => { expect(format([GEN, EXO, LEV], 'GEN1-50;EXO1-10;LEV1-27', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( - 'Genesis, Exodus (1-10), and Leviticus' + 'Genesis, Exodus 1-10, and Leviticus' ); }); it('handles a partial book at the start', () => { expect(format([GEN, EXO, LEV], 'GEN1-10;EXO1-40;LEV1-27', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( - 'Genesis (1-10) and Exodus - Leviticus' + 'Genesis 1-10, Exodus, and Leviticus' ); }); it('handles a partial book at the end', () => { expect(format([GEN, EXO, LEV], 'GEN1-50;EXO1-40;LEV1-10', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( - 'Genesis - Exodus and Leviticus (1-10)' + 'Genesis, Exodus, and Leviticus 1-10' ); }); it('does not merge full books across a partial book', () => { expect(format([GEN, EXO, LEV, NUM], 'GEN1-50;EXO1-5;LEV1-27;NUM1-36', 'GEN1-50;EXO1-40;LEV1-27;NUM1-36')).toEqual( - 'Genesis, Exodus (1-5), and Leviticus - Numbers' + 'Genesis, Exodus 1-5, Leviticus, and Numbers' ); }); it('shows the training chapter range for a partially-drafted book whose remaining chapters are all selected', () => { // Draft the latter two-thirds (GEN18-50); the complete first third (GEN1-17) is available and fully selected. - expect(format([GEN], 'GEN1-17', 'GEN1-17', 'GEN18-50')).toEqual('Genesis (1-17)'); + expect(format([GEN], 'GEN1-17', 'GEN1-17', 'GEN18-50')).toEqual('Genesis 1-17'); }); it('formats non-contiguous selected chapters with spaces after commas', () => { @@ -98,6 +98,6 @@ describe('formatTrainingBooksSummary', () => { it('treats a book missing from the target training selection as full (defensive)', () => { // EXO has no entry in the selection; it should not crash and should render as a full book. - expect(format([GEN, EXO], 'GEN1-50', 'GEN1-50;EXO1-40')).toEqual('Genesis - Exodus'); + expect(format([GEN, EXO], 'GEN1-50', 'GEN1-50;EXO1-40')).toEqual('Genesis and Exodus'); }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts index 16a4d998dfa..eec2c09f7d8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts @@ -1,14 +1,11 @@ import { Canon } from '@sillsdev/scripture'; import { I18nService } from 'xforge-common/i18n.service'; import { ChapterSet, VerboseScriptureRange } from '../../../shared/scripture-range'; - -/** - * A contiguous run of fully-used training books (rendered as a book range), or a single book that is only partly - * used as training data (rendered with its chapter range). Partial books are never absorbed into a range. - */ -export type TrainingBookSegment = - | { kind: 'range'; bookNumbers: number[] } - | { kind: 'partial'; bookId: string; chapterRange: string }; +import { + BookDisplaySegment, + formatBookDisplaySegments, + segmentBooksForDisplay +} from '../../../shared/scripture-range-display'; const EMPTY_CHAPTER_SET = new ChapterSet(''); @@ -29,32 +26,17 @@ export function segmentTrainingBooks( selectedTargetTraining: VerboseScriptureRange, availableTargetTraining: VerboseScriptureRange, selectedDrafting: VerboseScriptureRange -): TrainingBookSegment[] { - const segments: TrainingBookSegment[] = []; - let pendingFullBooks: number[] = []; - const flushFullBooks = (): void => { - if (pendingFullBooks.length > 0) { - segments.push({ kind: 'range', bookNumbers: pendingFullBooks }); - pendingFullBooks = []; - } - }; - - for (const bookNumber of [...bookNumbers].sort((a, b) => a - b)) { +): BookDisplaySegment[] { + const books = bookNumbers.map(bookNumber => { const bookId = Canon.bookNumberToId(bookNumber); const selected = selectedTargetTraining.books.get(bookId); const available = availableTargetTraining.books.get(bookId) ?? EMPTY_CHAPTER_SET; const drafted = selectedDrafting.books.get(bookId) ?? EMPTY_CHAPTER_SET; const wholeBook = available.union(drafted); const isPartial = selected != null && wholeBook.difference(selected).count() > 0; - if (isPartial) { - flushFullBooks(); - segments.push({ kind: 'partial', bookId, chapterRange: selected!.toStringForDisplay() }); - } else { - pendingFullBooks.push(bookNumber); - } - } - flushFullBooks(); - return segments; + return { bookNumber, chapterRange: isPartial ? selected.toStringForDisplay() : null }; + }); + return segmentBooksForDisplay(books); } /** @@ -68,15 +50,8 @@ export function formatTrainingBooksSummary( selectedDrafting: VerboseScriptureRange, i18n: I18nService ): string { - const labels = segmentTrainingBooks( - bookNumbers, - selectedTargetTraining, - availableTargetTraining, - selectedDrafting - ).map(segment => - segment.kind === 'range' - ? i18n.formatAndLocalizeBookRange(segment.bookNumbers) - : `${i18n.localizeBook(segment.bookId)} (${segment.chapterRange})` + return formatBookDisplaySegments( + segmentTrainingBooks(bookNumbers, selectedTargetTraining, availableTargetTraining, selectedDrafting), + i18n ); - return i18n.enumerateList(labels); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json index 8729dff2462..f2d3d976680 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json +++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json @@ -172,7 +172,9 @@ "REP": "Reproof (Proverbs 25-31)", "4BA": "4 Baruch (Rest of Baruch)", "LAO": "Laodiceans" - } + }, + "book_with_chapters": "{{ book }} {{ chapters }}", + "book_with_chapters_grouped": "{{ book }} ({{ chapters }})" }, "chapter_nav": { "previous_chapter": "Previous chapter", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts index f823d36ef8d..ea4554c9443 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts @@ -250,7 +250,7 @@ describe('I18nService', () => { const service = getI18nService(); // SUT - expect(service.formatAndLocalizeScriptureRange('EXO;GEN;MRK')).toBe('Genesis - Exodus and Mark'); + expect(service.formatAndLocalizeScriptureRange('EXO;GEN;MRK')).toBe('Genesis, Exodus, and Mark'); }); it('should localize multiple sequences of books', () => { @@ -266,7 +266,7 @@ describe('I18nService', () => { // SUT expect(service.formatAndLocalizeScriptureRange('JHN;PSA;GEN;LUK;JOB;MAT;MRK')).toBe( - 'Genesis, Job - Psalms, and Matthew - John' + 'Genesis, Job, Psalms, and Matthew - John' ); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts index 91dd65bf2df..20a4f66c430 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts @@ -7,6 +7,7 @@ import { CookieService } from 'ngx-cookie-service'; import { BehaviorSubject, Observable, of, zip } from 'rxjs'; import { map } from 'rxjs/operators'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { groupContiguousBookNumbers } from '../app/shared/scripture-range-display'; import { booksFromScriptureRange } from '../app/shared/utils'; import enChecking from '../assets/i18n/checking_en.json'; import enNonChecking from '../assets/i18n/non_checking_en.json'; @@ -240,6 +241,17 @@ export class I18nService { return `${this.localizeBook(book)} ${this.getDirectionMark()}${chapter}`; } + /** + * Localizes a book name followed by a chapter range, e.g. "Exodus 1-12". With `grouped: true` the chapters are + * wrapped in the locale's parentheses, e.g. "Exodus (1-3, 7)". This is intended for when the result sits in a list + * and a multi-part range would otherwise read as separate list items. For example "Genesis 1-3, 7, Exodus" reads + * poorly and benefits from parentheses around the the chapter list for Genesis. + */ + localizeBookWithChapters(book: number | string, chapters: string, options: { grouped?: boolean } = {}): string { + const key: I18nKey = options.grouped === true ? 'canon.book_with_chapters_grouped' : 'canon.book_with_chapters'; + return this.transloco.translate(key, { book: this.localizeBook(book), chapters }); + } + formatAndLocalizeScriptureRange(scriptureRange: string): string { const bookNumbers: number[] = booksFromScriptureRange(scriptureRange) .filter(i => i > 0) @@ -248,33 +260,23 @@ export class I18nService { } formatAndLocalizeBookRange(bookNumbers: number[]): string { - if (bookNumbers.length === 0) return ''; - - const ranges: string[] = []; - let start: number = bookNumbers[0]; - let end: number = bookNumbers[0]; - - if (bookNumbers.length === 1) return this.localizeBook(start); - - for (let i = 1; i < bookNumbers.length; i++) { - const current: number = bookNumbers[i]; - if (current === end + 1) { - end = current; - } else { - const fromBook: string = this.localizeBook(start); - const toBook: string = this.localizeBook(end); - ranges.push(start === end ? fromBook : `${fromBook} - ${toBook}`); - start = current; - end = current; - } - } - - // Handle the last book in the scripture range - const fromBook: string = this.localizeBook(start); - const toBook: string = this.localizeBook(end); - ranges.push(start === end ? fromBook : `${fromBook} - ${toBook}`); + return this.enumerateList(this.localizeBookRangeLabels(bookNumbers)); + } - return this.enumerateList(ranges); + /** + * Returns one localized label per contiguous group of book numbers (see {@link groupContiguousBookNumbers}), e.g. + * ["Genesis", "Exodus - Leviticus"]. The labels are not joined into a list string. Use this instead of + * {@link formatAndLocalizeBookRange} when the labels still need to be combined with other labels before a + * single, final {@link enumerateList} call. Calling {@link enumerateList} twice (once here, once on the outer + * list) nests one conjunction inside another: "Genesis 1-10 and Exodus and Leviticus" instead of the correct + * "Genesis 1-10, Exodus, and Leviticus". + */ + localizeBookRangeLabels(bookNumbers: number[]): string[] { + return groupContiguousBookNumbers(bookNumbers).map(group => + group.kind === 'single' + ? this.localizeBook(group.bookNumber) + : `${this.localizeBook(group.start)} - ${this.localizeBook(group.end)}` + ); } localizeReference(verse: VerseRef): string { From 0c7bd644f167ce735b44e0c6619de138df89104c Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Mon, 13 Jul 2026 22:21:59 -0400 Subject: [PATCH 13/17] Improve post-sync progress fetching and display of training draft in draft history --- .../e2e/workflows/partial-book-drafting.ts | 6 +- .../progress-service/progress.service.spec.ts | 134 +++++++++++++++++- .../progress-service/progress.service.ts | 46 ++++-- .../draft-generation.component.spec.ts | 2 +- .../draft-history-entry.component.spec.ts | 6 +- .../draft-history-entry.component.ts | 1 - .../new-draft/new-draft-logic-handler.spec.ts | 39 +++-- .../new-draft/new-draft-logic-handler.ts | 67 ++++----- .../new-draft/new-draft.component.html | 28 ++-- .../new-draft/new-draft.component.scss | 8 +- .../new-draft/new-draft.component.spec.ts | 53 ++++--- .../new-draft/new-draft.component.stories.ts | 16 +-- .../new-draft/new-draft.component.ts | 18 ++- .../src/assets/i18n/non_checking_en.json | 2 - .../ClientApp/src/styles.scss | 14 ++ .../Models/BookProgress.cs | 4 + 16 files changed, 308 insertions(+), 136 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts index b154fc7b569..65de6b3c476 100644 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts @@ -202,7 +202,7 @@ async function selectTrainingData(page: Page, user: UserEmulator, context: Scree /** The summary should reflect the partial book selection; set the engine and launch. */ async function reviewSummaryAndGenerate(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { - await expect(page.locator('.draft-books-list')).toContainText(`${PARTIAL_BOOK_NAME} (${DRAFT_CHAPTERS})`); + await expect(page.locator('.draft-books-list')).toContainText(`${PARTIAL_BOOK_NAME} ${DRAFT_CHAPTERS}`); if (ENGINE_MODE === 'echo') { await user.check(page.getByRole('checkbox', { name: 'Echo Translation Engine' })); @@ -268,9 +268,7 @@ async function importDraft(page: Page, user: UserEmulator, context: ScreenshotCo await user.click(page.getByRole('button', { name: 'Formatting options' })); await user.click(page.getByRole('button', { name: 'Save' })); - await expect( - page.getByRole('button', { name: new RegExp(`${PARTIAL_BOOK_NAME}\\s*\\(${DRAFT_CHAPTERS}\\)`) }) - ).toBeVisible(); + await expect(page.getByRole('button', { name: `${PARTIAL_BOOK_NAME} ${DRAFT_CHAPTERS}`, exact: true })).toBeVisible(); await user.click(page.getByRole('button', { name: 'Add to a project' })); await user.click(page.getByRole('combobox', { name: 'Choose a project' })); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.spec.ts index 63171071e68..33e1be9d67e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.spec.ts @@ -1,6 +1,7 @@ import { fakeAsync, flushMicrotasks } from '@angular/core/testing'; -import { instance, mock, reset, verify, when } from 'ts-mockito'; +import { anything, instance, mock, reset, verify, when } from 'ts-mockito'; import { NoticeService } from 'xforge-common/notice.service'; +import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SFProjectService } from '../../core/sf-project.service'; import { BookProgress, @@ -144,6 +145,126 @@ describe('ProgressService', () => { verify(mockedProjectService.getProjectProgress(projectId)).twice(); })); + it('refetches despite a warm cache when the project has synced since the data was fetched', fakeAsync(() => { + const env = new TestEnvironment(); + const projectId = 'project1'; + const preSyncBooks: BookProgressWithChapterProgress[] = [ + { bookId: 'GEN', verseSegments: 100, blankVerseSegments: 20, chapters: [] } + ]; + const postSyncBooks: BookProgressWithChapterProgress[] = [ + { bookId: 'GEN', verseSegments: 120, blankVerseSegments: 15, chapters: [] } + ]; + when(mockedProjectService.getProjectProgress(projectId)).thenResolve(preSyncBooks).thenResolve(postSyncBooks); + + let result1: ProjectProgress | undefined; + env.service.getProgress(projectId, { maxStalenessMs: 60_000 }).then(r => (result1 = r)); + flushMicrotasks(); + + // A sync completes; the cached data is well within the staleness window but must not be served anymore. + env.setLastSyncDateString(projectId, 'post-sync'); + let result2: ProjectProgress | undefined; + env.service.getProgress(projectId, { maxStalenessMs: 60_000 }).then(r => (result2 = r)); + flushMicrotasks(); + + expect(result1?.books).toEqual(preSyncBooks); + expect(result2?.books).toEqual(postSyncBooks); + verify(mockedProjectService.getProjectProgress(projectId)).twice(); + })); + + it('coalesces sibling reads issued after a sync while ignoring a request from before it', fakeAsync(() => { + const env = new TestEnvironment(); + const projectId = 'project1'; + const preSyncBooks: BookProgressWithChapterProgress[] = [ + { bookId: 'GEN', verseSegments: 100, blankVerseSegments: 20, chapters: [] } + ]; + const postSyncBooks: BookProgressWithChapterProgress[] = [ + { bookId: 'GEN', verseSegments: 120, blankVerseSegments: 15, chapters: [] } + ]; + let resolvePreSync: ((value: BookProgressWithChapterProgress[]) => void) | undefined; + let resolvePostSync: ((value: BookProgressWithChapterProgress[]) => void) | undefined; + const preSyncPromise: Promise = new Promise( + resolve => (resolvePreSync = resolve) + ); + const postSyncPromise: Promise = new Promise( + resolve => (resolvePostSync = resolve) + ); + when(mockedProjectService.getProjectProgress(projectId)).thenReturn(preSyncPromise).thenReturn(postSyncPromise); + + // A request from before the sync is in flight... + let result1: ProjectProgress | undefined; + env.service.getProgress(projectId, { maxStalenessMs: 60_000 }).then(r => (result1 = r)); + flushMicrotasks(); + + // ...then the sync completes and two sibling reads arrive. + env.setLastSyncDateString(projectId, 'post-sync'); + let result2: ProjectProgress | undefined; + let result3: ProjectProgress | undefined; + env.service.getProgress(projectId, { maxStalenessMs: 60_000 }).then(r => (result2 = r)); + env.service.getProgress(projectId, { maxStalenessMs: 60_000 }).then(r => (result3 = r)); + flushMicrotasks(); + + resolvePreSync!(preSyncBooks); + resolvePostSync!(postSyncBooks); + flushMicrotasks(); + + // The pre-sync request was not reused, but the two sibling reads shared a single new request. + expect(result1?.books).toEqual(preSyncBooks); + expect(result2?.books).toEqual(postSyncBooks); + expect(result3?.books).toEqual(postSyncBooks); + verify(mockedProjectService.getProjectProgress(projectId)).twice(); + })); + + it('does not let a slower request from before a sync overwrite fresher post-sync data', fakeAsync(() => { + const env = new TestEnvironment(); + const projectId = 'project1'; + const preSyncBooks: BookProgressWithChapterProgress[] = [ + { bookId: 'GEN', verseSegments: 100, blankVerseSegments: 20, chapters: [] } + ]; + const postSyncBooks: BookProgressWithChapterProgress[] = [ + { bookId: 'GEN', verseSegments: 120, blankVerseSegments: 15, chapters: [] } + ]; + let resolvePreSync: ((value: BookProgressWithChapterProgress[]) => void) | undefined; + let resolvePostSync: ((value: BookProgressWithChapterProgress[]) => void) | undefined; + const preSyncPromise: Promise = new Promise( + resolve => (resolvePreSync = resolve) + ); + const postSyncPromise: Promise = new Promise( + resolve => (resolvePostSync = resolve) + ); + when(mockedProjectService.getProjectProgress(projectId)).thenReturn(preSyncPromise).thenReturn(postSyncPromise); + + // Control the clock so the two requests demonstrably start at different times. + let now = 1000; + spyOn(Date, 'now').and.callFake(() => now); + + let result1: ProjectProgress | undefined; + env.service.getProgress(projectId, { maxStalenessMs: 60_000 }).then(r => (result1 = r)); + flushMicrotasks(); + + now = 2000; + env.setLastSyncDateString(projectId, 'post-sync'); + let result2: ProjectProgress | undefined; + env.service.getProgress(projectId, { maxStalenessMs: 60_000 }).then(r => (result2 = r)); + flushMicrotasks(); + + // The post-sync request resolves first; the pre-sync one afterward. + resolvePostSync!(postSyncBooks); + flushMicrotasks(); + resolvePreSync!(preSyncBooks); + flushMicrotasks(); + + // A later read within the staleness window must see the post-sync data, not the pre-sync straggler's. + now = 3000; + let result3: ProjectProgress | undefined; + env.service.getProgress(projectId, { maxStalenessMs: 60_000 }).then(r => (result3 = r)); + flushMicrotasks(); + + expect(result1?.books).toEqual(preSyncBooks); + expect(result2?.books).toEqual(postSyncBooks); + expect(result3?.books).toEqual(postSyncBooks); + verify(mockedProjectService.getProjectProgress(projectId)).twice(); + })); + it('should deduplicate concurrent requests for the same project', fakeAsync(() => { const env = new TestEnvironment(); const projectId = 'project1'; @@ -222,10 +343,21 @@ describe('ProgressService', () => { class TestEnvironment { readonly service: ProgressService; + /** sync.dateLastSuccessfulSync per project; change a project's value to simulate a completed sync. */ + private readonly lastSyncDateStrings = new Map(); constructor() { + when(mockedProjectService.getProfile(anything())).thenCall((projectId: string) => + Promise.resolve({ + data: { sync: { dateLastSuccessfulSync: this.lastSyncDateStrings.get(projectId) ?? 'initial-sync' } } + } as unknown as SFProjectProfileDoc) + ); this.service = new ProgressService(instance(mockedNoticeService), instance(mockedProjectService)); } + + setLastSyncDateString(projectId: string, dateString: string): void { + this.lastSyncDateStrings.set(projectId, dateString); + } } describe('ProjectProgress', () => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.ts index 0396f3a1717..c4f8072e6d2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/progress-service/progress.service.ts @@ -238,6 +238,7 @@ export interface BookProgress { blankVerseSegments: number; } +/** A book's translation-progress counts broken down per chapter, for features that need chapter-level detail. */ export interface BookProgressWithChapterProgress extends BookProgress { chapters: { chapterNumber: number; @@ -255,6 +256,7 @@ export class ProjectProgress { constructor(readonly books: BookProgress[]) {} } +/** Project-wide translation progress whose per-book data carries chapter-level detail (see {@link BookProgressWithChapterProgress}). */ export class ProjectProgressWithChapterProgress extends ProjectProgress { constructor(readonly books: BookProgressWithChapterProgress[]) { super(books); @@ -321,28 +323,50 @@ export class ProgressService { private readonly projectService: SFProjectService ) {} + // Alongside its age, every cache entry and in-flight request records the project's last-sync date string from + // when it was fetched/started, so data from before the latest sync can be recognized as stale regardless of age. private projectProgressCache = new Map< string, - { timestampMs: number; progress: ProjectProgressWithChapterProgress } + { timestampMs: number; lastSyncDateString: string | undefined; progress: ProjectProgressWithChapterProgress } >(); private requestCache = new Map< string, - { startedAtMs: number; promise: Promise } + { + startedAtMs: number; + lastSyncDateString: string | undefined; + promise: Promise; + } >(); + /** + * `maxStalenessMs` is how old cached data may be and still be returned. Independent of age, data from before the + * project's most recent successful sync never qualifies: entries are stamped with the project's last-sync date + * string (`sync.dateLastSuccessfulSync`) and served only while that string is unchanged. Strings are compared for + * equality, never parsed or compared against the clock, so server/client clock skew cannot break this. The age + * window exists for what a last-sync date string can't see: progress drift from live editing in Scripture Forge + * itself. + */ async getProgressWithChapterProgress( projectId: string, options: { maxStalenessMs: number } ): Promise { + const projectDoc = await this.projectService.getProfile(projectId); + const lastSyncDateString: string | undefined = projectDoc.data?.sync?.dateLastSuccessfulSync; + // Compared for equality, not parsed and compared chronologically: dateLastSuccessfulSync is set by the server, + // so a "newer than" comparison against Date.now() (client time) would be vulnerable to server/client clock + // skew. Equality only asks "has this changed since the entry was stamped", which needs no shared clock. + const qualifies = (timestampMs: number, dateString: string | undefined): boolean => + Date.now() - timestampMs < options.maxStalenessMs && dateString === lastSyncDateString; + const cachedProgress = this.projectProgressCache.get(projectId); - if (cachedProgress != null && Date.now() - cachedProgress.timestampMs < options.maxStalenessMs) { + if (cachedProgress != null && qualifies(cachedProgress.timestampMs, cachedProgress.lastSyncDateString)) { return cachedProgress.progress; } - // An in-flight request is a not-yet-settled cache entry: coalesce onto it only if it began recently enough to - // satisfy this caller's freshness window. A forced refresh (maxStalenessMs: 0) never qualifies, so it always - // starts its own fetch rather than reusing a request that may have begun before whatever prompted the refresh. + // An in-flight request is a not-yet-settled cache entry: coalesce onto it only if it satisfies this caller's + // freshness requirements. Sibling reads issued together share one request (same date string), while a request + // started before the latest sync never qualifies. const existingRequest = this.requestCache.get(projectId); - if (existingRequest != null && Date.now() - existingRequest.startedAtMs < options.maxStalenessMs) { + if (existingRequest != null && qualifies(existingRequest.startedAtMs, existingRequest.lastSyncDateString)) { return existingRequest.promise; } const requestTimestamp = Date.now(); @@ -353,7 +377,11 @@ export class ProgressService { (a, b) => Canon.bookIdToNumber(a.bookId) - Canon.bookIdToNumber(b.bookId) ); const progress = new ProjectProgressWithChapterProgress(sortedBookProgress); - this.projectProgressCache.set(projectId, { timestampMs: requestTimestamp, progress }); + // A slower request that began earlier must not clobber fresher data already written by a later request. + const cacheEntry = this.projectProgressCache.get(projectId); + if (cacheEntry == null || cacheEntry.timestampMs <= requestTimestamp) { + this.projectProgressCache.set(projectId, { timestampMs: requestTimestamp, lastSyncDateString, progress }); + } // Only clear the slot if it is still ours; a later forced refresh may have replaced this in-flight entry. if (this.requestCache.get(projectId)?.promise === requestPromise) { this.requestCache.delete(projectId); @@ -366,7 +394,7 @@ export class ProgressService { } throw error; }); - this.requestCache.set(projectId, { startedAtMs: requestTimestamp, promise: requestPromise }); + this.requestCache.set(projectId, { startedAtMs: requestTimestamp, lastSyncDateString, promise: requestPromise }); return requestPromise; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.spec.ts index 7574a08200a..19fa2ad72b6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.component.spec.ts @@ -130,7 +130,6 @@ describe('DraftGenerationComponent', () => { { draftHistoryCutOffDate: new Date('2025-04-03') } ); TestEnvironment.initProject('user01'); - mockUserService = jasmine.createSpyObj(['getCurrentUser']); mockDraftGenerationService.startBuildOrGetActiveBuild.and.returnValue(this.startedOrActiveBuild$); mockDraftGenerationService.getBuildHistory.and.returnValue(of([buildDto])); @@ -195,6 +194,7 @@ describe('DraftGenerationComponent', () => { currentUserId, currentUserRoles: [SystemRole.User] }); + mockUserService = jasmine.createSpyObj(['getCurrentUser'], { currentUserId }); mockActivatedProjectService = jasmine.createSpyObj([], { projectId: projectId, projectId$: of(projectId), diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts index 9f7ddde786c..5dcf3c66504 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-history-list/draft-history-entry/draft-history-entry.component.spec.ts @@ -183,8 +183,10 @@ describe('DraftHistoryEntryComponent', () => { })); it('should not treat an empty target project training entry as training configuration', fakeAsync(() => { - // A build with no training books still stores an empty entry for the target project (the target side of the - // training pairs). It is not training configuration. + // The target project's entry is the target side of the training configuration: it records which of the target's + // books/chapters the training pairs draw from. A build with no training books still stores this entry, just with + // an empty range, and an empty target selection with no source entries means no training data was configured, so + // the entry alone must not make the training section render. const entry = getStandardBuildDto({ trainingBooks: [], trainingDataFiles: [] }); entry.additionalInfo!.trainingScriptureRanges = [{ projectId: 'project01', scriptureRange: '' }]; 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 0df0fe4fb06..c5b6bf84e3b 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 @@ -377,7 +377,6 @@ export class DraftHistoryEntryComponent { }); } - // Called from the template (rather than precomputed) so the text re-localizes when the user switches languages. getTrainingRangeDisplay(scriptureRange: string): string { return formatScriptureRangeWithChapters(new VerboseScriptureRange(scriptureRange), this.i18n); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts index 8a780df7f86..cbddb0f07cb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts @@ -3,7 +3,7 @@ import { Canon } from '@sillsdev/scripture'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { ProjectScriptureRange } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; import { BehaviorSubject, filter, firstValueFrom, of } from 'rxjs'; -import { anything, deepEqual, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { instance, mock, resetCalls, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { SFProjectService } from '../../../core/sf-project.service'; @@ -741,7 +741,7 @@ describe('NewDraftLogicHandler', () => { await env.waitForInit(); expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5'); - await env.logicHandler.reload(['testProjectId']); + await env.logicHandler.reload(); // The post-sync target content is now reflected in what's available. expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-10'); @@ -758,7 +758,7 @@ describe('NewDraftLogicHandler', () => { }); await env.waitForInit(); - await env.logicHandler.reload(['testProjectId']); + await env.logicHandler.reload(); // Default = source - target. Pre-sync this would have been GEN6-50; with the synced chapters it must be GEN11-50, // so the chapters the user just translated aren't defaulted back into the draft. @@ -766,19 +766,17 @@ describe('NewDraftLogicHandler', () => { expect(env.selectedDraftingScriptureRange).toBe('GEN11-50'); }); - it('forces a fresh fetch only for the synced projects', async () => { + it('re-reads progress for every involved project', async () => { + // Which projects get an actual network refetch is ProgressService's concern: it refetches exactly the + // projects that have synced since their cache entries were fetched (see its sync-token tests). const env = new TestEnvironment(teamStartingToTranslateGenesis); await env.waitForInit(); resetCalls(mockedDraftProgressService); - await env.logicHandler.reload(['testProjectId']); // only the target synced + await env.logicHandler.reload(); - // Target (synced) is re-fetched with no staleness tolerance... - verify( - mockedDraftProgressService.getChaptersWithContent('testProjectId', deepEqual({ maxStalenessMs: 0 })) - ).once(); - // ...while the unsynced drafting source uses the default staleness (served from cache by the real service). - verify(mockedDraftProgressService.getChaptersWithContent('draft-source-1-id', deepEqual({}))).once(); + verify(mockedDraftProgressService.getChaptersWithContent('testProjectId')).once(); + verify(mockedDraftProgressService.getChaptersWithContent('draft-source-1-id')).once(); expect().nothing(); }); @@ -788,7 +786,7 @@ describe('NewDraftLogicHandler', () => { env.logicHandler.selectDraftingBooks(['GEN']); expect(env.selectedDraftingScriptureRange).not.toBe(''); - await env.logicHandler.reload(['testProjectId']); + await env.logicHandler.reload(); expect(env.selectedDraftingScriptureRange).toBe(''); expect(env.logicHandler.inputMode).toBe('draft_books'); @@ -1025,33 +1023,32 @@ class TestEnvironment { }) ); - // Set up the progress service to return the specified scripture ranges for the project and sources. The second - // argument (staleness options) is matched with anything() since callers pass per-project staleness overrides. + // Set up the progress service to return the specified scripture ranges for the project and sources. if (state.targetProjectBooksChaptersAfterReload != null) { // First load returns the pre-sync target; a subsequent fetch (after reload) returns the post-sync target. - when(mockedDraftProgressService.getChaptersWithContent(projectId, anything())) + when(mockedDraftProgressService.getChaptersWithContent(projectId)) .thenResolve(new VerboseScriptureRange(state.targetProjectBooksChapters)) .thenResolve(new VerboseScriptureRange(state.targetProjectBooksChaptersAfterReload)); } else { - when(mockedDraftProgressService.getChaptersWithContent(projectId, anything())).thenResolve( + when(mockedDraftProgressService.getChaptersWithContent(projectId)).thenResolve( new VerboseScriptureRange(state.targetProjectBooksChapters) ); } - when(mockedDraftProgressService.getPresentChapters(projectId, anything())).thenResolve( + when(mockedDraftProgressService.getPresentChapters(projectId)).thenResolve( new VerboseScriptureRange(state.targetProjectBooksChapters) ); - when(mockedDraftProgressService.getChaptersWithContent('draft-source-1-id', anything())).thenResolve( + when(mockedDraftProgressService.getChaptersWithContent('draft-source-1-id')).thenResolve( new VerboseScriptureRange(state.draftingSourceBooksChapters) ); - when(mockedDraftProgressService.getPresentChapters('draft-source-1-id', anything())).thenResolve( + when(mockedDraftProgressService.getPresentChapters('draft-source-1-id')).thenResolve( new VerboseScriptureRange(state.draftingSourceBooksChapters) ); for (const [trainingSourceProjectId, booksChapters] of Object.entries(state.trainingSourcesBooksChapters)) { - when(mockedDraftProgressService.getChaptersWithContent(trainingSourceProjectId, anything())).thenResolve( + when(mockedDraftProgressService.getChaptersWithContent(trainingSourceProjectId)).thenResolve( new VerboseScriptureRange(booksChapters) ); } - when(mockedDraftProgressService.getCompleteBookIds(projectId, anything())).thenResolve( + when(mockedDraftProgressService.getCompleteBookIds(projectId)).thenResolve( new Set(state.completeTargetBooks ?? []) ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts index c3ce070f842..b96c9ae263b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts @@ -45,10 +45,11 @@ function chapterHasContent(chapter: { verseSegments: number; blankVerseSegments: export type DraftingBookExclusionReason = 'non_canonical' | 'no_source_content'; /** - * Default freshness window for progress lookups. Progress data older than this is re-fetched. Callers that must have - * up-to-the-moment data (e.g. just after an in-place sync) pass `maxStalenessMs: 0` to force a fresh fetch. + * Default freshness window for progress lookups. Progress data older than this is re-fetched. A completed sync + * additionally invalidates a project's progress regardless of age; ProgressService detects that itself via the + * project's sync token, so callers here don't have to signal it. */ -const DEFAULT_PROGRESS_STALENESS_MS = 1000 * 60; +const DEFAULT_PROGRESS_STALENESS_MS = 1000; export interface ExcludedDraftingBook { bookId: string; @@ -65,32 +66,25 @@ export class DraftProgressService { * omitted entirely; empty books shouldn't be offered for selection. Note that a chapter can exist in the project * but be absent from this range because it is blank. getPresentChapters can be used to tell those apart. */ - async getChaptersWithContent( - projectId: string, - options: { maxStalenessMs?: number } = {} - ): Promise { - return await this.getChapters(projectId, options, chapterHasContent); + async getChaptersWithContent(projectId: string): Promise { + return await this.getChapters(projectId, chapterHasContent); } /** * Returns the chapters a project contains, whether or not they have any content. Chapters absent from this range * do not exist in the project at all. Reuses the progress data cached by getChaptersWithContent, so calling both - * for the same project (with the same staleness) costs no extra request. + * for the same project costs no extra request. */ - async getPresentChapters( - projectId: string, - options: { maxStalenessMs?: number } = {} - ): Promise { - return await this.getChapters(projectId, options, () => true); + async getPresentChapters(projectId: string): Promise { + return await this.getChapters(projectId, () => true); } private async getChapters( projectId: string, - options: { maxStalenessMs?: number }, includeChapter: (chapter: { verseSegments: number; blankVerseSegments: number }) => boolean ): Promise { const progress = await this.progressService.getProgressWithChapterProgress(projectId, { - maxStalenessMs: options.maxStalenessMs ?? DEFAULT_PROGRESS_STALENESS_MS + maxStalenessMs: DEFAULT_PROGRESS_STALENESS_MS }); const scriptureRange = new VerboseScriptureRange(); for (const bookProgress of progress.books) { @@ -111,11 +105,11 @@ export class DraftProgressService { * Returns the IDs of the books in a project that appear complete enough to be auto-selected as training data (see * bookAppearsCompleteForTrainingAutoSelection). Derived from the segment-level progress counts that * getChaptersWithContent discards, which is why this is computed separately. Reuses the cached progress, so calling - * it alongside getChaptersWithContent for the same project (with the same staleness) costs no extra request. + * it alongside getChaptersWithContent for the same project costs no extra request. */ - async getCompleteBookIds(projectId: string, options: { maxStalenessMs?: number } = {}): Promise> { + async getCompleteBookIds(projectId: string): Promise> { const progress = await this.progressService.getProgressWithChapterProgress(projectId, { - maxStalenessMs: options.maxStalenessMs ?? DEFAULT_PROGRESS_STALENESS_MS + maxStalenessMs: DEFAULT_PROGRESS_STALENESS_MS }); const completeBookIds = new Set(); for (const bookProgress of progress.books) { @@ -299,10 +293,10 @@ export class NewDraftLogicHandler { /** * Loads the progress data needed to derive book/chapter availability: the drafting source's content, the target's * content, each training source's content, and the set of target books that appear complete (for auto-selection). - * Projects in `freshProjectIds` are fetched with no staleness tolerance (forcing a fresh fetch — used after an - * in-place sync); all others use the default freshness window, so unchanged projects are served from cache. + * Recently fetched projects are served from cache, except that ProgressService refetches any project that has + * synced since its cache entry was fetched (so a reload after an in-place sync gets post-sync data automatically). */ - private async fetchProgressBundle(freshProjectIds: Set = new Set()): Promise<{ + private async fetchProgressBundle(): Promise<{ draftSourceProgress: VerboseScriptureRange; draftSourcePresentChapters: VerboseScriptureRange; targetProjectProgress: VerboseScriptureRange; @@ -325,15 +319,9 @@ export class NewDraftLogicHandler { } const draftingSource = draftConfig.draftingSources[0]; - const staleness = (id: string): { maxStalenessMs?: number } => - freshProjectIds.has(id) ? { maxStalenessMs: 0 } : {}; - const trainingSourcesProgressPromise = Promise.all( draftConfig.trainingSources.map(async trainingSource => { - const range = await this.progressService.getChaptersWithContent( - trainingSource.projectRef, - staleness(trainingSource.projectRef) - ); + const range = await this.progressService.getChaptersWithContent(trainingSource.projectRef); return { projectId: trainingSource.projectRef, range }; }) ); @@ -349,12 +337,12 @@ export class NewDraftLogicHandler { trainingSourcesProgress, completeTargetBookIds ] = await Promise.all([ - this.progressService.getChaptersWithContent(draftingSource.projectRef, staleness(draftingSource.projectRef)), - this.progressService.getPresentChapters(draftingSource.projectRef, staleness(draftingSource.projectRef)), - this.progressService.getChaptersWithContent(projectId, staleness(projectId)), - this.progressService.getPresentChapters(projectId, staleness(projectId)), + this.progressService.getChaptersWithContent(draftingSource.projectRef), + this.progressService.getPresentChapters(draftingSource.projectRef), + this.progressService.getChaptersWithContent(projectId), + this.progressService.getPresentChapters(projectId), trainingSourcesProgressPromise, - this.progressService.getCompleteBookIds(projectId, staleness(projectId)) + this.progressService.getCompleteBookIds(projectId) ]); return { @@ -406,12 +394,13 @@ export class NewDraftLogicHandler { /** * Re-derives book/chapter availability after the user syncs stale projects in place via the pending-updates - * pre-step. Only the projects in `syncedProjectIds` are re-fetched fresh; unchanged projects are served from cache. - * Valid only before the user has made any selection (the pre-step precedes Step 1), so it returns the selection - * state to its post-init baseline rather than trying to preserve stale selections. + * pre-step. ProgressService notices which projects have synced since their cache entries were fetched and refetches + * exactly those; unchanged projects are served from cache. Valid only before the user has made any selection (the + * pre-step precedes Step 1), so it returns the selection state to its post-init baseline rather than trying to + * preserve stale selections. */ - async reload(syncedProjectIds: string[]): Promise { - const bundle = await this.fetchProgressBundle(new Set(syncedProjectIds)); + async reload(): Promise { + const bundle = await this.fetchProgressBundle(); this.applyDerivedRanges(bundle); this.resetSelectionState(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html index 7eea04b237d..f2bb3a37106 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html @@ -68,8 +68,9 @@

{{ t("draft_books.title") }}

@if (draftingExclusionNotices.length > 0) {
- { numBooks: draftingHiddenBookCount } ) " - > + > @if (draftingExclusionsExpanded) { @for (notice of draftingExclusionNotices; track notice.key) {

{{ t(notice.key, notice.params) }}

@@ -139,8 +140,9 @@

{{ t("training_books.title") }}

@if (hasTargetTrainingBooksWithoutSource) {
- { numBooks: targetTrainingHiddenBookCount } ) " - > + > @if (trainingExclusionsExpanded) {

{{ t("training_books.excluded_not_in_any_source", { books: targetTrainingBooksWithoutSourceNames }) }} @@ -298,16 +300,16 @@

{{ t("summary.example_translations") }}

- - -

{{ t("summary.settings") }}

- @if (currentUserEmail != null) { + @if (currentUserEmail != null) { + + +

{{ t("summary.settings") }}

{{ t("summary.email_me", { email: currentUserEmail }) }} - } -
-
+
+
+ } @if (isCustomConfigSet) { {{ t("summary.custom_configurations_apply") }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss index 61f45c3542b..87739b7ef03 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss @@ -175,12 +175,8 @@ app-copyright-banner { } .books-hidden-message { - cursor: pointer; - - ::ng-deep u { - &:hover { - color: variables.$theme-secondary; - } + ::ng-deep u:hover { + color: variables.$theme-secondary; } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts index 6dfa231ef0d..e5b2e23f5bc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts @@ -817,7 +817,7 @@ describe('NewDraftComponent', () => { reset(mockedErrorHandler); }); - it('re-derives progress fresh for the synced projects, then shows the preface', fakeAsync(() => { + it('re-derives progress after an in-place sync, then shows the preface', fakeAsync(() => { const env = new TestEnvironment(testState); tick(); resetCalls(mockedProgressService); // ignore the calls made during init @@ -825,10 +825,8 @@ describe('NewDraftComponent', () => { void env.component.onPendingUpdatesComplete(['draft-source-1-id']); tick(); - // The reload re-fetches and forces fresh data (maxStalenessMs: 0) for the synced project. - verify( - mockedProgressService.getChaptersWithContent('draft-source-1-id', deepEqual({ maxStalenessMs: 0 })) - ).once(); + // The reload re-reads progress; ProgressService itself refetches projects that have synced since. + verify(mockedProgressService.getChaptersWithContent('draft-source-1-id')).once(); expect(env.component.page).toEqual('preface'); })); @@ -840,7 +838,7 @@ describe('NewDraftComponent', () => { void env.component.onPendingUpdatesComplete([]); tick(); - verify(mockedProgressService.getChaptersWithContent(anything(), anything())).never(); + verify(mockedProgressService.getChaptersWithContent(anything())).never(); expect(env.component.page).toEqual('preface'); })); @@ -848,9 +846,7 @@ describe('NewDraftComponent', () => { const env = new TestEnvironment(testState); tick(); // Init has already succeeded; make the reload's progress fetch fail. - when(mockedProgressService.getChaptersWithContent('draft-source-1-id', anything())).thenReject( - new Error('reload failed') - ); + when(mockedProgressService.getChaptersWithContent('draft-source-1-id')).thenReject(new Error('reload failed')); void env.component.onPendingUpdatesComplete(['draft-source-1-id']); tick(); @@ -948,7 +944,7 @@ describe('NewDraftComponent', () => { expect(env.component.page).toEqual('preface'); expect(env.component.abortMode).toBeNull(); - // Suppression persists past build completion (the latch is never reset, unlike `submitting`). + // Suppression persists past build completion (the latch stays set once the build request succeeds). build$.next(undefined); build$.complete(); tick(); @@ -956,6 +952,25 @@ describe('NewDraftComponent', () => { tick(); expect(env.component.abortMode).toBeNull(); })); + + it('re-arms the sync abort guard when the build request fails', fakeAsync(() => { + const build$ = new Subject(); + when(mockedDraftGenerationService.startBuildOrGetActiveBuild(anything())).thenReturn(build$); + const env = new TestEnvironment(testState); + tick(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + env.component.generateDraftClicked().catch(() => {}); // the failure propagates to the caller; swallow it here + tick(); + build$.error(new Error('failed to start build')); + tick(); + + // The build never started, so a sync that begins later must again abort the (now possibly stale) wizard. + env.startSyncFor('testProjectId'); + tick(); + expect(env.component.page).toEqual('abort'); + expect(env.component.abortMode).toEqual('project_syncing'); + })); }); describe('empty states', () => { @@ -1207,31 +1222,27 @@ class TestEnvironment { } if (options.progressError) { - when(mockedProgressService.getChaptersWithContent(projectId, anything())).thenReject( - new Error('progress failed') - ); + when(mockedProgressService.getChaptersWithContent(projectId)).thenReject(new Error('progress failed')); } else { - when(mockedProgressService.getChaptersWithContent(projectId, anything())).thenResolve( + when(mockedProgressService.getChaptersWithContent(projectId)).thenResolve( new VerboseScriptureRange(state.targetProjectBooksChapters) ); } - when(mockedProgressService.getPresentChapters(projectId, anything())).thenResolve( + when(mockedProgressService.getPresentChapters(projectId)).thenResolve( new VerboseScriptureRange(state.targetPresentChapters ?? state.targetProjectBooksChapters) ); - when(mockedProgressService.getChaptersWithContent('draft-source-1-id', anything())).thenResolve( + when(mockedProgressService.getChaptersWithContent('draft-source-1-id')).thenResolve( new VerboseScriptureRange(state.draftingSourceBooksChapters) ); - when(mockedProgressService.getPresentChapters('draft-source-1-id', anything())).thenResolve( + when(mockedProgressService.getPresentChapters('draft-source-1-id')).thenResolve( new VerboseScriptureRange(state.draftingSourcePresentChapters ?? state.draftingSourceBooksChapters) ); for (const [trainingSourceId, booksChapters] of Object.entries(state.trainingSourcesBooksChapters)) { - when(mockedProgressService.getChaptersWithContent(trainingSourceId, anything())).thenResolve( + when(mockedProgressService.getChaptersWithContent(trainingSourceId)).thenResolve( new VerboseScriptureRange(booksChapters) ); } - when(mockedProgressService.getCompleteBookIds(projectId, anything())).thenResolve( - new Set(state.completeTargetBooks ?? []) - ); + when(mockedProgressService.getCompleteBookIds(projectId)).thenResolve(new Set(state.completeTargetBooks ?? [])); when(mockedTrainingDataService.getTrainingData(anything(), anything())).thenReturn( options.trainingData$ ?? of(state.trainingDataFiles ?? []) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts index a2273ccabfb..c8630df8585 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts @@ -253,20 +253,16 @@ function setUpMocks(args: StoryArgs): void { // set the progress promises never resolve, leaving the wizard stuck on its loading spinner. const progressFor = (range: string): Promise => args.neverLoad ? new Promise(() => {}) : Promise.resolve(new VerboseScriptureRange(range)); - when(mockedDraftProgressService.getChaptersWithContent(TARGET_ID, anything())).thenCall(() => - progressFor(TARGET_BOOKS) - ); - when(mockedDraftProgressService.getPresentChapters(TARGET_ID, anything())).thenCall(() => progressFor(TARGET_BOOKS)); - when(mockedDraftProgressService.getChaptersWithContent(DRAFT_SOURCE_ID, anything())).thenCall(() => - progressFor(DRAFT_SOURCE_BOOKS) - ); - when(mockedDraftProgressService.getPresentChapters(DRAFT_SOURCE_ID, anything())).thenCall(() => + when(mockedDraftProgressService.getChaptersWithContent(TARGET_ID)).thenCall(() => progressFor(TARGET_BOOKS)); + when(mockedDraftProgressService.getPresentChapters(TARGET_ID)).thenCall(() => progressFor(TARGET_BOOKS)); + when(mockedDraftProgressService.getChaptersWithContent(DRAFT_SOURCE_ID)).thenCall(() => progressFor(DRAFT_SOURCE_BOOKS) ); - when(mockedDraftProgressService.getChaptersWithContent(TRAINING_SOURCE_ID, anything())).thenCall(() => + when(mockedDraftProgressService.getPresentChapters(DRAFT_SOURCE_ID)).thenCall(() => progressFor(DRAFT_SOURCE_BOOKS)); + when(mockedDraftProgressService.getChaptersWithContent(TRAINING_SOURCE_ID)).thenCall(() => progressFor(TRAINING_SOURCE_BOOKS) ); - when(mockedDraftProgressService.getCompleteBookIds(TARGET_ID, anything())).thenResolve(new Set()); + when(mockedDraftProgressService.getCompleteBookIds(TARGET_ID)).thenResolve(new Set()); when(mockedFeatureFlagService.showDeveloperTools).thenReturn(createTestFeatureFlag(args.developerTools)); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts index 8db2588a8c4..b059a4a43bc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts @@ -319,7 +319,7 @@ export class NewDraftComponent { } this.page = 'loading'; try { - await this.logicHandler.reload(syncedProjectIds); + await this.logicHandler.reload(); this.page = 'preface'; this.armSyncWatcher(); } catch (error) { @@ -476,16 +476,15 @@ export class NewDraftComponent { submitting = false; /** - * Latched once the user commits to generating, never reset (unlike `submitting`, which the `finally` resets so the - * button re-enables on failure). Suppresses the sync watcher so the backend sync that starting a build triggers - * doesn't self-abort with 'project_syncing'. + * Latched when the build request is sent, suppressing the sync watcher so the backend sync that starting a build + * triggers doesn't self-abort with 'project_syncing'. Reset if the request fails, so a retry is guarded against + * stale data the same way the first attempt was (unlike `submitting`, which only drives the button/spinner state). */ private building = false; async generateDraftClicked(): Promise { if (!this.onlineStatusService.isOnline || this.initData == null) return; this.submitting = true; - this.building = true; await Promise.resolve(); @@ -530,7 +529,14 @@ export class NewDraftComponent { sendEmailOnBuildFinished: this.sendEmailOnBuildFinished }; - await firstValueFrom(this.draftGenerationService.startBuildOrGetActiveBuild(buildConfig)); + this.building = true; + try { + await firstValueFrom(this.draftGenerationService.startBuildOrGetActiveBuild(buildConfig)); + } catch (error) { + // The build didn't start, so re-arm the sync-abort guard before the error propagates. + this.building = false; + throw error; + } void this.router.navigate(['/projects', projectId, 'draft-generation']); } finally { 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 da8f129b96d..0963a7d76e7 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 @@ -294,7 +294,6 @@ "subtitle": "Which books in {{ boldStart }}{{ project }}{{ boldEnd }} do you want to translate from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} to {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}?", "which_chapters": "Which chapters of each book do you want to draft?", "partial_explanation": "Some of the chapters in the books you selected already have text.", - "learn_more": "Learn more about drafting part of a book.", "excluded_no_source_content": "These books aren't available to draft because {{ source }} has no text for them: {{ books }}.", "no_available_books": "You have no books available for drafting." }, @@ -303,7 +302,6 @@ "subtitle": "Which books in {{ boldStart }}{{ project }}{{ boldEnd }} are good enough to use to train the model to translate from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} to {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}?", "which_chapters": "Which chapters of each book do you want to use to train the model?", "partial_explanation": "Some of the chapters in the books you selected will be translated and cannot be used as training data.", - "learn_more": "Learn more about training on part of a book.", "excluded_not_in_any_source": "These books aren't available to train on because they aren't in any of the reference projects: {{ books }}.", "auto_selected": "Books that appear to be complete have been pre-selected. Please review and update the training data that should be included.", "no_target_books": "No books from your project are available for training.", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/styles.scss b/src/SIL.XForge.Scripture/ClientApp/src/styles.scss index 57e99351258..f4452a1ea98 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/styles.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/styles.scss @@ -94,6 +94,20 @@ as-split > .as-split-gutter .as-split-gutter-icon { color: var(--sf-error-color); } +// A