From c1fc77f55fc8e29e0bdde4719aaf7bfd8fd4ea12 Mon Sep 17 00:00:00 2001 From: Martin Tan Date: Sun, 28 May 2023 19:58:12 +0800 Subject: [PATCH] Get listing storage paths working --- src-ui/src/app/app.module.ts | 148 ++--- .../explorer/explorer.component.html | 374 ++++++++++--- .../components/explorer/explorer.component.ts | 17 +- .../folder-card-small.component.html | 50 ++ .../folder-card-small.component.scss | 92 ++++ .../folder-card-small.component.ts | 107 ++++ .../popover-preview/popover-preview.scss | 22 + .../rest/custom-storage-path.service.ts | 143 +++++ .../storage-path-list-view.service.ts | 517 ++++++++++++++++++ 9 files changed, 1312 insertions(+), 158 deletions(-) create mode 100644 src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.html create mode 100644 src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.scss create mode 100644 src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.ts create mode 100644 src-ui/src/app/components/explorer/popover-preview/popover-preview.scss create mode 100644 src-ui/src/app/services/rest/custom-storage-path.service.ts create mode 100644 src-ui/src/app/services/storage-path-list-view.service.ts diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index ead9eb897..03ef89a8a 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -1,95 +1,98 @@ -import { BrowserModule } from '@angular/platform-browser' +import { DatePipe, registerLocaleData } from '@angular/common' +import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http' import { APP_INITIALIZER, NgModule } from '@angular/core' -import { AppRoutingModule } from './app-routing.module' -import { AppComponent } from './app.component' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { BrowserModule } from '@angular/platform-browser' import { NgbDateAdapter, NgbDateParserFormatter, NgbModule, } 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' -import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component' -import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component' -import { LogsComponent } from './components/manage/logs/logs.component' -import { SettingsComponent } from './components/manage/settings/settings.component' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { DatePipe, registerLocaleData } from '@angular/common' -import { NotFoundComponent } from './components/not-found/not-found.component' -import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component' -import { CorrespondentEditDialogComponent } from './components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' -import { TagEditDialogComponent } from './components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' -import { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' -import { TagComponent } from './components/common/tag/tag.component' -import { ClearableBadgeComponent } from './components/common/clearable-badge/clearable-badge.component' -import { PageHeaderComponent } from './components/common/page-header/page-header.component' +import { NgSelectModule } from '@ng-select/ng-select' +import { PdfViewerModule } from 'ng2-pdf-viewer' +import { ColorSliderModule } from 'ngx-color/slider' +import { CookieService } from 'ngx-cookie-service' +import { NgxFileDropModule } from 'ngx-file-drop' +import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' +import { AppRoutingModule } from './app-routing.module' +import { AppComponent } from './app.component' 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 { ClearableBadgeComponent } from './components/common/clearable-badge/clearable-badge.component' +import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component' +import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component' +import { CorrespondentEditDialogComponent } from './components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' +import { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' +import { GroupEditDialogComponent } from './components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component' +import { MailAccountEditDialogComponent } from './components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component' +import { MailRuleEditDialogComponent } from './components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component' +import { StoragePathEditDialogComponent } from './components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' +import { TagEditDialogComponent } from './components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' +import { UserEditDialogComponent } from './components/common/edit-dialog/user-edit-dialog/user-edit-dialog.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' -import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component' -import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component' -import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component' -import { NgxFileDropModule } from 'ngx-file-drop' -import { TextComponent } from './components/common/input/text/text.component' -import { SelectComponent } from './components/common/input/select/select.component' import { CheckComponent } from './components/common/input/check/check.component' +import { ColorComponent } from './components/common/input/color/color.component' +import { DateComponent } from './components/common/input/date/date.component' +import { NumberComponent } from './components/common/input/number/number.component' import { PasswordComponent } from './components/common/input/password/password.component' -import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component' +import { PermissionsFormComponent } from './components/common/input/permissions/permissions-form/permissions-form.component' +import { PermissionsGroupComponent } from './components/common/input/permissions/permissions-group/permissions-group.component' +import { PermissionsUserComponent } from './components/common/input/permissions/permissions-user/permissions-user.component' +import { SelectComponent } from './components/common/input/select/select.component' import { TagsComponent } from './components/common/input/tags/tags.component' -import { IfPermissionsDirective } from './directives/if-permissions.directive' -import { SortableDirective } from './directives/sortable.directive' -import { CookieService } from 'ngx-cookie-service' -import { CsrfInterceptor } from './interceptors/csrf.interceptor' +import { TextComponent } from './components/common/input/text/text.component' +import { PageHeaderComponent } from './components/common/page-header/page-header.component' +import { PermissionsDialogComponent } from './components/common/permissions-dialog/permissions-dialog.component' +import { PermissionsSelectComponent } from './components/common/permissions-select/permissions-select.component' +import { SelectDialogComponent } from './components/common/select-dialog/select-dialog.component' +import { TagComponent } from './components/common/tag/tag.component' +import { ToastsComponent } from './components/common/toasts/toasts.component' +import { DashboardComponent } from './components/dashboard/dashboard.component' import { SavedViewWidgetComponent } from './components/dashboard/widgets/saved-view-widget/saved-view-widget.component' import { StatisticsWidgetComponent } from './components/dashboard/widgets/statistics-widget/statistics-widget.component' import { UploadFileWidgetComponent } from './components/dashboard/widgets/upload-file-widget/upload-file-widget.component' -import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.component' -import { PdfViewerModule } from 'ng2-pdf-viewer' import { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-widget/welcome-widget.component' -import { YesNoPipe } from './pipes/yes-no.pipe' -import { FileSizePipe } from './pipes/file-size.pipe' -import { FilterPipe } from './pipes/filter.pipe' -import { DocumentTitlePipe } from './pipes/document-title.pipe' -import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component' -import { SelectDialogComponent } from './components/common/select-dialog/select-dialog.component' -import { NgSelectModule } from '@ng-select/ng-select' -import { NumberComponent } from './components/common/input/number/number.component' -import { SafeUrlPipe } from './pipes/safeurl.pipe' -import { SafeHtmlPipe } from './pipes/safehtml.pipe' -import { CustomDatePipe } from './pipes/custom-date.pipe' -import { DateComponent } from './components/common/input/date/date.component' -import { ISODateAdapter } from './utils/ngb-iso-date-adapter' -import { LocalizedDateParserFormatter } from './utils/ngb-date-parser-formatter' -import { ApiVersionInterceptor } from './interceptors/api-version.interceptor' -import { ColorSliderModule } from 'ngx-color/slider' -import { ColorComponent } from './components/common/input/color/color.component' +import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.component' import { DocumentAsnComponent } from './components/document-asn/document-asn.component' +import { DocumentDetailComponent } from './components/document-detail/document-detail.component' +import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component' +import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component' +import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component' +import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component' +import { DocumentListComponent } from './components/document-list/document-list.component' +import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component' +import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component' import { DocumentNotesComponent } from './components/document-notes/document-notes.component' -import { PermissionsGuard } from './guards/permissions.guard' +import { ExplorerComponent } from './components/explorer/explorer.component' +import { FilterEditorComponent as ExplorerFilterEditorComponent } from './components/explorer/filter-editor/filter-editor.component' +import { FolderCardSmallComponent } from './components/explorer/folder-card-small/folder-card-small.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 { 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 { NotFoundComponent } from './components/not-found/not-found.component' +import { IfObjectPermissionsDirective } from './directives/if-object-permissions.directive' +import { IfOwnerDirective } from './directives/if-owner.directive' +import { IfPermissionsDirective } from './directives/if-permissions.directive' +import { SortableDirective } from './directives/sortable.directive' import { DirtyDocGuard } from './guards/dirty-doc.guard' import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' -import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' -import { StoragePathEditDialogComponent } from './components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' +import { PermissionsGuard } from './guards/permissions.guard' +import { ApiVersionInterceptor } from './interceptors/api-version.interceptor' +import { CsrfInterceptor } from './interceptors/csrf.interceptor' +import { CustomDatePipe } from './pipes/custom-date.pipe' +import { DocumentTitlePipe } from './pipes/document-title.pipe' +import { FileSizePipe } from './pipes/file-size.pipe' +import { FilterPipe } from './pipes/filter.pipe' +import { SafeHtmlPipe } from './pipes/safehtml.pipe' +import { SafeUrlPipe } from './pipes/safeurl.pipe' +import { YesNoPipe } from './pipes/yes-no.pipe' import { SettingsService } from './services/settings.service' -import { TasksComponent } from './components/manage/tasks/tasks.component' -import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' -import { UserEditDialogComponent } from './components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component' -import { GroupEditDialogComponent } from './components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component' -import { PermissionsSelectComponent } from './components/common/permissions-select/permissions-select.component' -import { MailAccountEditDialogComponent } from './components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component' -import { MailRuleEditDialogComponent } from './components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component' -import { PermissionsUserComponent } from './components/common/input/permissions/permissions-user/permissions-user.component' -import { PermissionsGroupComponent } from './components/common/input/permissions/permissions-group/permissions-group.component' -import { IfOwnerDirective } from './directives/if-owner.directive' -import { IfObjectPermissionsDirective } from './directives/if-object-permissions.directive' +import { LocalizedDateParserFormatter } from './utils/ngb-date-parser-formatter' +import { ISODateAdapter } from './utils/ngb-iso-date-adapter' import localeAr from '@angular/common/locales/ar' import localeBe from '@angular/common/locales/be' @@ -111,8 +114,6 @@ import localeSr from '@angular/common/locales/sr' import localeSv from '@angular/common/locales/sv' import localeTr from '@angular/common/locales/tr' import localeZh from '@angular/common/locales/zh' -import { PermissionsDialogComponent } from './components/common/permissions-dialog/permissions-dialog.component' -import { PermissionsFormComponent } from './components/common/input/permissions/permissions-form/permissions-form.component' registerLocaleData(localeAr) registerLocaleData(localeBe) @@ -173,6 +174,7 @@ function initializeApp(settings: SettingsService) { DateDropdownComponent, DocumentCardLargeComponent, DocumentCardSmallComponent, + FolderCardSmallComponent, BulkEditorComponent, TextComponent, SelectComponent, diff --git a/src-ui/src/app/components/explorer/explorer.component.html b/src-ui/src/app/components/explorer/explorer.component.html index bea7aacea..718f8eed6 100644 --- a/src-ui/src/app/components/explorer/explorer.component.html +++ b/src-ui/src/app/components/explorer/explorer.component.html @@ -1,32 +1,67 @@ -
-
- - - + + +
- + - + - +
- - -
-
-
- +
-

@@ -89,13 +186,21 @@

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) - + {list.collectionSize, plural, =1 {Selected {{ list.selected.size }} of + one document} other {Selected {{ list.selected.size }} of + {{ list.collectionSize || 0 }} documents}}

