Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import { BuildDto } from '../../../../machine-api/build-dto';
import { BuildStates } from '../../../../machine-api/build-states';
import { RIGHT_TO_LEFT_MARK } from '../../../../shared/utils';
import { DraftDownloadButtonComponent } from '../../draft-download-button/draft-download-button.component';
import { DraftOptionsService } from '../../draft-options.service';
import { DraftPreviewBooksComponent } from '../../draft-preview-books/draft-preview-books.component';
import { FORMATTING_OPTIONS_SUPPORTED_DATE } from '../../draft-utils';
import { TrainingDataService } from '../../training-data/training-data.service';

const STATUS_INFO: Record<BuildStates, { icons: string; text: string; color: string }> = {
Expand Down Expand Up @@ -269,13 +269,11 @@ export class DraftHistoryEntryComponent {
}

get formattingOptionsSelected(): boolean {
return this.activatedProjectService.projectDoc?.data?.translateConfig.draftConfig.usfmConfig != null;
return this.draftOptionsService.isFormattingOptionsSelected();
}

get formattingOptionsSupported(): boolean {
return this.featureFlags.usfmFormat.enabled && this.entry?.additionalInfo?.dateFinished != null
? new Date(this.entry.additionalInfo.dateFinished) > FORMATTING_OPTIONS_SUPPORTED_DATE
: false;
return this.draftOptionsService.isFormattingOptionsSupported(this.entry);
}

@Input() isLatestBuild: boolean = false;
Expand All @@ -295,6 +293,7 @@ export class DraftHistoryEntryComponent {
private readonly trainingDataService: TrainingDataService,
private readonly activatedProjectService: ActivatedProjectService,
readonly featureFlags: FeatureFlagService,
private readonly draftOptionsService: DraftOptionsService,
private readonly destroyRef: DestroyRef
) {}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { ActivatedProjectService } from 'xforge-common/activated-project.service';
import { FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service';
import { BuildDto } from '../../machine-api/build-dto';

// Corresponds to Serval 1.11.0 release
export const FORMATTING_OPTIONS_SUPPORTED_DATE: Date = new Date('2025-09-25T00:00:00Z');

@Injectable({
providedIn: 'root'
})
export class DraftOptionsService {
constructor(
private readonly activatedProjectService: ActivatedProjectService,
private readonly featureFlags: FeatureFlagService
) {}

isFormattingOptionsSelected(): boolean {
return (
this.activatedProjectService.projectDoc?.data?.translateConfig.draftConfig.usfmConfig?.paragraphFormat != null &&
this.activatedProjectService.projectDoc?.data?.translateConfig.draftConfig.usfmConfig?.quoteFormat != null
);
}

isFormattingOptionsSupported(entry: BuildDto | undefined): boolean {
return this.featureFlags.usfmFormat.enabled && entry?.additionalInfo?.dateFinished != null
? new Date(entry.additionalInfo.dateFinished) > FORMATTING_OPTIONS_SUPPORTED_DATE
: false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/t
import language_code_mapping from '../../../../../language_code_mapping.json';
import { SelectableProjectWithLanguageCode } from '../../core/paratext.service';

// Corresponds to Serval 1.11.0 release
export const FORMATTING_OPTIONS_SUPPORTED_DATE: Date = new Date('2025-09-25T00:00:00Z');

/** Represents draft sources as a set of two {@link TranslateSource} arrays, and one {@link SFProjectProfile} array. */
export interface DraftSourcesAsTranslateSourceArrays {
trainingSources: TranslateSource[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,15 @@
}
<div class="apply-draft-button-container">
@if (featureFlags.usfmFormat.enabled) {
@if (mustChooseFormattingOptions) {
<button mat-flat-button (click)="navigateToFormatting()">
<span
[matTooltip]="t(doesLatestHaveDraft ? 'format_draft_can' : 'format_draft_cannot')"
[style.cursor]="doesLatestHaveDraft ? 'pointer' : 'not-allowed'"
>
<button mat-button (click)="navigateToFormatting()" [disabled]="!doesLatestHaveDraft">
<mat-icon>build</mat-icon>
<transloco class="hide-lt-md" key="editor_draft_tab.format_draft"></transloco>
<transloco class="hide-gt-md" key="editor_draft_tab.formatting"></transloco>
<transloco key="editor_draft_tab.format_draft"></transloco>
</button>
} @else if (formattingOptionsSupported) {
<span
[matTooltip]="t(doesLatestBuildHaveDraft ? 'format_draft_can' : 'format_draft_cannot')"
[style.cursor]="doesLatestBuildHaveDraft ? 'pointer' : 'not-allowed'"
>
<button mat-button (click)="navigateToFormatting()" [disabled]="!doesLatestBuildHaveDraft">
<mat-icon>build</mat-icon>
<transloco class="hide-lt-md" key="editor_draft_tab.format_draft"></transloco>
<transloco class="hide-gt-md" key="editor_draft_tab.formatting"></transloco>
</button>
</span>
}
</span>
}
@if (userAppliedDraft) {
<span class="draft-indicator">
Expand All @@ -70,19 +61,14 @@
</span>
}
@if (canApplyDraft) {
<span
[matTooltip]="t(mustChooseFormattingOptions ? 'format_draft_before' : 'add_chapter_to_project')"
[style.cursor]="mustChooseFormattingOptions ? 'not-allowed' : 'pointer'"
>
<button mat-flat-button color="primary" (click)="applyDraft()" [disabled]="mustChooseFormattingOptions">
<mat-icon>auto_awesome</mat-icon>
@if (isDraftApplied) {
<transloco key="editor_draft_tab.reapply_to_project"></transloco>
} @else {
<transloco key="editor_draft_tab.apply_to_project"></transloco>
}
</button>
</span>
<button mat-flat-button color="primary" (click)="applyDraft()">
<mat-icon>auto_awesome</mat-icon>
@if (isDraftApplied) {
<transloco key="editor_draft_tab.reapply_to_project"></transloco>
} @else {
<transloco key="editor_draft_tab.apply_to_project"></transloco>
}
</button>
}
</div>
@if (!canApplyDraft) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,71 +415,15 @@ describe('EditorDraftComponent', () => {
);
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
when(mockDialogService.confirm(anything(), anything())).thenResolve(true);
when(mockDraftHandlingService.canApplyDraft(anything(), anything(), anything(), anything())).thenReturn(true);
spyOn<any>(component, 'getTargetOps').and.returnValue(of(targetDelta.ops));
when(mockDraftHandlingService.getDraft(anything(), anything())).thenReturn(of(draftDelta.ops!));
when(mockDraftHandlingService.draftDataToOps(anything(), anything())).thenReturn(draftDelta.ops!);

fixture.detectChanges();
tick(EDITOR_READY_TIMEOUT);

expect(component.mustChooseFormattingOptions).toBe(false);
flush();
}));

it('should hide formatting options for drafts created before supported date', fakeAsync(() => {
const testProjectDoc: SFProjectProfileDoc = {
data: createTestProjectProfile({
texts: [
{
bookNum: 1,
chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator }, hasDraft: true }]
}
]
})
} as SFProjectProfileDoc;
when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(true));
when(mockDraftGenerationService.getGeneratedDraftHistory(anything(), anything(), anything())).thenReturn(
of(draftHistory)
);
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
when(mockDialogService.confirm(anything(), anything())).thenResolve(true);
spyOn<any>(component, 'getTargetOps').and.returnValue(of(targetDelta.ops));
when(mockDraftHandlingService.getDraft(anything(), anything())).thenReturn(of(draftDelta.ops!));
when(mockDraftHandlingService.draftDataToOps(anything(), anything())).thenReturn(draftDelta.ops!);

fixture.detectChanges();
tick(EDITOR_READY_TIMEOUT);

expect(component.mustChooseFormattingOptions).toBe(false);
flush();
}));

