Skip to content

Commit c50a90f

Browse files
committed
SF-3578 Add Import Questions from Paratext
The notes are fetched from the Paratext local copy, and the relevant pieces are parsed. The back end replaces the tag numbers with their user-displayed text (for readability). The front end makes this call when the user clicks the Paratext Import option in the import dialog. It first gathers the available tags and displays them as a dropdown. Once a value is chosen, it filters the notes down to the selection, and feeds them to the same display used for the Transcelerator questions. The brunt of this is done, but there are a number of edge cases to work through. And tests.
1 parent 142f019 commit c50a90f

File tree

14 files changed

+623
-33
lines changed

14 files changed

+623
-33
lines changed

src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.html

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ <h2 mat-dialog-title class="dialog-icon-title">
9797
<a mat-button [href]="urls.csvImportHelpPage" target="_blank">{{ t("learn_more") }}</a>
9898
</mat-card-actions>
9999
</mat-card>
100+
<mat-card>
101+
<mat-card-title>{{ t("import_from_paratext") }}</mat-card-title>
102+
<mat-card-content>
103+
<p>{{ t("import_from_paratext_description") }}</p>
104+
</mat-card-content>
105+
<mat-card-actions>
106+
<button mat-flat-button color="primary" (click)="importFromParatext()">
107+
{{ t("import_from_paratext") }}
108+
</button>
109+
</mat-card-actions>
110+
</mat-card>
100111
</div>
101112
<div class="support-message">
102113
@for (i of helpInstructions | async; track i) {
@@ -112,6 +123,33 @@ <h2 mat-dialog-title class="dialog-icon-title">
112123
</div>
113124
}
114125

126+
@case ("paratext_tag_selection") {
127+
<div class="paratext-tag-selection">
128+
@if (loadingParatextTags) {
129+
<mat-spinner></mat-spinner>
130+
} @else if (paratextTagLoadError) {
131+
<div class="paratext-tag-selection-message error">{{ t("import_from_paratext_tag_load_error") }}</div>
132+
} @else if (paratextTagOptions.length === 0) {
133+
<div class="paratext-tag-selection-message">{{ t("import_from_paratext_no_tags_available") }}</div>
134+
} @else {
135+
<p class="paratext-tag-selection-message">{{ t("import_from_paratext_select_tag_instructions") }}</p>
136+
@if (paratextTagConversionError) {
137+
<div class="paratext-tag-selection-message error">
138+
{{ t("import_from_paratext_tag_conversion_error") }}
139+
</div>
140+
}
141+
<mat-form-field appearance="fill">
142+
<mat-label>{{ t("import_from_paratext_select_tag") }}</mat-label>
143+
<mat-select [(ngModel)]="selectedParatextTagId" [ngModelOptions]="{ standalone: true }">
144+
@for (tag of paratextTagOptions; track tag.id) {
145+
<mat-option [value]="tag.id">{{ tag.name }}</mat-option>
146+
}
147+
</mat-select>
148+
</mat-form-field>
149+
}
150+
</div>
151+
}
152+
115153
@case ("loading") {
116154
<div class="loading"><mat-spinner></mat-spinner></div>
117155
}
@@ -247,6 +285,19 @@ <h2 mat-dialog-title class="dialog-icon-title">
247285
</mat-dialog-content>
248286
@if (questionSource != null && importCanceled === false) {
249287
<mat-dialog-actions align="end">
288+
@if (status === "paratext_tag_selection") {
289+
<button mat-button (click)="cancelParatextTagSelection()">
290+
{{ t("cancel") }}
291+
</button>
292+
<button
293+
mat-flat-button
294+
color="primary"
295+
(click)="confirmParatextTagSelection()"
296+
[disabled]="loadingParatextTags"
297+
>
298+
{{ t("import_from_paratext_next") }}
299+
</button>
300+
}
250301
@if (status === "filter" || status === "file_import_errors") {
251302
<button mat-button mat-dialog-close>
252303
{{ t("cancel") }}

src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.scss

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,30 @@
131131
}
132132
}
133133

