mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-21 15:28:13 +00:00
Use pngx inputs, move test button, generally make less terrible
This commit is contained in:
2 files changed
+170
-198
No files matched your search
+124
-177
@@ -34,62 +34,88 @@
|
||||
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
|
||||
<ng-template ngbNavContent>
|
||||
@if (selectedZone; as zone) {
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<button type="button" class="btn btn-sm btn-link px-0" (click)="deselectZone()">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<button type="button" class="btn btn-sm btn-link px-0 me-auto" (click)="deselectZone()">
|
||||
<i-bs name="arrow-left" class="me-1"></i-bs><ng-container i18n>All zones</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
|
||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete zone</ng-container>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="testZone()" [disabled]="!previewDocId || zoneTesting" [title]="previewDocId ? '' : noSampleDocumentTitle">
|
||||
@if (zoneTesting) {
|
||||
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||
} @else {
|
||||
<i-bs name="play-fill" class="me-1"></i-bs>
|
||||
}
|
||||
<ng-container i18n>Test</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Zone Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
[(ngModel)]="zone.name"
|
||||
/>
|
||||
</div>
|
||||
@if (zoneTestResult; as result) {
|
||||
@if (result.error) {
|
||||
<div class="alert alert-warning py-2 mb-3 small" role="alert">{{ result.error }}</div>
|
||||
} @else {
|
||||
<dl class="row small border rounded mx-0 py-2 mb-3">
|
||||
<dt class="col-4" i18n>OCR text</dt>
|
||||
<dd class="col-8"><code>{{ result.raw_text || '(nothing detected)' }}</code></dd>
|
||||
<dt class="col-4" i18n>Value</dt>
|
||||
<dd class="col-8" [class.mb-0]="!result.regex"><code>{{ result.value || '(empty)' }}</code></dd>
|
||||
@if (result.regex) {
|
||||
<dt class="col-4" i18n>Validation</dt>
|
||||
<dd class="col-8 mb-0">
|
||||
@if (result.regex_match) {
|
||||
<span class="badge bg-success" i18n>Regex matches</span>
|
||||
} @else {
|
||||
<span class="badge bg-danger" i18n>Regex does not match</span>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Page</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
[(ngModel)]="zone.page"
|
||||
min="-1"
|
||||
/>
|
||||
<small class="text-muted" i18n>Page this zone is on. Use -1 for the last page. Set automatically when you draw it.</small>
|
||||
</div>
|
||||
<pngx-input-text [(ngModel)]="zone.name" title="Name" i18n-title></pngx-input-text>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Field</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" [ngModel]="zoneFieldValue(zone)" (ngModelChange)="setZoneField(zone, $event)">
|
||||
<optgroup label="Built-in fields" i18n-label>
|
||||
@for (t of builtinTargets; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</optgroup>
|
||||
<optgroup label="Custom fields" i18n-label>
|
||||
@for (cf of customFields; track cf.id) {
|
||||
<option [ngValue]="cf.id">{{ cf.name }} ({{ cf.data_type }})</option>
|
||||
}
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="openQuickCreate(selectedZoneIndex)"
|
||||
title="Create new custom field"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs name="plus"></i-bs>
|
||||
</button>
|
||||
<pngx-input-select
|
||||
[items]="fieldItems"
|
||||
[ngModel]="zoneFieldValue(zone)"
|
||||
(ngModelChange)="setZoneField(zone, $event)"
|
||||
(createNew)="openQuickCreate(selectedZoneIndex, $event)"
|
||||
title="Field"
|
||||
i18n-title
|
||||
hint="Where the extracted value is written."
|
||||
i18n-hint
|
||||
></pngx-input-select>
|
||||
|
||||
@if (showQuickCreate) {
|
||||
<div class="card mb-3 border-primary">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" i18n>Create Custom Field</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
[(ngModel)]="quickCreateName" placeholder="e.g. Invoice Number" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Type</label>
|
||||
<select class="form-select form-select-sm" [(ngModel)]="quickCreateType">
|
||||
@for (t of quickCreateTypes; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" (click)="submitQuickCreate()"
|
||||
[disabled]="!quickCreateName.trim()" i18n>
|
||||
Create & Assign
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" (click)="cancelQuickCreate()" i18n>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted" i18n>Write the extracted value to a custom field, or to a built-in field (Title, ASN, Date created).</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isFieldShared(zone)) {
|
||||
<div class="card mb-3 border-info">
|
||||
@@ -132,141 +158,62 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showQuickCreate) {
|
||||
<div class="card mb-3 border-primary">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" i18n>Create Custom Field</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
[(ngModel)]="quickCreateName" placeholder="e.g. Invoice Number" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Type</label>
|
||||
<select class="form-select form-select-sm" [(ngModel)]="quickCreateType">
|
||||
@for (t of quickCreateTypes; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" (click)="submitQuickCreate()"
|
||||
[disabled]="!quickCreateName.trim()" i18n>
|
||||
Create & Assign
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" (click)="cancelQuickCreate()" i18n>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>OCR Language</label>
|
||||
<ng-select
|
||||
[items]="ocrLanguageOptions"
|
||||
bindLabel="name"
|
||||
bindValue="id"
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="false"
|
||||
[ngModel]="ocrLanguageArray(zone)"
|
||||
(ngModelChange)="setOcrLanguages(zone, $event)"
|
||||
placeholder="Select languages"
|
||||
i18n-placeholder
|
||||
></ng-select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Transform</label>
|
||||
<select class="form-select" [(ngModel)]="zone.transform">
|
||||
@for (opt of transformOptions; track opt.id) {
|
||||
<option [ngValue]="opt.id">{{ opt.name }}</option>
|
||||
<div class="row align-items-end">
|
||||
<div class="col">
|
||||
@if (!isLastPageZone(zone)) {
|
||||
<pngx-input-number [(ngModel)]="zone.page" [showAdd]="false" title="Page" i18n-title></pngx-input-number>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<pngx-input-switch [ngModel]="isLastPageZone(zone)" (ngModelChange)="setLastPageZone(zone, $event)" [horizontal]="true" title="Last page" i18n-title></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="border-top pt-3 mt-1 mb-3" i18n>Processing</h6>
|
||||
|
||||
<pngx-input-select
|
||||
[items]="ocrLanguageOptions"
|
||||
[multiple]="true"
|
||||
[ngModel]="ocrLanguageArray(zone)"
|
||||
(ngModelChange)="setOcrLanguages(zone, $event)"
|
||||
title="OCR language"
|
||||
i18n-title
|
||||
placeholder="Select languages"
|
||||
i18n-placeholder
|
||||
></pngx-input-select>
|
||||
|
||||
<pngx-input-select [items]="transformOptions" [(ngModel)]="zone.transform" title="Transform" i18n-title></pngx-input-select>
|
||||
|
||||
@if (zone.transform === dateTransform) {
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Date format</label>
|
||||
<select class="form-select" [ngModel]="dateFormatChoice(zone)" (ngModelChange)="setDateFormatChoice(zone, $event)">
|
||||
@for (opt of dateFormatOptions; track opt.id) {
|
||||
<option [ngValue]="opt.id">{{ opt.name }}</option>
|
||||
}
|
||||
<option [ngValue]="customDateFormatChoice" i18n>Custom...</option>
|
||||
</select>
|
||||
@if (usesCustomDateFormat(zone)) {
|
||||
<div class="input-group mt-2">
|
||||
<input type="text" class="form-control font-monospace" [(ngModel)]="zone.date_format" placeholder="%d.%m.%Y" />
|
||||
<button class="btn btn-outline-secondary" type="button" [ngbPopover]="dateFmtHelp" [autoClose]="true" title="Date format help" i18n-title>
|
||||
<i-bs name="question-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #dateFmtHelp>
|
||||
<p class="mb-1" i18n>Python date codes:</p>
|
||||
<ul class="mb-1 ps-3">
|
||||
<li><code>%d</code> <ng-container i18n>day (01-31)</ng-container></li>
|
||||
<li><code>%m</code> <ng-container i18n>month (01-12)</ng-container></li>
|
||||
<li><code>%Y</code> <ng-container i18n>year, 4-digit</ng-container></li>
|
||||
<li><code>%y</code> <ng-container i18n>year, 2-digit</ng-container></li>
|
||||
<li><code>%b</code> <ng-container i18n>month name (Jan)</ng-container></li>
|
||||
</ul>
|
||||
<span i18n>Example:</span> <code>%d.%m.%Y</code> -> 03.03.2026
|
||||
</ng-template>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Validation Regex</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control font-monospace"
|
||||
[(ngModel)]="zone.validation_regex"
|
||||
placeholder="e.g. \d{2}\.\d{2}\.\d{4}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small">
|
||||
{{ zone.x }}, {{ zone.y }} - {{ zone.width }}x{{ zone.height }}px
|
||||
</div>
|
||||
|
||||
<hr class="my-3" />
|
||||
<h6 i18n>Test</h6>
|
||||
@if (!previewDocId) {
|
||||
<p class="text-muted small mb-0" i18n>
|
||||
No sample document selected.
|
||||
</p>
|
||||
} @else {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="testZone()" [disabled]="zoneTesting">
|
||||
@if (zoneTesting) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<span i18n>Test this zone</span>
|
||||
</button>
|
||||
@if (zoneTestResult; as result) {
|
||||
@if (result.error) {
|
||||
<div class="alert alert-warning py-2 mt-2 mb-0 small" role="alert">{{ result.error }}</div>
|
||||
} @else {
|
||||
<dl class="row small mt-2 mb-0">
|
||||
<dt class="col-sm-4" i18n>OCR text</dt>
|
||||
<dd class="col-sm-8"><code>{{ result.raw_text || '(nothing detected)' }}</code></dd>
|
||||
<dt class="col-sm-4" i18n>Value</dt>
|
||||
<dd class="col-sm-8"><code>{{ result.value || '(empty)' }}</code></dd>
|
||||
@if (result.regex) {
|
||||
<dt class="col-sm-4" i18n>Validation</dt>
|
||||
<dd class="col-sm-8">
|
||||
@if (result.regex_match) {
|
||||
<span class="badge bg-success" i18n>Regex matches</span>
|
||||
} @else {
|
||||
<span class="badge bg-danger" i18n>Regex does not match</span>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
<pngx-input-select
|
||||
[items]="dateFormatItems"
|
||||
[ngModel]="dateFormatChoice(zone)"
|
||||
(ngModelChange)="setDateFormatChoice(zone, $event)"
|
||||
title="Date format"
|
||||
i18n-title
|
||||
></pngx-input-select>
|
||||
@if (usesCustomDateFormat(zone)) {
|
||||
<div class="input-group mt-n2 mb-3">
|
||||
<input type="text" class="form-control font-monospace" [(ngModel)]="zone.date_format" placeholder="%d.%m.%Y" aria-label="Custom date format" i18n-aria-label />
|
||||
<button class="btn btn-outline-secondary" type="button" [ngbPopover]="dateFmtHelp" [autoClose]="true" title="Date format help" i18n-title>
|
||||
<i-bs name="question-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #dateFmtHelp>
|
||||
<p class="mb-1" i18n>Python date codes:</p>
|
||||
<ul class="mb-1 ps-3">
|
||||
<li><code>%d</code> <ng-container i18n>day (01-31)</ng-container></li>
|
||||
<li><code>%m</code> <ng-container i18n>month (01-12)</ng-container></li>
|
||||
<li><code>%Y</code> <ng-container i18n>year, 4-digit</ng-container></li>
|
||||
<li><code>%y</code> <ng-container i18n>year, 2-digit</ng-container></li>
|
||||
<li><code>%b</code> <ng-container i18n>month name (Jan)</ng-container></li>
|
||||
</ul>
|
||||
<span i18n>Example:</span> <code>%d.%m.%Y</code> -> 03.03.2026
|
||||
</ng-template>
|
||||
}
|
||||
}
|
||||
|
||||
<pngx-input-text [(ngModel)]="zone.validation_regex" title="Validation regex" i18n-title placeholder="e.g. \d{2}\.\d{2}\.\d{4}" hint="The value must match this pattern to be applied." i18n-hint></pngx-input-text>
|
||||
} @else {
|
||||
<pngx-ocr-template-zone-list
|
||||
[zones]="template.zones"
|
||||
|
||||
+46
-21
@@ -18,7 +18,6 @@ import {
|
||||
NgbTypeaheadModule,
|
||||
NgbTypeaheadSelectItemEvent,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
catchError,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { NumberComponent } from 'src/app/components/common/input/number/number.component'
|
||||
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
|
||||
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
||||
@@ -131,6 +131,7 @@ const RESIZE_CURSOR: Record<ResizeHandle, string> = {
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent,
|
||||
NumberComponent,
|
||||
TextComponent,
|
||||
SelectComponent,
|
||||
SwitchComponent,
|
||||
@@ -140,7 +141,6 @@ const RESIZE_CURSOR: Record<ResizeHandle, string> = {
|
||||
NgbNavModule,
|
||||
NgbPopoverModule,
|
||||
NgbTypeaheadModule,
|
||||
NgSelectModule,
|
||||
NgxBootstrapIconsModule,
|
||||
OcrTemplateEditorZoneListComponent,
|
||||
],
|
||||
@@ -191,7 +191,23 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
zones: [],
|
||||
}
|
||||
|
||||
customFields: CustomField[] = []
|
||||
private _customFields: CustomField[] = []
|
||||
/** Items for the zone field select: built-in targets, then custom fields. */
|
||||
fieldItems: { id: ZoneFieldSelection; name: string }[] = [
|
||||
...OCR_BUILTIN_TARGETS,
|
||||
]
|
||||
|
||||
get customFields(): CustomField[] {
|
||||
return this._customFields
|
||||
}
|
||||
|
||||
set customFields(fields: CustomField[]) {
|
||||
this._customFields = fields
|
||||
this.fieldItems = [
|
||||
...OCR_BUILTIN_TARGETS,
|
||||
...fields.map((cf) => ({ id: cf.id, name: cf.name })),
|
||||
]
|
||||
}
|
||||
documentTypes: DocumentType[] = []
|
||||
transformOptions = TRANSFORM_OPTIONS
|
||||
builtinTargets = OCR_BUILTIN_TARGETS
|
||||
@@ -199,6 +215,11 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
ocrLanguageOptions = OCR_LANGUAGE_OPTIONS
|
||||
dateTransform = OCR_ZONE_TRANSFORM.Date
|
||||
customDateFormatChoice = CUSTOM_DATE_FORMAT_CHOICE
|
||||
noSampleDocumentTitle = $localize`Select a sample document to test this zone`
|
||||
dateFormatItems = [
|
||||
...DATE_FORMAT_OPTIONS,
|
||||
{ id: CUSTOM_DATE_FORMAT_CHOICE, name: $localize`Custom...` },
|
||||
]
|
||||
isNew = true
|
||||
saving = false
|
||||
|
||||
@@ -231,18 +252,18 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
overlayMode: OverlayMode = 'select'
|
||||
overlayCursor = 'default'
|
||||
|
||||
private zoneTest: ZoneTest | null = null
|
||||
private readonly zoneTest = signal<ZoneTest | null>(null)
|
||||
|
||||
get imageLoaded(): boolean {
|
||||
return this.previewStatus === 'ready'
|
||||
}
|
||||
|
||||
get zoneTestResult(): OcrZoneTestResult | null {
|
||||
return this.isCurrentZoneTest() ? this.zoneTest.result : null
|
||||
return this.isCurrentZoneTest() ? this.zoneTest().result : null
|
||||
}
|
||||
|
||||
get zoneTesting(): boolean {
|
||||
return this.isCurrentZoneTest() && this.zoneTest.result === null
|
||||
return this.isCurrentZoneTest() && this.zoneTest().result === null
|
||||
}
|
||||
|
||||
showQuickCreate = false
|
||||
@@ -454,6 +475,14 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
return getZonePage(zone, this.previewPage, this.previewPageCount)
|
||||
}
|
||||
|
||||
isLastPageZone(zone: OcrTemplateZone): boolean {
|
||||
return zone.page === -1
|
||||
}
|
||||
|
||||
setLastPageZone(zone: OcrTemplateZone, lastPage: boolean) {
|
||||
zone.page = lastPage ? -1 : this.previewPage + 1
|
||||
}
|
||||
|
||||
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
|
||||
return isZoneOnPage(zone, this.previewPage, this.previewPageCount)
|
||||
}
|
||||
@@ -835,31 +864,27 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
request: this.zoneTestRequest(zone),
|
||||
result: null,
|
||||
}
|
||||
this.zoneTest = test
|
||||
this.zoneTest.set(test)
|
||||
this.templateService
|
||||
.testZone(test.documentId, test.request)
|
||||
.pipe(takeUntil(this.destroy$), takeUntil(this.cancelZoneTest$))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
test.result = res
|
||||
this.changeDetector.markForCheck()
|
||||
},
|
||||
error: (err) => {
|
||||
test.result = {
|
||||
error: err.error?.error || $localize`Test failed`,
|
||||
}
|
||||
this.changeDetector.markForCheck()
|
||||
},
|
||||
next: (res) => this.zoneTest.set({ ...test, result: res }),
|
||||
error: (err) =>
|
||||
this.zoneTest.set({
|
||||
...test,
|
||||
result: { error: err.error?.error || $localize`Test failed` },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
private clearZoneTest() {
|
||||
this.cancelZoneTest$.next()
|
||||
this.zoneTest = null
|
||||
this.zoneTest.set(null)
|
||||
}
|
||||
|
||||
private isCurrentZoneTest(): boolean {
|
||||
const test = this.zoneTest
|
||||
const test = this.zoneTest()
|
||||
if (
|
||||
!test ||
|
||||
test.zone !== this.selectedZone ||
|
||||
@@ -1067,10 +1092,10 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
return dt ? dt.name : `Type #${id}`
|
||||
}
|
||||
|
||||
openQuickCreate(zoneIndex: number | null) {
|
||||
openQuickCreate(zoneIndex: number | null, name?: string) {
|
||||
if (zoneIndex === null) return
|
||||
this.quickCreateForZoneIndex = zoneIndex
|
||||
this.quickCreateName = this.template.zones[zoneIndex]?.name || ''
|
||||
this.quickCreateName = name || this.template.zones[zoneIndex]?.name || ''
|
||||
this.quickCreateType = CustomFieldDataType.String
|
||||
this.showQuickCreate = true
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user