- +
@@ -103,131 +208,246 @@
- - + +
- +
- +
- - + + i18n + > + Correspondent + - + - + - + + i18n + > + Storage path + - + + i18n + > + Added + - +
ASN + ASN + Correspondent Title + Title + Notes + Notes + Document type + Document type + Storage path Created + Created + Added
- - + +
- {{d.archive_serial_number}} + {{ d.archive_serial_number }} - {{(d.correspondent$ | async)?.name}} + {{ (d.correspondent$ | async)?.name }} - {{d.title | documentTitle}} - + {{ d.title | documentTitle }} + - + - - {{d.notes.length}} + + {{ d.notes.length }} - {{(d.document_type$ | async)?.name}} + {{ (d.document_type$ | async)?.name }} - {{(d.storage_path$ | async)?.name}} + {{ (d.storage_path$ | async)?.name }} - {{d.created_date | customDate}} + {{ d.created_date | customDate }} - {{d.added | customDate}} + {{ d.added | customDate }}
-
- +
+
- - diff --git a/src-ui/src/app/components/explorer/explorer.component.ts b/src-ui/src/app/components/explorer/explorer.component.ts index 99f98536a..cedb10878 100644 --- a/src-ui/src/app/components/explorer/explorer.component.ts +++ b/src-ui/src/app/components/explorer/explorer.component.ts @@ -6,9 +6,9 @@ import { ViewChild, ViewChildren, } from '@angular/core' -import { ActivatedRoute, convertToParamMap, Router } from '@angular/router' +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs' +import { Subject, filter, first, map, switchMap, takeUntil } from 'rxjs' import { FilterRule, filterRulesDiffer, @@ -19,11 +19,10 @@ 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, + SortableDirective, } 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, @@ -31,6 +30,7 @@ import { } 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 { StoragePathListViewService } from 'src/app/services/storage-path-list-view.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' @@ -45,7 +45,7 @@ export class ExplorerComponent implements OnInit, OnDestroy { constructor( - public list: DocumentListViewService, + public list: StoragePathListViewService, public savedViewService: SavedViewService, public route: ActivatedRoute, private router: Router, @@ -170,13 +170,14 @@ export class ExplorerComponent takeUntil(this.unsubscribeNotifier) ) .subscribe((queryParams) => { + console.log('test') 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 = [] + this.list.activateSavedView(null) + this.list.loadFromQueryParams(queryParams) + this.unmodifiedFilterRules = [] } }) } diff --git a/src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.html b/src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.html new file mode 100644 index 000000000..bdcd791ff --- /dev/null +++ b/src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.html @@ -0,0 +1,50 @@ +
+
+
+
+ + + +
+ +
+
+ + +
+
+
+ +
+

