Fixed rebase

This commit is contained in:
Adina Țeudan 2022-03-15 17:29:34 +02:00
parent 7fa5f6b4f1
commit 7e93e2f0f2
11 changed files with 7 additions and 1830 deletions

View File

@ -22,7 +22,7 @@ import { DICTIONARY_TYPE, DOSSIER_TEMPLATE_ID } from '@utils/constants';
import { DossierTemplateExistsGuard } from '@guards/dossier-template-exists.guard';
import { DictionaryExistsGuard } from '@guards/dictionary-exists.guard';
import { DossierStatesListingScreenComponent } from './screens/dossier-states-listing/dossier-states-listing-screen.component';
import { DossiersGuard } from '../../guards/dossiers.guard';
import { DossiersGuard } from '@guards/dossiers.guard';
import { ACTIVE_DOSSIERS_SERVICE } from '../../tokens';
const routes: Routes = [

View File

@ -9,14 +9,12 @@ import {
} from '@iqser/common-ui';
import { DossierState, IDossierState } from '@red/domain';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service';
import { DossierStateService } from '@services/entity-services/dossier-state.service';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, Observable } from 'rxjs';
import { AdminDialogService } from '../../services/admin-dialog.service';
import { DoughnutChartConfig } from '@shared/components/simple-doughnut-chart/simple-doughnut-chart.component';
import { ActivatedRoute } from '@angular/router';
import { DossierStatesMapService } from '@services/entity-services/dossier-states-map.service';
import { tap } from 'rxjs/operators';
import { map, tap } from 'rxjs/operators';
import { PermissionsService } from '@services/permissions.service';
import { DossierStatesService } from '@services/entity-services/dossier-states.service';

View File

@ -3,8 +3,8 @@ import { CommonModule } from '@angular/common';
import { ArchivedDossiersScreenComponent } from './screens/archived-dossiers-screen/archived-dossiers-screen.component';
import { ArchiveRoutingModule } from './archive-routing.module';
import { TableItemComponent } from './components/table-item/table-item.component';
import { ConfigService } from '@services/config.service';
import { SharedModule } from '@shared/shared.module';
import { ConfigService } from './services/config.service';
const components = [TableItemComponent];
const screens = [ArchivedDossiersScreenComponent];

View File

