Merge branch 'master' into VM/RED-8748

This commit is contained in:
Valentin Mihai 2024-04-24 21:49:45 +03:00
commit cae3f2dec3
26 changed files with 221 additions and 100 deletions

View File

@ -6,15 +6,21 @@
<ngx-monaco-editor (init)="onCodeEditorInit($event)" [(ngModel)]="codeEditorText" [options]="editorOptions"></ngx-monaco-editor>
<div *ngIf="changed && permissionsService.canEditRules() && !isLeaving" class="changes-box">
<div (click)="goToErrors()" *ngIf="numberOfErrors()" class="errors">
<div (click)="goToErrors()" *ngIf="numberOfErrors() || numberOfWarnings()" class="errors">
<span>
<mat-icon [svgIcon]="'iqser:alert-circle'" class="icon"></mat-icon>
<mat-icon *ngIf="numberOfErrors()" [svgIcon]="'iqser:alert-circle'" class="icon"></mat-icon>
<div class="found-errors">
<span [translateParams]="{ errors: numberOfErrors() }" [translate]="translations[this.type]['errors-found']"></span>
<span
*ngIf="numberOfErrors()"
[translateParams]="{ errors: numberOfErrors() }"
[translate]="translations[this.type]['errors-found']"
>
</span>
<span
[translateParams]="{ warnings: numberOfWarnings() }"
[translate]="translations[this.type]['warnings-found']"
class="warning"
[class.only-warning]="!numberOfErrors()"
></span>
</div>
</span>

View File

@ -63,6 +63,10 @@ ngx-monaco-editor {
.warning {
color: var(--iqser-warn);
&.only-warning {
margin-left: 15px;
}
}
}

View File