+ {{ storagePath.name }} +

+
+
+
diff --git a/src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.scss b/src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.scss new file mode 100644 index 000000000..4528f0412 --- /dev/null +++ b/src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.scss @@ -0,0 +1,92 @@ +.card-text { + font-size: 90%; +} + +.doc-img { + object-fit: cover; + object-position: top left; + height: 90px; + mix-blend-mode: multiply; + display: flex; + justify-content: center; + align-items: center; + padding: 30px; +} + +.document-card-check { + display: none; + position: absolute; + top: 0; + left: 0; + padding: 0.5rem; + border-top-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + pointer-events: none; + + .form-check { + padding: 0; + min-height: 0; + margin-bottom: 0; + + .form-check-input { + margin-left: 0; + } + } +} + +.document-card:hover .document-card-check { + display: block; +} + +.document-card-notes { + position: absolute; + right: 0; + top: 142px; +} + +.card-selected { + border-color:var(--bs-primary); + + .document-card-check { + display: block; + } +} + +.doc-img-background-selected { + background-color: var(--pngx-primary-faded); +} + +.card-info { + line-height: 1; + + button { + line-height: 1; + + &:hover, + &:focus { + background-color: transparent !important; + color: var(--bs-primary); + } + } +} + +.card-footer .btn { + padding-top: .10rem; +} + +::ng-deep .tooltip-inner { + text-align: left !important; + font-size: 90%; +} + +a { + cursor: pointer; +} + +.tags { + top: .2rem; + right: 0; + max-width: 80%; + row-gap: .2rem; + line-height: 1; +} diff --git a/src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.ts b/src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.ts new file mode 100644 index 000000000..95d9bb5c0 --- /dev/null +++ b/src-ui/src/app/components/explorer/folder-card-small/folder-card-small.component.ts @@ -0,0 +1,107 @@ +import { + Component, + EventEmitter, + Input, + Output, + ViewChild, +} from '@angular/core' +import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' +import { map } from 'rxjs/operators' +import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' +import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' +import { StoragePathService } from 'src/app/services/rest/storage-path.service' +import { SettingsService } from 'src/app/services/settings.service' +import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' + +@Component({ + selector: 'app-folder-card-small', + templateUrl: './folder-card-small.component.html', + styleUrls: [ + './folder-card-small.component.scss', + '../popover-preview/popover-preview.scss', + ], +}) +export class FolderCardSmallComponent extends ComponentWithPermissions { + constructor( + private storagePathService: StoragePathService, + private settingsService: SettingsService + ) { + super() + } + + @Input() + selected = false + + @Output() + toggleSelected = new EventEmitter() + + @Input() + storagePath: PaperlessStoragePath + + @Output() + dblClickDocument = new EventEmitter() + + @Output() + clickTag = new EventEmitter() + + @Output() + clickCorrespondent = new EventEmitter() + + @Output() + clickDocumentType = new EventEmitter() + + @Output() + clickStoragePath = new EventEmitter() + + moreTags: number = null + + @ViewChild('popover') popover: NgbPopover + + mouseOnPreview = false + popoverHidden = true + + getIsThumbInverted() { + return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) + } + + getThumbUrl() { + return '' + } + + getDownloadUrl() { + return '' + } + + get previewUrl() { + return '' + } + + mouseEnterPreview() { + this.mouseOnPreview = true + if (!this.popover.isOpen()) { + // we're going to open but hide to pre-load content during hover delay + this.popover.open() + this.popoverHidden = true + setTimeout(() => { + if (this.mouseOnPreview) { + // show popover + this.popoverHidden = false + } else { + this.popover.close() + } + }, 600) + } + } + + mouseLeavePreview() { + this.mouseOnPreview = false + } + + mouseLeaveCard() { + this.popover.close() + } + + get notesEnabled(): boolean { + return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED) + } +} diff --git a/src-ui/src/app/components/explorer/popover-preview/popover-preview.scss b/src-ui/src/app/components/explorer/popover-preview/popover-preview.scss new file mode 100644 index 000000000..8d31bf2fb --- /dev/null +++ b/src-ui/src/app/components/explorer/popover-preview/popover-preview.scss @@ -0,0 +1,22 @@ +::ng-deep app-document-list .popover { + max-width: 40rem; + + .preview { + min-width: 30rem; + min-height: 18rem; + max-height: 35rem; + overflow-y: scroll; + } + + .spinner-border { + position: absolute; + top: 4rem; + left: calc(50% - 0.5rem); + z-index: 0; + } +} + + ::ng-deep .popover-hidden .popover { + opacity: 0; + pointer-events: none; +} diff --git a/src-ui/src/app/services/rest/custom-storage-path.service.ts b/src-ui/src/app/services/rest/custom-storage-path.service.ts new file mode 100644 index 000000000..b7b7f3c21 --- /dev/null +++ b/src-ui/src/app/services/rest/custom-storage-path.service.ts @@ -0,0 +1,143 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { Observable, map } from 'rxjs' +import { FilterRule } from 'src/app/data/filter-rule' +import { PaperlessDocument } from 'src/app/data/paperless-document' +import { PaperlessDocumentMetadata } from 'src/app/data/paperless-document-metadata' +import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions' +import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' +import { Results } from 'src/app/data/results' +import { queryParamsFromFilterRules } from 'src/app/utils/query-params' +import { AbstractPaperlessService } from './abstract-paperless-service' + +interface SelectionDataItem { + id: number + document_count: number +} + +interface SelectionData { + selected_storage_paths: SelectionDataItem[] + selected_correspondents: SelectionDataItem[] + selected_tags: SelectionDataItem[] + selected_document_types: SelectionDataItem[] +} + +@Injectable({ + providedIn: 'root', +}) +export class CustomStoragePathService extends AbstractPaperlessService { + private _searchQuery: string + + constructor(http: HttpClient) { + super(http, 'storage_paths') + } + + listFiltered( + page?: number, + pageSize?: number, + sortField?: string, + sortReverse?: boolean, + filterRules?: FilterRule[], + extraParams = {} + ): Observable> { + return this.list( + page, + pageSize, + sortField, + sortReverse, + Object.assign(extraParams, queryParamsFromFilterRules(filterRules)) + ).pipe( + map((results) => { + return results + }) + ) + } + + listAllFilteredIds(filterRules?: FilterRule[]): Observable { + return this.listFiltered(1, 100000, null, null, filterRules, { + fields: 'id', + }).pipe(map((response) => response.results.map((doc) => doc.id))) + } + + getPreviewUrl(id: number, original: boolean = false): string { + let url = this.getResourceUrl(id, 'preview') + if (this._searchQuery) url += `#search="${this._searchQuery}"` + if (original) { + url += '?original=true' + } + return url + } + + getThumbUrl(id: number): string { + return this.getResourceUrl(id, 'thumb') + } + + getDownloadUrl(id: number, original: boolean = false): string { + let url = this.getResourceUrl(id, 'download') + if (original) { + url += '?original=true' + } + return url + } + + update(o: PaperlessDocument): Observable { + // we want to only set created_date + o.created = undefined + return super.update(o) + } + + uploadDocument(formData) { + return this.http.post( + this.getResourceUrl(null, 'post_document'), + formData, + { reportProgress: true, observe: 'events' } + ) + } + + getMetadata(id: number): Observable { + return this.http.get( + this.getResourceUrl(id, 'metadata') + ) + } + + bulkEdit(ids: number[], method: string, args: any) { + return this.http.post(this.getResourceUrl(null, 'bulk_edit'), { + documents: ids, + method: method, + parameters: args, + }) + } + + getSelectionData(ids: number[]): Observable { + return this.http.post( + this.getResourceUrl(null, 'selection_data'), + { documents: ids } + ) + } + + getSuggestions(id: number): Observable { + return this.http.get( + this.getResourceUrl(id, 'suggestions') + ) + } + + bulkDownload( + ids: number[], + content = 'both', + useFilenameFormatting: boolean = false + ) { + return this.http.post( + this.getResourceUrl(null, 'bulk_download'), + { + documents: ids, + content: content, + follow_formatting: useFilenameFormatting, + }, + { responseType: 'blob' } + ) + } + + public set searchQuery(query: string) { + this._searchQuery = query + } +} diff --git a/src-ui/src/app/services/storage-path-list-view.service.ts b/src-ui/src/app/services/storage-path-list-view.service.ts new file mode 100644 index 000000000..53aa29f51 --- /dev/null +++ b/src-ui/src/app/services/storage-path-list-view.service.ts @@ -0,0 +1,517 @@ +import { Injectable } from '@angular/core' +import { ParamMap, Router } from '@angular/router' +import { Observable } from 'rxjs' +import { + FilterRule, + cloneFilterRules, + filterRulesDiffer, + isFullTextFilterRule, +} from '../data/filter-rule' +import { PaperlessDocument } from '../data/paperless-document' +import { PaperlessSavedView } from '../data/paperless-saved-view' +import { PaperlessStoragePath } from '../data/paperless-storage-path' +import { SETTINGS_KEYS } from '../data/paperless-uisettings' +import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys' +import { paramsFromViewState, paramsToViewState } from '../utils/query-params' +import { CustomStoragePathService } from './rest/custom-storage-path.service' +import { DOCUMENT_SORT_FIELDS, SelectionData } from './rest/document.service' +import { SettingsService } from './settings.service' + +/** + * Captures the current state of the list view. + */ +export interface ListViewState { + /** + * Title of the document list view. Either "Documents" (localized) or the name of a saved view. + */ + title?: string + + /** + * Current paginated list of storage paths displayed. + */ + storagePaths?: PaperlessStoragePath[] + + currentPage: number + + /** + * Total amount of documents with the current filter rules. Used to calculate the number of pages. + */ + collectionSize?: number + + /** + * Currently selected sort field. + */ + sortField: string + + /** + * True if the list is sorted in reverse. + */ + sortReverse: boolean + + /** + * Filter rules for the current list view. + */ + filterRules: FilterRule[] + + /** + * Contains the IDs of all selected documents. + */ + selected?: Set +} + +/** + * This service manages the document list which is displayed using the document list view. + * + * This service also serves saved views by transparently switching between the document list + * and saved views on request. See below. + */ +@Injectable({ + providedIn: 'root', +}) +export class StoragePathListViewService { + isReloading: boolean = false + initialized: boolean = false + error: string = null + + rangeSelectionAnchorIndex: number + lastRangeSelectionToIndex: number + + selectionData?: SelectionData + + currentPageSize: number = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) + + private listViewStates: Map = new Map() + + private _activeSavedViewId: number = null + + get activeSavedViewId() { + return this._activeSavedViewId + } + + get activeSavedViewTitle() { + return this.activeListViewState.title + } + + constructor( + private storagePathService: CustomStoragePathService, + private settings: SettingsService, + private router: Router + ) {} + + private defaultListViewState(): ListViewState { + return { + title: null, + storagePaths: [], + currentPage: 1, + collectionSize: null, + sortField: 'created', + sortReverse: true, + filterRules: [], + selected: new Set(), + } + } + + private get activeListViewState() { + if (!this.listViewStates.has(this._activeSavedViewId)) { + this.listViewStates.set( + this._activeSavedViewId, + this.defaultListViewState() + ) + } + return this.listViewStates.get(this._activeSavedViewId) + } + + activateSavedView(view: PaperlessSavedView) { + this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null + if (view) { + this._activeSavedViewId = view.id + this.loadSavedView(view) + } else { + this._activeSavedViewId = null + } + } + + activateSavedViewWithQueryParams( + view: PaperlessSavedView, + queryParams: ParamMap + ) { + const viewState = paramsToViewState(queryParams) + this.activateSavedView(view) + this.currentPage = viewState.currentPage + } + + loadSavedView(view: PaperlessSavedView, closeCurrentView: boolean = false) { + if (closeCurrentView) { + this._activeSavedViewId = null + } + + this.activeListViewState.filterRules = cloneFilterRules(view.filter_rules) + this.activeListViewState.sortField = view.sort_field + this.activeListViewState.sortReverse = view.sort_reverse + if (this._activeSavedViewId) { + this.activeListViewState.title = view.name + } + + this.reduceSelectionToFilter() + + if (!this.router.routerState.snapshot.url.includes('/view/')) { + this.router.navigate(['view', view.id]) + } + } + + loadFromQueryParams(queryParams: ParamMap) { + const paramsEmpty: boolean = queryParams.keys.length == 0 + let newState: ListViewState = this.listViewStates.get( + this._activeSavedViewId + ) + if (!paramsEmpty) newState = paramsToViewState(queryParams) + if (newState == undefined) newState = this.defaultListViewState() // if nothing in local storage + + // only reload if things have changed + if ( + !this.initialized || + paramsEmpty || + this.activeListViewState.sortField !== newState.sortField || + this.activeListViewState.sortReverse !== newState.sortReverse || + this.activeListViewState.currentPage !== newState.currentPage || + filterRulesDiffer( + this.activeListViewState.filterRules, + newState.filterRules + ) + ) { + this.activeListViewState.filterRules = newState.filterRules + this.activeListViewState.sortField = newState.sortField + this.activeListViewState.sortReverse = newState.sortReverse + this.activeListViewState.currentPage = newState.currentPage + this.reload(null, paramsEmpty) // update the params if there arent any + } + } + + reload(onFinish?, updateQueryParams: boolean = true) { + this.isReloading = true + this.error = null + let activeListViewState = this.activeListViewState + this.storagePathService + .listFiltered( + activeListViewState.currentPage, + this.currentPageSize, + activeListViewState.sortField, + activeListViewState.sortReverse, + activeListViewState.filterRules, + { truncate_content: true } + ) + .subscribe({ + next: (result) => { + console.log('list filtered result:', result) + this.initialized = true + this.isReloading = false + activeListViewState.collectionSize = result.count + activeListViewState.storagePaths = result.results + + this.storagePathService + .getSelectionData(result.results.map((d) => d.id)) + .subscribe({ + next: (selectionData) => { + this.selectionData = selectionData + }, + error: () => { + this.selectionData = null + }, + }) + + // if (updateQueryParams && !this._activeSavedViewId) { + // let base = ['/documents'] + // this.router.navigate(base, { + // queryParams: paramsFromViewState(activeListViewState), + // replaceUrl: !this.router.routerState.snapshot.url.includes('?'), // in case navigating from params-less /documents + // }) + // } else if (this._activeSavedViewId) { + // this.router.navigate([], { + // queryParams: paramsFromViewState(activeListViewState, true), + // queryParamsHandling: 'merge', + // }) + // } + + if (onFinish) { + onFinish() + } + this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null + }, + error: (error) => { + this.isReloading = false + if (activeListViewState.currentPage != 1 && error.status == 404) { + // this happens when applying a filter: the current page might not be available anymore due to the reduced result set. + activeListViewState.currentPage = 1 + this.reload() + } else { + this.selectionData = null + let errorMessage + if ( + typeof error.error !== 'string' && + Object.keys(error.error).length > 0 + ) { + // e.g. { archive_serial_number: Array } + errorMessage = Object.keys(error.error) + .map((fieldName) => { + const fieldError: Array = error.error[fieldName] + return `${ + DOCUMENT_SORT_FIELDS.find((f) => f.field == fieldName)?.name + }: ${fieldError[0]}` + }) + .join(', ') + } else { + errorMessage = error.error + } + this.error = errorMessage + } + }, + }) + } + + set filterRules(filterRules: FilterRule[]) { + if ( + !isFullTextFilterRule(filterRules) && + this.activeListViewState.sortField == 'score' + ) { + this.activeListViewState.sortField = 'created' + } + this.activeListViewState.filterRules = filterRules + this.reload() + this.reduceSelectionToFilter() + this.saveDocumentListView() + } + + get filterRules(): FilterRule[] { + return this.activeListViewState.filterRules + } + + set sortField(field: string) { + this.activeListViewState.sortField = field + this.reload() + this.saveDocumentListView() + } + + get sortField(): string { + return this.activeListViewState.sortField + } + + set sortReverse(reverse: boolean) { + this.activeListViewState.sortReverse = reverse + this.reload() + this.saveDocumentListView() + } + + get sortReverse(): boolean { + return this.activeListViewState.sortReverse + } + + get collectionSize(): number { + return this.activeListViewState.collectionSize + } + + get currentPage(): number { + return this.activeListViewState.currentPage + } + + set currentPage(page: number) { + if (this.activeListViewState.currentPage == page) return + this.activeListViewState.currentPage = page + this.reload() + this.saveDocumentListView() + } + + get documents(): PaperlessDocument[] { + return this.activeListViewState.storagePaths + } + + get selected(): Set { + return this.activeListViewState.selected + } + + setSort(field: string, reverse: boolean) { + this.activeListViewState.sortField = field + this.activeListViewState.sortReverse = reverse + this.reload() + this.saveDocumentListView() + } + + private saveDocumentListView() { + if (this._activeSavedViewId == null) { + let savedState: ListViewState = { + collectionSize: this.activeListViewState.collectionSize, + currentPage: this.activeListViewState.currentPage, + filterRules: this.activeListViewState.filterRules, + sortField: this.activeListViewState.sortField, + sortReverse: this.activeListViewState.sortReverse, + } + localStorage.setItem( + DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG, + JSON.stringify(savedState) + ) + } + } + + quickFilter(filterRules: FilterRule[]) { + this._activeSavedViewId = null + this.filterRules = filterRules + } + + getLastPage(): number { + return Math.ceil(this.collectionSize / this.currentPageSize) + } + + hasNext(doc: number) { + if (this.documents) { + let index = this.documents.findIndex((d) => d.id == doc) + return ( + index != -1 && + (this.currentPage < this.getLastPage() || + index + 1 < this.documents.length) + ) + } + } + + hasPrevious(doc: number) { + if (this.documents) { + let index = this.documents.findIndex((d) => d.id == doc) + return index != -1 && !(index == 0 && this.currentPage == 1) + } + } + + getNext(currentDocId: number): Observable { + return new Observable((nextDocId) => { + if (this.documents != null) { + let index = this.documents.findIndex((d) => d.id == currentDocId) + + if (index != -1 && index + 1 < this.documents.length) { + nextDocId.next(this.documents[index + 1].id) + nextDocId.complete() + } else if (index != -1 && this.currentPage < this.getLastPage()) { + this.currentPage += 1 + this.reload(() => { + nextDocId.next(this.documents[0].id) + nextDocId.complete() + }) + } else { + nextDocId.complete() + } + } else { + nextDocId.complete() + } + }) + } + + getPrevious(currentDocId: number): Observable { + return new Observable((prevDocId) => { + if (this.documents != null) { + let index = this.documents.findIndex((d) => d.id == currentDocId) + + if (index != 0) { + prevDocId.next(this.documents[index - 1].id) + prevDocId.complete() + } else if (this.currentPage > 1) { + this.currentPage -= 1 + this.reload(() => { + prevDocId.next(this.documents[this.documents.length - 1].id) + prevDocId.complete() + }) + } else { + prevDocId.complete() + } + } else { + prevDocId.complete() + } + }) + } + + updatePageSize() { + let newPageSize = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) + if (newPageSize != this.currentPageSize) { + this.currentPageSize = newPageSize + } + } + + selectNone() { + this.selected.clear() + this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null + } + + reduceSelectionToFilter() { + if (this.selected.size > 0) { + this.storagePathService + .listAllFilteredIds(this.filterRules) + .subscribe((ids) => { + for (let id of this.selected) { + if (!ids.includes(id)) { + this.selected.delete(id) + } + } + }) + } + } + + selectAll() { + this.storagePathService + .listAllFilteredIds(this.filterRules) + .subscribe((ids) => ids.forEach((id) => this.selected.add(id))) + } + + selectPage() { + this.selected.clear() + this.documents.forEach((doc) => { + this.selected.add(doc.id) + }) + } + + isSelected(d: PaperlessDocument) { + return this.selected.has(d.id) + } + + toggleSelected(d: PaperlessDocument): void { + if (this.selected.has(d.id)) this.selected.delete(d.id) + else this.selected.add(d.id) + this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id) + this.lastRangeSelectionToIndex = null + } + + selectRangeTo(d: PaperlessDocument) { + if (this.rangeSelectionAnchorIndex !== null) { + const documentToIndex = this.documentIndexInCurrentView(d.id) + const fromIndex = Math.min( + this.rangeSelectionAnchorIndex, + documentToIndex + ) + const toIndex = Math.max(this.rangeSelectionAnchorIndex, documentToIndex) + + if (this.lastRangeSelectionToIndex !== null) { + // revert the old selection + this.documents + .slice( + Math.min( + this.rangeSelectionAnchorIndex, + this.lastRangeSelectionToIndex + ), + Math.max( + this.rangeSelectionAnchorIndex, + this.lastRangeSelectionToIndex + ) + 1 + ) + .forEach((d) => { + this.selected.delete(d.id) + }) + } + + this.documents.slice(fromIndex, toIndex + 1).forEach((d) => { + this.selected.add(d.id) + }) + this.lastRangeSelectionToIndex = documentToIndex + } else { + // e.g. shift key but was first click + this.toggleSelected(d) + } + } + + documentIndexInCurrentView(documentID: number): number { + return this.documents.map((d) => d.id).indexOf(documentID) + } +}