it('should guide user to select formatting options when formatting not selected', fakeAsync(() => {
const testProjectDoc: SFProjectProfileDoc = {
data: createTestProjectProfile({
texts: [
{
bookNum: 1,
chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator }, hasDraft: true }]
}
]
})
} as SFProjectProfileDoc;
when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(true));
const historyAfterFormattingOptions: Revision[] = [{ timestamp: '2025-10-01T12:00:00.000Z' }];
when(mockDraftGenerationService.getGeneratedDraftHistory(anything(), anything(), anything())).thenReturn(
of(historyAfterFormattingOptions)
);
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
when(mockDialogService.confirm(anything(), anything())).thenResolve(true);
spyOn<any>(component, 'getTargetOps').and.returnValue(of(targetDelta.ops));
when(mockDraftHandlingService.getDraft(anything(), anything())).thenReturn(of(draftDelta.ops!));
when(mockDraftHandlingService.draftDataToOps(anything(), anything())).thenReturn(draftDelta.ops!);

fixture.detectChanges();
tick(EDITOR_READY_TIMEOUT);

expect(component.mustChooseFormattingOptions).toBe(true);
expect(component.canApplyDraft).toBe(true);
flush();
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import { BuildStates } from '../../../machine-api/build-states';
import { TextComponent } from '../../../shared/text/text.component';
import { DraftGenerationService } from '../../draft-generation/draft-generation.service';
import { DraftHandlingService } from '../../draft-generation/draft-handling.service';
import { FORMATTING_OPTIONS_SUPPORTED_DATE } from '../../draft-generation/draft-utils';
@Component({
selector: 'app-editor-draft',
templateUrl: './editor-draft.component.html',
Expand All @@ -68,8 +67,6 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
isDraftReady = false;
isDraftApplied = false;
userAppliedDraft = false;
hasFormattingSelected = true;
formattingOptionsSupported = true;

private selectedRevisionSubject = new BehaviorSubject<Revision | undefined>(undefined);
private selectedRevision$ = this.selectedRevisionSubject.asObservable();
Expand Down Expand Up @@ -115,25 +112,15 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
return this.draftHandlingService.canApplyDraft(this.targetProject, this.bookNum, this.chapter, this.draftDelta.ops);
}

get doesLatestBuildHaveDraft(): boolean {
get doesLatestHaveDraft(): boolean {
return (
this.targetProject?.texts.find(t => t.bookNum === this.bookNum)?.chapters.find(c => c.number === this.chapter)
?.hasDraft ?? false
);
}

get mustChooseFormattingOptions(): boolean {
return (
this.featureFlags.usfmFormat.enabled &&
!this.hasFormattingSelected &&
this.formattingOptionsSupported &&
this.doesLatestBuildHaveDraft
);
}

set draftRevisions(value: Revision[]) {
this._draftRevisions = value;
this.formattingOptionsSupported = value.some(rev => new Date(rev.timestamp) > FORMATTING_OPTIONS_SUPPORTED_DATE);
}

get draftRevisions(): Revision[] {
Expand Down Expand Up @@ -216,7 +203,6 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
filterNullish(),
tap(projectDoc => {
this.targetProject = projectDoc.data;
this.hasFormattingSelected = projectDoc.data?.translateConfig.draftConfig.usfmConfig != null;
}),
distinctUntilChanged(),
map(() => initialTimestamp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4103,6 +4103,24 @@ describe('EditorComponent', () => {
env.dispose();
}));

it('should not add draft preview tab when draft formatting (usfmConfig) is not set', fakeAsync(() => {
const env = new TestEnvironment(env => {
Object.defineProperty(env.component, 'showSource', { get: () => true });
});
env.setupProject({ translateConfig: { draftConfig: {} } });
when(mockedPermissionsService.canAccessDrafts(anything(), anything())).thenReturn(true);
env.wait();
env.routeWithParams({ projectId: 'project01', bookId: 'LUK', chapter: '1' });
env.wait();

const sourceTabGroup = env.component.tabState.getTabGroup('source');
expect(sourceTabGroup?.tabs.find(t => t.type === 'draft')).toBeUndefined();

const targetTabGroup = env.component.tabState.getTabGroup('target');
expect(targetTabGroup?.tabs.find(t => t.type === 'draft')).toBeUndefined();
env.dispose();
}));

it('should hide source draft preview tab when switching to chapter with no draft', fakeAsync(() => {
const env = new TestEnvironment(env => {
Object.defineProperty(env.component, 'showSource', { get: () => true });
Expand Down Expand Up @@ -4637,6 +4655,9 @@ class TestEnvironment {
translateConfig: {
translationSuggestionsEnabled: true,
defaultNoteTagId: 2,
draftConfig: {
usfmConfig: {}
},
source: {
paratextId: 'source01',
projectRef: 'project02',
Expand Down Expand Up @@ -4882,6 +4903,7 @@ class TestEnvironment {
when(mockedDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(true));
when(mockedPermissionsService.isUserOnProject(anything())).thenResolve(true);
when(mockedFeatureFlagService.newDraftHistory).thenReturn(createTestFeatureFlag(false));
when(mockedFeatureFlagService.usfmFormat).thenReturn(createTestFeatureFlag(true));
when(mockedLynxWorkspaceService.rawInsightSource$).thenReturn(of([]));

this.realtimeService = TestBed.inject(TestRealtimeService);
Expand Down Expand Up @@ -5123,6 +5145,9 @@ class TestEnvironment {
data.translateConfig?.source
);
}
if (data.translateConfig?.draftConfig != null) {
projectProfileData.translateConfig.draftConfig = data.translateConfig.draftConfig as any;
}
if (data.biblicalTermsConfig !== undefined) {
projectProfileData.biblicalTermsConfig = merge(projectProfileData.biblicalTermsConfig, data.biblicalTermsConfig);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import { CONSOLE, ConsoleInterface } from 'xforge-common/browser-globals';
import { DataLoadingComponent } from 'xforge-common/data-loading-component';
import { DialogService } from 'xforge-common/dialog.service';
import { ErrorReportingService } from 'xforge-common/error-reporting.service';
import { FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service';
import { FontService } from 'xforge-common/font.service';
import { I18nService } from 'xforge-common/i18n.service';
import { Breakpoint, MediaBreakpointService } from 'xforge-common/media-breakpoints/media-breakpoint.service';
Expand Down Expand Up @@ -136,6 +137,7 @@ import {
XmlUtils
} from '../../shared/utils';
import { DraftGenerationService } from '../draft-generation/draft-generation.service';
import { DraftOptionsService } from '../draft-generation/draft-options.service';
import { EditorHistoryService } from './editor-history/editor-history.service';
import { LynxInsightStateService } from './lynx/insights/lynx-insight-state.service';
import { MultiCursorViewer } from './multi-viewer/multi-viewer.component';
Expand Down Expand Up @@ -313,11 +315,13 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy,
private readonly editorTabPersistenceService: EditorTabPersistenceService,
private readonly textDocService: TextDocService,
private readonly draftGenerationService: DraftGenerationService,
private readonly draftOptionsService: DraftOptionsService,
private readonly destroyRef: DestroyRef,
private readonly breakpointObserver: BreakpointObserver,
private readonly mediaBreakpointService: MediaBreakpointService,
private readonly permissionsService: PermissionsService,
readonly editorInsightState: LynxInsightStateService
readonly editorInsightState: LynxInsightStateService,
private readonly featureFlagService: FeatureFlagService
) {
super(noticeService);
const wordTokenizer = new LatinWordTokenizer();
Expand Down Expand Up @@ -1488,7 +1492,9 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy,
this.projectDoc,
this.userService.currentUserId
);
if (((hasDraft && !draftApplied) || urlDraftActive) && canViewDrafts) {
const hasSetDraftFormatting: boolean =
!this.featureFlagService.usfmFormat.enabled || this.draftOptionsService.isFormattingOptionsSelected();
if (((hasDraft && !draftApplied) || urlDraftActive) && canViewDrafts && hasSetDraftFormatting) {
// URL may indicate to select the 'draft' tab (such as when coming from generate draft page)
const groupIdToAddTo: EditorTabGroupType = this.showSource ? 'source' : 'target';

Expand Down
Loading
Loading