RED-9747 add initial ws entity log refresh

This commit is contained in:
Dan Percic 2024-07-26 12:22:53 +03:00
parent 2b00876e6b
commit 613a7429b8
13 changed files with 230 additions and 47 deletions

View File

@ -135,20 +135,20 @@ export const appModuleFactory = (config: AppConfig) => {
features: {
ANNOTATIONS: {
color: 'aqua',
enabled: true,
enabled: false,
level: NgxLoggerLevel.DEBUG,
},
FILTERS: {
enabled: false,
},
TENANTS: {
enabled: true,
enabled: false,
},
ROUTES: {
enabled: true,
enabled: false,
},
PDF: {
enabled: true,
enabled: false,
},
FILE: {
enabled: false,
@ -171,6 +171,9 @@ export const appModuleFactory = (config: AppConfig) => {
DOSSIERS_CHANGES: {
enabled: false,
},
GUARDS: {
enabled: false,
},
},
} as ILoggerConfig,
},

View File

@ -51,6 +51,7 @@ export function ifLoggedIn(): AsyncGuard {
const jwtToken = jwtDecode(token) as JwtToken;
const authTime = (jwtToken.auth_time || jwtToken.iat).toString();
localStorage.setItem('authTime', authTime);
localStorage.setItem('token', token);
}
}

View File

