RED-3623: skip reloading redactions multiple times

This commit is contained in:
Dan Percic 2022-03-15 19:09:07 +02:00
parent 7b0d460f06
commit 2b3779a911
11 changed files with 311 additions and 53 deletions

View File

@ -1,9 +1,9 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { AnnotationReferencesService } from '../../services/annotation-references.service';
import { FilePreviewStateService } from '../../services/file-preview-state.service';
import { combineLatest, Observable } from 'rxjs';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { FileDataService } from '../../services/file-data.service';
@Component({
selector: 'redaction-annotation-references-list',
@ -16,16 +16,12 @@ export class AnnotationReferencesListComponent {
@Output() readonly referenceClicked = new EventEmitter<AnnotationWrapper>();
references$ = this._annotationReferences;
constructor(
readonly annotationReferencesService: AnnotationReferencesService,
private readonly _filePreviewStateService: FilePreviewStateService,
) {}
constructor(readonly annotationReferencesService: AnnotationReferencesService, private readonly _fileDataService: FileDataService) {}
private get _annotationReferences(): Observable<AnnotationWrapper[]> {
const combination = combineLatest([this.annotationReferencesService.annotation$, this._filePreviewStateService.fileData$]);
return combination.pipe(
filter(([annotation]) => !!annotation),
map(([{ reference }, fileData]) => fileData.allAnnotations.filter(a => reference.includes(a.annotationId))),
return this.annotationReferencesService.annotation$.pipe(
filter(annotation => !!annotation),
map(({ reference }) => this._fileDataService.allAnnotations.filter(a => reference.includes(a.annotationId))),
);
}

View File

@ -91,7 +91,7 @@
<mat-icon svgIcon="red:nav-first"></mat-icon>
</div>
<div *ngIf="state.fileData$ | async as fileData" class="pages" id="pages">
<div class="pages" id="pages">
<redaction-page-indicator
(pageSelected)="pageSelectedByClick($event)"
*ngFor="let pageNumber of displayedPages"
@ -99,7 +99,7 @@
[active]="pageNumber === activeViewerPage"
[number]="pageNumber"
[showDottedIcon]="hasOnlyManualRedactionsAndIsExcluded(pageNumber)"
[viewedPages]="fileData.viewedPages"
[viewedPages]="fileDataService.viewedPages"
></redaction-page-indicator>
</div>

View File

@ -34,6 +34,7 @@ import { SkippedService } from '../../services/skipped.service';
import { FilePreviewStateService } from '../../services/file-preview-state.service';
import { ViewModeService } from '../../services/view-mode.service';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { FileDataService } from '../../services/file-data.service';
const COMMAND_KEY_ARRAY = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Escape'];
const ALL_HOTKEY_ARRAY = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
@ -76,6 +77,7 @@ export class FileWorkloadComponent {
readonly multiSelectService: MultiSelectService,
readonly documentInfoService: DocumentInfoService,
readonly excludedPagesService: ExcludedPagesService,
readonly fileDataService: FileDataService,
private readonly _viewModeService: ViewModeService,
private readonly _changeDetectorRef: ChangeDetectorRef,
private readonly _annotationProcessingService: AnnotationProcessingService,

View File

@ -3,7 +3,8 @@ import { ViewMode } from '@red/domain';
import { ViewModeService } from '../../services/view-mode.service';
import { FilePreviewStateService } from '../../services/file-preview-state.service';
import { combineLatest, Observable } from 'rxjs';
import { filter, map, switchMap } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import { FileDataService } from '../../services/file-data.service';
@Component({
selector: 'redaction-view-switch',
@ -17,14 +18,13 @@ export class ViewSwitchComponent {
readonly canSwitchToDeltaView$: Observable<boolean>;
readonly canSwitchToRedactedView$: Observable<boolean>;
constructor(readonly viewModeService: ViewModeService, private readonly _stateService: FilePreviewStateService) {
this.canSwitchToDeltaView$ = _stateService.fileData$.pipe(
filter(fileData => !!fileData),
switchMap(fileData =>
combineLatest([fileData.hasChangeLog$, _stateService.file$]).pipe(
map(([hasChangeLog, file]) => hasChangeLog && !file.isApproved),
),
),
constructor(
readonly viewModeService: ViewModeService,
private readonly _stateService: FilePreviewStateService,
private readonly _fileDataService: FileDataService,
) {
this.canSwitchToDeltaView$ = combineLatest([_fileDataService.hasChangeLog$, _stateService.file$]).pipe(
map(([hasChangeLog, file]) => hasChangeLog && !file.isApproved),
);
this.canSwitchToRedactedView$ = _stateService.file$.pipe(map(file => !file.analysisRequired && !file.excluded));

View File

@ -14,6 +14,7 @@ import { AnnotationProcessingService } from '../dossier/services/annotation-proc
import { dossiersServiceProvider } from '@services/entity-services/dossiers.service.provider';
import { PageRotationService } from './services/page-rotation.service';
import { PdfViewer } from './services/pdf-viewer.service';
import { FileDataService } from './services/file-data.service';
export const filePreviewScreenProviders = [
FilterService,
@ -31,5 +32,6 @@ export const filePreviewScreenProviders = [
PageRotationService,
PdfViewer,
AnnotationProcessingService,
FileDataService,
dossiersServiceProvider,
];

View File

@ -42,7 +42,6 @@ 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';
@ -50,6 +49,7 @@ import { PageRotationService } from './services/page-rotation.service';
import { ComponentCanDeactivate } from '../../guards/can-deactivate.guard';
import { PdfViewer } from './services/pdf-viewer.service';
import { FilePreviewDialogService } from './services/file-preview-dialog.service';
import { FileDataService } from './services/file-data.service';
import Annotation = Core.Annotations.Annotation;
import PDFNet = Core.PDFNet;
@ -106,6 +106,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
private readonly _errorService: ErrorService,
private readonly _pageRotationService: PageRotationService,
private readonly _skippedService: SkippedService,
private readonly _fileDataService: FileDataService,
private readonly _pdf: PdfViewer,
private readonly _manualAnnotationService: ManualAnnotationService,
readonly excludedPagesService: ExcludedPagesService,
@ -128,15 +129,11 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
}
get visibleAnnotations(): AnnotationWrapper[] {
return this._fileData ? this._fileData.getVisibleAnnotations(this._viewModeService.viewMode) : [];
return this._fileDataService.getVisibleAnnotations(this._viewModeService.viewMode);
}
get allAnnotations(): AnnotationWrapper[] {
return this._fileData ? this._fileData.allAnnotations : [];
}
private get _fileData(): FileDataModel {
return this.stateService.fileData;
return this._fileDataService.allAnnotations;
}
private get _canPerformAnnotationActions$() {
@ -157,14 +154,14 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
return;
}
const textHighlightAnnotationIds = this._fileData.textHighlightAnnotations.map(a => a.id);
const textHighlightAnnotationIds = this._fileDataService.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 ocrAnnotationIds = this.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'));
@ -201,8 +198,8 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
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._fileDataService.textHighlights = textHighlights;
await this._annotationDrawService.drawAnnotations(this._fileDataService.textHighlightAnnotations);
this._loadingService.stop();
}
}
@ -266,7 +263,10 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
this._filterService.addFilterGroup({
slug: 'secondaryFilters',
filterTemplate: this._filterTemplate,
filters: processFilters(secondaryFilters, AnnotationProcessingService.secondaryAnnotationFilters(this._fileData?.viewedPages)),
filters: processFilters(
secondaryFilters,
AnnotationProcessingService.secondaryAnnotationFilters(this._fileDataService.viewedPages),
),
});
console.log(`[REDACTION] Process time: ${new Date().getTime() - processStartTime} ms`);
console.log(`[REDACTION] Filter rebuild time: ${new Date().getTime() - startTime}`);
@ -279,7 +279,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
if (this.selectedAnnotations.length > 1) {
this.multiSelectService.activate();
}
this._workloadComponent.scrollToSelectedAnnotation();
this._workloadComponent?.scrollToSelectedAnnotation();
this._changeDetectorRef.markForCheck();
}
@ -344,7 +344,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
handleArrowEvent($event: KeyboardEvent): void {
if (['ArrowUp', 'ArrowDown'].includes($event.key)) {
if (this.selectedAnnotations.length === 1) {
this._workloadComponent.navigateAnnotations($event);
this._workloadComponent?.navigateAnnotations($event);
}
}
}
@ -585,7 +585,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
return;
}
this.stateService.fileData = await firstValueFrom(this._pdfViewerDataService.loadDataFor(file));
await this._fileDataService.load(file);
}
@Debounce(0)
@ -595,6 +595,10 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
}
private async _reloadAnnotations() {
if (!this._fileDataService.shouldUpdateAnnotations) {
console.log('skip reloading annotations');
return;
}
this._deleteAnnotations();
await this._cleanupAndRedrawAnnotations();
await this.updateViewMode();
@ -609,7 +613,9 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
}
const currentPageAnnotations = this.visibleAnnotations.filter(a => a.pageNumber === page);
this._fileData.redactionLog = await firstValueFrom(this._pdfViewerDataService.loadRedactionLogFor(this.dossierId, this.fileId));
await this._fileDataService.setRedactionLog(
await firstValueFrom(this._pdfViewerDataService.loadRedactionLogFor(this.dossierId, this.fileId)),
);
this._deleteAnnotations(currentPageAnnotations);
await this._cleanupAndRedrawAnnotations(annotation => annotation.pageNumber === page);
@ -637,7 +643,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
this.rebuildFilters();
const startTime = new Date().getTime();
const annotations = this._fileData.allAnnotations;
const annotations = this.allAnnotations;
const newAnnotations = newAnnotationsFilter ? annotations.filter(newAnnotationsFilter) : annotations;
if (currentFilters) {

View File

@ -0,0 +1,259 @@
import {
ChangeType,
File,
IRedactionLog,
IRedactionLogEntry,
IViewedPage,
LogEntryStatus,
ManualRedactionType,
TextHighlightResponse,
ViewMode,
} from '@red/domain';
import { AnnotationWrapper } from '../../../models/file/annotation.wrapper';
import * as moment from 'moment';
import { BehaviorSubject, firstValueFrom, of } from 'rxjs';
import { RedactionLogEntry } from '../../../models/file/redaction-log.entry';
import { Injectable } from '@angular/core';
import { FilePreviewStateService } from './file-preview-state.service';
import { ViewedPagesService } from '../../../services/entity-services/viewed-pages.service';
import { UserPreferenceService } from '../../../services/user-preference.service';
import { DictionariesMapService } from '../../../services/entity-services/dictionaries-map.service';
import { catchError, tap } from 'rxjs/operators';
import { PermissionsService } from '../../../services/permissions.service';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { Toaster } from '../../../../../../../libs/common-ui/src';
import { RedactionLogService } from '../../dossier/services/redaction-log.service';
@Injectable()
export class FileDataService {
static readonly DELTA_VIEW_TIME = 10 * 60 * 1000; // 10 minutes;
viewedPages: IViewedPage[] = [];
allAnnotations: AnnotationWrapper[] = [];
readonly hasChangeLog$ = new BehaviorSubject<boolean>(false);
missingTypes = new Set<string>();
textHighlightAnnotations: AnnotationWrapper[] = [];
shouldUpdateAnnotations = false;
#redactionLog: IRedactionLog;
#redactionLogHash = '';
constructor(
private readonly _state: FilePreviewStateService,
private readonly _viewedPagesService: ViewedPagesService,
private readonly _userPreferenceService: UserPreferenceService,
private readonly _dictionariesMapService: DictionariesMapService,
private readonly _permissionsService: PermissionsService,
private readonly _redactionLogService: RedactionLogService,
private readonly _toaster: Toaster,
) {}
set textHighlights(textHighlightResponse: TextHighlightResponse) {
const highlights = [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
for (const color of Object.keys(textHighlightResponse.redactionPerColor)) {
for (const entry of textHighlightResponse.redactionPerColor[color]) {
const annotation = AnnotationWrapper.fromHighlight(color, entry);
highlights.push(annotation);
}
}
this.textHighlightAnnotations = highlights;
}
async setRedactionLog(redactionLog: IRedactionLog) {
this.#redactionLog = redactionLog;
const hash = require('object-hash');
const newRedactionLogHash = hash(redactionLog.redactionLogEntry ?? []);
if (newRedactionLogHash === this.#redactionLogHash) {
this.shouldUpdateAnnotations = false;
return;
}
this.shouldUpdateAnnotations = true;
this.#redactionLogHash = newRedactionLogHash;
await this.#buildAllAnnotations();
}
async load(file: File) {
this.viewedPages = await firstValueFrom(this.getViewedPagesFor(file));
await this.setRedactionLog(await firstValueFrom(this.loadRedactionLog()));
if (this.missingTypes.size > 0) {
this._toaster.error(_('error.missing-types'), {
disableTimeOut: true,
params: { missingTypes: Array.from(this.missingTypes).join(', ') },
});
}
}
getViewedPagesFor(file: File) {
if (this._permissionsService.canMarkPagesAsViewed(file)) {
return this._viewedPagesService.getViewedPages(file.dossierId, file.fileId);
}
return of([] as IViewedPage[]);
}
loadRedactionLog() {
return this._redactionLogService.getRedactionLog(this._state.dossierId, this._state.fileId).pipe(
tap(redactionLog => redactionLog.redactionLogEntry.sort((a, b) => a.positions[0].page - b.positions[0].page)),
catchError(() => of({})),
);
}
getVisibleAnnotations(viewMode: ViewMode) {
if (viewMode === 'TEXT_HIGHLIGHTS') {
return this.textHighlightAnnotations;
}
return this.allAnnotations.filter(annotation => {
if (viewMode === 'STANDARD') {
return !annotation.isChangeLogRemoved;
} else if (viewMode === 'DELTA') {
return annotation.isChangeLogEntry;
} else {
return annotation.previewAnnotation;
}
});
}
async #buildAllAnnotations() {
const file = await this._state.file;
const entries: RedactionLogEntry[] = this.#convertData(file);
const previousAnnotations = [...this.allAnnotations];
this.allAnnotations = entries
.map(entry => AnnotationWrapper.fromData(entry))
.filter(ann => ann.manual || !file.excludedPages.includes(ann.pageNumber));
if (!this._userPreferenceService.areDevFeaturesEnabled) {
this.allAnnotations = this.allAnnotations.filter(annotation => !annotation.isFalsePositive);
}
this._setHiddenPropertyToNewAnnotations(this.allAnnotations, previousAnnotations);
}
private _setHiddenPropertyToNewAnnotations(newAnnotations: AnnotationWrapper[], oldAnnotations: AnnotationWrapper[]) {
newAnnotations.forEach(newAnnotation => {
const oldAnnotation = oldAnnotations.find(a => a.annotationId === newAnnotation.annotationId);
if (oldAnnotation) {
newAnnotation.hidden = oldAnnotation.hidden;
}
});
}
#convertData(file: File): RedactionLogEntry[] {
let result: RedactionLogEntry[] = [];
const reasonAnnotationIds: { [key: string]: RedactionLogEntry[] } = {};
const _dictionaryData = this._dictionariesMapService.get(this._state.dossierTemplateId);
this.#redactionLog.redactionLogEntry?.forEach(redactionLogEntry => {
// copy the redactionLog Entry
const changeLogValues = this.#getChangeLogValues(redactionLogEntry, file);
const dictionaryData = _dictionaryData.find(dict => dict.type === redactionLogEntry.type);
if (!dictionaryData) {
this.missingTypes.add(redactionLogEntry.type);
return;
}
const redactionLogEntryWrapper: RedactionLogEntry = new RedactionLogEntry(
redactionLogEntry,
changeLogValues.changeLogType,
changeLogValues.isChangeLogEntry,
changeLogValues.hidden,
this.#redactionLog.legalBasis,
!!dictionaryData?.hint,
);
if (
redactionLogEntry.manualChanges?.find(
mc =>
mc.manualRedactionType === ManualRedactionType.ADD_TO_DICTIONARY &&
(mc.annotationStatus === LogEntryStatus.APPROVED || mc.annotationStatus === LogEntryStatus.REQUESTED),
)
) {
// for dictionary entries -> I.E accepted recommendations or false positives,
// check reason
if (!reasonAnnotationIds[redactionLogEntry.reason]) {
reasonAnnotationIds[redactionLogEntry.reason] = [redactionLogEntryWrapper];
} else {
reasonAnnotationIds[redactionLogEntry.reason].push(redactionLogEntryWrapper);
}
}
result.push(redactionLogEntryWrapper);
});
const reasonKeys = Object.keys(reasonAnnotationIds);
result = result.filter(r => {
const matched = reasonKeys.indexOf(r.id) >= 0;
if (matched) {
reasonAnnotationIds[r.id].forEach(value => {
value.reason = null;
});
}
return !matched;
});
result = result.filter(r => !r.hidden);
return result;
}
#getChangeLogValues(
redactionLogEntry: IRedactionLogEntry,
file: File,
): {
hidden: boolean;
changeLogType: ChangeType;
isChangeLogEntry: boolean;
} {
if (file.numberOfAnalyses > 1) {
const viableChanges = redactionLogEntry.changes.filter(c => c.analysisNumber > 1);
viableChanges.sort((a, b) => moment(a.dateTime).valueOf() - moment(b.dateTime).valueOf());
const lastChange = viableChanges.length >= 1 ? viableChanges[viableChanges.length - 1] : undefined;
const page = redactionLogEntry.positions?.[0].page;
const viewedPage = this.viewedPages.filter(p => p.page === page).pop();
// page has been seen -> let's see if it's a change
if (viewedPage) {
const viewTime = moment(viewedPage.viewedTime).valueOf() - FileDataService.DELTA_VIEW_TIME;
// these are all unseen changes
const relevantChanges = viableChanges.filter(change => moment(change.dateTime).valueOf() > viewTime);
// at least one unseen change
if (relevantChanges.length > 0) {
// at least 1 relevant change
viewedPage.showAsUnseen = moment(viewedPage.viewedTime).valueOf() < moment(lastChange.dateTime).valueOf();
this.hasChangeLog$.next(true);
return {
changeLogType: relevantChanges[relevantChanges.length - 1].type,
isChangeLogEntry: true,
hidden: false,
};
} else {
// no relevant changes - hide removed anyway
return {
changeLogType: null,
isChangeLogEntry: false,
hidden: lastChange && lastChange.type === 'REMOVED',
};
}
} else {
// Page doesn't have a view-time
return {
changeLogType: null,
isChangeLogEntry: false,
hidden: lastChange && lastChange.type === 'REMOVED',
};
}
} else {
return {
changeLogType: null,
isChangeLogEntry: false,
hidden: false,
};
}
// console.log(wrapper.changeLogType, wrapper.hidden, wrapper.isChangeLogEntry, wrapper.value, lastChange);
}
}

View File

@ -1,6 +1,5 @@
import { Injectable, Injector } from '@angular/core';
import { BehaviorSubject, firstValueFrom, from, Observable, pairwise, switchMap } from 'rxjs';
import { FileDataModel } from '@models/file/file-data.model';
import { firstValueFrom, from, Observable, pairwise, switchMap } from 'rxjs';
import { Dossier, File } from '@red/domain';
import { ActivatedRoute } from '@angular/router';
import { FilesMapService } from '@services/entity-services/files-map.service';
@ -20,14 +19,11 @@ export class FilePreviewStateService {
readonly dossier$: Observable<Dossier>;
readonly isReadonly$: Observable<boolean>;
readonly isWritable$: Observable<boolean>;
readonly fileData$: Observable<FileDataModel>;
readonly dossierId: string;
readonly dossierTemplateId: string;
readonly fileId: string;
readonly #fileData$ = new BehaviorSubject<FileDataModel | undefined>(undefined);
constructor(
private readonly _fileManagementService: FileManagementService,
private readonly _injector: Injector,
@ -43,18 +39,9 @@ export class FilePreviewStateService {
this.file$ = _filesMapService.watch$(this.dossierId, this.fileId);
[this.isReadonly$, this.isWritable$] = boolFactory(this.file$, file => !_permissionsService.canPerformAnnotationActions(file));
this.fileData$ = this.#fileData$.asObservable().pipe(filter(value => !!value));
this.blob$ = this.#blob$;
}
get fileData(): FileDataModel {
return this.#fileData$.value;
}
set fileData(fileDataModel: FileDataModel) {
this.#fileData$.next(fileDataModel);
}
get file(): Promise<File> {
return firstValueFrom(this.file$);
}

View File

@ -26,7 +26,7 @@ export class ViewedPagesService extends GenericService<unknown> {
getViewedPages(@RequiredParam() dossierId: string, @RequiredParam() fileId: string) {
return this._getOne<{ pages?: IViewedPage[] }>([dossierId, fileId]).pipe(
map(res => res.pages),
catchError(() => of([])),
catchError(() => of([] as IViewedPage[])),
);
}
}

View File

@ -53,6 +53,7 @@
"ngx-color-picker": "^12.0.1",
"ngx-toastr": "^14.1.3",
"ngx-translate-messageformat-compiler": "^5.0.1",
"object-hash": "^3.0.0",
"papaparse": "^5.3.1",
"rxjs": "~7.5.2",
"sass": "^1.49.0",

View File

@ -9183,6 +9183,11 @@ object-copy@^0.1.0:
define-property "^0.2.5"
kind-of "^3.0.3"
object-hash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
object-inspect@^1.11.0, object-inspect@^1.9.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0"