134+
.paratext-tag-selection {
135+
display: flex;
136+
flex-direction: column;
137+
gap: 1.5em;
138+
align-items: stretch;
139+
140+
mat-form-field {
141+
width: 100%;
142+
}
143+
144+
mat-spinner {
145+
align-self: center;
146+
}
147+
}
148+
149+
.paratext-tag-selection-message {
150+
margin: 0;
151+
text-align: left;
152+
153+
&.error {
154+
color: var(--mdc-theme-error, #b00020);
155+
}
156+
}
157+
134158
// below is to make denser
135159
:host ::ng-deep {
136160
mat-form-field.mat-mdc-form-field.mat-form-field-appearance-outline {

src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts

Lines changed: 189 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { MatIcon } from '@angular/material/icon';
1919
import { MatInput } from '@angular/material/input';
2020
import { MatProgressBar } from '@angular/material/progress-bar';
2121
import { MatProgressSpinner } from '@angular/material/progress-spinner';
22+
import { MatOption, MatSelect } from '@angular/material/select';
2223
import {
2324
MatCell,
2425
MatCellDef,
@@ -45,10 +46,13 @@ import { RealtimeQuery } from 'xforge-common/models/realtime-query';
4546
import { OnlineStatusService } from 'xforge-common/online-status.service';
4647
import { RetryingRequest } from 'xforge-common/retrying-request.service';
4748
import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util';
49+
import { stripHtml } from 'xforge-common/util/string-util';
4850
import { objectId } from 'xforge-common/utils';
4951
import { environment } from '../../../environments/environment';
52+
import { ParatextProject } from '../../core/models/paratext-project';
5053
import { QuestionDoc } from '../../core/models/question-doc';
5154
import { TextsByBookId } from '../../core/models/texts-by-book-id';
55+
import { ParatextNote, ParatextNoteTag, ParatextService } from '../../core/paratext.service';
5256
import { SFProjectService } from '../../core/sf-project.service';
5357
import {
5458
ScriptureChooserDialogComponent,
@@ -93,7 +97,14 @@ interface DialogListItem {
9397
}
9498

9599
type DialogErrorState = 'update_transcelerator' | 'file_import_errors' | 'missing_header_row' | 'offline_conversion';
96-
type DialogStatus = 'initial' | 'no_questions' | 'filter' | 'loading' | 'progress' | DialogErrorState;
100+
type DialogStatus =
101+
| 'initial'
102+
| 'no_questions'
103+
| 'filter'
104+
| 'loading'
105+
| 'progress'
106+
| 'paratext_tag_selection'
107+
| DialogErrorState;
97108

98109
@Component({
99110
templateUrl: './import-questions-dialog.component.html',
@@ -134,11 +145,13 @@ type DialogStatus = 'initial' | 'no_questions' | 'filter' | 'loading' | 'progres
134145
MatCheckbox,
135146
MatProgressBar,
136147
MatDialogActions,
137-
AsyncPipe
148+
AsyncPipe,
149+
MatSelect,
150+
MatOption
138151
]
139152
})
140153
export class ImportQuestionsDialogComponent implements OnDestroy {
141-
questionSource: null | 'transcelerator' | 'csv_file' = null;
154+
questionSource: null | 'transcelerator' | 'csv_file' | 'paratext' = null;
142155

143156
questionList: DialogListItem[] = [];
144157
filteredList: DialogListItem[] = [];
@@ -155,6 +168,15 @@ export class ImportQuestionsDialogComponent implements OnDestroy {
155168
importCanceled: boolean = false;
156169
fileExtensions: string = '.csv,.tsv';
157170

171+
showParatextTagSelector = false;
172+
loadingParatextTags = false;
173+
paratextTagLoadError = false;
174+
paratextTagConversionError = false;
175+
paratextTagOptions: ParatextNoteTag[] = [];
176+
selectedParatextTagId: number | null = null;
177+
private paratextNotes: ParatextNote[] = [];
178+
private paratextProjectsPromise?: Promise<ParatextProject[] | undefined>;
179+
158180
@ViewChild('selectAllCheckbox') selectAllCheckbox!: MatCheckbox;
159181
@ViewChild('dialogContentBody') dialogContentBody!: ElementRef;
160182

@@ -177,6 +199,7 @@ export class ImportQuestionsDialogComponent implements OnDestroy {
177199
private readonly destroyRef: DestroyRef,
178200
@Inject(MAT_DIALOG_DATA) public readonly data: ImportQuestionsDialogData,
179201
projectService: SFProjectService,
202+
private readonly paratextService: ParatextService,
180203
private readonly checkingQuestionsService: CheckingQuestionsService,
181204
private readonly onlineStatusService: OnlineStatusService,
182205
private readonly dialogRef: MatDialogRef<ImportQuestionsDialogComponent>,
@@ -239,6 +262,9 @@ export class ImportQuestionsDialogComponent implements OnDestroy {
239262
if (this.importing) {
240263
return 'progress';
241264
}
265+
if (this.showParatextTagSelector) {
266+
return 'paratext_tag_selection';
267+
}
242268
if (this.invalidRows.length !== 0) {
243269
return 'file_import_errors';
244270
}
@@ -414,6 +440,7 @@ export class ImportQuestionsDialogComponent implements OnDestroy {
414440
isArchived: false,
415441
dateCreated: currentDate,
416442
dateModified: currentDate,
443+
//todo rename
417444
transceleratorQuestionId: listItem.question.id
418445
};
419446
await this.zone.runOutsideAngular(() =>
@@ -466,6 +493,165 @@ export class ImportQuestionsDialogComponent implements OnDestroy {
466493
this.loading = false;
467494
}
468495

496+
async importFromParatext(): Promise<void> {
497+
if (this.loadingParatextTags) {
498+
return;
499+
}
500+
501+
this.importClicked = false;
502+
this.errorState = undefined;
503+
this.questionSource = 'paratext';
504+
this.questionList = [];
505+
this.filteredList = [];
506+
this.invalidRows = [];
507+
this.loadingParatextTags = true;
508+
this.showParatextTagSelector = true;
509+
this.paratextTagLoadError = false;
510+
this.paratextTagConversionError = false;
511+
this.selectedParatextTagId = null;
512+
this.paratextNotes = [];
513+
this.paratextTagOptions = [];
514+
515+
try {
516+
const paratextId = await this.getParatextProjectId();
517+
if (paratextId == null) {
518+
this.paratextTagLoadError = true;
519+
return;
520+
}
521+
522+
const notes = await this.paratextService.getNotes(paratextId);
523+
this.paratextNotes = notes ?? [];
524+
this.paratextTagOptions = this.collectParatextTagOptions(this.paratextNotes);
525+
if (this.paratextTagOptions.length > 0) {
526+
this.selectedParatextTagId = this.paratextTagOptions[0].id;
527+
}
528+
} catch (error) {
529+
this.paratextNotes = [];
530+
this.paratextTagOptions = [];
531+
this.selectedParatextTagId = null;
532+
this.paratextTagLoadError = true;
533+
} finally {
534+
this.loadingParatextTags = false;
535+
}
536+
}
537+
538+
cancelParatextTagSelection(): void {
539+
this.showParatextTagSelector = false;
540+
this.questionSource = null;
541+
this.loadingParatextTags = false;
542+
this.paratextTagLoadError = false;
543+
this.paratextTagConversionError = false;
544+
this.paratextNotes = [];
545+
this.paratextTagOptions = [];
546+
this.selectedParatextTagId = null;
547+
this.importClicked = false;
548+
}
549+
550+
async confirmParatextTagSelection(): Promise<void> {
551+
const tagId = this.selectedParatextTagId;
552+
if (tagId == null) {
553+
return;
554+
}
555+
556+
this.loading = true;
557+
this.showParatextTagSelector = false;
558+
this.questionList = [];
559+
this.filteredList = [];
560+
this.invalidRows = [];
561+
this.paratextTagConversionError = false;
562+
563+
try {
564+
const questions = this.convertParatextCommentsToQuestions(this.paratextNotes, tagId);
565+
await this.setUpQuestionList(questions, true);
566+
} catch (error) {
567+
this.paratextTagConversionError = true;
568+
this.showParatextTagSelector = true;
569+
return;
570+
} finally {
571+
this.loading = false;
572+
}
573+
}
574+
575+
private async getParatextProjectId(): Promise<string | undefined> {
576+
try {
577+
this.paratextProjectsPromise ??= this.paratextService.getProjects();
578+
const projects = await this.paratextProjectsPromise;
579+
const project = projects?.find(p => p.projectId === this.data.projectId);
580+
return project?.paratextId;
581+
} catch (error) {
582+
console.error('Failed to load Paratext project list', error);
583+
this.paratextProjectsPromise = undefined;
584+
throw error;
585+
}
586+
}
587+
588+
private collectParatextTagOptions(notes: ParatextNote[]): ParatextNoteTag[] {
589+
const tagMap = new Map<number, ParatextNoteTag>();
590+
for (const note of notes) {
591+
for (const comment of note.comments ?? []) {
592+
if (comment.tag != null && !tagMap.has(comment.tag.id)) {
593+
tagMap.set(comment.tag.id, comment.tag);
594+
}
595+
}
596+
}
597+
598+
return Array.from(tagMap.values()).sort((a, b) =>
599+
a.name.localeCompare(b.name, this.i18n.localeCode, { sensitivity: 'base' })
600+
);
601+
}
602+
603+
private convertParatextCommentsToQuestions(notes: ParatextNote[], tagId: number): SourceQuestion[] {
604+
const questions: SourceQuestion[] = [];
605+
606+
for (const note of notes) {
607+
const comments = note.comments ?? [];
608+
for (let index = 0; index < comments.length; index++) {
609+
const comment = comments[index];
610+
if (comment.tag == null || comment.tag.id !== tagId) {
611+
continue;
612+
}
613+
614+
const verseRef = this.parseVerseReference(note.verseRef);
615+
if (verseRef == null) {
616+
continue;
617+
}
618+
619+
const questionText = stripHtml(comment.content ?? '').trim();
620+
if (questionText.length === 0) {
621+
continue;
622+
}
623+
624+
questions.push({
625+
id: note.id,
626+
verseRef,
627+
text: questionText
628+
});
629+
630+
break;
631+
}
632+
}
633+
634+
return questions;
635+
}
636+
637+
private parseVerseReference(reference: string | undefined): VerseRef | null {
638+
if (reference == null) {
639+
return null;
640+
}
641+
642+
const trimmedReference = reference.trim();
643+
if (trimmedReference.length === 0) {
644+
return null;
645+
}
646+
647+
const parseResult = VerseRef.tryParse(trimmedReference);
648+
if (parseResult.success !== true || parseResult.verseRef == null) {
649+
return null;
650+
}
651+
652+
return parseResult.verseRef;
653+
}
654+
469655
async fileSelected(file: File): Promise<void> {
470656
this.loading = true;
471657

0 commit comments

Comments
 (0)