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