diff --git a/src/app/components/shared/_dialogs/create-comment/create-comment.component.ts b/src/app/components/shared/_dialogs/create-comment/create-comment.component.ts index 6d2293ef26933cb45f39916938efdf56e5fa54ba..c44c9778382e1fb08506d94a8b7097a046bf93f7 100644 --- a/src/app/components/shared/_dialogs/create-comment/create-comment.component.ts +++ b/src/app/components/shared/_dialogs/create-comment/create-comment.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { Component, Inject, Input, OnInit, ViewChild } from '@angular/core'; import { Comment, Language as CommentLanguage } from '../../../../models/comment'; import { NotificationService } from '../../../../services/util/notification.service'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; @@ -11,6 +11,9 @@ import { CreateCommentKeywords } from '../../../../utils/create-comment-keywords import { WriteCommentComponent } from '../../write-comment/write-comment.component'; import { DeepLDialogComponent } from '../deep-ldialog/deep-ldialog.component'; import { DeepLService } from '../../../../services/http/deep-l.service'; +import { Observable } from 'rxjs'; +import { ViewCommentDataComponent } from '../../view-comment-data/view-comment-data.component'; +import { map } from 'rxjs/operators'; @Component({ selector: 'app-submit-comment', @@ -20,10 +23,13 @@ import { DeepLService } from '../../../../services/http/deep-l.service'; export class CreateCommentComponent implements OnInit { @ViewChild(WriteCommentComponent) commentComponent: WriteCommentComponent; - user: User; - roomId: string; - tags: string[]; + @Input() user: User; + @Input() roomId: string; + @Input() tags: string[]; + maxTextCharacters = 500; + maxDataCharacters = 1500; isSendingToSpacy = false; + isModerator = false; constructor( private notification: NotificationService, @@ -36,8 +42,27 @@ export class CreateCommentComponent implements OnInit { @Inject(MAT_DIALOG_DATA) public data: any) { } + private static encodeHTML(str: string): string { + return str.replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + private static decodeHTML(str: string): string { + return str.replace(/'/g, '\'') + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); + } + ngOnInit() { this.translateService.use(localStorage.getItem('currentLang')); + this.isModerator = this.user && this.user.role > 0; + this.maxTextCharacters = this.isModerator ? 1000 : 500; + this.maxDataCharacters = this.isModerator ? this.maxTextCharacters * 5 : this.maxTextCharacters * 3; } onNoClick(): void { @@ -59,7 +84,7 @@ export class CreateCommentComponent implements OnInit { } openDeeplDialog(body: string, text: string, onClose: (data: string, text: string) => void) { - this.deeplService.improveTextStyle(text).subscribe(improvedText => { + this.generateDeeplDelta(body).subscribe(([improvedBody, improvedText]) => { this.isSendingToSpacy = false; this.dialog.open(DeepLDialogComponent, { width: '900px', @@ -67,7 +92,11 @@ export class CreateCommentComponent implements OnInit { data: { body, text, - improvedText + improvedBody, + improvedText, + maxTextCharacters: this.maxTextCharacters, + maxDataCharacters: this.maxDataCharacters, + isModerator: this.isModerator } }).afterClosed().subscribe((res) => { if (res) { @@ -116,4 +145,26 @@ export class CreateCommentComponent implements OnInit { this.isSendingToSpacy = false; }); } + + private generateDeeplDelta(body: string): Observable<[string, string]> { + const delta = ViewCommentDataComponent.getDeltaFromData(body); + const xml = delta.ops.reduce((acc, e, i) => { + if (typeof e['insert'] === 'string' && e['insert'].trim().length) { + acc += '<x i="' + i + '">' + CreateCommentComponent.encodeHTML(e['insert']) + '</x>'; + e['insert'] = ''; + } + return acc; + }, ''); + return this.deeplService.improveTextStyle(xml).pipe( + map(str => { + const regex = /<x i="(\d+)">([^<]+)<\/x>/gm; + let m; + while ((m = regex.exec(str)) !== null) { + delta.ops[+m[1]]['insert'] += CreateCommentComponent.decodeHTML(m[2]); + } + const text = delta.ops.reduce((acc, el) => acc + (typeof el['insert'] === 'string' ? el['insert'] : ''), ''); + return [ViewCommentDataComponent.getDataFromDelta(delta), text]; + }) + ); + } } diff --git a/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.html b/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.html index 56e44687aefdc4b495dc8df440c6163d21fe2b3d..764ce81979ad332822b77d93a8efa8d46c187385 100644 --- a/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.html +++ b/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.html @@ -7,13 +7,23 @@ [(ngModel)]="radioButtonValue"> <mat-radio-button [value]="normalValue"> <strong>{{'deepl.option-normal' | translate}}</strong> - <app-view-comment-data [currentData]="normalValue.body"></app-view-comment-data> </mat-radio-button> + <app-view-comment-data #normal + [maxTextCharacters]="data.maxTextCharacters" + [maxDataCharacters]="data.maxDataCharacters" + [isModerator]="data.isModerator" + [isEditor]="true" + [currentData]="normalValue.body"></app-view-comment-data> <br> <mat-radio-button [value]="improvedValue"> <strong>{{'deepl.option-improved' | translate}}</strong> - <app-view-comment-data [currentData]="improvedValue.body"></app-view-comment-data> </mat-radio-button> + <app-view-comment-data #improved + [maxTextCharacters]="data.maxTextCharacters" + [maxDataCharacters]="data.maxDataCharacters" + [isModerator]="data.isModerator" + [isEditor]="true" + [currentData]="improvedValue.body"></app-view-comment-data> </mat-radio-group> </div> diff --git a/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.ts b/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.ts index 837351e120eec008f75ce558faa820ecbbc98fcf..886c709ddba6764ea699380022b6af64bbd6bf9d 100644 --- a/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.ts +++ b/src/app/components/shared/_dialogs/deep-ldialog/deep-ldialog.component.ts @@ -1,18 +1,16 @@ -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { ViewCommentDataComponent } from '../../view-comment-data/view-comment-data.component'; +import { NotificationService } from '../../../../services/util/notification.service'; +import { LanguageService } from '../../../../services/util/language.service'; +import { TranslateService } from '@ngx-translate/core'; +import { WriteCommentComponent } from '../../write-comment/write-comment.component'; interface ResultValue { body: string; text: string; } -interface TextMeta { - index: number; - textLines: string[]; - newlineEndings: number; -} - @Component({ selector: 'app-deep-ldialog', templateUrl: './deep-ldialog.component.html', @@ -20,158 +18,31 @@ interface TextMeta { }) export class DeepLDialogComponent implements OnInit { + @ViewChild('normal') normal: ViewCommentDataComponent; + @ViewChild('improved') improved: ViewCommentDataComponent; radioButtonValue: ResultValue; normalValue: ResultValue; improvedValue: ResultValue; constructor( private dialogRef: MatDialogRef<DeepLDialogComponent>, - @Inject(MAT_DIALOG_DATA) public data: any) { - } - - private static buildTextArrayFromDelta(delta: any): [number, TextMeta[]] { - const result: TextMeta[] = []; - let lastTextIndex = -1; - let newlinePrefix = 0; - for (let i = 0; i < delta.ops.length; i++) { - const data = delta.ops[i]['insert']; - if (typeof data !== 'string') { - continue; - } - if (data === '') { - continue; - } - const count = data.split('\n').reduce((acc, e) => e.trim() === '' && acc !== -2 ? acc + 1 : -2, -1); - if (count > 0) { - if (lastTextIndex >= 0) { - result[lastTextIndex].newlineEndings += count; - } else { - newlinePrefix += count; - } - continue; - } - lastTextIndex = result.length; - result.push({ index: i, textLines: data.split(/\n+/), newlineEndings: 0 }); - delta.ops[i]['insert'] = ''; - } - return [newlinePrefix, result]; - } - - private static applyDeeplTextByTextMeta(improvedText: string, prefixOffset: number, result: TextMeta[], delta: any) { - const data = improvedText.split('\n'); - let index = prefixOffset; - let previousResult = ''; - for (const meta of result) { - if (meta.newlineEndings > 0) { - delta.ops[meta.index]['insert'] += previousResult + data.slice(index, index + meta.textLines.length).join('\n'); - previousResult = ''; - } else { - if (meta.textLines.length > 1) { - delta.ops[meta.index]['insert'] += previousResult + data.slice(index, index + meta.textLines.length - 1).join('\n'); - previousResult = ''; - } - const str = meta.textLines[meta.textLines.length - 1]; - const dataStr = data[index + meta.textLines.length - 1] || '\n'; - const newStr = previousResult ? previousResult : dataStr; - if (str.trim() === '') { - if (newStr.trim() === '') { - delta.ops[meta.index]['insert'] += newStr; - previousResult = ''; - } else { - previousResult = newStr; - } - } else { - if (newStr.trim() === '') { - delta.ops[meta.index]['insert'] += newStr; - previousResult = ''; - } else { - const [current, next] = this.getOverlappingSentences(str, newStr); - delta.ops[meta.index]['insert'] += current; - previousResult = next; - if (next.trim() === '') { - index++; - previousResult = '\n'; - } - } - } - } - index += meta.textLines.length + meta.newlineEndings - 1; - } - if (previousResult.trim()) { - delta.ops[result[result.length - 1].index]['insert'] += previousResult; - } - } - - private static countSentencesInText(text: string): number[] { - const regex = /((?<=[^\t !.?]{2})\.( ?\.)*)|([!?]( ?[!?])*)/g; - let m; - const result = []; - while ((m = regex.exec(text)) !== null) { - result.push(m.index + m[0].length); - } - return result; - } - - private static getWordCount(text: string): number[] { - const regex = /[ \t!.?]+/g; - let m; - const result = []; - while ((m = regex.exec(text)) !== null) { - result.push(m.index + m[0].length); - } - if (result.length) { - const ind = result[result.length - 1]; - if (text.substring(ind).trim().length > 0) { - result.push(text.length); - } - } else if (text.trim().length) { - result.push(text.length); - } - return result; - } - - private static getOverlappingSentences(str: string, newStr: string): [current: string, next: string] { - const sentIndexes = this.countSentencesInText(str); - const sentUpdateIndexes = this.countSentencesInText(newStr); - if (sentIndexes.length && sentIndexes[sentIndexes.length - 1] === str.length) { - const index = sentUpdateIndexes[sentIndexes.length - 1]; - return [newStr.substring(0, index), newStr.substring(index)]; - } - let lastIndex = 0; - if (sentIndexes.length) { - lastIndex = sentIndexes[sentIndexes.length - 1]; - } - let updateWords; - const offset = sentIndexes.length > 0 ? sentUpdateIndexes[sentIndexes.length - 1] : 0; - if (sentIndexes.length < sentUpdateIndexes.length) { - const text = newStr.substring(offset, sentUpdateIndexes[sentIndexes.length]); - updateWords = this.getWordCount(text); - } else { - const text = newStr.substr(offset); - updateWords = this.getWordCount(text); - } - const words = this.getWordCount(str.substr(lastIndex)); - if (words.length === 0) { - return [newStr.substring(0, offset), newStr.substr(offset)]; - } - const startIndex = updateWords.length > words.length ? offset + updateWords[words.length] : newStr.length; - return [newStr.substring(0, startIndex), newStr.substr(startIndex)]; + @Inject(MAT_DIALOG_DATA) public data: any, + private notificationService: NotificationService, + private languageService: LanguageService, + private translateService: TranslateService) { + this.languageService.langEmitter.subscribe(lang => { + this.translateService.use(lang); + }); } ngOnInit(): void { + this.translateService.use(localStorage.getItem('currentLang')); this.normalValue = { body: this.data.body, text: this.data.text }; - const delta = ViewCommentDataComponent.getDeltaFromData(this.data.body); - if (delta === null) { - setTimeout(() => this.dialogRef.close(this.normalValue)); - return; - } - const [offset, meta] = DeepLDialogComponent.buildTextArrayFromDelta(delta); - DeepLDialogComponent.applyDeeplTextByTextMeta(this.data.improvedText, offset, meta, delta); this.improvedValue = { - body: ViewCommentDataComponent.getDataFromDelta(delta), + body: this.data.improvedBody, text: this.data.improvedText }; this.radioButtonValue = this.normalValue; @@ -182,7 +53,22 @@ export class DeepLDialogComponent implements OnInit { } buildSubmitBodyActionCallback(): () => void { - return () => this.dialogRef.close(this.radioButtonValue); + return () => { + let current: ResultValue; + if (this.radioButtonValue === this.normalValue) { + this.normalValue.body = this.normal.currentData; + this.normalValue.text = this.normal.currentText; + current = this.normalValue; + } else { + this.improvedValue.body = this.improved.currentData; + this.improvedValue.text = this.improved.currentText; + current = this.improvedValue; + } + if (WriteCommentComponent.checkInputData(current.body, current.text, + this.translateService, this.notificationService, this.data.maxTextCharacters, this.data.maxDataCharacters)) { + this.dialogRef.close(this.radioButtonValue); + } + }; } } diff --git a/src/app/components/shared/_dialogs/worker-dialog/worker-dialog-task.ts b/src/app/components/shared/_dialogs/worker-dialog/worker-dialog-task.ts index 62091d33b420a05ab2019fe8f60496aa458abd53..30778ca4cd9324909a88c076a3edefa5578a05e8 100644 --- a/src/app/components/shared/_dialogs/worker-dialog/worker-dialog-task.ts +++ b/src/app/components/shared/_dialogs/worker-dialog/worker-dialog-task.ts @@ -7,6 +7,7 @@ import { CreateCommentKeywords } from '../../../../utils/create-comment-keywords import { TSMap } from 'typescript-map'; import { HttpErrorResponse } from '@angular/common/http'; import { CURRENT_SUPPORTED_LANGUAGES, Model } from '../../../../services/http/spacy.interface'; +import { ViewCommentDataComponent } from '../../view-comment-data/view-comment-data.component'; const concurrentCallsPerTask = 4; @@ -59,7 +60,8 @@ export class WorkerDialogTask { return; } const currentComment = this._comments[currentIndex]; - CreateCommentKeywords.isSpellingAcceptable(this.languagetoolService, currentComment.body) + const text = ViewCommentDataComponent.getTextFromData(currentComment.body); + CreateCommentKeywords.isSpellingAcceptable(this.languagetoolService, text) .subscribe(result => { if (!result.isAcceptable) { this.finishSpacyCall(FinishType.badSpelled, currentIndex); diff --git a/src/app/components/shared/view-comment-data/view-comment-data.component.ts b/src/app/components/shared/view-comment-data/view-comment-data.component.ts index ccf7af27b688a1fec6c6c847d6317e249445ed3c..fddb0665565065be6a92dcc4a4a24082c31b7ba6 100644 --- a/src/app/components/shared/view-comment-data/view-comment-data.component.ts +++ b/src/app/components/shared/view-comment-data/view-comment-data.component.ts @@ -104,6 +104,18 @@ export class ViewCommentDataComponent implements OnInit, AfterViewInit { }; } + public static getTextFromData(jsonData: string): string { + return JSON.parse(jsonData).reduce((acc, e) => { + if (typeof e['insert'] === 'string') { + return acc + e['insert']; + } else if (typeof e === 'string') { + return acc + e; + } + return acc; + }, ''); + } + + ngOnInit(): void { if (this.isModerator) { this.quillModules.toolbar['container'] = moderatorToolbar; @@ -130,6 +142,19 @@ export class ViewCommentDataComponent implements OnInit, AfterViewInit { } this._currentData = ViewCommentDataComponent.getDataFromDelta(e.content); this.currentText = e.text; + // remove background + const data = e.content; + let changed = false; + data.ops.forEach(op => { + if (op.attributes && op.attributes.background) { + changed = true; + op.attributes.background = null; + delete op.attributes.background; + } + }); + if (changed) { + this.editor.quillEditor.setContents(data); + } }); this.editor.onEditorCreated.subscribe(_ => { if (this.markEvents && this.markEvents.onCreate) { diff --git a/src/app/components/shared/write-comment/write-comment.component.ts b/src/app/components/shared/write-comment/write-comment.component.ts index 324be593f6953fb88b24dec562dd298a7351e268..4a524a2bfae39a39f0283dcaec74cc33160d7277 100644 --- a/src/app/components/shared/write-comment/write-comment.component.ts +++ b/src/app/components/shared/write-comment/write-comment.component.ts @@ -56,6 +56,32 @@ export class WriteCommentComponent implements OnInit { }); } + public static checkInputData(data: string, + text: string, + translateService: TranslateService, + notificationService: NotificationService, + maxTextCharacters: number, + maxDataCharacters: number): boolean { + text = text.trim(); + if (!text.length) { + translateService.get('comment-page.error-comment').subscribe(message => { + notificationService.show(message); + }); + return false; + } else if (text.length > maxTextCharacters) { + translateService.get('comment-page.error-comment-text').subscribe(message => { + notificationService.show(message); + }); + return false; + } else if (data.length > maxDataCharacters) { + translateService.get('comment-page.error-comment-data').subscribe(message => { + notificationService.show(message); + }); + return false; + } + return true; + } + ngOnInit(): void { this.translateService.use(localStorage.getItem('currentLang')); if (this.isCommentAnswer) { @@ -78,7 +104,8 @@ export class WriteCommentComponent implements OnInit { return undefined; } return () => { - if (this.checkInputData(this.commentData.currentData, this.commentData.currentText)) { + if (WriteCommentComponent.checkInputData(this.commentData.currentData, this.commentData.currentText, + this.translateService, this.notification, this.maxTextCharacters, this.maxDataCharacters)) { this.onSubmit(this.commentData.currentData, this.commentData.currentText, this.selectedTag); } }; @@ -109,7 +136,7 @@ export class WriteCommentComponent implements OnInit { return; } if (this.selectedLang === 'auto' && - (langSelect.innerText.includes(this.newLang) || langSelect.innerText.includes('auto'))) { + (langSelect.innerText.includes(this.newLang) || langSelect.innerText.includes('auto'))) { if (wordsCheck.language.name.includes('German')) { this.selectedLang = 'de-DE'; } else if (wordsCheck.language.name.includes('English')) { @@ -153,25 +180,4 @@ export class WriteCommentComponent implements OnInit { }; } - private checkInputData(data: string, text: string): boolean { - text = text.trim(); - if (!text.length) { - this.translateService.get('comment-page.error-comment').subscribe(message => { - this.notification.show(message); - }); - return false; - } else if (text.length > this.maxTextCharacters) { - this.translateService.get('comment-page.error-comment-text').subscribe(message => { - this.notification.show(message); - }); - return false; - } else if (data.length > this.maxDataCharacters) { - this.translateService.get('comment-page.error-comment-data').subscribe(message => { - this.notification.show(message); - }); - return false; - } - return true; - } - } diff --git a/src/app/services/http/deep-l.service.ts b/src/app/services/http/deep-l.service.ts index 7125235d5dee9ab6e2435d9993df5de8e8f00ee5..f55238f5250b4cf707930883a0f8ff856a34e332 100644 --- a/src/app/services/http/deep-l.service.ts +++ b/src/app/services/http/deep-l.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { BaseHttpService } from './base-http.service'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { catchError, map, tap } from 'rxjs/operators'; +import { catchError, map, tap, timeout } from 'rxjs/operators'; import { flatMap } from 'rxjs/internal/operators'; const httpOptions = { @@ -12,11 +12,18 @@ const httpOptions = { interface DeepLResult { translations: { - detected_source_language: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + detected_source_language: SourceLang; text: string; }[]; } +type SourceLang = 'BG' | 'CS' | 'DA' | 'DE' | 'EL' | 'EN' | 'ES' | 'ET' | 'FI' | 'FR' | 'HU' | 'IT' | 'JA' | 'LT' + | 'LV' | 'NL' | 'PL' | 'PT' | 'RO' | 'RU' | 'SK' | 'SL' | 'SV' | 'ZH'; + +type TargetLang = 'BG' | 'CS' | 'DA' | 'DE' | 'EL' | 'EN-GB' | 'EN-US' | 'ES' | 'ET' | 'FI' | 'FR' | 'HU' | 'IT' | + 'JA' | 'LT' | 'LV' | 'NL' | 'PL' | 'PT-PT' | 'PT-BR' | 'RO' | 'RU' | 'SK' | 'SL' | 'SV' | 'ZH'; + @Injectable({ providedIn: 'root' }) @@ -26,24 +33,56 @@ export class DeepLService extends BaseHttpService { super(); } + private static transformSourceToTarget(lang: SourceLang): TargetLang { + switch (lang) { + case 'EN': + return 'EN-US'; + case 'PT': + return 'PT-PT'; + default: + return lang; + } + } + + private static supportsFormality(lang: TargetLang): boolean { + switch (lang) { + case 'DE': + case 'ES': + case 'FR': + case 'IT': + case 'NL': + case 'PL': + case 'PT-BR': + case 'PT-PT': + case 'RU': + return true; + default: + return false; + } + } + improveTextStyle(text: string): Observable<string> { - return this.makeTranslateRequest([text], 'EN-US').pipe( + return this.makeXMLTranslateRequest(text, 'EN-US').pipe( flatMap(result => - this.makeTranslateRequest([result.translations[0].text], result.translations[0].detected_source_language)), + this.makeXMLTranslateRequest( + result.translations[0].text, + DeepLService.transformSourceToTarget(result.translations[0].detected_source_language) + )), map(result => result.translations[0].text) ); } - private makeTranslateRequest(text: string[], targetLang: string): Observable<DeepLResult> { + private makeXMLTranslateRequest(text: string, targetLang: TargetLang): Observable<DeepLResult> { const url = '/deepl/translate'; - console.assert(text.length > 0, 'You need at least one text entry.'); - console.assert(text.length <= 50, 'Maximum 50 text entries are allowed'); + const formality = DeepLService.supportsFormality(targetLang) ? '&formality=more' : ''; const additional = '?target_lang=' + encodeURIComponent(targetLang) + - '&text=' + text.map(e => encodeURIComponent(e)).join('&text='); + '&tag_handling=xml' + formality + + '&text=' + encodeURIComponent(text); return this.http.get<string>(url + additional, httpOptions) .pipe( tap(_ => ''), - catchError(this.handleError<any>('makeTranslateRequest')), + timeout(5000), + catchError(this.handleError<any>('makeXMLTranslateRequest')), ); } }