@ -1,6 +1,8 @@
import { ActivatedRouteSnapshot, NavigationExtras, Router, RouterLink } from '@angular/router';
import { NgIf } from '@angular/common';
import { ChangeDetectorRef, Component, effect, NgZone, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { ActivatedRouteSnapshot, NavigationExtras, Router, RouterLink } from '@angular/router';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { InitialsAvatarComponent } from '@common-ui/users';
import { ComponentCanDeactivate } from '@guards/can-deactivate.guard';
import {
CircleButtonComponent,
@ -18,10 +20,11 @@ import {
Toaster,
} from '@iqser/common-ui';
import { copyLocalStorageFiltersValues, FilterService, NestedFilter, processFilters } from '@iqser/common-ui/lib/filtering';
import { AutoUnsubscribe, Bind, bool, List, OnAttach, OnDetach } from '@iqser/common-ui/lib/utils';
import { AutoUnsubscribe, Bind, bool, List, log, OnAttach, OnDetach } from '@iqser/common-ui/lib/utils';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { ManualRedactionEntryTypes, ManualRedactionEntryWrapper } from '@models/file/manual-redaction-entry.wrapper';
import { Dictionary, File, ViewModes } from '@red/domain';
import { TranslateModule } from '@ngx-translate/core';
import { AnalyseStatuses, AnalysisEvent, Dictionary, File, ViewModes, WsTopics } from '@red/domain';
import { ConfigService } from '@services/config.service';
import { DossierTemplatesService } from '@services/dossier-templates/dossier-templates.service';
import { DossiersService } from '@services/dossiers/dossiers.service';
@ -29,10 +32,13 @@ import { FilesMapService } from '@services/files/files-map.service';
import { FilesService } from '@services/files/files.service';
import { PermissionsService } from '@services/permissions.service';
import { ReanalysisService } from '@services/reanalysis.service';
import { WebSocketService } from '@services/web-socket.service';
import { ProcessingIndicatorComponent } from '@shared/components/processing-indicator/processing-indicator.component';
import { TypeFilterComponent } from '@shared/components/type-filter/type-filter.component';
import { Roles } from '@users/roles';
import { PreferencesKeys, UserPreferenceService } from '@users/user-preference.service';
import { NGXLogger } from 'ngx-logger';
import { combineLatest, first, firstValueFrom, Observable, of, pairwise } from 'rxjs';
import { combineLatest, first, firstValueFrom, Observable, of, pairwise, Subscription } from 'rxjs';
import { catchError, filter, map, startWith, switchMap, tap } from 'rxjs/operators';
import { byId, byPage, handleFilterDelta, hasChanges } from '../../utils';
import { AnnotationDrawService } from '../pdf-viewer/services/annotation-draw.service';
@ -43,34 +49,29 @@ import { PdfViewer } from '../pdf-viewer/services/pdf-viewer.service';
import { ReadableRedactionsService } from '../pdf-viewer/services/readable-redactions.service';
import { ViewerHeaderService } from '../pdf-viewer/services/viewer-header.service';
import { ROTATION_ACTION_BUTTONS, ViewerEvents } from '../pdf-viewer/utils/constants';
import { FileActionsComponent } from '../shared-dossiers/components/file-actions/file-actions.component';
import { FileHeaderComponent } from './components/file-header/file-header.component';
import { FilePreviewRightContainerComponent } from './components/right-container/file-preview-right-container.component';
import { StructuredComponentManagementComponent } from './components/structured-component-management/structured-component-management.component';
import { UserManagementComponent } from './components/user-management/user-management.component';
import { ViewSwitchComponent } from './components/view-switch/view-switch.component';
import { AddHintDialogComponent } from './dialogs/add-hint-dialog/add-hint-dialog.component';
import { AddAnnotationDialogComponent } from './dialogs/docu-mine/add-annotation-dialog/add-annotation-dialog.component';
import { RedactTextDialogComponent } from './dialogs/redact-text-dialog/redact-text-dialog.component';
import { filePreviewScreenProviders } from './file-preview-providers';
import { AnnotationProcessingService } from './services/annotation-processing.service';
import { AnnotationsListingService } from './services/annotations-listing.service';
import { DocumentInfoService } from './services/document-info.service';
import { FileDataService } from './services/file-data.service';
import { FilePreviewDialogService } from './services/file-preview-dialog.service';
import { FilePreviewStateService } from './services/file-preview-state.service';
import { ManualRedactionService } from './services/manual-redaction.service';
import { MultiSelectService } from './services/multi-select.service';
import { PdfProxyService } from './services/pdf-proxy.service';
import { SkippedService } from './services/skipped.service';
import { StampService } from './services/stamp.service';
import { ViewModeService } from './services/view-mode.service';
import { RedactTextData } from './utils/dialog-types';
import { MultiSelectService } from './services/multi-select.service';
import { NgIf } from '@angular/common';
import { ViewSwitchComponent } from './components/view-switch/view-switch.component';
import { ProcessingIndicatorComponent } from '@shared/components/processing-indicator/processing-indicator.component';
import { UserManagementComponent } from './components/user-management/user-management.component';
import { TranslateModule } from '@ngx-translate/core';
import { InitialsAvatarComponent } from '@common-ui/users';
import { FileActionsComponent } from '../shared-dossiers/components/file-actions/file-actions.component';
import { FilePreviewRightContainerComponent } from './components/right-container/file-preview-right-container.component';
import { TypeFilterComponent } from '@shared/components/type-filter/type-filter.component';
import { FileHeaderComponent } from './components/file-header/file-header.component';
import { StructuredComponentManagementComponent } from './components/structured-component-management/structured-component-management.component';
import { DocumentInfoService } from './services/document-info.service';
@Component({
templateUrl: './file-preview-screen.component.html',
@ -96,17 +97,19 @@ import { DocumentInfoService } from './services/document-info.service';
],
})
export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnInit, OnDestroy, OnAttach, OnDetach, ComponentCanDeactivate {
readonly circleButtonTypes = CircleButtonTypes;
readonly roles = Roles;
readonly fileId = this.state.fileId;
readonly dossierId = this.state.dossierId;
@ViewChild('annotationFilterTemplate', {
read: TemplateRef,
static: false,
})
private readonly _filterTemplate: TemplateRef<unknown>;
#loadAllAnnotationsEnabled = false;
readonly #wsConnection$: Observable<unknown>;
#wsConnectionSub: Subscription;
protected readonly isDocumine = getConfig().IS_DOCUMINE;
readonly circleButtonTypes = CircleButtonTypes;
readonly roles = Roles;
readonly fileId = this.state.fileId;
readonly dossierId = this.state.dossierId;
constructor(
readonly pdf: PdfViewer,
@ -145,13 +148,31 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
private readonly _dossierTemplatesService: DossierTemplatesService,
private readonly _multiSelectService: MultiSelectService,
private readonly _documentInfoService: DocumentInfoService,
private readonly _webSocketService: WebSocketService,
) {
super();
effect(() => {
const file = this.state.file();
this._fileDataService.loadAnnotations(file).then();
console.log('FILE CHANGED');
// this._fileDataService.loadAnnotations(file).then();
});
this.#wsConnection$ = this._webSocketService.listen<AnalysisEvent>(WsTopics.ANALYSIS).pipe(
log('[WS] Analysis events'),
filter(event => event.analyseStatus === AnalyseStatuses.FINISHED),
switchMap(event => this._fileDataService.updateAnnotations(this.state.file(), event.analysisNumber)),
log('[CONNNEECCCCCTIIONSSS] Annotations updated'),
);
const file = this.state.file();
console.log(file);
console.log(this._fileDataService.annotations());
if (this._fileDataService.annotations().length) {
firstValueFrom(this._fileDataService.updateAnnotations(file, file.numberOfAnalyses)).then();
} else {
this._fileDataService.loadAnnotations(file).then();
}
effect(
() => {
if (this._documentViewer.loaded()) {
@ -296,11 +317,13 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
super.ngOnDetach();
this.pdf.instance.UI.hotkeys.off('esc');
this._changeRef.markForCheck();
this.#wsConnectionSub.unsubscribe();
}
ngOnDestroy() {
this.pdf.instance.UI.hotkeys.off('esc');
super.ngOnDestroy();
this.#wsConnectionSub.unsubscribe();
}
@Bind()
@ -339,6 +362,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
}
async ngOnInit(): Promise<void> {
this.#wsConnectionSub = this.#wsConnection$.subscribe();
this.#updateViewerPosition();
const file = this.state.file();

View File

@ -244,7 +244,6 @@ export class AnnotationActionsService {
const text = annotation.AREA ? annotation.value : isImageText;
const isApprover = this._permissionsService.isApprover(dossier);
const dossierTemplate = this._dossierTemplatesService.find(this._state.dossierTemplateId);
const isUnprocessed = annotation.pending;
const data: ResizeRedactionData = {
redaction: annotation,
@ -275,7 +274,7 @@ export class AnnotationActionsService {
await this.cancelResize(annotation);
const { fileId, dossierId, file } = this._state;
const { fileId, dossierId } = this._state;
const request = this._manualRedactionService.resize([resizeRequest], dossierId, fileId, includeUnprocessed);
return this.#processObsAndEmit(request);
}
@ -321,7 +320,7 @@ export class AnnotationActionsService {
}
async #processObsAndEmit(obs: Observable<unknown>) {
await firstValueFrom(obs).finally(() => this._fileDataService.annotationsChanged());
await firstValueFrom(obs.pipe(log('==>>[[[CHANGES]]]'))).finally(() => this._fileDataService.annotationsChanged());
}
#getFalsePositiveText(annotation: AnnotationWrapper) {
@ -443,9 +442,15 @@ export class AnnotationActionsService {
// todo: might not be correct, probably shouldn't get to this point if they are not all the same
const isHint = redactions.every(r => r.isHint);
const { dossierId, fileId } = this._state;
this.#processObsAndEmit(
this._manualRedactionService.removeRedaction(body, dossierId, fileId, removeFromDictionary, isHint, includeUnprocessed),
).then();
const req$ = this._manualRedactionService.removeRedaction(
body,
dossierId,
fileId,
removeFromDictionary,
isHint,
includeUnprocessed,
);
this.#processObsAndEmit(req$).then(() => this._fileDataService.removeAnnotations(redactions.map(r => r.id)));
}
#getRemoveRedactionDialog(data: RemoveRedactionData) {

View File

@ -1,6 +1,8 @@
import { effect, inject, Injectable, Signal, signal } from '@angular/core';
import { effect, inject, Injectable, Signal, signal, WritableSignal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { TenantsService } from '@common-ui/tenants';
import { log } from '@common-ui/utils';
import { EntitiesService, getConfig, Toaster } from '@iqser/common-ui';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import {
@ -27,6 +29,7 @@ import { UserPreferenceService } from '@users/user-preference.service';
import dayjs from 'dayjs';
import { NGXLogger } from 'ngx-logger';
import { firstValueFrom, Observable } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { FilePreviewStateService } from './file-preview-state.service';
import { MultiSelectService } from './multi-select.service';
import { ViewModeService } from './view-mode.service';
@ -43,13 +46,14 @@ export function chronologicallyBy<T>(property: (x: T) => string) {
@Injectable()
export class FileDataService extends EntitiesService<AnnotationWrapper, AnnotationWrapper> {
readonly #annotations = signal<AnnotationWrapper[]>([]);
readonly #annotations: WritableSignal<AnnotationWrapper[]>;
readonly #earmarks = signal<Map<number, AnnotationWrapper[]>>(new Map());
#originalViewedPages: ViewedPage[] = [];
readonly #isDocumine = getConfig().IS_DOCUMINE;
readonly #logger = inject(NGXLogger);
readonly #toaster = inject(Toaster);
readonly #isIqserDevMode = inject(UserPreferenceService).isIqserDevMode;
readonly #tenantsService = inject(TenantsService);
protected readonly _entityClass = AnnotationWrapper;
missingTypes = new Set<string>();
readonly earmarks: Signal<Map<number, AnnotationWrapper[]>>;
@ -70,9 +74,17 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
private readonly _defaultColorsService: DefaultColorsService,
) {
super();
const localStorageKey = this.#tenantsService.activeTenantId + '-annotations-' + this._state.fileId;
this.#annotations = signal<AnnotationWrapper[]>(JSON.parse(localStorage.getItem(localStorageKey) || '[]'));
this.annotations$ = toObservable(this.#annotations);
this.annotations = this.#annotations.asReadonly();
this.earmarks = this.#earmarks.asReadonly();
effect(() => {
localStorage.setItem(localStorageKey, JSON.stringify(this.#annotations()));
console.log('FileDataService#annotations', this.#annotations());
});
effect(() => {
const viewMode = this._viewModeService.viewMode();
const earmarks = ([] as AnnotationWrapper[]).concat(...this.#earmarks().values());
@ -81,6 +93,10 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
});
}
removeAnnotations(id: string[]) {
this.#annotations.update(old => old.filter(annotation => !id.includes(annotation.id)));
}
setEntities(entities: AnnotationWrapper[]): void {
// this is a light version of setEntities to skip looping too much
// used mostly for earmarks (which are usually a lot)
@ -119,7 +135,8 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
const file = this._state.file();
const fileReloaded = await this._filesService.reload(file.dossierId, file);
if (!fileReloaded) {
await this.loadAnnotations(file);
await this.#loadViewedPages(file);
// await this.loadAnnotations(file);
}
}
@ -136,10 +153,37 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
const redactionLog = await this._entityLogService.getEntityLog(this._state.dossierId, this._state.fileId);
this.#logger.info('[REDACTION_LOG] Redaction log loaded', redactionLog);
let annotations = await this.#convertData(redactionLog);
const annotations = await this.processEntityLog(redactionLog);
this.#annotations.update(old => {
const notUpdated = old.filter(oldAnnotation => {
return !annotations.some(newAnnotation => newAnnotation.id === oldAnnotation.id);
});
return [...notUpdated, ...annotations].sort((a, b) => a.positions[0].page - b.positions[0].page);
});
}
async processEntityLog(entityLog: IEntityLog) {
let annotations = await this.#convertData(entityLog);
this.#checkMissingTypes();
annotations = this.#isIqserDevMode ? annotations : annotations.filter(a => !a.isFalsePositive);
this.#annotations.set(annotations);
return this.#isIqserDevMode ? annotations : annotations.filter(a => !a.isFalsePositive);
}
updateAnnotations(file: File, analysisNumber: number) {
const delta$ = this._entityLogService.getDelta(file.dossierId, file.fileId, analysisNumber);
return delta$.pipe(
log('[REDACTION_LOG] Delta loaded'),
switchMap(delta => this.processEntityLog(delta)),
tap(annotations => {
this.#annotations.update(old => {
const notUpdated = old.filter(oldAnnotation => {
return !oldAnnotation.pending && !annotations.some(newAnnotation => newAnnotation.id === oldAnnotation.id);
});
return [...notUpdated, ...annotations].sort((a, b) => a.positions[0].page - b.positions[0].page);
});
}),
tap(() => this.#logger.info('[REDACTION_LOG] Annotations updated', this.#annotations())),
);
}
#checkMissingTypes() {

View File

@ -1,15 +1,15 @@
import { inject, Injectable } from '@angular/core';
import { GenericService, isIqserDevMode, Toaster } from '@iqser/common-ui';
import { GenericService, Toaster } from '@iqser/common-ui';
import { EntryStates, IEntityLog, IEntityLogEntry, ISectionGrid } from '@red/domain';
import { firstValueFrom, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { catchError, map } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class EntityLogService extends GenericService<unknown> {
protected readonly _defaultModelPath = '';
readonly #toaster = inject(Toaster);
protected readonly _defaultModelPath = '';
async getEntityLog(dossierId: string, fileId: string) {
const queryParams = [{ key: 'includeUnprocessed', value: true }];
@ -20,6 +20,17 @@ export class EntityLogService extends GenericService<unknown> {
return entityLog;
}
getDelta(dossierId: string, fileId: string, analysisNumber: number) {
const req$ = this._getOne<IEntityLog>([dossierId, fileId, analysisNumber.toString()], 'entityLog');
return req$.pipe(
map(entityLog => {
entityLog.entityLogEntry = this.#filterInvalidEntries(entityLog.entityLogEntry);
return entityLog;
}),
catchError(() => of({} as IEntityLog)),
);
}
getSectionGrid(dossierId: string, fileId: string) {
return this._getOne<ISectionGrid>([dossierId, fileId], 'sectionGrid');
}

View File

@ -0,0 +1,60 @@
import { inject, Injectable } from '@angular/core';
import { TenantsService } from '@common-ui/tenants';
import { log } from '@common-ui/utils';
import { getConfig } from '@iqser/common-ui';
import { IMessage, IWatchParams, RxStomp } from '@stomp/rx-stomp';
import { StompHeaders } from '@stomp/stompjs';
import { NGXLogger } from 'ngx-logger';
import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class WebSocketService extends RxStomp {
readonly #logger = inject(NGXLogger);
readonly #config = getConfig();
readonly #tenantService = inject(TenantsService);
constructor() {
super();
setTimeout(() => this.connect(), 1000);
}
watch(opts: IWatchParams): Observable<IMessage>;
watch(destination: string, headers?: StompHeaders): Observable<IMessage>;
watch(opts: string | IWatchParams, headers?: StompHeaders): Observable<IMessage> {
if (typeof opts === 'string') {
return super.watch('/topic/' + this.#tenantService.activeTenantId + '/' + opts, headers);
}
return super.watch(opts);
}
listen<T>(topic: string): Observable<T> {
return this.watch(topic).pipe(map(msg => JSON.parse(msg.body)));
}
private connect() {
const headers = { Authorization: 'Bearer ' + localStorage.getItem('token') };
this.configure({
debug: (msg: string) => this.#logger.debug(msg),
brokerURL: this.#config.API_URL + '/redaction-gateway-v1/websocket',
connectHeaders: headers,
});
this.connectionState$.pipe(log('[WS] Connection state')).subscribe();
this.webSocketErrors$.pipe(log('[WS] Errors')).subscribe();
this.stompErrors$
.pipe(
tap(frame => {
console.error(frame);
console.error('Broker reported error: ' + frame.headers['message']);
console.error('Additional details: ' + frame.body);
}),
)
.subscribe();
this.activate();
}
}

View File

@ -31,3 +31,4 @@ export * from './lib/colors';
export * from './lib/component-log';
export * from './lib/component-mappings';
export * from './lib/component-definitions';
export * from './lib/web-socket';

View File

@ -0,0 +1,16 @@
export const AnalyseStatuses = {
PROCESSING: 'PROCESSING',
FINISHED: 'FINISHED',
} as const;
export type AnalyseStatus = keyof typeof AnalyseStatuses;
export interface AnalysisEvent {
analyseStatus: AnalyseStatus;
analysisNumber: number;
dossierId: string;
fileId: string;
numberOfOCRedPages: number;
numberOfPagesToOCR: number;
timestamp: string;
}

View File

@ -0,0 +1,2 @@
export * from './analysis-event';
export * from './topics';

View File

@ -0,0 +1,3 @@
export const WsTopics = {
ANALYSIS: 'analysis-events',
} as const;

View File

@ -35,12 +35,14 @@
"@ngx-translate/core": "15.0.0",
"@ngx-translate/http-loader": "8.0.0",
"@pdftron/webviewer": "10.10.1",
"@stomp/rx-stomp": "^2.0.0",
"@stomp/stompjs": "^7.0.0",
"chart.js": "4.4.3",
"dayjs": "1.11.11",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"jwt-decode": "^4.0.0",
"keycloak-angular": "15.1.0",
"keycloak-angular": "16.0.1",
"keycloak-js": "23.0.1",
"lodash-es": "^4.17.21",
"monaco-editor": "0.49.0",
@ -100,5 +102,6 @@
"webpack": "5.92.0",
"webpack-bundle-analyzer": "4.10.2",
"xliff": "^6.2.1"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@ -3396,6 +3396,16 @@
dependencies:
"@sinonjs/commons" "^3.0.0"
"@stomp/rx-stomp@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@stomp/rx-stomp/-/rx-stomp-2.0.0.tgz#5d75c87db280d2af9da7fccd3478c682df312065"
integrity sha512-3UxTxAA3NWGnwFfIvN8AigJ7BxGXG0u5IK8K12mQ9cCMuaT/MM7xlyZnuV8sDbHiqqLlbwA1wk1fDfUyOTIeug==
"@stomp/stompjs@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@stomp/stompjs/-/stompjs-7.0.0.tgz#46b5c454a9dc8262e0b20f3b3dbacaa113993077"
integrity sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw==
"@tootallnate/once@2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
@ -7442,10 +7452,10 @@ karma-source-map-support@1.4.0:
dependencies:
source-map-support "^0.5.5"
keycloak-angular@15.1.0:
version "15.1.0"
resolved "https://registry.yarnpkg.com/keycloak-angular/-/keycloak-angular-15.1.0.tgz#56d25025ace2596ea8265e7158b66b2fb20054d1"
integrity sha512-9Wz1jEalUXeq3v88MMYEcFnF2GwUht1slMbDau8lpNEe0Wp9xcv5/NpMUP0RjsHKmNg8cX47BUsxL27Ypy7pmA==
keycloak-angular@16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/keycloak-angular/-/keycloak-angular-16.0.1.tgz#9dd30e36d5320db35cf1bdb681be5552ba1104ce"
integrity sha512-ytkL32R/tfHEyZ3txQtgH1y0WofW/D36zTbo2agDCYUtZETq0wAQ3E/4bVDUAr6ZKwotgAnIyOORfErnvDkXng==
dependencies:
tslib "^2.3.1"