@ -1,12 +1,10 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Dossier, DossierAttributeWithValue, DossierStats } from '@red/domain';
import { DossiersDialogService } from '../../../dossier/services/dossiers-dialog.service';
import { DossierTemplatesService } from '@services/entity-services/dossier-templates.service';
import { FilesService } from '@services/entity-services/files.service';
import { firstValueFrom, Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { DossierStatsService } from '@services/dossiers/dossier-stats.service';
import { FilesMapService } from '@services/entity-services/files-map.service';
import { DossiersDialogService } from '../../../dossier/services/dossiers-dialog.service';
@Component({
selector: 'redaction-dossier-details-stats',
@ -20,7 +18,6 @@ export class DossierDetailsStatsComponent implements OnInit {
attributesExpanded = false;
dossierTemplateName: string;
deletedFilesCount$: Observable<number>;
dossierStats$: Observable<DossierStats>;
constructor(
@ -28,15 +25,10 @@ export class DossierDetailsStatsComponent implements OnInit {
private readonly _dialogService: DossiersDialogService,
private readonly _filesService: FilesService,
private readonly _dossierStatsService: DossierStatsService,
private readonly _filesMapService: FilesMapService,
) {}
ngOnInit() {
this.dossierStats$ = this._dossierStatsService.watch$(this.dossier.dossierId);
this.deletedFilesCount$ = this._filesMapService.get$(this.dossier.dossierId).pipe(
switchMap(() => this._filesService.getDeletedFilesFor(this.dossier.id)),
map(files => files.length),
);
this.dossierTemplateName = this._dossierTemplatesService.find(this.dossier.dossierTemplateId)?.name || '-';
}

View File

@ -1,41 +0,0 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Dossier, DossierAttributeWithValue, DossierStats } from '@red/domain';
import { DossiersDialogService } from '../../../../services/dossiers-dialog.service';
import { DossierTemplatesService } from '@services/entity-services/dossier-templates.service';
import { FilesService } from '@services/entity-services/files.service';
import { firstValueFrom, Observable } from 'rxjs';
import { DossierStatsService } from '@services/dossiers/dossier-stats.service';
@Component({
selector: 'redaction-dossier-details-stats',
templateUrl: './dossier-details-stats.component.html',
styleUrls: ['./dossier-details-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DossierDetailsStatsComponent implements OnInit {
@Input() dossierAttributes: DossierAttributeWithValue[];
@Input() dossier: Dossier;
attributesExpanded = false;
dossierTemplateName: string;
dossierStats$: Observable<DossierStats>;
constructor(
private readonly _dossierTemplatesService: DossierTemplatesService,
private readonly _dialogService: DossiersDialogService,
private readonly _filesService: FilesService,
private readonly _dossierStatsService: DossierStatsService,
) {}
ngOnInit() {
this.dossierStats$ = this._dossierStatsService.watch$(this.dossier.dossierId);
this.dossierTemplateName = this._dossierTemplatesService.find(this.dossier.dossierTemplateId)?.name || '-';
}
openEditDossierDialog(section: string): void {
const data = { dossierId: this.dossier.dossierId, section };
this._dialogService.openDialog('editDossier', null, data, async () => {
await firstValueFrom(this._filesService.loadAll(this.dossier.dossierId, this.dossier.routerPath));
});
}
}

View File

@ -1,88 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { DoughnutChartConfig } from '@shared/components/simple-doughnut-chart/simple-doughnut-chart.component';
import { FilterService, mapEach } from '@iqser/common-ui';
import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service';
import { combineLatest, Observable } from 'rxjs';
import { DossierStats, FileCountPerWorkflowStatus, StatusSorter } from '@red/domain';
import { workflowFileStatusTranslations } from '../../../../../../translations/file-status-translations';
import { TranslateChartService } from '@services/translate-chart.service';
import { filter, map, switchMap } from 'rxjs/operators';
import { DossierStatsService } from '@services/dossiers/dossier-stats.service';
import { DossierStateService } from '@services/entity-services/dossier-state.service';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'redaction-dossiers-listing-details',
templateUrl: './dossiers-listing-details.component.html',
styleUrls: ['./dossiers-listing-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DossiersListingDetailsComponent {
readonly documentsChartData$: Observable<DoughnutChartConfig[]>;
readonly dossiersChartData$: Observable<DoughnutChartConfig[]>;
constructor(
readonly filterService: FilterService,
readonly activeDossiersService: ActiveDossiersService,
private readonly _dossierStatsMap: DossierStatsService,
private readonly _translateChartService: TranslateChartService,
private readonly _dossierStateService: DossierStateService,
private readonly _translateService: TranslateService,
) {
this.documentsChartData$ = this.activeDossiersService.all$.pipe(
mapEach(dossier => _dossierStatsMap.watch$(dossier.dossierId)),
switchMap(stats$ => combineLatest(stats$)),
filter(stats => !stats.some(s => s === undefined)),
map(stats => this._toChartData(stats)),
);
this.dossiersChartData$ = this.activeDossiersService.all$.pipe(map(() => this._toDossierChartData()));
}
private _toDossierChartData(): DoughnutChartConfig[] {
this._dossierStateService.all.forEach(
state => (state.dossierCount = this.activeDossiersService.getCountWithState(state.dossierStatusId)),
);
const configArray: DoughnutChartConfig[] = [
...this._dossierStateService.all
.reduce((acc, { color, dossierCount, name }) => {
const key = name + '-' + color;
const item = acc.get(key) ?? Object.assign({}, { value: 0, label: name, color: color });
return acc.set(key, { ...item, value: item.value + dossierCount });
}, new Map<string, DoughnutChartConfig>())
.values(),
];
const notAssignedLength = this.activeDossiersService.all.length - configArray.map(v => v.value).reduce((acc, val) => acc + val, 0);
configArray.push({
value: notAssignedLength,
label: this._translateService.instant('edit-dossier-dialog.general-info.form.dossier-status.placeholder'),
color: '#E2E4E9',
});
return configArray;
}
private _toChartData(stats: DossierStats[]) {
const chartData: FileCountPerWorkflowStatus = {};
stats.forEach(stat => {
const statuses: FileCountPerWorkflowStatus = stat.fileCountPerWorkflowStatus;
Object.keys(statuses).forEach(status => {
chartData[status] = chartData[status] ? (chartData[status] as number) + (statuses[status] as number) : statuses[status];
});
});
const documentsChartData = Object.keys(chartData).map(
status =>
({
value: chartData[status],
color: status,
label: workflowFileStatusTranslations[status],
key: status,
} as DoughnutChartConfig),
);
documentsChartData.sort((a, b) => StatusSorter.byStatus(a.key, b.key));
return this._translateChartService.translateStatus(documentsChartData);
}
}

View File

@ -1,266 +0,0 @@
import { Injectable, TemplateRef } from '@angular/core';
import { ButtonConfig, IFilterGroup, INestedFilter, keyChecker, NestedFilter, TableColumnConfig } from '@iqser/common-ui';
import { Dossier, StatusSorter, User } from '@red/domain';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { TranslateService } from '@ngx-translate/core';
import { UserPreferenceService } from '@services/user-preference.service';
import { UserService } from '@services/user.service';
import { workflowFileStatusTranslations } from '../../../../translations/file-status-translations';
import { dossierMemberChecker, dossierStateChecker, dossierTemplateChecker, RedactionFilterSorter } from '@utils/index';
import { workloadTranslations } from '../../translations/workload-translations';
import { DossierTemplatesService } from '@services/entity-services/dossier-templates.service';
import { DossierStatsService } from '@services/dossiers/dossier-stats.service';
import { DossierStateService } from '@services/entity-services/dossier-state.service';
@Injectable()
export class ConfigService {
constructor(
private readonly _translateService: TranslateService,
private readonly _userPreferenceService: UserPreferenceService,
private readonly _userService: UserService,
private readonly _dossierTemplatesService: DossierTemplatesService,
private readonly _dossierStatsService: DossierStatsService,
private readonly _dossierStateService: DossierStateService,
) {}
get tableConfig(): TableColumnConfig<Dossier>[] {
return [
{ label: _('dossier-listing.table-col-names.name'), sortByKey: 'searchKey', width: '2fr' },
// { label: _('dossier-listing.table-col-names.last-modified') },
{ label: _('dossier-listing.table-col-names.needs-work') },
{ label: _('dossier-listing.table-col-names.owner'), class: 'user-column' },
{ label: _('dossier-listing.table-col-names.documents-status'), class: 'flex-end', width: 'auto' },
{ label: _('dossier-listing.table-col-names.dossier-status'), class: 'flex-end' },
];
}
get _currentUser(): User {
return this._userService.currentUser;
}
_myDossiersChecker = (dw: Dossier) => dw.ownerId === this._currentUser.id;
_toApproveChecker = (dw: Dossier) => dw.approverIds.includes(this._currentUser.id);
_toReviewChecker = (dw: Dossier) => dw.memberIds.includes(this._currentUser.id);
_otherChecker = (dw: Dossier) => !dw.memberIds.includes(this._currentUser.id);
buttonsConfig(addDossier: () => void): ButtonConfig[] {
return [
{
label: _('dossier-listing.add-new'),
action: addDossier,
hide: !this._currentUser.isManager,
icon: 'iqser:plus',
type: 'primary',
helpModeKey: 'new_dossier_button',
},
];
}
filterGroups(entities: Dossier[], needsWorkFilterTemplate: TemplateRef<unknown>) {
const allDistinctFileStatus = new Set<string>();
const allDistinctPeople = new Set<string>();
const allDistinctNeedsWork = new Set<string>();
const allDistinctDossierTemplates = new Set<string>();
const allDistinctDossierStates = new Set<string>();
const filterGroups: IFilterGroup[] = [];
entities?.forEach(entry => {
entry.memberIds.forEach(f => allDistinctPeople.add(f));
allDistinctDossierTemplates.add(entry.dossierTemplateId);
if (entry.dossierStatusId) {
allDistinctDossierStates.add(entry.dossierStatusId);
}
const stats = this._dossierStatsService.get(entry.dossierId);
if (!stats) {
return;
}
Object.keys(stats?.fileCountPerWorkflowStatus).forEach(status => allDistinctFileStatus.add(status));
if (stats.hasHintsNoRedactionsFilePresent) {
allDistinctNeedsWork.add('hint');
}
if (stats.hasRedactionsFilePresent) {
allDistinctNeedsWork.add('redaction');
}
if (stats.hasSuggestionsFilePresent) {
allDistinctNeedsWork.add('suggestion');
}
if (stats.hasNoFlagsFilePresent) {
allDistinctNeedsWork.add('none');
}
});
const dossierStatesFilters = [...allDistinctDossierStates].map(
id =>
new NestedFilter({
id: id,
label: this._dossierStateService.find(id).name,
}),
);
filterGroups.push({
slug: 'dossierStatesFilters',
label: this._translateService.instant('filters.dossier-status'),
icon: 'red:status',
hide: dossierStatesFilters.length <= 1,
filters: dossierStatesFilters,
checker: dossierStateChecker,
});
const statusFilters = [...allDistinctFileStatus].map(
status =>
new NestedFilter({
id: status,
label: this._translateService.instant(workflowFileStatusTranslations[status]),
}),
);
filterGroups.push({
slug: 'statusFilters',
label: this._translateService.instant('filters.documents-status'),
icon: 'red:status',
filters: statusFilters.sort((a, b) => StatusSorter[a.id] - StatusSorter[b.id]),
checker: (dossier: Dossier, filter: INestedFilter) => this._dossierStatusChecker(dossier, filter),
});
const peopleFilters = [...allDistinctPeople].map(
userId =>
new NestedFilter({
id: userId,
label: this._userService.getNameForId(userId),
}),
);
filterGroups.push({
slug: 'peopleFilters',
label: this._translateService.instant('filters.people'),
icon: 'red:user',
filters: peopleFilters,
checker: dossierMemberChecker,
});
const needsWorkFilters = [...allDistinctNeedsWork].map(
type =>
new NestedFilter({
id: type,
label: workloadTranslations[type],
}),
);
filterGroups.push({
slug: 'needsWorkFilters',
label: this._translateService.instant('filters.needs-work'),
icon: 'red:needs-work',
filterTemplate: needsWorkFilterTemplate,
filters: needsWorkFilters.sort((a, b) => RedactionFilterSorter[a.id] - RedactionFilterSorter[b.id]),
checker: (dossier: Dossier, filter: INestedFilter) => this._annotationFilterChecker(dossier, filter),
matchAll: true,
});
const dossierTemplateFilters = [...allDistinctDossierTemplates].map(
id =>
new NestedFilter({
id: id,
label: this._dossierTemplatesService.find(id)?.name || '-',
}),
);
filterGroups.push({
slug: 'dossierTemplateFilters',
label: this._translateService.instant('filters.dossier-templates'),
icon: 'red:template',
hide: dossierTemplateFilters.length <= 1,
filters: dossierTemplateFilters,
checker: dossierTemplateChecker,
});
filterGroups.push({
slug: 'quickFilters',
filters: this._quickFilters(entities),
checker: (dw: Dossier, filter: NestedFilter) => filter.checked && filter.checker(dw),
});
const dossierFilters = entities.map(
dossier =>
new NestedFilter({
id: dossier.dossierName,
label: dossier.dossierName,
}),
);
filterGroups.push({
slug: 'dossierNameFilter',
label: this._translateService.instant('dossier-listing.filters.label'),
icon: 'red:folder',
filters: dossierFilters,
filterceptionPlaceholder: this._translateService.instant('dossier-listing.filters.search'),
checker: keyChecker('dossierName'),
});
return filterGroups;
}
private _quickFilters(entities: Dossier[]): NestedFilter[] {
const myDossiersLabel = this._translateService.instant('dossier-listing.quick-filters.my-dossiers');
const filters = [
{
id: 'my-dossiers',
label: myDossiersLabel,
checker: this._myDossiersChecker,
disabled: entities.filter(this._myDossiersChecker).length === 0,
helpModeKey: 'dossiers_quickfilter_my_dossiers',
},
{
id: 'to-approve',
label: this._translateService.instant('dossier-listing.quick-filters.to-approve'),
checker: this._toApproveChecker,
disabled: entities.filter(this._toApproveChecker).length === 0,
},
{
id: 'to-review',
label: this._translateService.instant('dossier-listing.quick-filters.to-review'),
checker: this._toReviewChecker,
disabled: entities.filter(this._toReviewChecker).length === 0,
},
{
id: 'other',
label: this._translateService.instant('dossier-listing.quick-filters.other'),
checker: this._otherChecker,
disabled: entities.filter(this._otherChecker).length === 0,
},
].map(filter => new NestedFilter(filter));
return filters.filter(f => f.label === myDossiersLabel || this._userPreferenceService.areDevFeaturesEnabled);
}
private _dossierStatusChecker = (dossier: Dossier, filter: INestedFilter) => {
const stats = this._dossierStatsService.get(dossier.dossierId);
return stats?.fileCountPerWorkflowStatus[filter.id];
};
private _annotationFilterChecker = (dossier: Dossier, filter: INestedFilter) => {
const stats = this._dossierStatsService.get(dossier.dossierId);
switch (filter.id) {
// case 'analysis': {
// return stats.reanalysisRequired;
// }
case 'suggestion': {
return stats.hasSuggestionsFilePresent;
}
case 'redaction': {
return stats.hasRedactionsFilePresent;
}
case 'hint': {
return stats.hasHintsNoRedactionsFilePresent;
}
case 'none': {
return stats.hasNoFlagsFilePresent;
}
}
};
}

View File

@ -1,115 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Dossier, File, StatusBarConfigs, User } from '@red/domain';
import { List, LoadingService, Toaster } from '@iqser/common-ui';
import { PermissionsService } from '@services/permissions.service';
import { FileAssignService } from '../../../../shared/services/file-assign.service';
import { workflowFileStatusTranslations } from '../../../../../../translations/file-status-translations';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { UserService } from '@services/user.service';
import { FilesService } from '@services/entity-services/files.service';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, combineLatest, firstValueFrom, Observable, switchMap } from 'rxjs';
import { FilePreviewStateService } from '../../services/file-preview-state.service';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service';
@Component({
selector: 'redaction-user-management',
templateUrl: './user-management.component.html',
styleUrls: ['./user-management.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserManagementComponent {
readonly translations = workflowFileStatusTranslations;
readonly statusBarConfig$: Observable<StatusBarConfigs>;
readonly assignTooltip$: Observable<string>;
readonly canAssignReviewer$: Observable<boolean>;
readonly canAssignToSelf$: Observable<boolean>;
readonly editingReviewer$ = new BehaviorSubject<boolean>(false);
readonly canAssignOrUnassign$: Observable<boolean>;
readonly canAssign$: Observable<boolean>;
readonly usersOptions$: Observable<List>;
private readonly _dossier$: Observable<Dossier>;
private readonly _canAssignUser$: Observable<boolean>;
private readonly _canUnassignUser$: Observable<boolean>;
constructor(
readonly fileAssignService: FileAssignService,
readonly permissionsService: PermissionsService,
readonly userService: UserService,
readonly filesService: FilesService,
readonly toaster: Toaster,
readonly loadingService: LoadingService,
readonly translateService: TranslateService,
readonly stateService: FilePreviewStateService,
private readonly _activeDossiersService: ActiveDossiersService,
) {
this._dossier$ = this.stateService.file$.pipe(switchMap(file => this._activeDossiersService.getEntityChanged$(file.dossierId)));
this.statusBarConfig$ = this.stateService.file$.pipe(map(file => [{ length: 1, color: file.workflowStatus }]));
this.assignTooltip$ = this.stateService.file$.pipe(
map(file =>
file.isUnderApproval
? this.translateService.instant(_('dossier-overview.assign-approver'))
: file.assignee
? this.translateService.instant(_('file-preview.change-reviewer'))
: this.translateService.instant(_('file-preview.assign-reviewer')),
),
);
this.canAssignToSelf$ = this.stateService.file$.pipe(
map(file => this.permissionsService.canAssignToSelf(file)),
distinctUntilChanged(),
);
this._canAssignUser$ = this.stateService.file$.pipe(
map(file => this.permissionsService.canAssignUser(file)),
distinctUntilChanged(),
);
this._canUnassignUser$ = this.stateService.file$.pipe(
map(file => this.permissionsService.canUnassignUser(file)),
distinctUntilChanged(),
);
this.canAssignOrUnassign$ = combineLatest([this._canAssignUser$, this._canUnassignUser$]).pipe(
map(([canAssignUser, canUnassignUser]) => canAssignUser || canUnassignUser),
distinctUntilChanged(),
);
this.canAssign$ = combineLatest([this.canAssignToSelf$, this.canAssignOrUnassign$]).pipe(
map(([canAssignToSelf, canAssignOrUnassign]) => canAssignToSelf || canAssignOrUnassign),
distinctUntilChanged(),
);
this.canAssignReviewer$ = combineLatest([this.stateService.file$, this._canAssignUser$, this._dossier$]).pipe(
map(([file, canAssignUser, dossier]) => !file.assignee && canAssignUser && dossier.hasReviewers),
distinctUntilChanged(),
);
this.usersOptions$ = combineLatest([this._canUnassignUser$, this.stateService.file$, this._dossier$]).pipe(
map(([canUnassignUser, file, dossier]) => {
const unassignUser = canUnassignUser ? [undefined] : [];
return file.isUnderApproval ? [...dossier.approverIds, ...unassignUser] : [...dossier.memberIds, ...unassignUser];
}),
);
}
async assignReviewer(file: File, user: User | string) {
const assigneeId = typeof user === 'string' ? user : user?.id;
const reviewerName = this.userService.getNameForId(assigneeId);
const { dossierId, filename } = file;
this.loadingService.start();
if (!assigneeId) {
await firstValueFrom(this.filesService.setUnassigned([file], dossierId));
} else if (file.isNew || file.isUnderReview) {
await firstValueFrom(this.filesService.setReviewerFor([file], dossierId, assigneeId));
} else if (file.isUnderApproval) {
await firstValueFrom(this.filesService.setUnderApprovalFor([file], dossierId, assigneeId));
}
this.loadingService.stop();
this.toaster.info(_('assignment.reviewer'), { params: { reviewerName, filename } });
this.editingReviewer$.next(false);
}
}

View File

@ -1,703 +0,0 @@
import { ChangeDetectorRef, Component, HostListener, NgZone, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { ActivatedRoute, ActivatedRouteSnapshot, NavigationExtras, Router } from '@angular/router';
import { Core } from '@pdftron/webviewer';
import {
AutoUnsubscribe,
CircleButtonTypes,
CustomError,
Debounce,
ErrorService,
FilterService,
LoadingService,
NestedFilter,
OnAttach,
OnDetach,
processFilters,
shareDistinctLast,
} from '@iqser/common-ui';
import { MatDialogRef, MatDialogState } from '@angular/material/dialog';
import { ManualRedactionEntryWrapper } from '@models/file/manual-redaction-entry.wrapper';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { ManualAnnotationResponse } from '@models/file/manual-annotation-response';
import { AnnotationDrawService } from './services/annotation-draw.service';
import { AnnotationProcessingService } from '../../services/annotation-processing.service';
import { File, ViewMode } from '@red/domain';
import { PermissionsService } from '@services/permissions.service';
import { combineLatest, firstValueFrom, Observable, of, timer } from 'rxjs';
import { UserPreferenceService } from '@services/user-preference.service';
import { PdfViewerDataService } from '../../services/pdf-viewer-data.service';
import { download } from '@utils/file-download-utils';
import { FileWorkloadComponent } from './components/file-workload/file-workload.component';
import { DossiersDialogService } from '../../services/dossiers-dialog.service';
import { clearStamps, stampPDFPage } from '@utils/page-stamper';
import { TranslateService } from '@ngx-translate/core';
import { handleFilterDelta } from '@utils/filter-utils';
import { FilesService } from '@services/entity-services/files.service';
import { FileManagementService } from '@services/entity-services/file-management.service';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { FilesMapService } from '@services/entity-services/files-map.service';
import { WatermarkService } from '@shared/services/watermark.service';
import { ExcludedPagesService } from './services/excluded-pages.service';
import { ViewModeService } from './services/view-mode.service';
import { MultiSelectService } from './services/multi-select.service';
import { DocumentInfoService } from './services/document-info.service';
import { ReanalysisService } from '../../../../services/reanalysis.service';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { SkippedService } from './services/skipped.service';
import { FilePreviewStateService } from './services/file-preview-state.service';
import { FileDataModel } from '../../../../models/file/file-data.model';
import { filePreviewScreenProviders } from './file-preview-providers';
import { ManualAnnotationService } from '../../services/manual-annotation.service';
import { DossiersService } from '@services/dossiers/dossiers.service';
import { PageRotationService } from './services/page-rotation.service';
import { ComponentCanDeactivate } from '../../../../guards/can-deactivate.guard';
import { PdfViewer } from './services/pdf-viewer.service';
import Annotation = Core.Annotations.Annotation;
import PDFNet = Core.PDFNet;
const ALL_HOTKEY_ARRAY = ['Escape', 'F', 'f', 'ArrowUp', 'ArrowDown'];
@Component({
templateUrl: './file-preview-screen.component.html',
styleUrls: ['./file-preview-screen.component.scss'],
providers: filePreviewScreenProviders,
})
export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnInit, OnDestroy, OnAttach, OnDetach, ComponentCanDeactivate {
readonly circleButtonTypes = CircleButtonTypes;
dialogRef: MatDialogRef<unknown>;
fullScreen = false;
selectedAnnotations: AnnotationWrapper[] = [];
displayPdfViewer = false;
activeViewerPage: number = null;
readonly canPerformAnnotationActions$: Observable<boolean>;
readonly fileId = this.stateService.fileId;
readonly dossierId = this.stateService.dossierId;
readonly file$ = this.stateService.file$.pipe(tap(file => this._fileUpdated(file)));
ready = false;
private _lastPage: string;
@ViewChild('fileWorkloadComponent') private readonly _workloadComponent: FileWorkloadComponent;
@ViewChild('annotationFilterTemplate', {
read: TemplateRef,
static: false,
})
private readonly _filterTemplate: TemplateRef<unknown>;
constructor(
readonly permissionsService: PermissionsService,
readonly userPreferenceService: UserPreferenceService,
readonly stateService: FilePreviewStateService,
private readonly _watermarkService: WatermarkService,
private readonly _changeDetectorRef: ChangeDetectorRef,
private readonly _activatedRoute: ActivatedRoute,
private readonly _dialogService: DossiersDialogService,
private readonly _router: Router,
private readonly _annotationProcessingService: AnnotationProcessingService,
private readonly _annotationDrawService: AnnotationDrawService,
private readonly _pdfViewerDataService: PdfViewerDataService,
private readonly _filesService: FilesService,
private readonly _ngZone: NgZone,
private readonly _fileManagementService: FileManagementService,
private readonly _loadingService: LoadingService,
private readonly _filterService: FilterService,
private readonly _translateService: TranslateService,
private readonly _filesMapService: FilesMapService,
private readonly _dossiersService: DossiersService,
private readonly _reanalysisService: ReanalysisService,
private readonly _errorService: ErrorService,
private readonly _pageRotationService: PageRotationService,
private readonly _skippedService: SkippedService,
private readonly _pdf: PdfViewer,
private readonly _manualAnnotationService: ManualAnnotationService,
readonly excludedPagesService: ExcludedPagesService,
private readonly _viewModeService: ViewModeService,
readonly multiSelectService: MultiSelectService,
readonly documentInfoService: DocumentInfoService,
) {
super();
this.canPerformAnnotationActions$ = this._canPerformAnnotationActions$;
document.documentElement.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
this.fullScreen = false;
}
});
}
get changed() {
return this._pageRotationService.hasRotations();
}
get visibleAnnotations(): AnnotationWrapper[] {
return this._fileData ? this._fileData.getVisibleAnnotations(this._viewModeService.viewMode) : [];
}
get allAnnotations(): AnnotationWrapper[] {
return this._fileData ? this._fileData.allAnnotations : [];
}
private get _fileData(): FileDataModel {
return this.stateService.fileData;
}
private get _canPerformAnnotationActions$() {
const viewMode$ = this._viewModeService.viewMode$.pipe(tap(() => this.#deactivateMultiSelect()));
return combineLatest([this.stateService.file$, viewMode$, this._viewModeService.compareMode$]).pipe(
map(([file, viewMode]) => this.permissionsService.canPerformAnnotationActions(file) && viewMode === 'STANDARD'),
shareDistinctLast(),
);
}
async save() {
await this._pageRotationService.applyRotation();
}
async updateViewMode(): Promise<void> {
if (!this._pdf.ready) {
return;
}
const textHighlightAnnotationIds = this._fileData.textHighlightAnnotations.map(a => a.id);
const textHighlightAnnotations = this._pdf.getAnnotations((a: Core.Annotations.Annotation) =>
textHighlightAnnotationIds.includes(a.Id),
);
this._pdf.deleteAnnotations(textHighlightAnnotations);
const ocrAnnotationIds = this._fileData.allAnnotations.filter(a => a.isOCR).map(a => a.id);
const annotations = this._pdf.getAnnotations(a => a.getCustomData('redact-manager'));
const redactions = annotations.filter(a => a.getCustomData('redaction'));
switch (this._viewModeService.viewMode) {
case 'STANDARD': {
this._setAnnotationsColor(redactions, 'annotationColor');
const standardEntries = annotations
.filter(a => a.getCustomData('changeLogRemoved') === 'false')
.filter(a => !ocrAnnotationIds.includes(a.Id));
const nonStandardEntries = annotations.filter(a => a.getCustomData('changeLogRemoved') === 'true');
this._setAnnotationsOpacity(standardEntries, true);
this._pdf.showAnnotations(standardEntries);
this._pdf.hideAnnotations(nonStandardEntries);
break;
}
case 'DELTA': {
const changeLogEntries = annotations.filter(a => a.getCustomData('changeLog') === 'true');
const nonChangeLogEntries = annotations.filter(a => a.getCustomData('changeLog') === 'false');
this._setAnnotationsColor(redactions, 'annotationColor');
this._setAnnotationsOpacity(changeLogEntries, true);
this._pdf.showAnnotations(changeLogEntries);
this._pdf.hideAnnotations(nonChangeLogEntries);
break;
}
case 'REDACTED': {
const nonRedactionEntries = annotations.filter(a => a.getCustomData('redaction') === 'false');
this._setAnnotationsOpacity(redactions);
this._setAnnotationsColor(redactions, 'redactionColor');
this._pdf.showAnnotations(redactions);
this._pdf.hideAnnotations(nonRedactionEntries);
break;
}
case 'TEXT_HIGHLIGHTS': {
this._loadingService.start();
const textHighlights = await firstValueFrom(this._pdfViewerDataService.loadTextHighlightsFor(this.dossierId, this.fileId));
this._pdf.hideAnnotations(annotations);
this._fileData.textHighlights = textHighlights;
await this._annotationDrawService.drawAnnotations(this._fileData.textHighlightAnnotations);
this._loadingService.stop();
}
}
await this._stampPDF();
this.rebuildFilters();
}
ngOnDetach(): void {
this._pageRotationService.clearRotations();
this.displayPdfViewer = false;
super.ngOnDetach();
this._changeDetectorRef.markForCheck();
}
async ngOnAttach(previousRoute: ActivatedRouteSnapshot): Promise<boolean> {
const file = await this.stateService.file;
if (!file.canBeOpened) {
return this._router.navigate([this._dossiersService.find(this.dossierId)?.routerLink]);
}
this._viewModeService.compareMode = false;
this._viewModeService.switchToStandard();
await this.ngOnInit();
this._lastPage = previousRoute.queryParams.page;
this._changeDetectorRef.markForCheck();
}
async ngOnInit(): Promise<void> {
this.ready = false;
this._loadingService.start();
await this.userPreferenceService.saveLastOpenedFileForDossier(this.dossierId, this.fileId);
this._subscribeToFileUpdates();
const file = await this.stateService.file;
if (file?.analysisRequired && !file.excludedFromAutomaticAnalysis) {
const reanalyzeFiles = this._reanalysisService.reanalyzeFilesForDossier([file], this.dossierId, { force: true });
await firstValueFrom(reanalyzeFiles);
}
this.displayPdfViewer = true;
}
rebuildFilters(deletePreviousAnnotations = false): void {
const startTime = new Date().getTime();
if (deletePreviousAnnotations) {
this._pdf.deleteAnnotations();
console.log(`[REDACTION] Delete previous annotations time: ${new Date().getTime() - startTime} ms`);
}
const processStartTime = new Date().getTime();
const annotationFilters = this._annotationProcessingService.getAnnotationFilter(this.visibleAnnotations);
const primaryFilters = this._filterService.getGroup('primaryFilters')?.filters;
this._filterService.addFilterGroup({
slug: 'primaryFilters',
filterTemplate: this._filterTemplate,
filters: processFilters(primaryFilters, annotationFilters),
});
const secondaryFilters = this._filterService.getGroup('secondaryFilters')?.filters;
this._filterService.addFilterGroup({
slug: 'secondaryFilters',
filterTemplate: this._filterTemplate,
filters: processFilters(secondaryFilters, AnnotationProcessingService.secondaryAnnotationFilters(this._fileData?.viewedPages)),
});
console.log(`[REDACTION] Process time: ${new Date().getTime() - processStartTime} ms`);
console.log(`[REDACTION] Filter rebuild time: ${new Date().getTime() - startTime}`);
}
handleAnnotationSelected(annotationIds: string[]) {
this.selectedAnnotations = annotationIds
.map(id => this.visibleAnnotations.find(annotationWrapper => annotationWrapper.id === id))
.filter(ann => ann !== undefined);
if (this.selectedAnnotations.length > 1) {
this.multiSelectService.activate();
}
this._workloadComponent.scrollToSelectedAnnotation();
this._changeDetectorRef.markForCheck();
}
@Debounce(10)
selectAnnotations(annotations?: AnnotationWrapper[]) {
if (annotations) {
const annotationsToSelect = this.multiSelectService.isActive ? [...this.selectedAnnotations, ...annotations] : annotations;
this._pdf.selectAnnotations(annotationsToSelect, this.multiSelectService.isActive);
} else {
this._pdf.deselectAllAnnotations();
}
}
deselectAnnotations(annotations: AnnotationWrapper[]) {
this._pdf.deselectAnnotations(annotations);
}
selectPage(pageNumber: number) {
this._pdf.navigateToPage(pageNumber);
this._workloadComponent?.scrollAnnotationsToPage(pageNumber, 'always');
this._lastPage = pageNumber.toString();
}
openManualAnnotationDialog(manualRedactionEntryWrapper: ManualRedactionEntryWrapper) {
this._ngZone.run(() => {
this.dialogRef = this._dialogService.openDialog(
'manualAnnotation',
null,
{ manualRedactionEntryWrapper, dossierId: this.dossierId },
async (entryWrapper: ManualRedactionEntryWrapper) => {
const addAnnotation$ = this._manualAnnotationService
.addAnnotation(entryWrapper.manualRedactionEntry, this.dossierId, this.fileId)
.pipe(catchError(() => of(undefined)));
const addAnnotationResponse = await firstValueFrom(addAnnotation$);
const response = new ManualAnnotationResponse(entryWrapper, addAnnotationResponse);
if (response?.annotationId) {
const annotation = this._pdf.annotationManager.getAnnotationById(response.manualRedactionEntryWrapper.rectId);
this._pdf.deleteAnnotations([annotation]);
const distinctPages = manualRedactionEntryWrapper.manualRedactionEntry.positions
.map(p => p.page)
.filter((item, pos, self) => self.indexOf(item) === pos);
for (const page of distinctPages) {
await this._reloadAnnotationsForPage(page);
}
await this.updateViewMode();
}
},
);
});
}
toggleFullScreen() {
this.fullScreen = !this.fullScreen;
if (this.fullScreen) {
this._openFullScreen();
} else {
this.closeFullScreen();
}
}
handleArrowEvent($event: KeyboardEvent): void {
if (['ArrowUp', 'ArrowDown'].includes($event.key)) {
if (this.selectedAnnotations.length === 1) {
this._workloadComponent.navigateAnnotations($event);
}
}
}
@HostListener('window:keyup', ['$event'])
handleKeyEvent($event: KeyboardEvent) {
if (this._router.url.indexOf('/file/') < 0) {
return;
}
if (!ALL_HOTKEY_ARRAY.includes($event.key) || this.dialogRef?.getState() === MatDialogState.OPEN) {
return;
}
if (['Escape'].includes($event.key)) {
this.fullScreen = false;
this.closeFullScreen();
this._changeDetectorRef.markForCheck();
}
if (['f', 'F'].includes($event.key)) {
// if you type in an input, don't toggle full-screen
if ($event.target instanceof HTMLInputElement || $event.target instanceof HTMLTextAreaElement) {
return;
}
this.toggleFullScreen();
return;
}
}
async viewerPageChanged($event: any) {
if (typeof $event !== 'number') {
return;
}
this._scrollViews();
this.multiSelectService.deactivate();
// Add current page in URL query params
const extras: NavigationExtras = {
queryParams: { page: $event },
queryParamsHandling: 'merge',
replaceUrl: true,
};
await this._router.navigate([], extras);
this.activeViewerPage = this._pdf.currentPage;
this._changeDetectorRef.markForCheck();
}
@Debounce()
async viewerReady() {
this.ready = true;
this._pdf.ready = true;
await this._reloadAnnotations();
this._setExcludedPageStyles();
this._pdf.documentViewer.addEventListener('pageComplete', () => {
this._setExcludedPageStyles();
});
// Go to initial page from query params
const pageNumber: string = this._lastPage || this._activatedRoute.snapshot.queryParams.page;
if (pageNumber) {
setTimeout(() => {
this.selectPage(parseInt(pageNumber, 10));
this.activeViewerPage = this._pdf.currentPage;
this._scrollViews();
this._changeDetectorRef.markForCheck();
this._loadingService.stop();
});
} else {
this._loadingService.stop();
}
this._changeDetectorRef.markForCheck();
}
async annotationsChangedByReviewAction(annotation?: AnnotationWrapper) {
this.multiSelectService.deactivate();
const file = await this.stateService.file;
const fileReloaded = await firstValueFrom(this._filesService.reload(this.dossierId, file));
if (!fileReloaded) {
await this._reloadAnnotationsForPage(annotation?.pageNumber ?? this.activeViewerPage);
}
}
closeFullScreen() {
if (!!document.fullscreenElement && document.exitFullscreen) {
document.exitFullscreen().then();
}
}
async switchView(viewMode: ViewMode) {
this._viewModeService.viewMode = viewMode;
await this.updateViewMode();
this._scrollViews();
}
async downloadOriginalFile(file: File) {
const originalFile = this._fileManagementService.downloadOriginalFile(
this.dossierId,
this.fileId,
'response',
file.cacheIdentifier,
);
download(await firstValueFrom(originalFile), file.filename);
}
#deactivateMultiSelect(): void {
this.multiSelectService.deactivate();
this._pdf.deselectAllAnnotations();
this.handleAnnotationSelected([]);
}
private _setExcludedPageStyles() {
const file = this._filesMapService.get(this.dossierId, this.fileId);
setTimeout(() => {
const iframeDoc = this._pdf.UI.iframeWindow.document;
const pageContainer = iframeDoc.getElementById(`pageWidgetContainer${this.activeViewerPage}`);
if (pageContainer) {
if (file.excludedPages.includes(this.activeViewerPage)) {
pageContainer.classList.add('excluded-page');
} else {
pageContainer.classList.remove('excluded-page');
}
}
}, 100);
}
private async _stampPDF() {
const pdfDoc = await this._pdf.documentViewer.getDocument().getPDFDoc();
const file = await this.stateService.file;
const allPages = [...Array(file.numberOfPages).keys()].map(page => page + 1);
if (!pdfDoc || !this._pdf.ready) {
return;
}
await clearStamps(pdfDoc, this._pdf.PDFNet, allPages);
if (this._viewModeService.isRedacted) {
const dossier = await this.stateService.dossier;
if (dossier.watermarkPreviewEnabled) {
await this._stampPreview(pdfDoc, dossier.dossierTemplateId);
}
} else {
await this._stampExcludedPages(pdfDoc, file.excludedPages);
}
this._pdf.documentViewer.refreshAll();
this._pdf.documentViewer.updateView([this.activeViewerPage], this.activeViewerPage);
this._changeDetectorRef.markForCheck();
}
private async _stampPreview(document: PDFNet.PDFDoc, dossierTemplateId: string) {
const watermark = await this._watermarkService.getWatermark(dossierTemplateId).toPromise();
await stampPDFPage(
document,
this._pdf.PDFNet,
watermark.text,
watermark.fontSize,
watermark.fontType,
watermark.orientation,
watermark.opacity,
watermark.hexColor,
Array.from({ length: await document.getPageCount() }, (x, i) => i + 1),
);
}
private async _stampExcludedPages(document: PDFNet.PDFDoc, excludedPages: number[]) {
if (excludedPages && excludedPages.length > 0) {
await stampPDFPage(
document,
this._pdf.PDFNet,
this._translateService.instant('file-preview.excluded-from-redaction') as string,
17,
'courier',
'TOP_LEFT',
50,
'#dd4d50',
excludedPages,
);
}
}
private async _fileUpdated(file: File): Promise<void> {
await this._loadFileData(file);
await this._reloadAnnotations();
}
private _subscribeToFileUpdates(): void {
this.addActiveScreenSubscription = timer(0, 5000)
.pipe(
switchMap(() => this.stateService.file$),
switchMap(file => this._filesService.reload(this.dossierId, file)),
)
.subscribe();
this.addActiveScreenSubscription = this._dossiersService
.getEntityDeleted$(this.dossierId)
.pipe(tap(() => this._handleDeletedDossier()))
.subscribe();
this.addActiveScreenSubscription = this._filesMapService
.watchDeleted$(this.fileId)
.pipe(tap(() => this._handleDeletedFile()))
.subscribe();
this.addActiveScreenSubscription = this._skippedService.hideSkipped$
.pipe(tap(hideSkipped => this._handleIgnoreAnnotationsDrawing(hideSkipped)))
.subscribe();
}
private _handleDeletedDossier(): void {
this._errorService.set(
new CustomError(_('error.deleted-entity.file-dossier.label'), _('error.deleted-entity.file-dossier.action'), 'iqser:expand'),
);
}
private _handleDeletedFile(): void {
this._errorService.set(
new CustomError(_('error.deleted-entity.file.label'), _('error.deleted-entity.file.action'), 'iqser:expand'),
);
}
private async _loadFileData(file: File): Promise<void | boolean> {
if (!file || file.isError) {
const dossier = await this.stateService.dossier;
return this._router.navigate([dossier.routerLink]);
}
if (file.isUnprocessed) {
return;
}
this.stateService.fileData = await firstValueFrom(this._pdfViewerDataService.loadDataFor(file));
}
@Debounce(0)
private _scrollViews() {
this._workloadComponent?.scrollQuickNavigation();
this._workloadComponent?.scrollAnnotations();
}
private async _reloadAnnotations() {
this._deleteAnnotations();
await this._cleanupAndRedrawAnnotations();
await this.updateViewMode();
}
private async _reloadAnnotationsForPage(page: number) {
const file = await this.stateService.file;
// if this action triggered a re-processing,
// we don't want to redraw for this page since they will get redrawn as soon as processing ends;
if (file.isProcessing) {
return;
}
const currentPageAnnotations = this.visibleAnnotations.filter(a => a.pageNumber === page);
this._fileData.redactionLog = await firstValueFrom(this._pdfViewerDataService.loadRedactionLogFor(this.dossierId, this.fileId));
this._deleteAnnotations(currentPageAnnotations);
await this._cleanupAndRedrawAnnotations(annotation => annotation.pageNumber === page);
}
private _deleteAnnotations(annotationsToDelete?: AnnotationWrapper[]) {
if (!this._pdf.ready) {
return;
}
if (!annotationsToDelete) {
this._pdf.deleteAnnotations();
}
annotationsToDelete?.forEach(annotation => {
this._findAndDeleteAnnotation(annotation.id);
});
}
private async _cleanupAndRedrawAnnotations(newAnnotationsFilter?: (annotation: AnnotationWrapper) => boolean) {
if (!this._pdf.ready) {
return;
}
const currentFilters = this._filterService.getGroup('primaryFilters')?.filters || [];
this.rebuildFilters();
const startTime = new Date().getTime();
const annotations = this._fileData.allAnnotations;
const newAnnotations = newAnnotationsFilter ? annotations.filter(newAnnotationsFilter) : annotations;
if (currentFilters) {
this._handleDeltaAnnotationFilters(currentFilters, this.visibleAnnotations);
}
await this._annotationDrawService.drawAnnotations(newAnnotations);
console.log(`[REDACTION] Annotations redraw time: ${new Date().getTime() - startTime} ms for ${newAnnotations.length} annotations`);
}
private _handleDeltaAnnotationFilters(currentFilters: NestedFilter[], newAnnotations: AnnotationWrapper[]) {
const primaryFilterGroup = this._filterService.getGroup('primaryFilters');
const primaryFilters = primaryFilterGroup.filters;
const secondaryFilters = this._filterService.getGroup('secondaryFilters').filters;
const hasAnyFilterSet = [...primaryFilters, ...secondaryFilters].find(f => f.checked || f.indeterminate);
if (!hasAnyFilterSet) {
return;
}
const newPageSpecificFilters = this._annotationProcessingService.getAnnotationFilter(newAnnotations);
handleFilterDelta(currentFilters, newPageSpecificFilters, primaryFilters);
this._filterService.addFilterGroup({
...primaryFilterGroup,
filters: primaryFilters,
});
}
private _findAndDeleteAnnotation(id: string) {
const viewerAnnotation = this._pdf.annotationManager.getAnnotationById(id);
if (viewerAnnotation) {
this._pdf.deleteAnnotations([viewerAnnotation]);
}
}
private _openFullScreen() {
const documentElement = document.documentElement;
if (documentElement.requestFullscreen) {
documentElement.requestFullscreen().then();
}
}
private _handleIgnoreAnnotationsDrawing(hideSkipped: boolean): void {
const ignored = this._pdf.getAnnotations(a => a.getCustomData('skipped'));
if (hideSkipped) {
this._pdf.hideAnnotations(ignored);
} else {
this._pdf.showAnnotations(ignored);
}
}
private _setAnnotationsOpacity(annotations: Annotation[], restoreToOriginal: boolean = false) {
annotations.forEach(annotation => {
annotation['Opacity'] = restoreToOriginal ? parseFloat(annotation.getCustomData('opacity')) : 1;
});
}
private _setAnnotationsColor(annotations: Annotation[], customData: string) {
annotations.forEach(annotation => {
const color = this._annotationDrawService.convertColor(annotation.getCustomData(customData));
annotation['StrokeColor'] = color;
annotation['FillColor'] = color;
});
}
}

View File

@ -1,600 +0,0 @@
import { EventEmitter, Inject, Injectable, NgZone } from '@angular/core';
import { PermissionsService } from '@services/permissions.service';
import { ManualAnnotationService } from '../../../services/manual-annotation.service';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { getFirstRelevantTextPart } from '@utils/functions';
import { AnnotationPermissions } from '@models/file/annotation.permissions';
import { DossiersDialogService } from '../../../services/dossiers-dialog.service';
import { BASE_HREF } from '../../../../../tokens';
import { UserService } from '@services/user.service';
import { Core } from '@pdftron/webviewer';
import { Dossier, IAddRedactionRequest, ILegalBasisChangeRequest, IRectangle, IResizeRequest } from '@red/domain';
import { toPosition } from '../../../utils/pdf-calculation.utils';
import { AnnotationDrawService } from './annotation-draw.service';
import { translateQuads } from '@utils/pdf-coordinates';
import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service';
import {
AcceptRecommendationData,
AcceptRecommendationDialogComponent,
AcceptRecommendationReturnType,
} from '../dialogs/accept-recommendation-dialog/accept-recommendation-dialog.component';
import { defaultDialogConfig } from '@iqser/common-ui';
import { filter } from 'rxjs/operators';
import { MatDialog } from '@angular/material/dialog';
import { FilePreviewStateService } from './file-preview-state.service';
import { PdfViewer } from './pdf-viewer.service';
import Annotation = Core.Annotations.Annotation;
@Injectable()
export class AnnotationActionsService {
constructor(
@Inject(BASE_HREF) private readonly _baseHref: string,
private readonly _ngZone: NgZone,
private readonly _userService: UserService,
private readonly _permissionsService: PermissionsService,
private readonly _manualAnnotationService: ManualAnnotationService,
private readonly _translateService: TranslateService,
private readonly _dialogService: DossiersDialogService,
private readonly _dialog: MatDialog,
private readonly _pdf: PdfViewer,
private readonly _annotationDrawService: AnnotationDrawService,
private readonly _activeDossiersService: ActiveDossiersService,
private readonly _screenStateService: FilePreviewStateService,
) {}
private get _dossier(): Dossier {
return this._activeDossiersService.find(this._screenStateService.dossierId);
}
acceptSuggestion($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter<AnnotationWrapper>) {
$event?.stopPropagation();
const { dossierId, fileId } = this._screenStateService;
annotations.forEach(annotation => {
this._processObsAndEmit(
this._manualAnnotationService.approve(annotation.id, dossierId, fileId, annotation.isModifyDictionary),
annotation,
annotationsChanged,
);
});
}
rejectSuggestion($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter<AnnotationWrapper>) {
$event?.stopPropagation();
const { dossierId, fileId } = this._screenStateService;
annotations.forEach(annotation => {
this._processObsAndEmit(
this._manualAnnotationService.declineOrRemoveRequest(annotation, dossierId, fileId),
annotation,
annotationsChanged,
);
});
}
forceAnnotation(
$event: MouseEvent,
annotations: AnnotationWrapper[],
annotationsChanged: EventEmitter<AnnotationWrapper>,
hint: boolean = false,
) {
const { dossierId, fileId } = this._screenStateService;
const data = { dossier: this._dossier, annotations, hint };
this._dialogService.openDialog('forceAnnotation', $event, data, (request: ILegalBasisChangeRequest) => {
annotations.forEach(annotation => {
this._processObsAndEmit(
this._manualAnnotationService.force(
{
...request,
annotationId: annotation.id,
},
dossierId,
fileId,
),
annotation,
annotationsChanged,
);
});
});
}
changeLegalBasis($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter<AnnotationWrapper>) {
const { dossierId, fileId } = this._screenStateService;
this._dialogService.openDialog(
'changeLegalBasis',
$event,
{ annotations, dossier: this._dossier },
(data: { comment: string; legalBasis: string; section: string; value: string }) => {
annotations.forEach(annotation => {
this._processObsAndEmit(
this._manualAnnotationService.changeLegalBasis(
annotation.annotationId,
dossierId,
fileId,
data.section,
data.value,
data.legalBasis,
data.comment,
),
annotation,
annotationsChanged,
);
});
},
);
}
removeOrSuggestRemoveAnnotation(
$event: MouseEvent,
annotations: AnnotationWrapper[],
removeFromDictionary: boolean,
annotationsChanged: EventEmitter<AnnotationWrapper>,
) {
const data = {
annotationsToRemove: annotations,
removeFromDictionary,
dossier: this._dossier,
hint: annotations[0].hintDictionary,
};
const { dossierId, fileId } = this._screenStateService;
this._dialogService.openDialog('removeAnnotations', $event, data, (result: { comment: string }) => {
annotations.forEach(annotation => {
this._processObsAndEmit(
this._manualAnnotationService.removeOrSuggestRemoveAnnotation(
annotation,
dossierId,
fileId,
result.comment,
removeFromDictionary,
),
annotation,
annotationsChanged,
);
});
});
}
markAsFalsePositive($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter<AnnotationWrapper>) {
annotations.forEach(annotation => {
this._markAsFalsePositive($event, annotation, this._getFalsePositiveText(annotation), annotationsChanged);
});
}
recategorizeImages($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter<AnnotationWrapper>) {
const data = { annotations, dossier: this._dossier };
const { dossierId, fileId } = this._screenStateService;
this._dialogService.openDialog('recategorizeImage', $event, data, (res: { type: string; comment: string }) => {
annotations.forEach(annotation => {
this._processObsAndEmit(
this._manualAnnotationService.recategorizeImg(annotation.annotationId, dossierId, fileId, res.type, res.comment),
annotation,
annotationsChanged,
);
});
});
}
undoDirectAction($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter<AnnotationWrapper>) {
$event?.stopPropagation();
const { dossierId, fileId } = this._screenStateService;
annotations.forEach(annotation => {
this._processObsAndEmit(
this._manualAnnotationService.undoRequest(annotation, dossierId, fileId),
annotation,
annotationsChanged,
);
});
}
convertRecommendationToAnnotation(
$event: any,
recommendations: AnnotationWrapper[],
annotationsChanged: EventEmitter<AnnotationWrapper>,
) {
$event?.stopPropagation();
const { dossierId, fileId } = this._screenStateService;
const dialogRef = this._dialog.open<AcceptRecommendationDialogComponent, AcceptRecommendationData, AcceptRecommendationReturnType>(
AcceptRecommendationDialogComponent,
{ ...defaultDialogConfig, autoFocus: true, data: { annotations: recommendations, dossierId } },
);
const dialogClosed = dialogRef.afterClosed().pipe(filter(value => !!value && !!value.annotations));
dialogClosed.subscribe(({ annotations, comment: commentText }) => {
const comment = commentText ? { text: commentText } : undefined;
annotations.forEach(annotation => {
this._processObsAndEmit(
this._manualAnnotationService.addRecommendation(annotation, dossierId, fileId, comment),
annotation,
annotationsChanged,
);
});
});
}
getViewerAvailableActions(
dossier: Dossier,
annotations: AnnotationWrapper[],
annotationsChanged: EventEmitter<AnnotationWrapper>,
): Record<string, unknown>[] {
const availableActions = [];
const annotationPermissions = annotations.map(annotation => ({
annotation,
permissions: AnnotationPermissions.forUser(
this._permissionsService.isApprover(dossier),
this._userService.currentUser,
annotation,
),
}));
// you can only resize one annotation at a time
const canResize = annotationPermissions.length === 1 && annotationPermissions[0].permissions.canResizeAnnotation;
if (canResize) {
const firstAnnotation = annotations[0];
// if we already entered resize-mode previously
if (firstAnnotation.resizing) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/check.svg'),
title: this._translateService.instant('annotation-actions.resize-accept.label'),
onClick: () =>
this._ngZone.run(() => {
this.acceptResize(null, firstAnnotation, annotationsChanged);
}),
});
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/close.svg'),
title: this._translateService.instant('annotation-actions.resize-cancel.label'),
onClick: () =>
this._ngZone.run(() => {
this.cancelResize(null, firstAnnotation, annotationsChanged);
}),
});
return availableActions;
}
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/resize.svg'),
title: this._translateService.instant('annotation-actions.resize.label'),
onClick: () => this._ngZone.run(() => this.resize(null, annotations[0])),
});
}
const canChangeLegalBasis = annotationPermissions.reduce((acc, next) => acc && next.permissions.canChangeLegalBasis, true);
if (canChangeLegalBasis) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/edit.svg'),
title: this._translateService.instant('annotation-actions.edit-reason.label'),
onClick: () =>
this._ngZone.run(() => {
this.changeLegalBasis(null, annotations, annotationsChanged);
}),
});
}
const canRecategorizeImage = annotationPermissions.reduce((acc, next) => acc && next.permissions.canRecategorizeImage, true);
if (canRecategorizeImage) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/thumb-down.svg'),
title: this._translateService.instant('annotation-actions.recategorize-image'),
onClick: () =>
this._ngZone.run(() => {
this.recategorizeImages(null, annotations, annotationsChanged);
}),
});
}
const canRemoveOrSuggestToRemoveFromDictionary = annotationPermissions.reduce(
(acc, next) => acc && next.permissions.canRemoveOrSuggestToRemoveFromDictionary,
true,
);
if (canRemoveOrSuggestToRemoveFromDictionary) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/remove-from-dict.svg'),
title: this._translateService.instant('annotation-actions.remove-annotation.remove-from-dict'),
onClick: () =>
this._ngZone.run(() => {
this.removeOrSuggestRemoveAnnotation(null, annotations, true, annotationsChanged);
}),
});
}
const canAcceptRecommendation = annotationPermissions.reduce((acc, next) => acc && next.permissions.canAcceptRecommendation, true);
if (canAcceptRecommendation) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/check.svg'),
title: this._translateService.instant('annotation-actions.accept-recommendation.label'),
onClick: () =>
this._ngZone.run(() => {
this.convertRecommendationToAnnotation(null, annotations, annotationsChanged);
}),
});
}
const canAcceptSuggestion = annotationPermissions.reduce((acc, next) => acc && next.permissions.canAcceptSuggestion, true);
if (canAcceptSuggestion) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/check.svg'),
title: this._translateService.instant('annotation-actions.accept-suggestion.label'),
onClick: () =>
this._ngZone.run(() => {
this.acceptSuggestion(null, annotations, annotationsChanged);
}),
});
}
const canUndo = annotationPermissions.reduce((acc, next) => acc && next.permissions.canUndo, true);
if (canUndo) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/undo.svg'),
title: this._translateService.instant('annotation-actions.undo'),
onClick: () =>
this._ngZone.run(() => {
this.undoDirectAction(null, annotations, annotationsChanged);
}),
});
}
const canMarkAsFalsePositive = annotationPermissions.reduce((acc, next) => acc && next.permissions.canMarkAsFalsePositive, true);
if (canMarkAsFalsePositive) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/thumb-down.svg'),
title: this._translateService.instant('annotation-actions.remove-annotation.false-positive'),
onClick: () =>
this._ngZone.run(() => {
this.markAsFalsePositive(null, annotations, annotationsChanged);
}),
});
}
const canForceRedaction = annotationPermissions.reduce((acc, next) => acc && next.permissions.canForceRedaction, true);
if (canForceRedaction) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/thumb-up.svg'),
title: this._translateService.instant('annotation-actions.force-redaction.label'),
onClick: () =>
this._ngZone.run(() => {
this.forceAnnotation(null, annotations, annotationsChanged);
}),
});
}
const canForceHint = annotationPermissions.reduce((acc, next) => acc && next.permissions.canForceHint, true);
if (canForceHint) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/thumb-up.svg'),
title: this._translateService.instant('annotation-actions.force-hint.label'),
onClick: () =>
this._ngZone.run(() => {
this.forceAnnotation(null, annotations, annotationsChanged, true);
}),
});
}
const canRejectSuggestion = annotationPermissions.reduce((acc, next) => acc && next.permissions.canRejectSuggestion, true);
if (canRejectSuggestion) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/close.svg'),
title: this._translateService.instant('annotation-actions.reject-suggestion'),
onClick: () =>
this._ngZone.run(() => {
this.rejectSuggestion(null, annotations, annotationsChanged);
}),
});
}
const canRemoveOrSuggestToRemoveOnlyHere = annotationPermissions.reduce(
(acc, next) => acc && next.permissions.canRemoveOrSuggestToRemoveOnlyHere,
true,
);
if (canRemoveOrSuggestToRemoveOnlyHere) {
availableActions.push({
type: 'actionButton',
img: this._convertPath('/assets/icons/general/trash.svg'),
title: this._translateService.instant('annotation-actions.remove-annotation.only-here'),
onClick: () =>
this._ngZone.run(() => {
this.removeOrSuggestRemoveAnnotation(null, annotations, false, annotationsChanged);
}),
});
}
return availableActions;
}
updateHiddenAnnotation(annotations: AnnotationWrapper[], viewerAnnotations: Annotation[], hidden: boolean) {
const annotationId = viewerAnnotations[0].Id;
const annotationToBeUpdated = annotations.find((a: AnnotationWrapper) => a.annotationId === annotationId);
annotationToBeUpdated.hidden = hidden;
}
resize($event: MouseEvent, annotationWrapper: AnnotationWrapper) {
$event?.stopPropagation();
annotationWrapper.resizing = true;
const viewerAnnotation = this._pdf.annotationManager.getAnnotationById(annotationWrapper.id);
viewerAnnotation.ReadOnly = false;
viewerAnnotation.Hidden = false;
viewerAnnotation.disableRotationControl();
this._pdf.annotationManager.redrawAnnotation(viewerAnnotation);
this._pdf.annotationManager.selectAnnotation(viewerAnnotation);
this._annotationDrawService.annotationToQuads(viewerAnnotation);
}
acceptResize($event: MouseEvent, annotationWrapper: AnnotationWrapper, annotationsChanged?: EventEmitter<AnnotationWrapper>) {
const data = { dossier: this._dossier };
const fileId = this._screenStateService.fileId;
this._dialogService.openDialog('resizeAnnotation', $event, data, async (result: { comment: string }) => {
const textAndPositions = await this._extractTextAndPositions(annotationWrapper.id);
const text =
annotationWrapper.value === 'Rectangle' ? 'Rectangle' : annotationWrapper.isImage ? 'Image' : textAndPositions.text;
const resizeRequest: IResizeRequest = {
annotationId: annotationWrapper.id,
comment: result.comment,
positions: textAndPositions.positions,
value: text,
};
this._processObsAndEmit(
this._manualAnnotationService.resizeOrSuggestToResize(annotationWrapper, data.dossier.dossierId, fileId, resizeRequest),
annotationWrapper,
annotationsChanged,
);
});
}
cancelResize($event: MouseEvent, annotationWrapper: AnnotationWrapper, annotationsChanged: EventEmitter<AnnotationWrapper>) {
$event?.stopPropagation();
annotationWrapper.resizing = false;
const viewerAnnotation = this._pdf.annotationManager.getAnnotationById(annotationWrapper.id);
viewerAnnotation.ReadOnly = false;
this._pdf.annotationManager.redrawAnnotation(viewerAnnotation);
this._pdf.annotationManager.deselectAllAnnotations();
annotationsChanged.emit(annotationWrapper);
}
private _processObsAndEmit(
obs: Observable<unknown>,
annotation: AnnotationWrapper,
annotationsChanged: EventEmitter<AnnotationWrapper>,
) {
obs.subscribe({
next: () => {
annotationsChanged.emit(annotation);
},
error: () => {
annotationsChanged.emit();
},
});
}
private _getFalsePositiveText(annotation: AnnotationWrapper) {
if (annotation.canBeMarkedAsFalsePositive) {
let text: string;
if (annotation.hasTextAfter) {
text = getFirstRelevantTextPart(annotation.textAfter, 'FORWARD');
return text ? (annotation.value + text).trim() : annotation.value;
}
if (annotation.hasTextAfter) {
text = getFirstRelevantTextPart(annotation.textBefore, 'BACKWARD');
return text ? (text + annotation.value).trim() : annotation.value;
} else {
return annotation.value;
}
}
}
private _markAsFalsePositive(
$event: MouseEvent,
annotation: AnnotationWrapper,
text: string,
annotationsChanged: EventEmitter<AnnotationWrapper>,
) {
$event?.stopPropagation();
const falsePositiveRequest: IAddRedactionRequest = {};
falsePositiveRequest.reason = annotation.id;
falsePositiveRequest.value = text;
falsePositiveRequest.type = 'false_positive';
falsePositiveRequest.positions = annotation.positions;
falsePositiveRequest.addToDictionary = true;
falsePositiveRequest.comment = { text: 'False Positive' };
const { dossierId, fileId } = this._screenStateService;
this._processObsAndEmit(
this._manualAnnotationService.addAnnotation(falsePositiveRequest, dossierId, fileId),
annotation,
annotationsChanged,
);
}
private _convertPath(path: string): string {
return this._baseHref + path;
}
private async _extractTextAndPositions(annotationId: string) {
const viewerAnnotation = this._pdf.annotationManager.getAnnotationById(annotationId);
const document = await this._pdf.documentViewer.getDocument().getPDFDoc();
const page = await document.getPage(viewerAnnotation.getPageNumber());
if (viewerAnnotation instanceof this._pdf.Annotations.TextHighlightAnnotation) {
const words = [];
const rectangles: IRectangle[] = [];
for (const quad of viewerAnnotation.Quads) {
const rect = toPosition(
viewerAnnotation.getPageNumber(),
this._pdf.documentViewer.getPageHeight(viewerAnnotation.getPageNumber()),
this._translateQuads(viewerAnnotation.getPageNumber(), quad),
);
rectangles.push(rect);
// TODO: this is an educated guess for lines that are close together
// TODO: so that we do not extract text from line above/line below
const percentHeightOffset = rect.height / 10;
const pdfNetRect = new this._pdf.instance.Core.PDFNet.Rect(
rect.topLeft.x,
rect.topLeft.y + percentHeightOffset,
rect.topLeft.x + rect.width,
rect.topLeft.y + rect.height - percentHeightOffset,
);
const quadWords = await this._extractTextFromRect(page, pdfNetRect);
words.push(...quadWords);
}
console.log(words.join(' '));
return {
text: words.join(' '),
positions: rectangles,
};
} else {
const rect = toPosition(
viewerAnnotation.getPageNumber(),
this._pdf.documentViewer.getPageHeight(viewerAnnotation.getPageNumber()),
this._annotationDrawService.annotationToQuads(viewerAnnotation),
);
return {
positions: [rect],
text: null,
};
}
}
private _translateQuads(page: number, quads: any) {
const rotation = this._pdf.documentViewer.getCompleteRotation(page);
return translateQuads(page, rotation, quads);
}
private async _extractTextFromRect(page: Core.PDFNet.Page, rect: Core.PDFNet.Rect) {
const txt = await this._pdf.PDFNet.TextExtractor.create();
txt.begin(page, rect); // Read the page.
const words: string[] = [];
// Extract words one by one.
let line = await txt.getFirstLine();
for (; await line.isValid(); line = await line.getNextLine()) {
for (let word = await line.getFirstWord(); await word.isValid(); word = await word.getNextWord()) {
words.push(await word.getString());
}
}
return words;
}
}

View File

@ -28,9 +28,9 @@ import { EditorComponent } from './components/editor/editor.component';
import { ExpandableFileActionsComponent } from './components/expandable-file-actions/expandable-file-actions.component';
import { ProcessingIndicatorComponent } from '@shared/components/processing-indicator/processing-indicator.component';
import { DossierStateComponent } from '@shared/components/dossier-state/dossier-state.component';
import { DossiersListingDossierNameComponent } from '@shared/components/dossiers-listing-dossier-name/dossiers-listing-dossier-name.component';
import { FileStatsComponent } from './components/file-stats/file-stats.component';
import { FileNameColumnComponent } from '@shared/components/file-name-column/file-name-column.component';
import { DossierNameColumnComponent } from '@shared/components/dossier-name-column/dossier-name-column.component';
const buttons = [FileDownloadBtnComponent, UserButtonComponent];
@ -48,7 +48,7 @@ const components = [
ExpandableFileActionsComponent,
ProcessingIndicatorComponent,
DossierStateComponent,
DossiersListingDossierNameComponent,
DossierNameColumnComponent,
FileStatsComponent,
FileNameColumnComponent,