Merge branch 'master' into VM/RED-7345
This commit is contained in:
commit
8caa92d663
@ -21,9 +21,10 @@ localazy update:
|
||||
paths:
|
||||
- .yarn-cache/
|
||||
script:
|
||||
- git config user.email "${CI_EMAIL}"
|
||||
- git config user.name "${CI_USERNAME}"
|
||||
- git remote add gitlab_origin https://${CI_USERNAME}:${CI_ACCESS_TOKEN}@gitlab.knecon.com/redactmanager/red-ui.git
|
||||
# - git config user.email "${CI_EMAIL}"
|
||||
# - git config user.name "${CI_USERNAME}"
|
||||
# - git remote add gitlab_origin https://${CI_USERNAME}:${CI_ACCESS_TOKEN}@gitlab.knecon.com/redactmanager/red-ui.git
|
||||
- git push https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.knecon.com/redactmanager/red-ui.git
|
||||
- cd tools/localazy
|
||||
- yarn install --cache-folder .yarn-cache
|
||||
- yarn start
|
||||
@ -36,6 +37,8 @@ localazy update:
|
||||
git status
|
||||
git commit -m "push back localazy update"
|
||||
git push gitlab_origin HEAD:${CI_COMMIT_REF_NAME}
|
||||
# git push https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.knecon.com/redactmanager/red-ui.git
|
||||
# git push
|
||||
fi
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule"
|
||||
|
||||
@ -28,7 +28,7 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { SelectComponent } from '@shared/components/select/select.component';
|
||||
import { MatSuffix } from '@angular/material/form-field';
|
||||
|
||||
const downloadTypes = ['ORIGINAL', 'PREVIEW', 'DELTA_PREVIEW', 'REDACTED'].map(type => ({
|
||||
const downloadTypes = ['ORIGINAL', 'PREVIEW', 'OPTIMIZED_PREVIEW', 'DELTA_PREVIEW', 'REDACTED'].map(type => ({
|
||||
key: type,
|
||||
label: downloadTypesTranslations[type],
|
||||
}));
|
||||
|
||||
@ -3,7 +3,7 @@ import { LicenseService } from '@services/license.service';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ChartDataset } from 'chart.js';
|
||||
import { ChartBlue, ChartGreen, ChartRed } from '../../utils/constants';
|
||||
import { getDataUntilCurrentMonth, getLabelsFromLicense, getLineConfig, isCurrentMonthAndYear } from '../../utils/functions';
|
||||
import { getDataUntilCurrentMonth, getLabelsFromLicense, getLineConfig, isCurrentMonth } from '../../utils/functions';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
|
||||
import { size } from '@iqser/common-ui/lib/utils';
|
||||
@ -43,7 +43,7 @@ export class LicenseAnalysisCapacityUsageComponent {
|
||||
#getCapacityDatasets(): ChartDataset[] {
|
||||
const monthlyData = [...this.licenseService.selectedLicenseReport.monthlyData];
|
||||
const dataUntilCurrentMonth = getDataUntilCurrentMonth(monthlyData);
|
||||
if (monthlyData.length === 1 || isCurrentMonthAndYear(monthlyData[0].startDate)) {
|
||||
if (monthlyData.length === 1 || isCurrentMonth(monthlyData[0].startDate)) {
|
||||
const empty = { analysedFilesBytes: null } as ILicenseData;
|
||||
dataUntilCurrentMonth.splice(0, 0, empty);
|
||||
monthlyData.splice(0, 0, empty);
|
||||
|
||||
@ -3,7 +3,7 @@ import { LicenseService } from '@services/license.service';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ChartDataset } from 'chart.js';
|
||||
import { ChartBlue, ChartGreen, ChartRed } from '../../utils/constants';
|
||||
import { getDataUntilCurrentMonth, getLabelsFromLicense, getLineConfig, isCurrentMonthAndYear } from '../../utils/functions';
|
||||
import { getDataUntilCurrentMonth, getLabelsFromLicense, getLineConfig, isCurrentMonth } from '../../utils/functions';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
|
||||
import { ILicenseData } from '@red/domain';
|
||||
@ -40,7 +40,7 @@ export class LicensePageUsageComponent {
|
||||
#getPagesDatasets(): ChartDataset[] {
|
||||
const monthlyData = [...this.licenseService.selectedLicenseReport.monthlyData];
|
||||
const dataUntilCurrentMonth = getDataUntilCurrentMonth(monthlyData);
|
||||
if (monthlyData.length === 1 || isCurrentMonthAndYear(monthlyData[0].startDate)) {
|
||||
if (monthlyData.length === 1 || isCurrentMonth(monthlyData[0].startDate)) {
|
||||
const empty = { numberOfAnalyzedPages: null } as ILicenseData;
|
||||
dataUntilCurrentMonth.splice(0, 0, empty);
|
||||
monthlyData.splice(0, 0, empty);
|
||||
|
||||
@ -44,7 +44,7 @@ export const getLabelsFromLicense = (license: ILicenseReport) => {
|
||||
monthIterator = monthIterator.add(1, 'month');
|
||||
}
|
||||
|
||||
if (startMonth.month() === endMonth.month() || startMonth.month() === currentMonth) {
|
||||
if (startMonth.isSame(endMonth, 'month') || isCurrentMonth(startMonth.toDate())) {
|
||||
result.splice(0, 0, '');
|
||||
}
|
||||
|
||||
@ -55,6 +55,6 @@ export const getDataUntilCurrentMonth = (monthlyData: ILicenseData[]) => {
|
||||
return monthlyData.filter(data => dayjs(data.startDate).isSameOrBefore(dayjs(Date.now()), 'month'));
|
||||
};
|
||||
|
||||
export const isCurrentMonthAndYear = (date: Date | string) => {
|
||||
return dayjs(date).isSame(dayjs(Date.now()), 'month') && dayjs(date).isSame(dayjs(Date.now()), 'year');
|
||||
export const isCurrentMonth = (date: Date | string) => {
|
||||
return dayjs(date).isSame(dayjs(Date.now()), 'month');
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { IqserDialog } from '@common-ui/dialog/iqser-dialog.service';
|
||||
import { getConfig, Toaster } from '@iqser/common-ui';
|
||||
import { getConfig } from '@iqser/common-ui';
|
||||
import { List, log } from '@iqser/common-ui/lib/utils';
|
||||
import { AnnotationPermissions } from '@models/file/annotation.permissions';
|
||||
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
|
||||
@ -15,7 +15,6 @@ import {
|
||||
type IRemoveRedactionRequest,
|
||||
IResizeRequest,
|
||||
} from '@red/domain';
|
||||
import { CommentsApiService } from '@services/comments-api.service';
|
||||
import { DossierTemplatesService } from '@services/dossier-templates/dossier-templates.service';
|
||||
import { PermissionsService } from '@services/permissions.service';
|
||||
import { firstValueFrom, Observable } from 'rxjs';
|
||||
@ -52,7 +51,6 @@ import { NON_READABLE_CONTENT } from '../dialogs/manual-redaction-dialog/manual-
|
||||
@Injectable()
|
||||
export class AnnotationActionsService {
|
||||
readonly #isDocumine = getConfig().IS_DOCUMINE;
|
||||
readonly #commentsApiService = inject(CommentsApiService);
|
||||
|
||||
constructor(
|
||||
private readonly _manualRedactionService: ManualRedactionService,
|
||||
@ -67,7 +65,6 @@ export class AnnotationActionsService {
|
||||
private readonly _skippedService: SkippedService,
|
||||
private readonly _dossierTemplatesService: DossierTemplatesService,
|
||||
private readonly _permissionsService: PermissionsService,
|
||||
private readonly _toaster: Toaster,
|
||||
) {}
|
||||
|
||||
removeHighlights(highlights: AnnotationWrapper[]): void {
|
||||
@ -126,7 +123,11 @@ export class AnnotationActionsService {
|
||||
}
|
||||
|
||||
const recategorizeBody: List<IRecategorizationRequest> = annotations.map(annotation => {
|
||||
const body = { annotationId: annotation.id, type: result.type ?? annotation.type };
|
||||
const body: IRecategorizationRequest = {
|
||||
annotationId: annotation.id,
|
||||
type: result.type ?? annotation.type,
|
||||
comment: result.comment,
|
||||
};
|
||||
if (!this.#isDocumine) {
|
||||
return {
|
||||
...body,
|
||||
@ -149,16 +150,6 @@ export class AnnotationActionsService {
|
||||
)
|
||||
.pipe(log()),
|
||||
);
|
||||
|
||||
if (result.comment) {
|
||||
try {
|
||||
for (const a of annotations) {
|
||||
await this.#commentsApiService.add(result.comment, a.id, dossierId, fileId);
|
||||
}
|
||||
} catch (error) {
|
||||
this._toaster.rawError(error.error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async removeRedaction(redactions: AnnotationWrapper[], permissions: AnnotationPermissions) {
|
||||
|
||||
@ -1,62 +1,64 @@
|
||||
<div class="cell filename">
|
||||
<div [matTooltip]="item.filename" class="table-item-title heading" matTooltipPosition="above">
|
||||
<span
|
||||
*ngIf="item.highlights.filename; else defaultFilename"
|
||||
[innerHTML]="sanitize(item.highlights.filename[0])"
|
||||
class="highlights"
|
||||
></span>
|
||||
<ng-template #defaultFilename>{{ item.filename }}</ng-template>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="item.highlights['sections.text'] as highlights">
|
||||
<div *ngIf="highlights.length > 0" class="small-label">
|
||||
<span [innerHTML]="sanitize(highlights[0])" class="highlights"></span>
|
||||
<a [routerLink]="item.routerLink" class="item-link">
|
||||
<div class="cell filename">
|
||||
<div [matTooltip]="item.filename" class="table-item-title heading" matTooltipPosition="above">
|
||||
<span
|
||||
*ngIf="item.highlights.filename; else defaultFilename"
|
||||
[innerHTML]="sanitize(item.highlights.filename[0])"
|
||||
class="highlights"
|
||||
></span>
|
||||
<ng-template #defaultFilename>{{ item.filename }}</ng-template>
|
||||
</div>
|
||||
<div *ngIf="highlights.length > 1" class="small-label">
|
||||
<span [innerHTML]="sanitize(highlights[1])" class="highlights"></span>
|
||||
|
||||
<ng-container *ngIf="item.highlights['sections.text'] as highlights">
|
||||
<div *ngIf="highlights.length > 0" class="small-label">
|
||||
<span [innerHTML]="sanitize(highlights[0])" class="highlights"></span>
|
||||
</div>
|
||||
<div *ngIf="highlights.length > 1" class="small-label">
|
||||
<span [innerHTML]="sanitize(highlights[1])" class="highlights"></span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="item.unmatched?.length && item.unmatched as unmatched" class="small-label">
|
||||
<span>
|
||||
{{ 'search-screen.missing' | translate }}:<span *ngFor="let term of unmatched"
|
||||
> <s>{{ term }}</s></span
|
||||
>. {{ 'search-screen.must-contain' | translate }}:
|
||||
<span (click)="mustContain.emit(term)" *ngFor="let term of unmatched" iqserStopPropagation
|
||||
> <u>{{ term }}</u></span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="item.unmatched?.length && item.unmatched as unmatched" class="small-label">
|
||||
<span>
|
||||
{{ 'search-screen.missing' | translate }}:<span *ngFor="let term of unmatched"
|
||||
> <s>{{ term }}</s></span
|
||||
>. {{ 'search-screen.must-contain' | translate }}:
|
||||
<span (click)="mustContain.emit(term)" *ngFor="let term of unmatched" iqserStopPropagation
|
||||
> <u>{{ term }}</u></span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cell">
|
||||
<iqser-initials-avatar [user]="item.assignee" [withName]="true"></iqser-initials-avatar>
|
||||
</div>
|
||||
|
||||
<div class="cell">
|
||||
<iqser-status-bar
|
||||
[configs]="[
|
||||
{
|
||||
color: item.status,
|
||||
label: fileStatusTranslations[item.status] | translate,
|
||||
length: 1,
|
||||
cssClass: 'all-caps-label'
|
||||
}
|
||||
]"
|
||||
[small]="true"
|
||||
></iqser-status-bar>
|
||||
</div>
|
||||
|
||||
<div class="cell small-label full-opacity stats-subtitle">
|
||||
<div>
|
||||
<mat-icon *ngIf="item.archived" svgIcon="red:archive"></mat-icon>
|
||||
<a [routerLink]="routerLink" iqserStopPropagation> {{ item.dossierName }}</a>
|
||||
<div class="cell">
|
||||
<iqser-initials-avatar [user]="item.assignee" [withName]="true"></iqser-initials-avatar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cell small-label stats-subtitle">
|
||||
<div>
|
||||
<mat-icon svgIcon="iqser:pages"></mat-icon>
|
||||
{{ item.numberOfPages }}
|
||||
<div class="cell">
|
||||
<iqser-status-bar
|
||||
[configs]="[
|
||||
{
|
||||
color: item.status,
|
||||
label: fileStatusTranslations[item.status] | translate,
|
||||
length: 1,
|
||||
cssClass: 'all-caps-label',
|
||||
},
|
||||
]"
|
||||
[small]="true"
|
||||
></iqser-status-bar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cell small-label full-opacity stats-subtitle">
|
||||
<div>
|
||||
<mat-icon *ngIf="item.archived" svgIcon="red:archive"></mat-icon>
|
||||
<a [routerLink]="routerLink" iqserStopPropagation> {{ item.dossierName }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cell small-label stats-subtitle">
|
||||
<div>
|
||||
<mat-icon svgIcon="iqser:pages"></mat-icon>
|
||||
{{ item.numberOfPages }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@ -7,3 +7,8 @@
|
||||
background-color: var(--iqser-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
a.item-link {
|
||||
display: contents;
|
||||
@include common-mixins.clear-a;
|
||||
}
|
||||
|
||||
@ -18,12 +18,15 @@
|
||||
<div class="small-label stats-subtitle">
|
||||
<div>
|
||||
<mat-icon svgIcon="red:entries"></mat-icon>
|
||||
{{
|
||||
dictionary.entries.length +
|
||||
dictionary.falsePositiveEntries.length +
|
||||
dictionary.falseRecommendationEntries.length
|
||||
}}
|
||||
entries
|
||||
<span
|
||||
[translateParams]="{
|
||||
count:
|
||||
dictionary.entries.length +
|
||||
dictionary.falsePositiveEntries.length +
|
||||
dictionary.falseRecommendationEntries.length,
|
||||
}"
|
||||
translate="edit-dossier-dialog.dictionary.entries-count"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -36,12 +39,12 @@
|
||||
<div class="flex-align-items-center">
|
||||
{{ selectedDictionary?.label }}
|
||||
<iqser-circle-button
|
||||
*ngIf="selectedDictionary.dossierDictionaryOnly && selectedDictionary.hasDictionary"
|
||||
(action)="openEditDictionaryModal()"
|
||||
*ngIf="selectedDictionary.dossierDictionaryOnly && selectedDictionary.hasDictionary"
|
||||
[size]="20"
|
||||
[tooltip]="'edit-dossier-dialog.dictionary.edit-button-tooltip' | translate"
|
||||
icon="iqser:edit"
|
||||
class="p-left-8"
|
||||
icon="iqser:edit"
|
||||
></iqser-circle-button>
|
||||
</div>
|
||||
<div class="small-label stats-subtitle">
|
||||
@ -68,23 +71,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!selectedDictionary.hint" [class.read-only]="!canEdit" class="header-right flex">
|
||||
<iqser-icon-button
|
||||
(click)="selectEntryType(entryTypes.ENTRY)"
|
||||
[active]="activeEntryType === entryTypes.ENTRY"
|
||||
[label]="'edit-dossier-dialog.dictionary.to-redact' | translate"
|
||||
></iqser-icon-button>
|
||||
<iqser-icon-button
|
||||
(click)="selectEntryType(entryTypes.FALSE_POSITIVE)"
|
||||
[active]="activeEntryType === entryTypes.FALSE_POSITIVE"
|
||||
[label]="'edit-dossier-dialog.dictionary.false-positives' | translate"
|
||||
></iqser-icon-button>
|
||||
<iqser-icon-button
|
||||
(click)="selectEntryType(entryTypes.FALSE_RECOMMENDATION)"
|
||||
[active]="activeEntryType === entryTypes.FALSE_RECOMMENDATION"
|
||||
[label]="'edit-dossier-dialog.dictionary.false-recommendations' | translate"
|
||||
></iqser-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<redaction-dictionary-manager
|
||||
@ -97,6 +83,32 @@
|
||||
[selectedDictionaryTypeLabel]="selectedDictionary.label"
|
||||
[selectedDictionaryType]="selectedDictionary.type"
|
||||
[withFloatingActions]="false"
|
||||
></redaction-dictionary-manager>
|
||||
>
|
||||
<ng-container slot="typeSwitch">
|
||||
<div *ngIf="!selectedDictionary.hint" [class.read-only]="!canEdit" class="header-right flex">
|
||||
<iqser-icon-button
|
||||
(click)="selectEntryType(entryTypes.ENTRY)"
|
||||
[active]="activeEntryType === entryTypes.ENTRY"
|
||||
[label]="'edit-dossier-dialog.dictionary.to-redact' | translate: { count: selectedDictionary.entries.length }"
|
||||
></iqser-icon-button>
|
||||
<iqser-icon-button
|
||||
(click)="selectEntryType(entryTypes.FALSE_POSITIVE)"
|
||||
[active]="activeEntryType === entryTypes.FALSE_POSITIVE"
|
||||
[label]="
|
||||
'edit-dossier-dialog.dictionary.false-positives'
|
||||
| translate: { count: selectedDictionary.falsePositiveEntries.length }
|
||||
"
|
||||
></iqser-icon-button>
|
||||
<iqser-icon-button
|
||||
(click)="selectEntryType(entryTypes.FALSE_RECOMMENDATION)"
|
||||
[active]="activeEntryType === entryTypes.FALSE_RECOMMENDATION"
|
||||
[label]="
|
||||
'edit-dossier-dialog.dictionary.false-recommendations'
|
||||
| translate: { count: selectedDictionary.falseRecommendationEntries.length }
|
||||
"
|
||||
></iqser-icon-button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</redaction-dictionary-manager>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -42,12 +42,16 @@ export class EditDossierDownloadPackageComponent
|
||||
{
|
||||
#existsWatermarks$: Observable<boolean>;
|
||||
form: FormGroup;
|
||||
downloadTypes: { key: DownloadFileType; label: string }[] = ['ORIGINAL', 'PREVIEW', 'DELTA_PREVIEW', 'REDACTED'].map(
|
||||
(type: DownloadFileType) => ({
|
||||
key: type,
|
||||
label: downloadTypesTranslations[type],
|
||||
}),
|
||||
);
|
||||
downloadTypes: { key: DownloadFileType; label: string }[] = [
|
||||
'ORIGINAL',
|
||||
'PREVIEW',
|
||||
'OPTIMIZED_PREVIEW',
|
||||
'DELTA_PREVIEW',
|
||||
'REDACTED',
|
||||
].map((type: DownloadFileType) => ({
|
||||
key: type,
|
||||
label: downloadTypesTranslations[type],
|
||||
}));
|
||||
availableReportTypes: IReportTemplate[] = [];
|
||||
readonly roles = Roles;
|
||||
@Input() dossier: Dossier;
|
||||
|
||||
@ -1,29 +1,7 @@
|
||||
<div class="content-container">
|
||||
<div class="actions-bar">
|
||||
<div class="flex-align-items-center mr-32">
|
||||
<div class="iqser-input-group w-250">
|
||||
<input
|
||||
#inputElement
|
||||
(keyup)="searchChanged(searchText)"
|
||||
[(ngModel)]="searchText"
|
||||
[class.with-matches]="searchText.length > 0"
|
||||
[placeholder]="'dictionary-overview.search' | translate"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<div class="input-icons">
|
||||
<div *ngIf="searchText.length === 0" class="no-input">
|
||||
<mat-icon svgIcon="iqser:search"></mat-icon>
|
||||
</div>
|
||||
|
||||
<div *ngIf="searchText.length > 0" class="with-input">
|
||||
{{ currentMatch + '/' + findMatches.length }}
|
||||
<mat-icon (click)="previousSearchMatch()" class="pointer" svgIcon="red:arrow-up"></mat-icon>
|
||||
<mat-icon (click)="nextSearchMatch()" class="pointer" svgIcon="iqser:arrow-down"></mat-icon>
|
||||
<mat-icon (click)="revert(); inputElement.focus()" class="pointer" svgIcon="iqser:close"></mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-content select="[slot=typeSwitch]"></ng-content>
|
||||
|
||||
<iqser-circle-button
|
||||
(action)="download()"
|
||||
@ -33,6 +11,13 @@
|
||||
class="ml-8"
|
||||
icon="iqser:download"
|
||||
></iqser-circle-button>
|
||||
|
||||
<iqser-circle-button
|
||||
(action)="editor.openFindPanel()"
|
||||
[matTooltip]="'dictionary-overview.search' | translate"
|
||||
class="ml-8"
|
||||
icon="iqser:search"
|
||||
></iqser-circle-button>
|
||||
</div>
|
||||
|
||||
<div class="compare">
|
||||
|
||||
@ -8,9 +8,7 @@ import { DossierTemplatesService } from '@services/dossier-templates/dossier-tem
|
||||
import { EditorComponent } from '@shared/components/editor/editor.component';
|
||||
import { DictionariesMapService } from '@services/entity-services/dictionaries-map.service';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { Debounce, List } from '@iqser/common-ui/lib/utils';
|
||||
import IModelDeltaDecoration = monaco.editor.IModelDeltaDecoration;
|
||||
import FindMatch = monaco.editor.FindMatch;
|
||||
import { List } from '@iqser/common-ui/lib/utils';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { NgForOf, NgIf } from '@angular/common';
|
||||
@ -23,7 +21,6 @@ import { MatOption, MatSelect } from '@angular/material/select';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
|
||||
const COMPARE_ENTRIES_ERROR = 'compare-entries-error';
|
||||
const SMOOTH_SCROLL = 0;
|
||||
const HELP_MODE_KEYS = {
|
||||
dictionary: 'dictionary_entity',
|
||||
'false-positive': 'false_recommendations_entity',
|
||||
@ -53,8 +50,6 @@ const HELP_MODE_KEYS = {
|
||||
],
|
||||
})
|
||||
export class DictionaryManagerComponent implements OnChanges, OnInit {
|
||||
private _searchDecorations: string[] = [];
|
||||
readonly #currentTab = window.location.href.split('/').pop();
|
||||
@Input() type: DictionaryType = 'dictionary';
|
||||
@Input() entityType?: string;
|
||||
@Input() currentDossierId: string;
|
||||
@ -73,11 +68,8 @@ export class DictionaryManagerComponent implements OnChanges, OnInit {
|
||||
readonly iconButtonTypes = IconButtonTypes;
|
||||
dossiers: Dossier[];
|
||||
dossierTemplates: DossierTemplate[] = this.dossierTemplatesService.all;
|
||||
currentMatch = 0;
|
||||
findMatches: FindMatch[] = [];
|
||||
diffEditorText = '';
|
||||
showDiffEditor = false;
|
||||
searchText = '';
|
||||
selectDossier = { dossierName: _('dictionary-overview.compare.select-dossier') } as Dossier;
|
||||
selectDictionary = {
|
||||
label: _('dictionary-overview.compare.select-dictionary'),
|
||||
@ -85,10 +77,11 @@ export class DictionaryManagerComponent implements OnChanges, OnInit {
|
||||
selectDossierTemplate = { name: _('dictionary-overview.compare.select-dossier-template') } as DossierTemplate;
|
||||
compare = false;
|
||||
dictionaries: List<Dictionary> = this.#dictionaries;
|
||||
protected initialDossierTemplateId: string;
|
||||
readonly #currentTab = window.location.href.split('/').pop();
|
||||
#dossierTemplate = this.dossierTemplatesService.all[0];
|
||||
#dossier = this.selectDossier;
|
||||
#dictionary = this.selectDictionary;
|
||||
protected initialDossierTemplateId: string;
|
||||
|
||||
constructor(
|
||||
private readonly _dictionaryService: DictionaryService,
|
||||
@ -99,11 +92,6 @@ export class DictionaryManagerComponent implements OnChanges, OnInit {
|
||||
readonly dossierTemplatesService: DossierTemplatesService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.initialDossierTemplateId = this.currentDossierTemplateId;
|
||||
this.#updateDropdownsOptions();
|
||||
}
|
||||
|
||||
get selectedDossierTemplate() {
|
||||
return this.#dossierTemplate;
|
||||
}
|
||||
@ -150,6 +138,7 @@ export class DictionaryManagerComponent implements OnChanges, OnInit {
|
||||
if (dictionary.type) {
|
||||
this.selectedDictionaryType = dictionary.type;
|
||||
this.#dictionary = dictionary;
|
||||
console.log(dictionary);
|
||||
this.#onDossierChanged(this.#dossier.dossierTemplateId).then(entries => this.#updateDiffEditorText(entries));
|
||||
}
|
||||
}
|
||||
@ -185,6 +174,11 @@ export class DictionaryManagerComponent implements OnChanges, OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.initialDossierTemplateId = this.currentDossierTemplateId;
|
||||
this.#updateDropdownsOptions();
|
||||
}
|
||||
|
||||
download(): void {
|
||||
const content = this.editor.currentEntries.join('\n');
|
||||
const blob = new Blob([content], {
|
||||
@ -195,35 +189,6 @@ export class DictionaryManagerComponent implements OnChanges, OnInit {
|
||||
|
||||
revert() {
|
||||
this.editor?.revert();
|
||||
this.searchText = '';
|
||||
this.searchChanged('');
|
||||
}
|
||||
|
||||
@Debounce()
|
||||
searchChanged(text: string) {
|
||||
this.findMatches = this.#getMatches(text.toLowerCase());
|
||||
this.#applySearchDecorations();
|
||||
|
||||
this.currentMatch = 0;
|
||||
this.nextSearchMatch();
|
||||
}
|
||||
|
||||
nextSearchMatch(): void {
|
||||
if (this.findMatches.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentMatch = this.currentMatch < this.findMatches.length ? this.currentMatch + 1 : 1;
|
||||
this.#scrollToCurrentMatch();
|
||||
}
|
||||
|
||||
previousSearchMatch(): void {
|
||||
if (this.findMatches.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentMatch = this.currentMatch > 1 ? this.currentMatch - 1 : this.findMatches.length;
|
||||
this.#scrollToCurrentMatch();
|
||||
}
|
||||
|
||||
toggleCompareMode() {
|
||||
@ -246,30 +211,6 @@ export class DictionaryManagerComponent implements OnChanges, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
#applySearchDecorations() {
|
||||
this._searchDecorations = this.editor.codeEditor?.deltaDecorations(this._searchDecorations, []) || [];
|
||||
|
||||
const decorations = this.findMatches.map(match => this.#getSearchDecoration(match));
|
||||
|
||||
this._searchDecorations = this.editor.codeEditor?.deltaDecorations(this._searchDecorations, decorations) || [];
|
||||
}
|
||||
|
||||
#getMatches(text: string): FindMatch[] {
|
||||
const model = this.editor.codeEditor?.getModel();
|
||||
return model?.findMatches(text, false, false, false, null, false) || [];
|
||||
}
|
||||
|
||||
#getSearchDecoration(match: FindMatch): IModelDeltaDecoration {
|
||||
return { range: match.range, options: { inlineClassName: 'search-marker' } };
|
||||
}
|
||||
|
||||
#scrollToCurrentMatch(): void {
|
||||
const range = this.findMatches[this.currentMatch - 1].range;
|
||||
|
||||
this.editor.codeEditor.setSelection(range);
|
||||
this.editor.codeEditor.revealLineInCenter(range.startLineNumber, SMOOTH_SCROLL);
|
||||
}
|
||||
|
||||
async #onDossierChanged(dossierTemplateId: string, dossierId?: string) {
|
||||
let dictionary: IDictionary;
|
||||
if (dossierId === 'template') {
|
||||
|
||||
@ -5,15 +5,15 @@ import { Subject } from 'rxjs';
|
||||
import { debounceTime, filter, tap } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { List, OnChange } from '@iqser/common-ui/lib/utils';
|
||||
import IStandaloneEditorConstructionOptions = monaco.editor.IStandaloneEditorConstructionOptions;
|
||||
import ICodeEditor = monaco.editor.ICodeEditor;
|
||||
import IDiffEditor = monaco.editor.IDiffEditor;
|
||||
import IModelDeltaDecoration = monaco.editor.IModelDeltaDecoration;
|
||||
import ILineChange = monaco.editor.ILineChange;
|
||||
import IEditorMouseEvent = monaco.editor.IEditorMouseEvent;
|
||||
import { MonacoEditorModule, MonacoStandaloneCodeEditor, MonacoStandaloneDiffEditor } from '@materia-ui/ngx-monaco-editor';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIf } from '@angular/common';
|
||||
import IStandaloneEditorConstructionOptions = monaco.editor.IStandaloneEditorConstructionOptions;
|
||||
import IDiffEditor = monaco.editor.IDiffEditor;
|
||||
import ICodeEditor = monaco.editor.ICodeEditor;
|
||||
import IModelDeltaDecoration = monaco.editor.IModelDeltaDecoration;
|
||||
import ILineChange = monaco.editor.ILineChange;
|
||||
import IEditorMouseEvent = monaco.editor.IEditorMouseEvent;
|
||||
|
||||
const MIN_WORD_LENGTH = 2;
|
||||
const lineChangeToDecoration = ({ originalEndLineNumber, originalStartLineNumber }: ILineChange) =>
|
||||
@ -35,10 +35,6 @@ const notZero = (lineChange: ILineChange) => lineChange.originalEndLineNumber !=
|
||||
imports: [MonacoEditorModule, FormsModule, NgIf],
|
||||
})
|
||||
export class EditorComponent implements OnInit, OnChanges {
|
||||
#initialEntriesSet = new Set<string>();
|
||||
private _diffEditor: IDiffEditor;
|
||||
private _decorations: string[] = [];
|
||||
protected readonly _editorTextChanged$ = new Subject<string>();
|
||||
@Input() showDiffEditor = false;
|
||||
@Input() diffEditorText: string;
|
||||
@Input() @OnChange<List, EditorComponent>('revert') initialEntries: List;
|
||||
@ -51,6 +47,10 @@ export class EditorComponent implements OnInit, OnChanges {
|
||||
editorOptions: IStandaloneEditorConstructionOptions = {};
|
||||
codeEditor: ICodeEditor;
|
||||
value: string;
|
||||
protected readonly _editorTextChanged$ = new Subject<string>();
|
||||
#initialEntriesSet = new Set<string>();
|
||||
private _diffEditor: IDiffEditor;
|
||||
private _decorations: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly _loadingService: LoadingService,
|
||||
@ -84,6 +84,11 @@ export class EditorComponent implements OnInit, OnChanges {
|
||||
return this.currentEntries.length;
|
||||
}
|
||||
|
||||
async openFindPanel(): Promise<void> {
|
||||
const editor = this.showDiffEditor ? this._diffEditor.getOriginalEditor() : this.codeEditor;
|
||||
await editor.getAction('actions.find').run();
|
||||
}
|
||||
|
||||
onPaste(event: ClipboardEvent) {
|
||||
if ((event.target as HTMLTextAreaElement).ariaRoleDescription === 'editor') {
|
||||
this._loadingService.start();
|
||||
|
||||
@ -71,12 +71,16 @@ export class AddDossierDialogComponent extends BaseDialogComponent implements On
|
||||
readonly roles = Roles;
|
||||
readonly iconButtonTypes = IconButtonTypes;
|
||||
hasDueDate = false;
|
||||
readonly downloadTypes: { key: DownloadFileType; label: string }[] = ['ORIGINAL', 'PREVIEW', 'DELTA_PREVIEW', 'REDACTED'].map(
|
||||
(type: DownloadFileType) => ({
|
||||
key: type,
|
||||
label: downloadTypesTranslations[type],
|
||||
}),
|
||||
);
|
||||
readonly downloadTypes: { key: DownloadFileType; label: string }[] = [
|
||||
'ORIGINAL',
|
||||
'PREVIEW',
|
||||
'OPTIMIZED_PREVIEW',
|
||||
'DELTA_PREVIEW',
|
||||
'REDACTED',
|
||||
].map((type: DownloadFileType) => ({
|
||||
key: type,
|
||||
label: downloadTypesTranslations[type],
|
||||
}));
|
||||
dossierTemplates: IDossierTemplate[];
|
||||
availableReportTypes: IReportTemplate[] = [];
|
||||
dossierTemplateId: string;
|
||||
|
||||
@ -90,7 +90,7 @@ export class DownloadDialogComponent extends IqserDialogComponent<DownloadDialog
|
||||
}
|
||||
|
||||
get #formDownloadTypes() {
|
||||
return ['ORIGINAL', 'PREVIEW', 'DELTA_PREVIEW', 'REDACTED']
|
||||
return ['ORIGINAL', 'PREVIEW', 'OPTIMIZED_PREVIEW', 'DELTA_PREVIEW', 'REDACTED']
|
||||
.map((type: DownloadFileType) => ({
|
||||
key: type,
|
||||
label: downloadTypesForDownloadTranslations[type],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
|
||||
import { EntitiesService, getConfig, isIqserDevMode, QueryParam, Toaster } from '@iqser/common-ui';
|
||||
import { EntitiesService, isIqserDevMode, Toaster } from '@iqser/common-ui';
|
||||
import { List } from '@iqser/common-ui/lib/utils';
|
||||
import { Dictionary, DictionaryEntryType, DictionaryEntryTypes, IDictionary, IUpdateDictionary, SuperTypes } from '@red/domain';
|
||||
import { firstValueFrom, forkJoin, Observable } from 'rxjs';
|
||||
@ -112,23 +112,18 @@ export class DictionaryService extends EntitiesService<IDictionary, Dictionary>
|
||||
}
|
||||
entriesToAdd.push(entry);
|
||||
}
|
||||
const deletedEntries: Array<string> = [];
|
||||
const entriesToDelete: Array<string> = [];
|
||||
const entriesSet = new Set(entries);
|
||||
for (let i = 0; i < initialEntries.length; i++) {
|
||||
const entry = initialEntries.at(i);
|
||||
if (entriesSet.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
deletedEntries.push(entry);
|
||||
entriesToDelete.push(entry);
|
||||
}
|
||||
|
||||
try {
|
||||
if (deletedEntries.length) {
|
||||
await this.#deleteEntries(deletedEntries, dossierTemplateId, type, dictionaryEntryType, dossierId);
|
||||
}
|
||||
if (entriesToAdd.length) {
|
||||
await this.#addEntries(entriesToAdd, dossierTemplateId, type, dictionaryEntryType, dossierId);
|
||||
}
|
||||
await this.#updateEntries(entriesToAdd, entriesToDelete, dossierTemplateId, type, dictionaryEntryType, dossierId);
|
||||
|
||||
if (showToast) {
|
||||
this._toaster.success(_('dictionary-overview.success.generic'));
|
||||
@ -249,30 +244,20 @@ export class DictionaryService extends EntitiesService<IDictionary, Dictionary>
|
||||
return forkJoin(requests);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add dictionary entries with entry type.
|
||||
*/
|
||||
|
||||
#addEntries(entries: List, dossierTemplateId: string, type: string, dictionaryEntryType: DictionaryEntryType, dossierId: string) {
|
||||
const queryParams: List<QueryParam> = [
|
||||
{ key: 'dossierId', value: dossierId },
|
||||
{ key: 'dictionaryEntryType', value: dictionaryEntryType },
|
||||
];
|
||||
const url = `${this._defaultModelPath}/${type}/${dossierTemplateId}`;
|
||||
return firstValueFrom(this._post(entries, url, queryParams));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete dictionary entries with entry type.
|
||||
*/
|
||||
|
||||
#deleteEntries(entries: List, dossierTemplateId: string, type: string, dictionaryEntryType: DictionaryEntryType, dossierId: string) {
|
||||
#updateEntries(
|
||||
entriesToAdd: List,
|
||||
entriesToDelete: List,
|
||||
dossierTemplateId: string,
|
||||
type: string,
|
||||
dictionaryEntryType: DictionaryEntryType,
|
||||
dossierId: string,
|
||||
) {
|
||||
const queryParams = [
|
||||
{ key: 'dossierId', value: dossierId },
|
||||
{ key: 'dictionaryEntryType', value: dictionaryEntryType },
|
||||
];
|
||||
const url = `${this._defaultModelPath}/delete/${type}/${dossierTemplateId}`;
|
||||
return firstValueFrom(this._post(entries, url, queryParams));
|
||||
const url = `${this._defaultModelPath}/update/${type}/${dossierTemplateId}`;
|
||||
return firstValueFrom(this._post({ entriesToAdd, entriesToDelete }, url, queryParams));
|
||||
}
|
||||
|
||||
#extractDossierLevelTypes(dossierId: string) {
|
||||
|
||||
@ -401,11 +401,7 @@ export class PermissionsService {
|
||||
}
|
||||
|
||||
#canReanalyseFile(file: File, dossier: Dossier): boolean {
|
||||
return (
|
||||
dossier.isActive &&
|
||||
((this.isAssigneeOrApprover(file, dossier) && file.analysisRequired) ||
|
||||
(file.isError && (this.isOwner(dossier) || this.isFileAssignee(file))))
|
||||
);
|
||||
return dossier.isActive && ((this.isAssigneeOrApprover(file, dossier) && file.analysisRequired) || file.isError);
|
||||
}
|
||||
|
||||
#canEnableAutoAnalysis(file: File, dossier: Dossier): boolean {
|
||||
|
||||
@ -4,6 +4,7 @@ import { DownloadFileType } from '@red/domain';
|
||||
export const downloadTypesTranslations: { [key in DownloadFileType]: string } = {
|
||||
ORIGINAL: _('download-type.original'),
|
||||
PREVIEW: _('download-type.preview'),
|
||||
OPTIMIZED_PREVIEW: _('download-type.optimized-preview'),
|
||||
REDACTED: _('download-type.redacted'),
|
||||
ANNOTATED: _('download-type.annotated'),
|
||||
FLATTEN: _('download-type.flatten'),
|
||||
@ -13,6 +14,7 @@ export const downloadTypesTranslations: { [key in DownloadFileType]: string } =
|
||||
export const downloadTypesForDownloadTranslations: { [key in DownloadFileType]: string } = {
|
||||
ORIGINAL: _('download-type.original'),
|
||||
PREVIEW: _('download-type.preview'),
|
||||
OPTIMIZED_PREVIEW: _('download-type.optimized-preview'),
|
||||
REDACTED: _('download-type.redacted-only'),
|
||||
ANNOTATED: _('download-type.annotated'),
|
||||
FLATTEN: _('download-type.flatten'),
|
||||
|
||||
@ -1167,6 +1167,7 @@
|
||||
"delta-preview": "Delta-PDF",
|
||||
"flatten": "Verflachte PDF",
|
||||
"label": "{length} Dokumenten{length, plural, one{typ} other{typen}}",
|
||||
"optimized-preview": "Optimized Preview PDF",
|
||||
"original": "Optimierte PDF",
|
||||
"preview": "Vorschau-PDF",
|
||||
"redacted": "Geschwärzte PDF",
|
||||
@ -1233,11 +1234,12 @@
|
||||
"title": "{label} bearbeiten"
|
||||
},
|
||||
"entries": "{length} {length, plural, one{Eintrag} other{Einträge}}",
|
||||
"entries-count": "",
|
||||
"false-positive-entries": "{length} {length, plural, one{Falsch-Positiver} other{Falsch-Positive}}",
|
||||
"false-positives": "Falsch-Positive",
|
||||
"false-positives": "Falsch-Positive ({count})",
|
||||
"false-recommendation-entries": "{length} {length, plural, one{falsche Empfehlung} other{falsche Empfehlungen}}",
|
||||
"false-recommendations": "Falsche Empfehlungen",
|
||||
"to-redact": "Schwärzungen"
|
||||
"false-recommendations": "Falsche Empfehlungen ({count})",
|
||||
"to-redact": "Schwärzungen ({count})"
|
||||
},
|
||||
"general-info": {
|
||||
"form": {
|
||||
|
||||
@ -1167,6 +1167,7 @@
|
||||
"delta-preview": "Delta PDF",
|
||||
"flatten": "Flatten PDF",
|
||||
"label": "{length} document {length, plural, one{version} other{versions}}",
|
||||
"optimized-preview": "Optimized Preview PDF",
|
||||
"original": "Optimized PDF",
|
||||
"preview": "Preview PDF",
|
||||
"redacted": "Redacted PDF",
|
||||
@ -1233,11 +1234,12 @@
|
||||
"title": "Edit {label}"
|
||||
},
|
||||
"entries": "{length} {length, plural, one{entry} other{entries}} to redact",
|
||||
"entries-count": "{count} {count, plural, one{entry} other{entries}}",
|
||||
"false-positive-entries": "{length} false positive {length, plural, one{entry} other{entries}}",
|
||||
"false-positives": "False positives",
|
||||
"false-positives": "False positives ({count})",
|
||||
"false-recommendation-entries": "{length} false recommendation {length, plural, one{entry} other{entries}}",
|
||||
"false-recommendations": "False recommendations",
|
||||
"to-redact": "To redact"
|
||||
"false-recommendations": "False recommendations ({count})",
|
||||
"to-redact": "Entries ({count})"
|
||||
},
|
||||
"general-info": {
|
||||
"form": {
|
||||
@ -1263,7 +1265,7 @@
|
||||
"choose-download": "Select the documents for your download:",
|
||||
"dictionary": "Dictionaries",
|
||||
"dossier-attributes": "Dossier attributes",
|
||||
"dossier-dictionary": "Dictionaries",
|
||||
"dossier-dictionary": "Dossier entries",
|
||||
"dossier-info": "Dossier info",
|
||||
"download-package": "Download package",
|
||||
"general-info": "General information",
|
||||
|
||||
@ -1167,6 +1167,7 @@
|
||||
"delta-preview": "Delta PDF",
|
||||
"flatten": "PDF verflachen",
|
||||
"label": "{length} document {length, plural, one{version} other{versions}}",
|
||||
"optimized-preview": "Optimized Preview PDF",
|
||||
"original": "Optimiertes PDF",
|
||||
"preview": "PDF-Vorschau",
|
||||
"redacted": "geschwärztes PDF",
|
||||
@ -1233,11 +1234,12 @@
|
||||
"title": ""
|
||||
},
|
||||
"entries": "{length} {length, plural, one{entry} other{entries}} to {hint, select, true{annotate} other{redact}}",
|
||||
"entries-count": "",
|
||||
"false-positive-entries": "{length} false positive {length, plural, one{entry} other{entries}}",
|
||||
"false-positives": "False positives",
|
||||
"false-positives": "False positives ({count})",
|
||||
"false-recommendation-entries": "{length} false recommendation {length, plural, one{entry} other{entries}}",
|
||||
"false-recommendations": "False recommendations",
|
||||
"to-redact": "To redact"
|
||||
"false-recommendations": "False recommendations ({count})",
|
||||
"to-redact": "To redact ({count})"
|
||||
},
|
||||
"general-info": {
|
||||
"form": {
|
||||
@ -1651,7 +1653,7 @@
|
||||
},
|
||||
"app-name": {
|
||||
"label": "Name der Applikation",
|
||||
"placeholder": "RedactManager"
|
||||
"placeholder": "DocuMine"
|
||||
},
|
||||
"form": {
|
||||
"auth": "Authentifizierung aktivieren",
|
||||
|
||||
@ -1167,6 +1167,7 @@
|
||||
"delta-preview": "Delta PDF",
|
||||
"flatten": "Flatten PDF",
|
||||
"label": "{length} document {length, plural, one{version} other{versions}}",
|
||||
"optimized-preview": "Optimized Preview PDF",
|
||||
"original": "Optimized PDF",
|
||||
"preview": "Preview PDF",
|
||||
"redacted": "Redacted PDF",
|
||||
@ -1233,11 +1234,12 @@
|
||||
"title": ""
|
||||
},
|
||||
"entries": "{length} {length, plural, one{entry} other{entries}} to {hint, select, true{annotate} other{redact}}",
|
||||
"entries-count": "{count} {count, plural, one{entry} other{entries}}",
|
||||
"false-positive-entries": "{length} false positive {length, plural, one{entry} other{entries}}",
|
||||
"false-positives": "False positives",
|
||||
"false-positives": "False positives ({count})",
|
||||
"false-recommendation-entries": "{length} false recommendation {length, plural, one{entry} other{entries}}",
|
||||
"false-recommendations": "False recommendations",
|
||||
"to-redact": "To redact"
|
||||
"false-recommendations": "False recommendations ({count})",
|
||||
"to-redact": "To redact ({count})"
|
||||
},
|
||||
"general-info": {
|
||||
"form": {
|
||||
@ -1651,7 +1653,7 @@
|
||||
},
|
||||
"app-name": {
|
||||
"label": "Display name",
|
||||
"placeholder": "RedactManager"
|
||||
"placeholder": "DocuMine"
|
||||
},
|
||||
"form": {
|
||||
"auth": "Enable authentication",
|
||||
|
||||
@ -3,6 +3,7 @@ export const DownloadFileTypes = {
|
||||
FLATTEN: 'FLATTEN',
|
||||
ORIGINAL: 'ORIGINAL',
|
||||
PREVIEW: 'PREVIEW',
|
||||
OPTIMIZED_PREVIEW: 'OPTIMIZED_PREVIEW',
|
||||
REDACTED: 'REDACTED',
|
||||
DELTA_PREVIEW: 'DELTA_PREVIEW',
|
||||
} as const;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user