@ -144,7 +144,7 @@ export class RulesScreenComponent implements OnInit, ComponentCanDeactivate {
}),
).then(
async (response: UploadResponse) => {
const errors = this.#mapErrors(response);
const errors = this.#mapErrors(response, dryRun);
this.#drawErrorMarkers(errors);
if (!dryRun) {
await this.#initialize();
@ -154,10 +154,13 @@ export class RulesScreenComponent implements OnInit, ComponentCanDeactivate {
error => {
let errors: SyntaxError[];
if (error.error?.syntaxErrorMessages) {
errors = this.#mapErrors(error.error);
errors = this.#mapErrors(error.error, dryRun);
} else {
const syntaxError: SyntaxError = { message: error.error.message, line: 1, column: 0 };
errors = this.#mapErrors({ blacklistErrorMessages: [], syntaxErrorMessages: [syntaxError], deprecatedWarnings: [] });
errors = this.#mapErrors(
{ blacklistErrorMessages: [], syntaxErrorMessages: [syntaxError], deprecatedWarnings: [] },
dryRun,
);
}
this.#drawErrorMarkers(errors);
this._loadingService.stop();
@ -176,12 +179,12 @@ export class RulesScreenComponent implements OnInit, ComponentCanDeactivate {
this._loadingService.stop();
}
#mapErrors(response: UploadResponse) {
return [
...response.blacklistErrorMessages,
...response.syntaxErrorMessages,
...response.deprecatedWarnings.map(w => ({ ...w, warning: true })),
];
#mapErrors(response: UploadResponse, dryRun = false) {
const warnings = response.deprecatedWarnings.map(w => ({ ...w, warning: true }));
if (dryRun) {
return warnings;
}
return [...response.blacklistErrorMessages, ...response.syntaxErrorMessages, ...warnings];
}
#getValue(): string {
@ -238,6 +241,7 @@ export class RulesScreenComponent implements OnInit, ComponentCanDeactivate {
#drawErrorMarkers(errors: SyntaxError[] | undefined) {
const model = this.#codeEditor?.getModel();
if (!model || !errors?.length) {
this.#removeErrorMarkers();
return;
}
const markers = [];

View File

@ -1,14 +1,14 @@
<table>
<thead>
<tr>
<th *ngFor="let column of columns" [ngClass]="{ hide: !column.show }">
<th *ngFor="let column of columns" [ngClass]="{ hide: !column.show, 'w-50': staticColumns }">
<label>{{ column.label }}</label>
</th>
</tr>
</thead>
<tbody [ngStyle]="{ height: redactedTextsAreaHeight + 'px' }">
<tr *ngFor="let row of data">
<td *ngFor="let cell of row" [ngClass]="{ hide: !cell.show, bold: cell.bold }">
<td *ngFor="let cell of row" [ngClass]="{ hide: !cell.show, bold: cell.bold, 'w-50': staticColumns }">
{{ cell.label }}
</td>
</tr>

View File

@ -27,8 +27,10 @@ table {
th,
td {
&:not(.w-50) {
width: 25%;
}
max-width: 0;
width: 25%;
text-align: start;
white-space: nowrap;
@ -58,6 +60,15 @@ tbody tr:nth-child(odd) {
visibility: hidden;
}
.w-50 {
max-width: 0;
min-width: 50%;
&.hide {
display: none;
}
}
.bold {
font-weight: bold;
}

View File

@ -20,6 +20,7 @@ const MAX_ITEMS_DISPLAY = 10;
export class SelectedAnnotationsTableComponent {
@Input({ required: true }) columns: ValueColumn[];
@Input({ required: true }) data: ValueColumn[][];
@Input() staticColumns = false;
get redactedTextsAreaHeight() {
return this.data.length <= MAX_ITEMS_DISPLAY ? TABLE_ROW_SIZE * this.data.length : TABLE_ROW_SIZE * MAX_ITEMS_DISPLAY;

View File

@ -8,7 +8,11 @@
<div class="dialog-content redaction" [class.fixed-height]="isRedacted && isImage">
<div class="iqser-input-group" *ngIf="!isImage && redactedTexts.length && !allRectangles">
<redaction-selected-annotations-list [values]="redactedTexts"></redaction-selected-annotations-list>
<redaction-selected-annotations-table
[columns]="tableColumns"
[data]="tableData"
[staticColumns]="true"
></redaction-selected-annotations-table>
</div>
<div *ngIf="!isManualRedaction" class="iqser-input-group w-450" [class.required]="!form.controls.type.disabled">

View File

@ -1,6 +1,6 @@
@use 'common-mixins';
.dialog-content {
padding-top: 8px;
&.fixed-height {
height: 386px;
overflow-y: auto;

View File

@ -11,6 +11,7 @@ import { EditRedactionData, EditRedactResult } from '../../utils/dialog-types';
import { LegalBasisOption } from '../manual-redaction-dialog/manual-annotation-dialog.component';
import { Roles } from '@users/roles';
import { DialogHelpModeKeys } from '../../utils/constants';
import { ValueColumn } from '../../components/selected-annotations-table/selected-annotations-table.component';
interface TypeSelectOptions {
type: string;
@ -38,6 +39,20 @@ export class EditRedactionDialogComponent
readonly isRedacted = this.annotations.every(annotation => annotation.isRedacted);
readonly isImported: boolean = this.annotations.every(annotation => annotation.imported);
readonly allRectangles = this.annotations.reduce((acc, a) => acc && a.AREA, true);
readonly tableColumns = [
{
label: 'Value',
show: true,
},
{
label: 'Type',
show: true,
},
];
readonly tableData: ValueColumn[][] = this.data.annotations.map(redaction => [
{ label: redaction.value, show: true, bold: true },
{ label: redaction.typeLabel, show: true },
]);
protected readonly roles = Roles;
options: DetailsRadioOption<RedactOrHintOption>[] | undefined;
@ -112,6 +127,10 @@ export class EditRedactionDialogComponent
return DialogHelpModeKeys.REDACTION_EDIT;
}
get sameType() {
return this.annotations.every(annotation => annotation.type === this.annotations[0].type);
}
async ngOnInit() {
this.#setTypes();
const data = await firstValueFrom(this._justificationsService.loadAll(this.#dossier.dossierTemplateId));
@ -163,10 +182,11 @@ export class EditRedactionDialogComponent
#setTypes() {
this.dictionaries = this._dictionaryService.getEditableRedactionTypes(
this.#dossier.dossierTemplateId,
this.#dossier.dossierId,
this.isImage,
this.isHint,
this.annotations.every(annotation => annotation.isOCR),
this.sameType ? this.annotations[0].type : null,
);
this.typeSelectOptions = this.dictionaries.map(dictionary => ({
@ -198,13 +218,12 @@ export class EditRedactionDialogComponent
}
#getForm() {
const sameType = this.annotations.every(annotation => annotation.type === this.annotations[0].type);
const sameSection = this.annotations.every(annotation => annotation.section === this.annotations[0].section);
return new FormGroup({
reason: new FormControl<LegalBasisOption>({ value: null, disabled: this.someSkipped }),
comment: new FormControl<string>(null),
type: new FormControl<string>({
value: sameType ? this.annotations[0].type : null,
value: this.sameType ? this.annotations[0].type : null,
disabled: this.isImported,
}),
section: new FormControl<string>({ value: sameSection ? this.annotations[0].section : null, disabled: this.isImported }),

View File

@ -4,7 +4,11 @@
<div *ngIf="isHintDialog" class="dialog-header heading-l" [translate]="'manual-annotation.dialog.header.force-hint'"></div>
<div class="dialog-content">
<redaction-selected-annotations-table [columns]="tableColumns" [data]="tableData"></redaction-selected-annotations-table>
<redaction-selected-annotations-table
[columns]="tableColumns"
[data]="tableData"
[staticColumns]="true"
></redaction-selected-annotations-table>
<div *ngIf="!isHintDialog && !isDocumine" class="iqser-input-group required w-400">
<label [translate]="'manual-annotation.dialog.content.reason'"></label>
<mat-form-field>

View File

@ -4,7 +4,11 @@
<div class="dialog-content redaction">
<div class="iqser-input-group">
<redaction-selected-annotations-list [values]="selectedValues"></redaction-selected-annotations-list>
<redaction-selected-annotations-table
[columns]="tableColumns"
[data]="tableData"
[staticColumns]="true"
></redaction-selected-annotations-table>
</div>
<iqser-details-radio
@ -17,7 +21,7 @@
<label [translate]="'redact-text.dialog.content.type'"></label>
<mat-form-field>
<mat-select [placeholder]="'redact-text.dialog.content.type-placeholder' | translate" formControlName="dictionary">
<mat-select [placeholder]="'redact-text.dialog.content.unchanged' | translate" formControlName="dictionary">
<mat-select-trigger>{{ displayedDictionaryLabel }}</mat-select-trigger>
<mat-option
(click)="typeChanged()"
@ -46,7 +50,12 @@
</div>
<div class="dialog-actions">
<iqser-icon-button [label]="'redact-text.dialog.actions.save' | translate" [submit]="true" [type]="iconButtonTypes.primary">
<iqser-icon-button
[disabled]="disabled"
[label]="'redact-text.dialog.actions.save' | translate"
[submit]="true"
[type]="iconButtonTypes.primary"
>
</iqser-icon-button>
<div [translate]="'redact-text.dialog.actions.cancel'" class="all-caps-label cancel" mat-dialog-close></div>

View File

@ -12,9 +12,11 @@ import { tap } from 'rxjs/operators';
import { getRedactOrHintOptions, RedactOrHintOption, RedactOrHintOptions } from '../../utils/dialog-options';
import { RedactRecommendationData, RedactRecommendationResult } from '../../utils/dialog-types';
import { LegalBasisOption } from '../manual-redaction-dialog/manual-annotation-dialog.component';
import { ValueColumn } from '../../components/selected-annotations-table/selected-annotations-table.component';
@Component({
templateUrl: './redact-recommendation-dialog.component.html',
styleUrl: './redact-recommendation-dialog.component.scss',
})
export class RedactRecommendationDialogComponent
extends IqserDialogComponent<RedactRecommendationDialogComponent, RedactRecommendationData, RedactRecommendationResult>
@ -30,7 +32,6 @@ export class RedactRecommendationDialogComponent
dictionaryRequest = false;
legalOptions: LegalBasisOption[] = [];
dictionaries: Dictionary[] = [];
readonly selectedValues = this.data.annotations.map(annotation => annotation.value);
readonly form = inject(FormBuilder).group({
selectedText: this.isMulti ? null : this.firstEntry.value,
comment: [null],
@ -39,6 +40,21 @@ export class RedactRecommendationDialogComponent
reason: [null],
});
readonly tableColumns = [
{
label: 'Value',
show: true,
},
{
label: 'Type',
show: true,
},
];
readonly tableData: ValueColumn[][] = this.data.annotations.map(redaction => [
{ label: redaction.value, show: true, bold: true },
{ label: redaction.typeLabel, show: true },
]);
constructor(
private readonly _justificationsService: JustificationsService,
private readonly _dictionaryService: DictionaryService,
@ -69,7 +85,7 @@ export class RedactRecommendationDialogComponent
}
get disabled() {
return this.dictionaryRequest && !this.form.controls.dictionary.value;
return !this.form.controls.dictionary.value;
}
async ngOnInit(): Promise<void> {
@ -144,9 +160,10 @@ export class RedactRecommendationDialogComponent
}
#resetValues() {
const sameType = this.data.annotations.every(annotation => annotation.type === this.firstEntry.type);
this.#applyToAllDossiers = this.data.applyToAllDossiers ?? true;
if (this.dictionaryRequest) {
this.form.controls.dictionary.setValue(this.firstEntry.type);
this.form.controls.dictionary.setValue(sameType ? this.firstEntry.type : null);
return;
}
this.form.controls.dictionary.setValue(this.#manualRedactionTypeExists ? SuperTypes.ManualRedaction : null);

View File

@ -3,48 +3,56 @@
<div [translate]="'redact-text.dialog.title'" class="dialog-header heading-l"></div>
<div class="dialog-content redaction">
<div class="iqser-input-group w-450 selected-text-group">
<div class="iqser-input-group w-full selected-text-group">
<div
[class.fixed-height-36]="dictionaryRequest"
[ngClass]="isEditingSelectedText ? 'flex relative' : 'flex-align-items-center'"
>
<ul>
<li>
<span *ngIf="!isEditingSelectedText" [innerHTML]="form.controls.selectedText.value"></span>
</li>
</ul>
<div class="table">
<label>Value</label>
<div class="row">
<span
*ngIf="!isEditingSelectedText"
[innerHTML]="form.controls.selectedText.value"
[ngStyle]="{
'min-width': textWidth > maximumSelectedTextWidth ? '95%' : 'unset',
'max-width': textWidth > maximumSelectedTextWidth ? 0 : 'unset'
}"
></span>
<textarea
*ngIf="isEditingSelectedText"
[rows]="selectedTextRows"
[ngStyle]="{ width: maximumTextAreaWidth + 'px' }"
formControlName="selectedText"
iqserHasScrollbar
name="comment"
type="text"
></textarea>
<textarea
*ngIf="isEditingSelectedText"
[rows]="selectedTextRows"
class="w-full"
formControlName="selectedText"
iqserHasScrollbar
name="comment"
type="text"
></textarea>
<iqser-circle-button
(action)="toggleEditingSelectedText()"
*ngIf="dictionaryRequest"
[showDot]="initialText !== form.get('selectedText').value && !isEditingSelectedText"
[tooltip]="'redact-text.dialog.content.edit-text' | translate"
[type]="isEditingSelectedText ? 'dark' : 'default'"
[size]="18"
[iconSize]="13"
icon="iqser:edit"
tooltipPosition="below"
></iqser-circle-button>
<iqser-circle-button
(action)="toggleEditingSelectedText()"
*ngIf="dictionaryRequest"
[class.absolute]="isEditingSelectedText"
[showDot]="initialText !== form.get('selectedText').value && !isEditingSelectedText"
[tooltip]="'redact-text.dialog.content.edit-text' | translate"
[type]="isEditingSelectedText ? 'dark' : 'default'"
class="edit-button"
icon="iqser:edit"
tooltipPosition="below"
></iqser-circle-button>
<iqser-circle-button
(action)="undoTextChange()"
*ngIf="isEditingSelectedText"
[showDot]="initialText !== form.get('selectedText').value"
[tooltip]="'redact-text.dialog.content.revert-text' | translate"
class="absolute undo-button"
icon="red:undo"
tooltipPosition="below"
></iqser-circle-button>
<iqser-circle-button
(action)="undoTextChange()"
*ngIf="isEditingSelectedText"
[showDot]="initialText !== form.get('selectedText').value"
[tooltip]="'redact-text.dialog.content.revert-text' | translate"
[size]="18"
[iconSize]="13"
icon="red:undo"
tooltipPosition="below"
></iqser-circle-button>
</div>
</div>
</div>
</div>

View File

@ -13,29 +13,14 @@
}
}
.edit-button {
top: 0;
right: calc((0.5rem + 34px) * -1);
position: sticky;
}
.undo-button {
top: 0;
right: calc((0.5rem + 34px) * -2);
iqser-circle-button {
padding-left: 8px;
}
.w-full {
width: 100%;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.fixed-height-36 {
min-height: 36px;
}
@ -43,3 +28,30 @@
textarea {
margin-top: 0;
}
.table {
display: flex;
flex-direction: column;
min-width: calc(100% - 26px);
padding: 0 13px;
label {
opacity: 0.7;
font-weight: normal;
}
.row {
display: inline-flex;
flex-direction: row;
align-items: center;
background-color: var(--iqser-alt-background);
min-width: 100%;
span {
white-space: nowrap;
text-overflow: ellipsis;
list-style-position: inside;
overflow: hidden;
}
}
}

View File

@ -14,7 +14,7 @@ import { getRedactOrHintOptions, RedactOrHintOption, RedactOrHintOptions } from
import { RedactTextData, RedactTextResult } from '../../utils/dialog-types';
import { LegalBasisOption } from '../manual-redaction-dialog/manual-annotation-dialog.component';
const MAXIMUM_SELECTED_TEXT_WIDTH = 421;
const MAXIMUM_TEXT_AREA_WIDTH = 421;
@Component({
templateUrl: './redact-text-dialog.component.html',
@ -39,6 +39,10 @@ export class RedactTextDialogComponent
readonly #dossier = inject(ActiveDossiersService).find(this.data.dossierId);
readonly #manualRedactionTypeExists = inject(DictionaryService).hasManualType(this.#dossier.dossierTemplateId);
#applyToAllDossiers = this.data.applyToAllDossiers ?? true;
readonly maximumTextAreaWidth = MAXIMUM_TEXT_AREA_WIDTH;
readonly maximumSelectedTextWidth = 567;
textWidth: number;
get defaultOption() {
const inDossierOption = this.options.find(option => option.value === RedactOrHintOptions.IN_DOSSIER);
@ -60,7 +64,7 @@ export class RedactTextDialogComponent
this.options = getRedactOrHintOptions(this.#dossier, this.#applyToAllDossiers, this.data.isApprover, this.data.isPageExcluded);
this.form = this.#getForm();
this.#setupValidators(this.dictionaryRequest ? RedactOrHintOptions.IN_DOSSIER : RedactOrHintOptions.ONLY_HERE);
this.textWidth = calcTextWidthInPixels(this.form.controls.selectedText.value);
this.form.controls.option.valueChanges
.pipe(
tap((option: DetailsRadioOption<RedactOrHintOption>) => {
@ -136,7 +140,7 @@ export class RedactTextDialogComponent
this.isEditingSelectedText = !this.isEditingSelectedText;
if (this.isEditingSelectedText) {
const width = calcTextWidthInPixels(this.form.controls.selectedText.value);
this.selectedTextRows = Math.ceil(width / MAXIMUM_SELECTED_TEXT_WIDTH);
this.selectedTextRows = Math.ceil(width / MAXIMUM_TEXT_AREA_WIDTH);
}
}
@ -213,4 +217,6 @@ export class RedactTextDialogComponent
}
this.form.controls.dictionary.setValue(this.#manualRedactionTypeExists ? SuperTypes.ManualRedaction : null);
}
protected readonly window = window;
}

View File

@ -10,6 +10,7 @@
<redaction-selected-annotations-table
[columns]="tableColumns()"
[data]="tableData()"
[staticColumns]="!hasFalsePositiveOption"
></redaction-selected-annotations-table>
</div>

View File

@ -73,6 +73,10 @@ export class RemoveRedactionDialogComponent extends IqserDialogComponent<
return DialogHelpModeKeys.REDACTION_REMOVE;
}
get hasFalsePositiveOption() {
return !!this.options.find(option => option.value === RemoveRedactionOptions.FALSE_POSITIVE);
}
get defaultOption() {
const removeHereOption = this.options.find(option => option.value === RemoveRedactionOptions.ONLY_HERE);
if (!!removeHereOption && !removeHereOption.disabled) return removeHereOption;

View File

@ -56,7 +56,7 @@ export class ManualRedactionService extends GenericService<IManualAddResponse> {
value: annotation.value,
reason: annotation.legalBasis ?? 'Dictionary Request',
positions: annotation.positions,
type: redaction.type,
type: redaction.type ?? annotation.type,
comment: redaction.comment,
}));
return this.addAnnotation(recommendations, dossierId, fileId);

View File

@ -162,15 +162,24 @@ export class DictionaryService extends EntitiesService<IDictionary, Dictionary>
.sort((a, b) => a.label.localeCompare(b.label));
}
getEditableRedactionTypes(dossierTemplateId: string, isImage: boolean, isHint: boolean, isOCR: boolean): Dictionary[] {
return this._dictionariesMapService
.get(dossierTemplateId)
getEditableRedactionTypes(
dossierId: string,
isImage: boolean,
isHint: boolean,
isOCR: boolean,
currentlySelectedType: string,
): Dictionary[] {
return this.#extractDossierLevelTypes(dossierId)
.filter(
d =>
d.model['typeId'] &&
(isImage
? (isOCR ? [...IMAGE_CATEGORIES, 'ocr'] : IMAGE_CATEGORIES).includes(d.type)
: (isHint ? d.hint : !d.hint) && !d.virtual && !d.systemManaged && ![...IMAGE_CATEGORIES, 'ocr'].includes(d.type)),
: (isHint ? d.hint : !d.hint) &&
(d.addToDictionaryAction || currentlySelectedType === d.type) &&
!d.virtual &&
!d.systemManaged &&
![...IMAGE_CATEGORIES, 'ocr'].includes(d.type)),
)
.sort((a, b) => a.label.localeCompare(b.label));
}

View File

@ -115,10 +115,7 @@ export class TrashService extends EntitiesService<TrashItem, TrashItem> {
}
private _hardDeleteFiles(dossierId: string, fileIds: List) {
const queryParams = fileIds.map<QueryParam>(id => ({ key: 'fileIds', value: id }));
return super
.delete({}, `delete/hard-delete/${dossierId}`, queryParams)
.pipe(switchMap(() => this._dossierStatsService.getFor([dossierId])));
return super._post(fileIds, `delete/hard-delete/${dossierId}`).pipe(switchMap(() => this._dossierStatsService.getFor([dossierId])));
}
private _restoreFiles(dossierId: string, fileIds: List) {

View File

@ -16,7 +16,7 @@ export class FileManagementService extends GenericService<unknown> {
delete(files: List<File>, dossierId: string) {
const fileIds = files.map(f => f.id);
return super._post(fileIds, `delete/hard-delete/${dossierId}`).pipe(switchMap(() => this.#filesService.loadAll(dossierId)));
return super._post(fileIds, `delete/${dossierId}`).pipe(switchMap(() => this.#filesService.loadAll(dossierId)));
}
rotatePage(body: IPageRotationRequest, dossierId: string, fileId: string) {

View File

@ -367,7 +367,6 @@
"annotation": {
"pending": "(Pending analysis)"
},
"annotations": "",
"archived-dossiers-listing": {
"no-data": {
"title": "No archived dossiers."
@ -1998,7 +1997,8 @@
"reason-placeholder": "Select a reason...",
"revert-text": "Revert to selected text",
"type": "Type",
"type-placeholder": "Select type..."
"type-placeholder": "Select type...",
"unchanged": ""
},
"title": "Redact text"
}

View File

@ -1998,7 +1998,8 @@
"reason-placeholder": "Select a reason...",
"revert-text": "Revert to selected text",
"type": "Type",
"type-placeholder": "Select type..."
"type-placeholder": "Select type...",
"unchanged": "Unchanged"
},
"title": "Redact text"
}

View File

@ -367,7 +367,6 @@
"annotation": {
"pending": "(Pending analysis)"
},
"annotations": "",
"archived-dossiers-listing": {
"no-data": {
"title": "No archived dossiers."
@ -1998,7 +1997,8 @@
"reason-placeholder": "Select a reasons...",
"revert-text": "",
"type": "Type",
"type-placeholder": "Select type..."
"type-placeholder": "Select type...",
"unchanged": ""
},
"title": "Redact text"
}

View File

@ -1998,7 +1998,8 @@
"reason-placeholder": "Select a reasons...",
"revert-text": "",
"type": "Type",
"type-placeholder": "Select type..."
"type-placeholder": "Select type...",
"unchanged": ""
},
"title": "Redact text"
}