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 @@
+ Search
+
+
+
+
Documents
+ File Explorer
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading...
+
+ 0">{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)
+
+
+
+
+
+
+
+
+
+
+
+ Error while loading documents: {{list.error}}
+
+
+
+
+
+
+
+
+ 15" class="mt-3">
+
+
+
+
+
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()
+ }
+ }
+}