From 62d6c30ee1b4c43cb465e5af5be8a45d63da9b7b Mon Sep 17 00:00:00 2001 From: Martin Tan Date: Thu, 18 May 2023 02:23:09 +0800 Subject: [PATCH] Prepare explorer page --- src-ui/src/app/app-routing.module.ts | 25 +- src-ui/src/app/app.module.ts | 4 + .../app-frame/app-frame.component.html | 9 +- .../explorer/explorer.component.html | 233 +++++ .../explorer/explorer.component.scss | 82 ++ .../components/explorer/explorer.component.ts | 243 +++++ .../filter-editor.component.html | 85 ++ .../filter-editor.component.scss | 27 + .../filter-editor/filter-editor.component.ts | 856 ++++++++++++++++++ 9 files changed, 1557 insertions(+), 7 deletions(-) create mode 100644 src-ui/src/app/components/explorer/explorer.component.html create mode 100644 src-ui/src/app/components/explorer/explorer.component.scss create mode 100644 src-ui/src/app/components/explorer/explorer.component.ts create mode 100644 src-ui/src/app/components/explorer/filter-editor/filter-editor.component.html create mode 100644 src-ui/src/app/components/explorer/filter-editor/filter-editor.component.scss create mode 100644 src-ui/src/app/components/explorer/filter-editor/filter-editor.component.ts diff --git a/src-ui/src/app/app-routing.module.ts b/src-ui/src/app/app-routing.module.ts index 4d12ee4f3..35bb38e13 100644 --- a/src-ui/src/app/app-routing.module.ts +++ b/src-ui/src/app/app-routing.module.ts @@ -1,22 +1,23 @@ import { NgModule } from '@angular/core' -import { Routes, RouterModule } from '@angular/router' +import { RouterModule, Routes } from '@angular/router' import { AppFrameComponent } from './components/app-frame/app-frame.component' import { DashboardComponent } from './components/dashboard/dashboard.component' +import { DocumentAsnComponent } from './components/document-asn/document-asn.component' import { DocumentDetailComponent } from './components/document-detail/document-detail.component' import { DocumentListComponent } from './components/document-list/document-list.component' +import { ExplorerComponent } from './components/explorer/explorer.component' import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component' import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component' import { LogsComponent } from './components/manage/logs/logs.component' import { SettingsComponent } from './components/manage/settings/settings.component' -import { TagListComponent } from './components/manage/tag-list/tag-list.component' -import { NotFoundComponent } from './components/not-found/not-found.component' -import { DocumentAsnComponent } from './components/document-asn/document-asn.component' -import { DirtyFormGuard } from './guards/dirty-form.guard' import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' +import { TagListComponent } from './components/manage/tag-list/tag-list.component' import { TasksComponent } from './components/manage/tasks/tasks.component' -import { PermissionsGuard } from './guards/permissions.guard' +import { NotFoundComponent } from './components/not-found/not-found.component' import { DirtyDocGuard } from './guards/dirty-doc.guard' +import { DirtyFormGuard } from './guards/dirty-form.guard' import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' +import { PermissionsGuard } from './guards/permissions.guard' import { PermissionAction, PermissionType, @@ -30,6 +31,18 @@ const routes: Routes = [ canDeactivate: [DirtyDocGuard], children: [ { path: 'dashboard', component: DashboardComponent }, + { + path: 'explorer', + component: ExplorerComponent, + canDeactivate: [DirtySavedViewGuard], + canActivate: [PermissionsGuard], + data: { + requiredPermission: { + action: PermissionAction.View, + type: PermissionType.Document, + }, + }, + }, { path: 'documents', component: DocumentListComponent, diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index 446f63254..ead9eb897 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -9,6 +9,7 @@ import { } from '@ng-bootstrap/ng-bootstrap' import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http' import { DocumentListComponent } from './components/document-list/document-list.component' +import { ExplorerComponent } from './components/explorer/explorer.component' import { DocumentDetailComponent } from './components/document-detail/document-detail.component' import { DashboardComponent } from './components/dashboard/dashboard.component' import { TagListComponent } from './components/manage/tag-list/tag-list.component' @@ -29,6 +30,7 @@ import { PageHeaderComponent } from './components/common/page-header/page-header import { AppFrameComponent } from './components/app-frame/app-frame.component' import { ToastsComponent } from './components/common/toasts/toasts.component' import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component' +import { FilterEditorComponent as ExplorerFilterEditorComponent } from './components/explorer/filter-editor/filter-editor.component' import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component' import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component' import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component' @@ -144,6 +146,7 @@ function initializeApp(settings: SettingsService) { declarations: [ AppComponent, DocumentListComponent, + ExplorerComponent, DocumentDetailComponent, DashboardComponent, TagListComponent, @@ -164,6 +167,7 @@ function initializeApp(settings: SettingsService) { AppFrameComponent, ToastsComponent, FilterEditorComponent, + ExplorerFilterEditorComponent, FilterableDropdownComponent, ToggleableDropdownButtonComponent, DateDropdownComponent, diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index bb5b2e206..774257f11 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -72,9 +72,16 @@ + diff --git a/src-ui/src/app/components/explorer/explorer.component.html b/src-ui/src/app/components/explorer/explorer.component.html new file mode 100644 index 000000000..bea7aacea --- /dev/null +++ b/src-ui/src/app/components/explorer/explorer.component.html @@ -0,0 +1,233 @@ + + +
+ +
+ + + +
+
+
+ + + + + + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + + +
+

+ +

+ Loading... + + {list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}} + + {list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}} (filtered) + +

+ +
+
+ +
+ +
+ + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ASNCorrespondentTitleNotesDocument typeStorage pathCreatedAdded
+
+ + +
+
+ {{d.archive_serial_number}} + + + {{(d.correspondent$ | async)?.name}} + + + {{d.title | documentTitle}} + + + + + + {{d.notes.length}} + + + + {{(d.document_type$ | async)?.name}} + + + + {{(d.storage_path$ | async)?.name}} + + + {{d.created_date | customDate}} + + {{d.added | customDate}} +
+ +
+ +
+
+ +
+ + +
diff --git a/src-ui/src/app/components/explorer/explorer.component.scss b/src-ui/src/app/components/explorer/explorer.component.scss new file mode 100644 index 000000000..3be54ae15 --- /dev/null +++ b/src-ui/src/app/components/explorer/explorer.component.scss @@ -0,0 +1,82 @@ +::ng-deep app-document-list app-page-header > div.mb-3 { + margin-bottom: 0 !important; +} + +tr { + user-select: none; +} + +th { + cursor: pointer; +} + +.table-row-selected { + background-color: var(--pngx-primary-faded); +} + +$paperless-card-breakpoints: ( + // 0: 2, // xs is manual for slim-sidebar + 768px: 3, //md + 992px: 4, //lg + 1200px: 5, //xl + 1400px: 6, // xxl + 1600px: 7, + 1800px: 8, + 2000px: 9 +); + +.row-cols-paperless-cards { + // xs, we dont want in .col-slim block + > * { + flex: 0 0 auto; + width: calc(100% / 2); + } + + @each $width, $n_cols in $paperless-card-breakpoints { + @media(min-width: $width) { + > * { + flex: 0 0 auto; + width: calc(100% / $n-cols); + } + } + } +} + +::ng-deep .col-slim .row-cols-paperless-cards { + @each $width, $n_cols in $paperless-card-breakpoints { + @media(min-width: $width) { + > * { + flex: 0 0 auto; + width: calc(100% / ($n-cols + 1)) !important; + } + } + } +} + +.dropdown-menu-right { + right: 0 !important; + left: auto !important; +} + +.sticky-top { + z-index: 990; // below main navbar + top: calc(7rem - 2px); // height of navbar (mobile) + + @media (min-width: 580px) { + top: 3.5rem; // height of navbar + } +} + +.table .form-check { + padding: 0.2rem; + min-height: 0; + margin-bottom: 0; + + .form-check-input { + margin-left: 0; + } +} + +a { + cursor: pointer; +} diff --git a/src-ui/src/app/components/explorer/explorer.component.ts b/src-ui/src/app/components/explorer/explorer.component.ts new file mode 100644 index 000000000..99f98536a --- /dev/null +++ b/src-ui/src/app/components/explorer/explorer.component.ts @@ -0,0 +1,243 @@ +import { + Component, + OnDestroy, + OnInit, + QueryList, + ViewChild, + ViewChildren, +} from '@angular/core' +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs' +import { + FilterRule, + filterRulesDiffer, + isFullTextFilterRule, +} from 'src/app/data/filter-rule' +import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' +import { PaperlessDocument } from 'src/app/data/paperless-document' +import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' +import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' +import { + SortableDirective, + SortEvent, +} from 'src/app/directives/sortable.directive' +import { ConsumerStatusService } from 'src/app/services/consumer-status.service' +import { DocumentListViewService } from 'src/app/services/document-list-view.service' +import { OpenDocumentsService } from 'src/app/services/open-documents.service' +import { + DOCUMENT_SORT_FIELDS, + DOCUMENT_SORT_FIELDS_FULLTEXT, +} from 'src/app/services/rest/document.service' +import { SavedViewService } from 'src/app/services/rest/saved-view.service' +import { SettingsService } from 'src/app/services/settings.service' +import { ToastService } from 'src/app/services/toast.service' +import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' +import { FilterEditorComponent } from './filter-editor/filter-editor.component' + +@Component({ + selector: 'app-explorer', + templateUrl: './explorer.component.html', + styleUrls: ['./explorer.component.scss'], +}) +export class ExplorerComponent + extends ComponentWithPermissions + implements OnInit, OnDestroy +{ + constructor( + public list: DocumentListViewService, + public savedViewService: SavedViewService, + public route: ActivatedRoute, + private router: Router, + private toastService: ToastService, + private modalService: NgbModal, + private consumerStatusService: ConsumerStatusService, + public openDocumentsService: OpenDocumentsService, + private settingsService: SettingsService + ) { + super() + } + + @ViewChild('filterEditor') + private filterEditor: FilterEditorComponent + + @ViewChildren(SortableDirective) headers: QueryList + + displayMode = 'smallCards' // largeCards, smallCards, details + + unmodifiedFilterRules: FilterRule[] = [] + private unmodifiedSavedView: PaperlessSavedView + + private unsubscribeNotifier: Subject = new Subject() + + get savedViewIsModified(): boolean { + if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false + else { + return ( + this.unmodifiedSavedView.sort_field !== this.list.sortField || + this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse || + filterRulesDiffer( + this.unmodifiedSavedView.filter_rules, + this.list.filterRules + ) + ) + } + } + + get isFiltered() { + return this.list.filterRules?.length > 0 + } + + getTitle() { + let title = this.list.activeSavedViewTitle + if (title && this.savedViewIsModified) { + title += '*' + } else if (!title) { + title = $localize`File Explorer` + } + return title + } + + getSortFields() { + return isFullTextFilterRule(this.list.filterRules) + ? DOCUMENT_SORT_FIELDS_FULLTEXT + : DOCUMENT_SORT_FIELDS + } + + set listSortReverse(reverse: boolean) { + this.list.sortReverse = reverse + } + + get listSortReverse(): boolean { + return this.list.sortReverse + } + + setSortField(field: string) { + this.list.sortField = field + } + + onSort(event: SortEvent) { + this.list.setSort(event.column, event.reverse) + } + + get isBulkEditing(): boolean { + return this.list.selected.size > 0 + } + + saveDisplayMode() { + localStorage.setItem('document-list:displayMode', this.displayMode) + } + + ngOnInit(): void { + if (localStorage.getItem('document-list:displayMode') != null) { + this.displayMode = localStorage.getItem('document-list:displayMode') + } + + this.consumerStatusService + .onDocumentConsumptionFinished() + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => { + this.list.reload() + }) + + this.route.paramMap + .pipe( + filter((params) => params.has('id')), // only on saved view e.g. /view/id + switchMap((params) => { + return this.savedViewService + .getCached(+params.get('id')) + .pipe(map((view) => ({ view }))) + }) + ) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(({ view }) => { + if (!view) { + this.router.navigate(['404']) + return + } + this.unmodifiedSavedView = view + this.list.activateSavedViewWithQueryParams( + view, + convertToParamMap(this.route.snapshot.queryParams) + ) + // this.list.reload() + this.unmodifiedFilterRules = view.filter_rules + }) + + this.route.queryParamMap + .pipe( + filter(() => !this.route.snapshot.paramMap.has('id')), // only when not on /view/id + takeUntil(this.unsubscribeNotifier) + ) + .subscribe((queryParams) => { + if (queryParams.has('view')) { + // loading a saved view on /documents + this.loadViewConfig(parseInt(queryParams.get('view'))) + } else { + // this.list.activateSavedView(null) + // this.list.loadFromQueryParams(queryParams) + // this.unmodifiedFilterRules = [] + } + }) + } + + ngOnDestroy() { + // unsubscribes all + this.unsubscribeNotifier.next(this) + this.unsubscribeNotifier.complete() + } + + loadViewConfig(viewID: number) { + this.savedViewService + .getCached(viewID) + .pipe(first()) + .subscribe((view) => { + this.unmodifiedSavedView = view + this.list.activateSavedView(view) + this.list.reload() + }) + } + + openDocumentDetail(document: PaperlessDocument) { + this.router.navigate(['documents', document.id]) + } + + toggleSelected(document: PaperlessDocument, event: MouseEvent): void { + if (!event.shiftKey) this.list.toggleSelected(document) + else this.list.selectRangeTo(document) + } + + clickTag(tagID: number) { + this.list.selectNone() + this.filterEditor.toggleTag(tagID) + } + + clickCorrespondent(correspondentID: number) { + this.list.selectNone() + this.filterEditor.toggleCorrespondent(correspondentID) + } + + clickDocumentType(documentTypeID: number) { + this.list.selectNone() + this.filterEditor.toggleDocumentType(documentTypeID) + } + + clickStoragePath(storagePathID: number) { + this.list.selectNone() + this.filterEditor.toggleStoragePath(storagePathID) + } + + clickMoreLike(documentID: number) { + this.list.quickFilter([ + { rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() }, + ]) + } + + trackByDocumentId(index, item: PaperlessDocument) { + return item.id + } + + get notesEnabled(): boolean { + return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED) + } +} diff --git a/src-ui/src/app/components/explorer/filter-editor/filter-editor.component.html b/src-ui/src/app/components/explorer/filter-editor/filter-editor.component.html new file mode 100644 index 000000000..4e7851a57 --- /dev/null +++ b/src-ui/src/app/components/explorer/filter-editor/filter-editor.component.html @@ -0,0 +1,85 @@ +
+
+
+
+
+ + +
+ + + +
+
+
+
+
+
+
+ + + + +
+
+ + +
+
+
+
+
+ +
+
diff --git a/src-ui/src/app/components/explorer/filter-editor/filter-editor.component.scss b/src-ui/src/app/components/explorer/filter-editor/filter-editor.component.scss new file mode 100644 index 000000000..82ad7e7a5 --- /dev/null +++ b/src-ui/src/app/components/explorer/filter-editor/filter-editor.component.scss @@ -0,0 +1,27 @@ +.quick-filter { + min-width: 250px; + max-height: 400px; + overflow-y: scroll; + + .selected-icon { + min-width: 1em; + min-height: 1em; + } +} + +.input-group .dropdown .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.d-flex.flex-wrap { + column-gap: 0.7rem; +} + +input[type="text"] { + min-width: 120px; +} + +.z-10 { + z-index: 10; +} diff --git a/src-ui/src/app/components/explorer/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/explorer/filter-editor/filter-editor.component.ts new file mode 100644 index 000000000..15e42db61 --- /dev/null +++ b/src-ui/src/app/components/explorer/filter-editor/filter-editor.component.ts @@ -0,0 +1,856 @@ +import { + Component, + EventEmitter, + Input, + Output, + OnInit, + OnDestroy, + ViewChild, + ElementRef, +} from '@angular/core' +import { PaperlessTag } from 'src/app/data/paperless-tag' +import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' +import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' +import { Subject, Subscription } from 'rxjs' +import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators' +import { DocumentTypeService } from 'src/app/services/rest/document-type.service' +import { TagService } from 'src/app/services/rest/tag.service' +import { CorrespondentService } from 'src/app/services/rest/correspondent.service' +import { filterRulesDiffer, FilterRule } from 'src/app/data/filter-rule' +import { + FILTER_ADDED_AFTER, + FILTER_ADDED_BEFORE, + FILTER_ASN, + FILTER_HAS_CORRESPONDENT_ANY, + FILTER_CREATED_AFTER, + FILTER_CREATED_BEFORE, + FILTER_HAS_DOCUMENT_TYPE_ANY, + FILTER_FULLTEXT_MORELIKE, + FILTER_FULLTEXT_QUERY, + FILTER_HAS_ANY_TAG, + FILTER_HAS_TAGS_ALL, + FILTER_HAS_TAGS_ANY, + FILTER_DOES_NOT_HAVE_TAG, + FILTER_TITLE, + FILTER_TITLE_CONTENT, + FILTER_HAS_STORAGE_PATH_ANY, + FILTER_ASN_ISNULL, + FILTER_ASN_GT, + FILTER_ASN_LT, + FILTER_DOES_NOT_HAVE_CORRESPONDENT, + FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE, + FILTER_DOES_NOT_HAVE_STORAGE_PATH, + FILTER_DOCUMENT_TYPE, + FILTER_CORRESPONDENT, + FILTER_STORAGE_PATH, +} from 'src/app/data/filter-rule-type' +import { + FilterableDropdownSelectionModel, + Intersection, + LogicalOperator, +} from '../../common/filterable-dropdown/filterable-dropdown.component' +import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component' +import { + DocumentService, + SelectionData, + SelectionDataItem, +} from 'src/app/services/rest/document.service' +import { PaperlessDocument } from 'src/app/data/paperless-document' +import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' +import { StoragePathService } from 'src/app/services/rest/storage-path.service' +import { RelativeDate } from '../../common/date-dropdown/date-dropdown.component' + +const TEXT_FILTER_TARGET_TITLE = 'title' +const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' +const TEXT_FILTER_TARGET_ASN = 'asn' +const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query' +const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike' + +const TEXT_FILTER_MODIFIER_EQUALS = 'equals' +const TEXT_FILTER_MODIFIER_NULL = 'is null' +const TEXT_FILTER_MODIFIER_NOTNULL = 'not null' +const TEXT_FILTER_MODIFIER_GT = 'greater' +const TEXT_FILTER_MODIFIER_LT = 'less' + +const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:\[([^\]]+)\]/g +const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:\[([^\]]+)\]/g +const RELATIVE_DATE_QUERYSTRINGS = [ + { + relativeDate: RelativeDate.LAST_7_DAYS, + dateQuery: '-1 week to now', + }, + { + relativeDate: RelativeDate.LAST_MONTH, + dateQuery: '-1 month to now', + }, + { + relativeDate: RelativeDate.LAST_3_MONTHS, + dateQuery: '-3 month to now', + }, + { + relativeDate: RelativeDate.LAST_YEAR, + dateQuery: '-1 year to now', + }, +] + +@Component({ + selector: 'app-explorer-filter-editor', + templateUrl: './filter-editor.component.html', + styleUrls: ['./filter-editor.component.scss'], +}) +export class FilterEditorComponent implements OnInit, OnDestroy { + generateFilterName() { + if (this.filterRules.length == 1) { + let rule = this.filterRules[0] + switch (this.filterRules[0].rule_type) { + case FILTER_HAS_CORRESPONDENT_ANY: + if (rule.value) { + return $localize`Correspondent: ${ + this.correspondents.find((c) => c.id == +rule.value)?.name + }` + } else { + return $localize`Without correspondent` + } + + case FILTER_HAS_DOCUMENT_TYPE_ANY: + if (rule.value) { + return $localize`Type: ${ + this.documentTypes.find((dt) => dt.id == +rule.value)?.name + }` + } else { + return $localize`Without document type` + } + + case FILTER_HAS_TAGS_ALL: + return $localize`Tag: ${ + this.tags.find((t) => t.id == +rule.value)?.name + }` + + case FILTER_HAS_ANY_TAG: + if (rule.value == 'false') { + return $localize`Without any tag` + } + + case FILTER_TITLE: + return $localize`Title: ${rule.value}` + + case FILTER_ASN: + return $localize`ASN: ${rule.value}` + } + } + + return '' + } + + constructor( + private documentTypeService: DocumentTypeService, + private tagService: TagService, + private correspondentService: CorrespondentService, + private documentService: DocumentService, + private storagePathService: StoragePathService + ) {} + + @ViewChild('textFilterInput') + textFilterInput: ElementRef + + tags: PaperlessTag[] = [] + correspondents: PaperlessCorrespondent[] = [] + documentTypes: PaperlessDocumentType[] = [] + storagePaths: PaperlessStoragePath[] = [] + + tagDocumentCounts: SelectionDataItem[] + correspondentDocumentCounts: SelectionDataItem[] + documentTypeDocumentCounts: SelectionDataItem[] + storagePathDocumentCounts: SelectionDataItem[] + + _textFilter = '' + _moreLikeId: number + _moreLikeDoc: PaperlessDocument + + get textFilterTargets() { + let targets = [ + { id: TEXT_FILTER_TARGET_TITLE, name: $localize`Title` }, + { + id: TEXT_FILTER_TARGET_TITLE_CONTENT, + name: $localize`Title & content`, + }, + { id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` }, + { + id: TEXT_FILTER_TARGET_FULLTEXT_QUERY, + name: $localize`Advanced search`, + }, + ] + if (this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) { + targets.push({ + id: TEXT_FILTER_TARGET_FULLTEXT_MORELIKE, + name: $localize`More like`, + }) + } + return targets + } + + textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT + + get textFilterTargetName() { + return this.textFilterTargets.find((t) => t.id == this.textFilterTarget) + ?.name + } + + public textFilterModifier: string + + get textFilterModifiers() { + return [ + { + id: TEXT_FILTER_MODIFIER_EQUALS, + label: $localize`equals`, + }, + { + id: TEXT_FILTER_MODIFIER_NULL, + label: $localize`is empty`, + }, + { + id: TEXT_FILTER_MODIFIER_NOTNULL, + label: $localize`is not empty`, + }, + { + id: TEXT_FILTER_MODIFIER_GT, + label: $localize`greater than`, + }, + { + id: TEXT_FILTER_MODIFIER_LT, + label: $localize`less than`, + }, + ] + } + + get textFilterModifierIsNull(): boolean { + return [TEXT_FILTER_MODIFIER_NULL, TEXT_FILTER_MODIFIER_NOTNULL].includes( + this.textFilterModifier + ) + } + + tagSelectionModel = new FilterableDropdownSelectionModel() + correspondentSelectionModel = new FilterableDropdownSelectionModel() + documentTypeSelectionModel = new FilterableDropdownSelectionModel() + storagePathSelectionModel = new FilterableDropdownSelectionModel() + + dateCreatedBefore: string + dateCreatedAfter: string + dateAddedBefore: string + dateAddedAfter: string + dateCreatedRelativeDate: RelativeDate + dateAddedRelativeDate: RelativeDate + + _unmodifiedFilterRules: FilterRule[] = [] + _filterRules: FilterRule[] = [] + + @Input() + set unmodifiedFilterRules(value: FilterRule[]) { + this._unmodifiedFilterRules = value + this.rulesModified = filterRulesDiffer( + this._unmodifiedFilterRules, + this._filterRules + ) + } + + get unmodifiedFilterRules(): FilterRule[] { + return this._unmodifiedFilterRules + } + + @Input() + set filterRules(value: FilterRule[]) { + this._filterRules = value + + this.documentTypeSelectionModel.clear(false) + this.storagePathSelectionModel.clear(false) + this.tagSelectionModel.clear(false) + this.correspondentSelectionModel.clear(false) + this._textFilter = null + this._moreLikeId = null + this.dateAddedBefore = null + this.dateAddedAfter = null + this.dateCreatedBefore = null + this.dateCreatedAfter = null + this.dateCreatedRelativeDate = null + this.dateAddedRelativeDate = null + this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS + + value.forEach((rule) => { + switch (rule.rule_type) { + case FILTER_TITLE: + this._textFilter = rule.value + this.textFilterTarget = TEXT_FILTER_TARGET_TITLE + break + case FILTER_TITLE_CONTENT: + this._textFilter = rule.value + this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT + break + case FILTER_ASN: + this._textFilter = rule.value + this.textFilterTarget = TEXT_FILTER_TARGET_ASN + break + case FILTER_FULLTEXT_QUERY: + let allQueryArgs = rule.value.split(',') + let textQueryArgs = [] + allQueryArgs.forEach((arg) => { + if (arg.match(RELATIVE_DATE_QUERY_REGEXP_CREATED)) { + ;[...arg.matchAll(RELATIVE_DATE_QUERY_REGEXP_CREATED)].forEach( + (match) => { + if (match[1]?.length) { + this.dateCreatedRelativeDate = + RELATIVE_DATE_QUERYSTRINGS.find( + (qS) => qS.dateQuery == match[1] + )?.relativeDate + } + } + ) + } else if (arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)) { + ;[...arg.matchAll(RELATIVE_DATE_QUERY_REGEXP_ADDED)].forEach( + (match) => { + if (match[1]?.length) { + this.dateAddedRelativeDate = + RELATIVE_DATE_QUERYSTRINGS.find( + (qS) => qS.dateQuery == match[1] + )?.relativeDate + } + } + ) + } else { + textQueryArgs.push(arg) + } + }) + if (textQueryArgs.length) { + this._textFilter = textQueryArgs.join(',') + this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_QUERY + } + break + case FILTER_FULLTEXT_MORELIKE: + this._moreLikeId = +rule.value + this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_MORELIKE + this.documentService.get(this._moreLikeId).subscribe((result) => { + this._moreLikeDoc = result + this._textFilter = result.title + }) + break + case FILTER_CREATED_AFTER: + this.dateCreatedAfter = rule.value + break + case FILTER_CREATED_BEFORE: + this.dateCreatedBefore = rule.value + break + case FILTER_ADDED_AFTER: + this.dateAddedAfter = rule.value + break + case FILTER_ADDED_BEFORE: + this.dateAddedBefore = rule.value + break + case FILTER_HAS_TAGS_ALL: + this.tagSelectionModel.logicalOperator = LogicalOperator.And + this.tagSelectionModel.set( + rule.value ? +rule.value : null, + ToggleableItemState.Selected, + false + ) + break + case FILTER_HAS_TAGS_ANY: + this.tagSelectionModel.logicalOperator = LogicalOperator.Or + this.tagSelectionModel.set( + rule.value ? +rule.value : null, + ToggleableItemState.Selected, + false + ) + break + case FILTER_HAS_ANY_TAG: + this.tagSelectionModel.set(null, ToggleableItemState.Selected, false) + break + case FILTER_DOES_NOT_HAVE_TAG: + this.tagSelectionModel.set( + rule.value ? +rule.value : null, + ToggleableItemState.Excluded, + false + ) + break + case FILTER_CORRESPONDENT: + case FILTER_HAS_CORRESPONDENT_ANY: + this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or + this.correspondentSelectionModel.intersection = Intersection.Include + this.correspondentSelectionModel.set( + rule.value ? +rule.value : null, + ToggleableItemState.Selected, + false + ) + break + case FILTER_DOES_NOT_HAVE_CORRESPONDENT: + this.correspondentSelectionModel.intersection = Intersection.Exclude + this.correspondentSelectionModel.set( + rule.value ? +rule.value : null, + ToggleableItemState.Excluded, + false + ) + break + case FILTER_DOCUMENT_TYPE: + case FILTER_HAS_DOCUMENT_TYPE_ANY: + this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or + this.documentTypeSelectionModel.intersection = Intersection.Include + this.documentTypeSelectionModel.set( + rule.value ? +rule.value : null, + ToggleableItemState.Selected, + false + ) + break + case FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE: + this.documentTypeSelectionModel.intersection = Intersection.Exclude + this.documentTypeSelectionModel.set( + rule.value ? +rule.value : null, + ToggleableItemState.Excluded, + false + ) + break + case FILTER_STORAGE_PATH: + case FILTER_HAS_STORAGE_PATH_ANY: + this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or + this.storagePathSelectionModel.intersection = Intersection.Include + this.storagePathSelectionModel.set( + rule.value ? +rule.value : null, + ToggleableItemState.Selected, + false + ) + break + case FILTER_DOES_NOT_HAVE_STORAGE_PATH: + this.storagePathSelectionModel.intersection = Intersection.Exclude + this.storagePathSelectionModel.set( + rule.value ? +rule.value : null, + ToggleableItemState.Excluded, + false + ) + break + case FILTER_ASN_ISNULL: + this.textFilterTarget = TEXT_FILTER_TARGET_ASN + this.textFilterModifier = + rule.value == 'true' || rule.value == '1' + ? TEXT_FILTER_MODIFIER_NULL + : TEXT_FILTER_MODIFIER_NOTNULL + break + case FILTER_ASN_GT: + this.textFilterTarget = TEXT_FILTER_TARGET_ASN + this.textFilterModifier = TEXT_FILTER_MODIFIER_GT + this._textFilter = rule.value + break + case FILTER_ASN_LT: + this.textFilterTarget = TEXT_FILTER_TARGET_ASN + this.textFilterModifier = TEXT_FILTER_MODIFIER_LT + this._textFilter = rule.value + break + } + }) + this.rulesModified = filterRulesDiffer( + this._unmodifiedFilterRules, + this._filterRules + ) + } + + get filterRules(): FilterRule[] { + let filterRules: FilterRule[] = [] + if ( + this._textFilter && + this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT + ) { + filterRules.push({ + rule_type: FILTER_TITLE_CONTENT, + value: this._textFilter, + }) + } + if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) { + filterRules.push({ rule_type: FILTER_TITLE, value: this._textFilter }) + } + if (this.textFilterTarget == TEXT_FILTER_TARGET_ASN) { + if ( + this.textFilterModifier == TEXT_FILTER_MODIFIER_EQUALS && + this._textFilter + ) { + filterRules.push({ rule_type: FILTER_ASN, value: this._textFilter }) + } else if (this.textFilterModifierIsNull) { + filterRules.push({ + rule_type: FILTER_ASN_ISNULL, + value: ( + this.textFilterModifier == TEXT_FILTER_MODIFIER_NULL + ).toString(), + }) + } else if ( + [TEXT_FILTER_MODIFIER_GT, TEXT_FILTER_MODIFIER_LT].includes( + this.textFilterModifier + ) && + this._textFilter + ) { + filterRules.push({ + rule_type: + this.textFilterModifier == TEXT_FILTER_MODIFIER_GT + ? FILTER_ASN_GT + : FILTER_ASN_LT, + value: this._textFilter, + }) + } + } + if ( + this._textFilter && + this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY + ) { + filterRules.push({ + rule_type: FILTER_FULLTEXT_QUERY, + value: this._textFilter, + }) + } + if ( + this._moreLikeId && + this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE + ) { + filterRules.push({ + rule_type: FILTER_FULLTEXT_MORELIKE, + value: this._moreLikeId?.toString(), + }) + } + if (this.tagSelectionModel.isNoneSelected()) { + filterRules.push({ rule_type: FILTER_HAS_ANY_TAG, value: 'false' }) + } else { + const tagFilterType = + this.tagSelectionModel.logicalOperator == LogicalOperator.And + ? FILTER_HAS_TAGS_ALL + : FILTER_HAS_TAGS_ANY + this.tagSelectionModel + .getSelectedItems() + .filter((tag) => tag.id) + .forEach((tag) => { + filterRules.push({ + rule_type: tagFilterType, + value: tag.id?.toString(), + }) + }) + this.tagSelectionModel + .getExcludedItems() + .filter((tag) => tag.id) + .forEach((tag) => { + filterRules.push({ + rule_type: FILTER_DOES_NOT_HAVE_TAG, + value: tag.id?.toString(), + }) + }) + } + if (this.correspondentSelectionModel.isNoneSelected()) { + filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null }) + } else { + this.correspondentSelectionModel + .getSelectedItems() + .forEach((correspondent) => { + filterRules.push({ + rule_type: FILTER_HAS_CORRESPONDENT_ANY, + value: correspondent.id?.toString(), + }) + }) + this.correspondentSelectionModel + .getExcludedItems() + .forEach((correspondent) => { + filterRules.push({ + rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT, + value: correspondent.id?.toString(), + }) + }) + } + if (this.documentTypeSelectionModel.isNoneSelected()) { + filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null }) + } else { + this.documentTypeSelectionModel + .getSelectedItems() + .forEach((documentType) => { + filterRules.push({ + rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY, + value: documentType.id?.toString(), + }) + }) + this.documentTypeSelectionModel + .getExcludedItems() + .forEach((documentType) => { + filterRules.push({ + rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE, + value: documentType.id?.toString(), + }) + }) + } + if (this.storagePathSelectionModel.isNoneSelected()) { + filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null }) + } else { + this.storagePathSelectionModel + .getSelectedItems() + .forEach((storagePath) => { + filterRules.push({ + rule_type: FILTER_HAS_STORAGE_PATH_ANY, + value: storagePath.id?.toString(), + }) + }) + this.storagePathSelectionModel + .getExcludedItems() + .forEach((storagePath) => { + filterRules.push({ + rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH, + value: storagePath.id?.toString(), + }) + }) + } + if (this.dateCreatedBefore) { + filterRules.push({ + rule_type: FILTER_CREATED_BEFORE, + value: this.dateCreatedBefore, + }) + } + if (this.dateCreatedAfter) { + filterRules.push({ + rule_type: FILTER_CREATED_AFTER, + value: this.dateCreatedAfter, + }) + } + if (this.dateAddedBefore) { + filterRules.push({ + rule_type: FILTER_ADDED_BEFORE, + value: this.dateAddedBefore, + }) + } + if (this.dateAddedAfter) { + filterRules.push({ + rule_type: FILTER_ADDED_AFTER, + value: this.dateAddedAfter, + }) + } + if ( + this.dateAddedRelativeDate !== null || + this.dateCreatedRelativeDate !== null + ) { + let queryArgs: Array = [] + let existingRule = filterRules.find( + (fr) => fr.rule_type == FILTER_FULLTEXT_QUERY + ) + + // if had a title / content search and added a relative date we need to carry it over... + if ( + !existingRule && + this._textFilter?.length > 0 && + (this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT || + this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) + ) { + existingRule = filterRules.find( + (fr) => + fr.rule_type == FILTER_TITLE_CONTENT || fr.rule_type == FILTER_TITLE + ) + existingRule.rule_type = FILTER_FULLTEXT_QUERY + } + + let existingRuleArgs = existingRule?.value.split(',') + if (this.dateCreatedRelativeDate !== null) { + queryArgs.push( + `created:[${ + RELATIVE_DATE_QUERYSTRINGS.find( + (qS) => qS.relativeDate == this.dateCreatedRelativeDate + ).dateQuery + }]` + ) + if (existingRule) { + queryArgs = existingRuleArgs + .filter((arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_CREATED)) + .concat(queryArgs) + } + } + if (this.dateAddedRelativeDate !== null) { + queryArgs.push( + `added:[${ + RELATIVE_DATE_QUERYSTRINGS.find( + (qS) => qS.relativeDate == this.dateAddedRelativeDate + ).dateQuery + }]` + ) + if (existingRule) { + queryArgs = existingRuleArgs + .filter((arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)) + .concat(queryArgs) + } + } + + if (existingRule) { + existingRule.value = queryArgs.join(',') + } else { + filterRules.push({ + rule_type: FILTER_FULLTEXT_QUERY, + value: queryArgs.join(','), + }) + } + } + if ( + this.dateCreatedRelativeDate == null && + this.dateAddedRelativeDate == null + ) { + const existingRule = filterRules.find( + (fr) => fr.rule_type == FILTER_FULLTEXT_QUERY + ) + if ( + existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_CREATED) || + existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_ADDED) + ) { + // remove any existing date query + existingRule.value = existingRule.value + .replace(RELATIVE_DATE_QUERY_REGEXP_CREATED, '') + .replace(RELATIVE_DATE_QUERY_REGEXP_ADDED, '') + if (existingRule.value.replace(',', '').trim() === '') { + // if its empty now, remove it entirely + filterRules.splice(filterRules.indexOf(existingRule), 1) + } + } + } + return filterRules + } + + @Output() + filterRulesChange = new EventEmitter() + + @Input() + set selectionData(selectionData: SelectionData) { + this.tagDocumentCounts = selectionData?.selected_tags ?? null + this.documentTypeDocumentCounts = + selectionData?.selected_document_types ?? null + this.correspondentDocumentCounts = + selectionData?.selected_correspondents ?? null + this.storagePathDocumentCounts = + selectionData?.selected_storage_paths ?? null + } + + rulesModified: boolean = false + + updateRules() { + this.filterRulesChange.next(this.filterRules) + } + + get textFilter() { + return this.textFilterModifierIsNull ? '' : this._textFilter + } + + set textFilter(value) { + this.textFilterDebounce.next(value) + } + + textFilterDebounce: Subject + subscription: Subscription + + ngOnInit() { + this.tagService + .listAll() + .subscribe((result) => (this.tags = result.results)) + this.correspondentService + .listAll() + .subscribe((result) => (this.correspondents = result.results)) + this.documentTypeService + .listAll() + .subscribe((result) => (this.documentTypes = result.results)) + this.storagePathService + .listAll() + .subscribe((result) => (this.storagePaths = result.results)) + + this.textFilterDebounce = new Subject() + + this.subscription = this.textFilterDebounce + .pipe( + debounceTime(400), + distinctUntilChanged(), + filter((query) => !query.length || query.length > 2) + ) + .subscribe((text) => this.updateTextFilter(text)) + + if (this._textFilter) this.documentService.searchQuery = this._textFilter + } + + ngOnDestroy() { + this.textFilterDebounce.complete() + } + + resetSelected() { + this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT + this.filterRules = this._unmodifiedFilterRules + this.updateRules() + } + + toggleTag(tagId: number) { + this.tagSelectionModel.toggle(tagId) + } + + toggleCorrespondent(correspondentId: number) { + this.correspondentSelectionModel.toggle(correspondentId) + } + + toggleDocumentType(documentTypeId: number) { + this.documentTypeSelectionModel.toggle(documentTypeId) + } + + toggleStoragePath(storagePathID: number) { + this.storagePathSelectionModel.toggle(storagePathID) + } + + onTagsDropdownOpen() { + this.tagSelectionModel.apply() + } + + onCorrespondentDropdownOpen() { + this.correspondentSelectionModel.apply() + } + + onDocumentTypeDropdownOpen() { + this.documentTypeSelectionModel.apply() + } + + onStoragePathDropdownOpen() { + this.storagePathSelectionModel.apply() + } + + updateTextFilter(text) { + this._textFilter = text + this.documentService.searchQuery = text + this.updateRules() + } + + textFilterKeyup(event: KeyboardEvent) { + if (event.key == 'Enter') { + const filterString = ( + this.textFilterInput.nativeElement as HTMLInputElement + ).value + if (filterString.length) { + this.updateTextFilter(filterString) + } + } else if (event.key == 'Escape') { + this.resetTextField() + } + } + + resetTextField() { + this.updateTextFilter('') + } + + changeTextFilterTarget(target) { + if ( + this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE && + target != TEXT_FILTER_TARGET_FULLTEXT_MORELIKE + ) { + this._textFilter = '' + } + this.textFilterTarget = target + this.textFilterInput.nativeElement.focus() + this.updateRules() + } + + textFilterModifierChange() { + if ( + this.textFilterModifierIsNull || + ([ + TEXT_FILTER_MODIFIER_EQUALS, + TEXT_FILTER_MODIFIER_GT, + TEXT_FILTER_MODIFIER_LT, + ].includes(this.textFilterModifier) && + this._textFilter) + ) { + this.updateRules() + } + } +}