mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Merge branch 'dev' into feature-server-side-saved-views
This commit is contained in:
commit
277e668e07
@ -24,3 +24,7 @@ feature-X branches is for experimental stuff that will eventually be merged into
|
|||||||
I'm trying to get most of paperless tested, so please do the same for your code! I know its a hassle, but it makes sure that your code works now and will allow us to detect regressions easily.
|
I'm trying to get most of paperless tested, so please do the same for your code! I know its a hassle, but it makes sure that your code works now and will allow us to detect regressions easily.
|
||||||
|
|
||||||
To test your code, execute `pytest` in the src/ directory. Executing that in the project root is no good. This also generates a html coverage report, which you can use to see if you missed anything important during testing.
|
To test your code, execute `pytest` in the src/ directory. Executing that in the project root is no good. This also generates a html coverage report, which you can use to see if you missed anything important during testing.
|
||||||
|
|
||||||
|
## More info:
|
||||||
|
|
||||||
|
... is available in the documentation. https://paperless-ng.readthedocs.io/en/latest/extending.html
|
||||||
|
@ -28,6 +28,7 @@ Here's what you get:
|
|||||||
# Features
|
# Features
|
||||||
|
|
||||||
* Performs OCR on your documents, adds selectable text to image only documents and adds tags, correspondents and document types to your documents.
|
* Performs OCR on your documents, adds selectable text to image only documents and adds tags, correspondents and document types to your documents.
|
||||||
|
* Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and can be configured freely.
|
||||||
* Single page application front end. Should be pretty snappy. Will be mobile friendly in the future.
|
* Single page application front end. Should be pretty snappy. Will be mobile friendly in the future.
|
||||||
* Includes a dashboard that shows basic statistics and has document upload.
|
* Includes a dashboard that shows basic statistics and has document upload.
|
||||||
* Filtering by tags, correspondents, types, and more.
|
* Filtering by tags, correspondents, types, and more.
|
||||||
|
@ -78,6 +78,12 @@ that automatically, I'm all ears. For now, you have to grab the latest release
|
|||||||
archive from the project page and build the image yourself. The release comes
|
archive from the project page and build the image yourself. The release comes
|
||||||
with the front end already compiled, so you don't have to do this on the Pi.
|
with the front end already compiled, so you don't have to do this on the Pi.
|
||||||
|
|
||||||
|
**Q:** *How do I run this on unRaid?*
|
||||||
|
|
||||||
|
**A:** Head over to `<https://github.com/selfhosters/unRAID-CA-templates>`_,
|
||||||
|
`Uli Fahrer <https://github.com/Tooa>`_ created a container template for that.
|
||||||
|
I don't exactly know how to use that though, since I don't use unRaid.
|
||||||
|
|
||||||
**Q:** *How do I run this on my toaster?*
|
**Q:** *How do I run this on my toaster?*
|
||||||
|
|
||||||
**A:** I honestly don't know! As for all other devices that might be able
|
**A:** I honestly don't know! As for all other devices that might be able
|
||||||
|
63
src-ui/package-lock.json
generated
63
src-ui/package-lock.json
generated
@ -2215,6 +2215,11 @@
|
|||||||
"integrity": "sha512-UV1/ZJMC+HcP902wWdpC43cAcGu0IQk/I5bXjP2aSuCjsk3cE74mDvFrLKga7oDC170ugOAYBwfT4DSQW3akDA==",
|
"integrity": "sha512-UV1/ZJMC+HcP902wWdpC43cAcGu0IQk/I5bXjP2aSuCjsk3cE74mDvFrLKga7oDC170ugOAYBwfT4DSQW3akDA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/pdfjs-dist": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pdfjs-dist/-/pdfjs-dist-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-nQIwcPUhkAIyn7x9NS0lR/qxYfd5unRtfGkMjvpgF4Sh28IXftRymaNmFKTTdejDNY25NDGSIyjwj/BRwAPexg=="
|
||||||
|
},
|
||||||
"@types/q": {
|
"@types/q": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz",
|
||||||
@ -3023,6 +3028,16 @@
|
|||||||
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
|
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"bindings": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"file-uri-to-path": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"blob": {
|
"blob": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz",
|
||||||
@ -5508,6 +5523,13 @@
|
|||||||
"schema-utils": "^2.6.5"
|
"schema-utils": "^2.6.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"file-uri-to-path": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"fill-range": {
|
"fill-range": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||||
@ -8208,6 +8230,13 @@
|
|||||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"nan": {
|
||||||
|
"version": "2.14.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
|
||||||
|
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"nanomatch": {
|
"nanomatch": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
||||||
@ -8260,6 +8289,23 @@
|
|||||||
"moment": "2.18.1"
|
"moment": "2.18.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ng2-pdf-viewer": {
|
||||||
|
"version": "6.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ng2-pdf-viewer/-/ng2-pdf-viewer-6.3.2.tgz",
|
||||||
|
"integrity": "sha512-H2tBhDd+Lq6CUzK2g54HsCcZDR2wTn1sDjYqKY3yF0Ydasl2R5ppCKynZBU/zge4EKvmHglJI120FbQMpJKDYQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/pdfjs-dist": "^2.1.4",
|
||||||
|
"pdfjs-dist": "^2.4.456",
|
||||||
|
"tslib": "^1.10.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ngx-cookie-service": {
|
"ngx-cookie-service": {
|
||||||
"version": "10.1.1",
|
"version": "10.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-10.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-10.1.1.tgz",
|
||||||
@ -9270,6 +9316,11 @@
|
|||||||
"sha.js": "^2.4.8"
|
"sha.js": "^2.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"pdfjs-dist": {
|
||||||
|
"version": "2.5.207",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.5.207.tgz",
|
||||||
|
"integrity": "sha512-xGDUhnCYPfHy+unMXCLCJtlpZaaZ17Ew3WIL0tnSgKFUZXHAPD49GO9xScyszSsQMoutNDgRb+rfBXIaX/lJbw=="
|
||||||
|
},
|
||||||
"performance-now": {
|
"performance-now": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
@ -13228,7 +13279,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"nan": "^2.12.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"glob-parent": {
|
"glob-parent": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@ -13832,7 +13887,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"nan": "^2.12.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"glob-parent": {
|
"glob-parent": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
"@ng-bootstrap/ng-bootstrap": "^8.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^8.0.0",
|
||||||
"bootstrap": "^4.5.0",
|
"bootstrap": "^4.5.0",
|
||||||
"ng-bootstrap": "^1.6.3",
|
"ng-bootstrap": "^1.6.3",
|
||||||
|
"ng2-pdf-viewer": "^6.3.2",
|
||||||
"ngx-cookie-service": "^10.1.1",
|
"ngx-cookie-service": "^10.1.1",
|
||||||
"ngx-file-drop": "^10.0.0",
|
"ngx-file-drop": "^10.0.0",
|
||||||
"ngx-infinite-scroll": "^9.1.0",
|
"ngx-infinite-scroll": "^9.1.0",
|
||||||
|
@ -14,10 +14,9 @@ import { LogsComponent } from './components/manage/logs/logs.component';
|
|||||||
import { SettingsComponent } from './components/manage/settings/settings.component';
|
import { SettingsComponent } from './components/manage/settings/settings.component';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { DatePipe } from '@angular/common';
|
import { DatePipe } from '@angular/common';
|
||||||
import { SafePipe } from './pipes/safe.pipe';
|
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component';
|
import { NotFoundComponent } from './components/not-found/not-found.component';
|
||||||
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component';
|
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component';
|
||||||
import { DeleteDialogComponent } from './components/common/delete-dialog/delete-dialog.component';
|
import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component';
|
||||||
import { CorrespondentEditDialogComponent } from './components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component';
|
import { CorrespondentEditDialogComponent } from './components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component';
|
||||||
import { TagEditDialogComponent } from './components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
|
import { TagEditDialogComponent } from './components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
|
||||||
import { DocumentTypeEditDialogComponent } from './components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
|
import { DocumentTypeEditDialogComponent } from './components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
|
||||||
@ -28,6 +27,9 @@ import { PageHeaderComponent } from './components/common/page-header/page-header
|
|||||||
import { AppFrameComponent } from './components/app-frame/app-frame.component';
|
import { AppFrameComponent } from './components/app-frame/app-frame.component';
|
||||||
import { ToastsComponent } from './components/common/toasts/toasts.component';
|
import { ToastsComponent } from './components/common/toasts/toasts.component';
|
||||||
import { FilterEditorComponent } from './components/filter-editor/filter-editor.component';
|
import { FilterEditorComponent } from './components/filter-editor/filter-editor.component';
|
||||||
|
import { FilterDropdownComponent } from './components/filter-editor/filter-dropdown/filter-dropdown.component';
|
||||||
|
import { FilterDropdownButtonComponent } from './components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component';
|
||||||
|
import { FilterDropdownDateComponent } from './components/filter-editor/filter-dropdown-date/filter-dropdown-date.component';
|
||||||
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.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 { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component';
|
||||||
import { NgxFileDropModule } from 'ngx-file-drop';
|
import { NgxFileDropModule } from 'ngx-file-drop';
|
||||||
@ -45,10 +47,13 @@ import { SavedViewWidgetComponent } from './components/dashboard/widgets/saved-v
|
|||||||
import { StatisticsWidgetComponent } from './components/dashboard/widgets/statistics-widget/statistics-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 { UploadFileWidgetComponent } from './components/dashboard/widgets/upload-file-widget/upload-file-widget.component';
|
||||||
import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.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 { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-widget/welcome-widget.component';
|
||||||
import { YesNoPipe } from './pipes/yes-no.pipe';
|
import { YesNoPipe } from './pipes/yes-no.pipe';
|
||||||
import { FileSizePipe } from './pipes/file-size.pipe';
|
import { FileSizePipe } from './pipes/file-size.pipe';
|
||||||
|
import { FilterPipe } from './pipes/filter.pipe';
|
||||||
import { DocumentTitlePipe } from './pipes/document-title.pipe';
|
import { DocumentTitlePipe } from './pipes/document-title.pipe';
|
||||||
|
import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
@ -61,10 +66,9 @@ import { DocumentTitlePipe } from './pipes/document-title.pipe';
|
|||||||
DocumentTypeListComponent,
|
DocumentTypeListComponent,
|
||||||
LogsComponent,
|
LogsComponent,
|
||||||
SettingsComponent,
|
SettingsComponent,
|
||||||
SafePipe,
|
|
||||||
NotFoundComponent,
|
NotFoundComponent,
|
||||||
CorrespondentEditDialogComponent,
|
CorrespondentEditDialogComponent,
|
||||||
DeleteDialogComponent,
|
ConfirmDialogComponent,
|
||||||
TagEditDialogComponent,
|
TagEditDialogComponent,
|
||||||
DocumentTypeEditDialogComponent,
|
DocumentTypeEditDialogComponent,
|
||||||
TagComponent,
|
TagComponent,
|
||||||
@ -74,6 +78,9 @@ import { DocumentTitlePipe } from './pipes/document-title.pipe';
|
|||||||
AppFrameComponent,
|
AppFrameComponent,
|
||||||
ToastsComponent,
|
ToastsComponent,
|
||||||
FilterEditorComponent,
|
FilterEditorComponent,
|
||||||
|
FilterDropdownComponent,
|
||||||
|
FilterDropdownButtonComponent,
|
||||||
|
FilterDropdownDateComponent,
|
||||||
DocumentCardLargeComponent,
|
DocumentCardLargeComponent,
|
||||||
DocumentCardSmallComponent,
|
DocumentCardSmallComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
@ -90,7 +97,9 @@ import { DocumentTitlePipe } from './pipes/document-title.pipe';
|
|||||||
WelcomeWidgetComponent,
|
WelcomeWidgetComponent,
|
||||||
YesNoPipe,
|
YesNoPipe,
|
||||||
FileSizePipe,
|
FileSizePipe,
|
||||||
DocumentTitlePipe
|
FilterPipe,
|
||||||
|
DocumentTitlePipe,
|
||||||
|
MetadataCollapseComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
@ -100,7 +109,8 @@ import { DocumentTitlePipe } from './pipes/document-title.pipe';
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxFileDropModule,
|
NgxFileDropModule,
|
||||||
InfiniteScrollModule
|
InfiniteScrollModule,
|
||||||
|
PdfViewerModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
DatePipe,
|
DatePipe,
|
||||||
@ -108,7 +118,8 @@ import { DocumentTitlePipe } from './pipes/document-title.pipe';
|
|||||||
provide: HTTP_INTERCEPTORS,
|
provide: HTTP_INTERCEPTORS,
|
||||||
useClass: CsrfInterceptor,
|
useClass: CsrfInterceptor,
|
||||||
multi: true
|
multi: true
|
||||||
}
|
},
|
||||||
|
FilterPipe
|
||||||
],
|
],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent]
|
||||||
})
|
})
|
||||||
|
@ -132,7 +132,7 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="https://paperless-ng.readthedocs.io/en/latest/">
|
<a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://paperless-ng.readthedocs.io/en/latest/">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<svg class="sidebaricon" fill="currentColor">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
|
<use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -140,7 +140,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="https://github.com/jonaswinkler/paperless-ng">
|
<a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<svg class="sidebaricon" fill="currentColor">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#link"/>
|
<use xlink:href="assets/bootstrap-icons.svg#link"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
@ -5,10 +5,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p><b>{{message}}</b></p>
|
<p *ngIf="messageBold"><b>{{messageBold}}</b></p>
|
||||||
<p *ngIf="message2">{{message2}}</p>
|
<p *ngIf="message">{{message}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()">Cancel</button>
|
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()">Cancel</button>
|
||||||
<button type="button" class="btn btn-danger" (click)="deleteClicked.emit()">Delete</button>
|
<button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()">{{btnCaption}}</button>
|
||||||
</div>
|
</div>
|
@ -1,20 +1,20 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { DeleteDialogComponent } from './delete-dialog.component';
|
import { ConfirmDialogComponent } from './confirm-dialog.component';
|
||||||
|
|
||||||
describe('DeleteDialogComponent', () => {
|
describe('ConfirmDialogComponent', () => {
|
||||||
let component: DeleteDialogComponent;
|
let component: ConfirmDialogComponent;
|
||||||
let fixture: ComponentFixture<DeleteDialogComponent>;
|
let fixture: ComponentFixture<ConfirmDialogComponent>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
declarations: [ DeleteDialogComponent ]
|
declarations: [ ConfirmDialogComponent ]
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(DeleteDialogComponent);
|
fixture = TestBed.createComponent(ConfirmDialogComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
@ -0,0 +1,37 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-confirm-dialog',
|
||||||
|
templateUrl: './confirm-dialog.component.html',
|
||||||
|
styleUrls: ['./confirm-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class ConfirmDialogComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor(public activeModal: NgbActiveModal) { }
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
public confirmClicked = new EventEmitter()
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
title = "Confirmation"
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
messageBold
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
message
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
btnClass = "btn-primary"
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
btnCaption = "Confirm"
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelClicked() {
|
||||||
|
this.activeModal.close()
|
||||||
|
}
|
||||||
|
}
|
@ -1,31 +0,0 @@
|
|||||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-delete-dialog',
|
|
||||||
templateUrl: './delete-dialog.component.html',
|
|
||||||
styleUrls: ['./delete-dialog.component.scss']
|
|
||||||
})
|
|
||||||
export class DeleteDialogComponent implements OnInit {
|
|
||||||
|
|
||||||
constructor(public activeModal: NgbActiveModal) { }
|
|
||||||
|
|
||||||
@Output()
|
|
||||||
public deleteClicked = new EventEmitter()
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
title = "Delete confirmation"
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
message = "Do you really want to delete this?"
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
message2
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelClicked() {
|
|
||||||
this.activeModal.close()
|
|
||||||
}
|
|
||||||
}
|
|
@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xl">
|
<div class="col mb-4">
|
||||||
|
|
||||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||||
|
|
||||||
@ -110,53 +110,8 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h6 *ngIf="metadata?.original_metadata.length > 0">
|
<app-metadata-collapse title="Original document metadata" [metadata]="metadata.original_metadata" *ngIf="metadata?.original_metadata.length > 0"></app-metadata-collapse>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm mr-2"
|
<app-metadata-collapse title="Archived document metadata" [metadata]="metadata.archive_metadata" *ngIf="metadata?.archive_metadata.length > 0"></app-metadata-collapse>
|
||||||
(click)="expandOriginalMetadata = !expandOriginalMetadata" aria-controls="collapseExample">
|
|
||||||
<svg class="buttonicon" fill="currentColor" *ngIf="!expandOriginalMetadata">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#caret-down" />
|
|
||||||
</svg>
|
|
||||||
<svg class="buttonicon" fill="currentColor" *ngIf="expandOriginalMetadata">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#caret-up" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
Original document metadata
|
|
||||||
</h6>
|
|
||||||
|
|
||||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="!expandOriginalMetadata">
|
|
||||||
<table class="table table-borderless">
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let m of metadata?.original_metadata">
|
|
||||||
<td>{{m.prefix}}:{{m.key}}</td>
|
|
||||||
<td>{{m.value}}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h6 *ngIf="metadata?.has_archive_version && metadata?.archive_metadata.length > 0">
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm mr-2"
|
|
||||||
(click)="expandArchivedMetadata = !expandArchivedMetadata" aria-controls="collapseExample">
|
|
||||||
<svg class="buttonicon" fill="currentColor" *ngIf="!expandArchivedMetadata">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#caret-down" />
|
|
||||||
</svg>
|
|
||||||
<svg class="buttonicon" fill="currentColor" *ngIf="expandArchivedMetadata">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#caret-up" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
Archived document metadata
|
|
||||||
</h6>
|
|
||||||
|
|
||||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="!expandArchivedMetadata">
|
|
||||||
<table class="table table-borderless">
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let m of metadata?.archive_metadata">
|
|
||||||
<td>{{m.prefix}}:{{m.key}}</td>
|
|
||||||
<td>{{m.value}}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
@ -171,11 +126,9 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xl d-none d-xl-block document-preview">
|
<div class="col-md-6 col-xl-8 mb-3">
|
||||||
<object [data]="previewUrl | safe" type="application/pdf" width="100%" height="100%">
|
<div class="pdf-viewer-container" *ngIf="getContentType() == 'application/pdf'">
|
||||||
<p>Your browser does not support PDFs.
|
<pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true"></pdf-viewer>
|
||||||
<a href="previewUrl">Download the PDF</a>.</p>
|
</div>
|
||||||
</object>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
.document-preview {
|
.pdf-viewer-container {
|
||||||
height: calc(100vh - 180px);
|
height: calc(100vh - 160px);
|
||||||
top: 70px;
|
top: 70px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
}
|
background-color: gray;
|
||||||
|
}
|
||||||
|
@ -13,7 +13,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
|||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service';
|
import { DocumentService } from 'src/app/services/rest/document.service';
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { DeleteDialogComponent } from '../common/delete-dialog/delete-dialog.component';
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component';
|
||||||
import { CorrespondentEditDialogComponent } from '../manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component';
|
import { CorrespondentEditDialogComponent } from '../manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component';
|
||||||
import { DocumentTypeEditDialogComponent } from '../manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
|
import { DocumentTypeEditDialogComponent } from '../manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
|
||||||
|
|
||||||
@ -59,6 +59,10 @@ export class DocumentDetailComponent implements OnInit {
|
|||||||
private documentListViewService: DocumentListViewService,
|
private documentListViewService: DocumentListViewService,
|
||||||
private titleService: Title) { }
|
private titleService: Title) { }
|
||||||
|
|
||||||
|
getContentType() {
|
||||||
|
return this.metadata?.has_archive_version ? 'application/pdf' : this.metadata?.original_mime_type
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.documentForm.valueChanges.subscribe(wow => {
|
this.documentForm.valueChanges.subscribe(wow => {
|
||||||
Object.assign(this.document, this.documentForm.value)
|
Object.assign(this.document, this.documentForm.value)
|
||||||
@ -151,10 +155,13 @@ export class DocumentDetailComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete() {
|
delete() {
|
||||||
let modal = this.modalService.open(DeleteDialogComponent, {backdrop: 'static'})
|
let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
|
||||||
modal.componentInstance.message = `Do you really want to delete document '${this.document.title}'?`
|
modal.componentInstance.title = "Confirm delete"
|
||||||
modal.componentInstance.message2 = `The files for this document will be deleted permanently. This operation cannot be undone.`
|
modal.componentInstance.messageBold = `Do you really want to delete document '${this.document.title}'?`
|
||||||
modal.componentInstance.deleteClicked.subscribe(() => {
|
modal.componentInstance.message = `The files for this document will be deleted permanently. This operation cannot be undone.`
|
||||||
|
modal.componentInstance.btnClass = "btn-danger"
|
||||||
|
modal.componentInstance.btnCaption = "Delete document"
|
||||||
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
this.documentsService.delete(this.document).subscribe(() => {
|
this.documentsService.delete(this.document).subscribe(() => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.close()
|
this.close()
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
<h6>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm mr-2"
|
||||||
|
(click)="expand = !expand">
|
||||||
|
<svg class="buttonicon" fill="currentColor" *ngIf="!expand">
|
||||||
|
<use xlink:href="assets/bootstrap-icons.svg#caret-down" />
|
||||||
|
</svg>
|
||||||
|
<svg class="buttonicon" fill="currentColor" *ngIf="expand">
|
||||||
|
<use xlink:href="assets/bootstrap-icons.svg#caret-up" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{{title}}
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<div #collapse="ngbCollapse" [(ngbCollapse)]="!expand">
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let m of metadata">
|
||||||
|
<td>{{m.prefix}}:{{m.key}}</td>
|
||||||
|
<td>{{m.value}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MetadataCollapseComponent } from './metadata-collapse.component';
|
||||||
|
|
||||||
|
describe('MetadataCollapseComponent', () => {
|
||||||
|
let component: MetadataCollapseComponent;
|
||||||
|
let fixture: ComponentFixture<MetadataCollapseComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ MetadataCollapseComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(MetadataCollapseComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,23 @@
|
|||||||
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-metadata-collapse',
|
||||||
|
templateUrl: './metadata-collapse.component.html',
|
||||||
|
styleUrls: ['./metadata-collapse.component.scss']
|
||||||
|
})
|
||||||
|
export class MetadataCollapseComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
expand = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
metadata
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
title = "Metadata"
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -7,7 +7,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h5 class="card-title">
|
<h5 class="card-title">
|
||||||
<ng-container *ngIf="document.correspondent">
|
<ng-container *ngIf="document.correspondent">
|
||||||
<a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>
|
<a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>
|
||||||
<ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>:
|
<ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>:
|
||||||
@ -52,4 +52,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<p class="card-text">
|
<p class="card-text">
|
||||||
<ng-container *ngIf="document.correspondent">
|
<ng-container *ngIf="document.correspondent">
|
||||||
@ -44,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<small class="text-muted">{{document.created | date}}</small>
|
<small class="text-muted">{{document.created | date}}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
<app-page-header [title]="getTitle()">
|
<app-page-header [title]="getTitle()">
|
||||||
|
|
||||||
<div class="btn-group btn-group-toggle" ngbRadioGroup [(ngModel)]="displayMode"
|
<div class="btn-group btn-group-toggle" ngbRadioGroup [(ngModel)]="displayMode"
|
||||||
(ngModelChange)="saveDisplayMode()">
|
(ngModelChange)="saveDisplayMode()">
|
||||||
<label ngbButtonLabel class="btn-outline-primary btn-sm">
|
<label ngbButtonLabel class="btn-outline-primary btn-sm">
|
||||||
@ -21,6 +20,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-group btn-group-toggle ml-2" ngbRadioGroup [(ngModel)]="list.sortDirection">
|
<div class="btn-group btn-group-toggle ml-2" ngbRadioGroup [(ngModel)]="list.sortDirection">
|
||||||
<div ngbDropdown class="btn-group">
|
<div ngbDropdown class="btn-group">
|
||||||
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle>Sort by</button>
|
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle>Sort by</button>
|
||||||
@ -42,36 +42,28 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-group ml-2">
|
<div class="btn-group ml-2">
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm" [ngClass]="isFiltered ? 'btn-primary' : 'btn-outline-primary'" (click)="showFilter=!showFilter">
|
|
||||||
<svg class="toolbaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#funnel" />
|
|
||||||
</svg>
|
|
||||||
Filter
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="btn-group" ngbDropdown role="group">
|
<div class="btn-group" ngbDropdown role="group">
|
||||||
<button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
<button class="btn btn-sm btn-outline-primary dropdown-toggle" ngbDropdownToggle>Views</button>
|
||||||
<div class="dropdown-menu" ngbDropdownMenu class="shadow">
|
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||||
<ng-container *ngIf="!list.savedViewId" >
|
<ng-container *ngIf="!list.savedViewId">
|
||||||
<button ngbDropdownItem *ngFor="let config of savedViewConfigService.getConfigs()" (click)="loadViewConfig(config)">{{config.title}}</button>
|
<button ngbDropdownItem *ngFor="let config of savedViewConfigService.getConfigs()" (click)="loadViewConfig(config)">{{config.title}}</button>
|
||||||
<div class="dropdown-divider" *ngIf="savedViewConfigService.getConfigs().length > 0"></div>
|
<div class="dropdown-divider" *ngIf="savedViewConfigService.getConfigs().length > 0"></div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId">Save "{{list.savedViewTitle}}"</button>
|
<button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId">Save "{{list.savedViewTitle}}"</button>
|
||||||
<button ngbDropdownItem (click)="saveViewConfigAs()">Save as...</button>
|
<button ngbDropdownItem (click)="saveViewConfigAs()">Save as...</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</app-page-header>
|
</app-page-header>
|
||||||
|
|
||||||
<div class="card w-100 mb-3" [hidden]="!showFilter">
|
<div class="w-100 mb-4">
|
||||||
<div class="card-body">
|
<app-filter-editor [(filterEditorService)]="filterEditorService" (apply)="applyFilterRules()" (clear)="clearFilterRules()" #filterEditor></app-filter-editor>
|
||||||
<h5 class="card-title">Filter</h5>
|
|
||||||
<app-filter-editor [(filterRules)]="filterRules" (apply)="applyFilterRules()" (clear)="clearFilterRules()"></app-filter-editor>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
@ -81,7 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="displayMode == 'largeCards'">
|
<div *ngIf="displayMode == 'largeCards'">
|
||||||
<app-document-card-large *ngFor="let d of list.documents" [document]="d" [details]="d.content" (clickTag)="filterByTag($event)" (clickCorrespondent)="filterByCorrespondent($event)">
|
<app-document-card-large *ngFor="let d of list.documents" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)">
|
||||||
</app-document-card-large>
|
</app-document-card-large>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -101,16 +93,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="d-none d-md-table-cell">
|
<td class="d-none d-md-table-cell">
|
||||||
<ng-container *ngIf="d.correspondent">
|
<ng-container *ngIf="d.correspondent">
|
||||||
<a [routerLink]="" (click)="filterByCorrespondent(d.correspondent)" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a>
|
<a [routerLink]="" (click)="clickCorrespondent(d.correspondent)" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
<a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||||
<app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ml-1" clickable="true" linkTitle="Filter by tag" (click)="filterByTag(t.id)"></app-tag>
|
<app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ml-1" clickable="true" linkTitle="Filter by tag" (click)="clickTag(t)"></app-tag>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-xl-table-cell">
|
<td class="d-none d-xl-table-cell">
|
||||||
<ng-container *ngIf="d.document_type">
|
<ng-container *ngIf="d.document_type">
|
||||||
<a [routerLink]="" (click)="filterByDocumentType(d.document_type)" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a>
|
<a [routerLink]="" (click)="clickDocumentType(d.document_type)" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -125,5 +117,5 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class=" m-n2 row" *ngIf="displayMode == 'smallCards'">
|
<div class=" m-n2 row" *ngIf="displayMode == 'smallCards'">
|
||||||
<app-document-card-small [document]="d" *ngFor="let d of list.documents" (clickTag)="filterByTag($event)" (clickCorrespondent)="filterByCorrespondent($event)"></app-document-card-small>
|
<app-document-card-small [document]="d" *ngFor="let d of list.documents" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
@ -6,11 +6,16 @@ import { cloneFilterRules, FilterRule } from 'src/app/data/filter-rule';
|
|||||||
import { FILTER_CORRESPONDENT, FILTER_DOCUMENT_TYPE, FILTER_HAS_TAG, FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
|
import { FILTER_CORRESPONDENT, FILTER_DOCUMENT_TYPE, FILTER_HAS_TAG, FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
|
||||||
import { SavedViewConfig } from 'src/app/data/saved-view-config';
|
import { SavedViewConfig } from 'src/app/data/saved-view-config';
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||||
|
import { FilterEditorViewService } from 'src/app/services/filter-editor-view.service';
|
||||||
import { DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service';
|
import { DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service';
|
||||||
import { SavedViewConfigService } from 'src/app/services/saved-view-config.service';
|
import { SavedViewConfigService } from 'src/app/services/saved-view-config.service';
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service';
|
import { Toast, ToastService } from 'src/app/services/toast.service';
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component';
|
import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component';
|
||||||
|
import { FilterEditorComponent } from 'src/app/components/filter-editor/filter-editor.component';
|
||||||
|
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';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-document-list',
|
selector: 'app-document-list',
|
||||||
@ -22,6 +27,7 @@ export class DocumentListComponent implements OnInit {
|
|||||||
constructor(
|
constructor(
|
||||||
public list: DocumentListViewService,
|
public list: DocumentListViewService,
|
||||||
public savedViewConfigService: SavedViewConfigService,
|
public savedViewConfigService: SavedViewConfigService,
|
||||||
|
public filterEditorService: FilterEditorViewService,
|
||||||
public route: ActivatedRoute,
|
public route: ActivatedRoute,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
public modalService: NgbModal,
|
public modalService: NgbModal,
|
||||||
@ -29,13 +35,18 @@ export class DocumentListComponent implements OnInit {
|
|||||||
|
|
||||||
displayMode = 'smallCards' // largeCards, smallCards, details
|
displayMode = 'smallCards' // largeCards, smallCards, details
|
||||||
|
|
||||||
filterRules: FilterRule[] = []
|
|
||||||
showFilter = false
|
|
||||||
|
|
||||||
get isFiltered() {
|
get isFiltered() {
|
||||||
return this.list.filterRules?.length > 0
|
return this.list.filterRules?.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set filterRules(filterRules: FilterRule[]) {
|
||||||
|
this.filterEditorService.filterRules = filterRules
|
||||||
|
}
|
||||||
|
|
||||||
|
get filterRules(): FilterRule[] {
|
||||||
|
return this.filterEditorService.filterRules
|
||||||
|
}
|
||||||
|
|
||||||
getTitle() {
|
getTitle() {
|
||||||
return this.list.savedViewTitle || "Documents"
|
return this.list.savedViewTitle || "Documents"
|
||||||
}
|
}
|
||||||
@ -55,31 +66,29 @@ export class DocumentListComponent implements OnInit {
|
|||||||
this.route.paramMap.subscribe(params => {
|
this.route.paramMap.subscribe(params => {
|
||||||
if (params.has('id')) {
|
if (params.has('id')) {
|
||||||
this.list.savedView = this.savedViewConfigService.getConfig(params.get('id'))
|
this.list.savedView = this.savedViewConfigService.getConfig(params.get('id'))
|
||||||
this.filterRules = this.list.filterRules
|
this.filterEditorService.filterRules = this.list.filterRules
|
||||||
this.showFilter = false
|
|
||||||
this.titleService.setTitle(`${this.list.savedView.title} - ${environment.appTitle}`)
|
this.titleService.setTitle(`${this.list.savedView.title} - ${environment.appTitle}`)
|
||||||
} else {
|
} else {
|
||||||
this.list.savedView = null
|
this.list.savedView = null
|
||||||
this.filterRules = this.list.filterRules
|
this.filterEditorService.filterRules = this.list.filterRules
|
||||||
this.showFilter = this.filterRules.length > 0
|
|
||||||
this.titleService.setTitle(`Documents - ${environment.appTitle}`)
|
this.titleService.setTitle(`Documents - ${environment.appTitle}`)
|
||||||
}
|
}
|
||||||
this.list.clear()
|
this.list.clear()
|
||||||
this.list.reload()
|
this.list.reload()
|
||||||
})
|
})
|
||||||
|
this.filterEditorService.filterRules = this.list.filterRules
|
||||||
}
|
}
|
||||||
|
|
||||||
applyFilterRules() {
|
applyFilterRules() {
|
||||||
this.list.filterRules = this.filterRules
|
this.list.filterRules = this.filterEditorService.filterRules
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFilterRules() {
|
clearFilterRules() {
|
||||||
this.list.filterRules = this.filterRules
|
this.list.filterRules = this.filterEditorService.filterRules
|
||||||
this.showFilter = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadViewConfig(config: SavedViewConfig) {
|
loadViewConfig(config: SavedViewConfig) {
|
||||||
this.filterRules = cloneFilterRules(config.filterRules)
|
this.filterEditorService.filterRules = cloneFilterRules(config.filterRules)
|
||||||
this.list.load(config)
|
this.list.load(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,42 +112,18 @@ export class DocumentListComponent implements OnInit {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
filterByTag(tag_id: number) {
|
clickTag(tagID: number) {
|
||||||
let filterRules = this.list.filterRules
|
this.filterEditorService.toggleFilterByTag(tagID)
|
||||||
if (filterRules.find(rule => rule.type.id == FILTER_HAS_TAG && rule.value == tag_id)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_HAS_TAG), value: tag_id})
|
|
||||||
this.filterRules = filterRules
|
|
||||||
this.applyFilterRules()
|
this.applyFilterRules()
|
||||||
}
|
}
|
||||||
|
|
||||||
filterByCorrespondent(correspondent_id: number) {
|
clickCorrespondent(correspondentID: number) {
|
||||||
let filterRules = this.list.filterRules
|
this.filterEditorService.toggleFilterByCorrespondent(correspondentID)
|
||||||
let existing_rule = filterRules.find(rule => rule.type.id == FILTER_CORRESPONDENT)
|
|
||||||
if (existing_rule && existing_rule.value == correspondent_id) {
|
|
||||||
return
|
|
||||||
} else if (existing_rule) {
|
|
||||||
existing_rule.value = correspondent_id
|
|
||||||
} else {
|
|
||||||
filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_CORRESPONDENT), value: correspondent_id})
|
|
||||||
}
|
|
||||||
this.filterRules = filterRules
|
|
||||||
this.applyFilterRules()
|
this.applyFilterRules()
|
||||||
}
|
}
|
||||||
|
|
||||||
filterByDocumentType(document_type_id: number) {
|
clickDocumentType(documentTypeID: number) {
|
||||||
let filterRules = this.list.filterRules
|
this.filterEditorService.toggleFilterByDocumentType(documentTypeID)
|
||||||
let existing_rule = filterRules.find(rule => rule.type.id == FILTER_DOCUMENT_TYPE)
|
|
||||||
if (existing_rule && existing_rule.value == document_type_id) {
|
|
||||||
return
|
|
||||||
} else if (existing_rule) {
|
|
||||||
existing_rule.value = document_type_id
|
|
||||||
} else {
|
|
||||||
filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_DOCUMENT_TYPE), value: document_type_id})
|
|
||||||
}
|
|
||||||
this.filterRules = filterRules
|
|
||||||
this.applyFilterRules()
|
this.applyFilterRules()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,63 @@
|
|||||||
|
<div class="btn-group" ngbDropdown role="group">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" id="dropdown{{title}}" ngbDropdownToggle>
|
||||||
|
<ng-container *ngIf="dateBefore || dateAfter">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-check-circle-fill text-secondary" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||||
|
</svg>
|
||||||
|
</ng-container>
|
||||||
|
{{title}}
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu date-filter shadow pt-0" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<button *ngFor="let range of [7, 30, 'month', 'year']" class="list-group-item small list-goup list-group-item-action d-flex p-2 pl-3" role="menuitem" (click)="setDateQuickFilter(range)">
|
||||||
|
<ng-container *ngIf="isStringRange(range)">This </ng-container>
|
||||||
|
{{ range }}
|
||||||
|
<ng-container *ngIf="!isStringRange(range)"> days</ng-container>
|
||||||
|
</button>
|
||||||
|
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||||
|
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||||
|
<div>Before</div>
|
||||||
|
<a *ngIf="dateBefore" class="btn btn-link p-0 m-0" (click)="clearBefore()">
|
||||||
|
<svg width="0.8em" height="0.8em" viewBox="0 0 16 16" class="bi bi-x" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
<small>Clear</small>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input class="form-control" type="text" placeholder="yyyy-mm-dd" name="before" [(ngModel)]="_dateBefore" [maxDate]="this._maxDate" ngbDatepicker (dateSelect)="onDateSelected($event)" #dpBefore="ngbDatepicker">
|
||||||
|
<div class="input-group-append">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" (click)="dpBefore.toggle()" type="button">
|
||||||
|
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-calendar-date" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||||
|
<path d="M6.445 11.688V6.354h-.633A12.6 12.6 0 0 0 4.5 7.16v.695c.375-.257.969-.62 1.258-.777h.012v4.61h.675zm1.188-1.305c.047.64.594 1.406 1.703 1.406 1.258 0 2-1.066 2-2.871 0-1.934-.781-2.668-1.953-2.668-.926 0-1.797.672-1.797 1.809 0 1.16.824 1.77 1.676 1.77.746 0 1.23-.376 1.383-.79h.027c-.004 1.316-.461 2.164-1.305 2.164-.664 0-1.008-.45-1.05-.82h-.684zm2.953-2.317c0 .696-.559 1.18-1.184 1.18-.601 0-1.144-.383-1.144-1.2 0-.823.582-1.21 1.168-1.21.633 0 1.16.398 1.16 1.23z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||||
|
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||||
|
<div>After</div>
|
||||||
|
<a *ngIf="dateAfter" class="btn btn-link p-0 m-0" (click)="clearAfter()">
|
||||||
|
<svg width="0.8em" height="0.8em" viewBox="0 0 16 16" class="bi bi-x" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
<small>Clear</small>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input class="form-control form-control-sm" type="text" placeholder="yyyy-mm-dd" name="after" [(ngModel)]="_dateAfter" [maxDate]="this._maxDate" ngbDatepicker (dateSelect)="onDateSelected($event)" #dpAfter="ngbDatepicker">
|
||||||
|
<div class="input-group-append">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" (click)="dpAfter.toggle()" type="button">
|
||||||
|
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-calendar-date" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||||
|
<path d="M6.445 11.688V6.354h-.633A12.6 12.6 0 0 0 4.5 7.16v.695c.375-.257.969-.62 1.258-.777h.012v4.61h.675zm1.188-1.305c.047.64.594 1.406 1.703 1.406 1.258 0 2-1.066 2-2.871 0-1.934-.781-2.668-1.953-2.668-.926 0-1.797.672-1.797 1.809 0 1.16.824 1.77 1.676 1.77.746 0 1.23-.376 1.383-.79h.027c-.004 1.316-.461 2.164-1.305 2.164-.664 0-1.008-.45-1.05-.82h-.684zm2.953-2.317c0 .696-.559 1.18-1.184 1.18-.601 0-1.144-.383-1.144-1.2 0-.823.582-1.21 1.168-1.21.633 0 1.16.398 1.16 1.23z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,7 @@
|
|||||||
|
.date-filter {
|
||||||
|
min-width: 250px;
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { FilterDropdownDateComponent } from './filter-dropdown-date.component';
|
||||||
|
|
||||||
|
describe('FilterDropdownDateComponent', () => {
|
||||||
|
let component: FilterDropdownDateComponent;
|
||||||
|
let fixture: ComponentFixture<FilterDropdownDateComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ FilterDropdownDateComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(FilterDropdownDateComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,108 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output, ElementRef, ViewChild, OnChanges, SimpleChange } from '@angular/core';
|
||||||
|
import { FilterRule } from 'src/app/data/filter-rule';
|
||||||
|
import { ObjectWithId } from 'src/app/data/object-with-id';
|
||||||
|
import { NgbDate, NgbDateStruct, NgbDatepicker } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-filter-dropdown-date',
|
||||||
|
templateUrl: './filter-dropdown-date.component.html',
|
||||||
|
styleUrls: ['./filter-dropdown-date.component.scss']
|
||||||
|
})
|
||||||
|
export class FilterDropdownDateComponent {
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
dateBefore: NgbDateStruct
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
dateAfter: NgbDateStruct
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
title: string
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
dateBeforeSet = new EventEmitter()
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
dateAfterSet = new EventEmitter()
|
||||||
|
|
||||||
|
@ViewChild('dpAfter') dpAfter: NgbDatepicker
|
||||||
|
@ViewChild('dpBefore') dpBefore: NgbDatepicker
|
||||||
|
|
||||||
|
_dateBefore: NgbDateStruct
|
||||||
|
_dateAfter: NgbDateStruct
|
||||||
|
|
||||||
|
get _maxDate(): NgbDate {
|
||||||
|
let date = new Date()
|
||||||
|
return NgbDate.from({year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate()})
|
||||||
|
}
|
||||||
|
|
||||||
|
isStringRange(range: any) {
|
||||||
|
return typeof range == 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChange) {
|
||||||
|
// this is a hacky workaround perhaps because of https://github.com/angular/angular/issues/11097
|
||||||
|
let dateString: string = ''
|
||||||
|
let dateAfterChange: SimpleChange
|
||||||
|
let dateBeforeChange: SimpleChange
|
||||||
|
if (changes) {
|
||||||
|
dateAfterChange = changes['dateAfter']
|
||||||
|
dateBeforeChange = changes['dateBefore']
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dpBefore && this.dpAfter) {
|
||||||
|
let dpAfterElRef: ElementRef = this.dpAfter['_elRef']
|
||||||
|
let dpBeforeElRef: ElementRef = this.dpBefore['_elRef']
|
||||||
|
|
||||||
|
if (dateAfterChange && dateAfterChange.currentValue) {
|
||||||
|
let dateAfterDate = dateAfterChange.currentValue as NgbDateStruct
|
||||||
|
dateString = `${dateAfterDate.year}-${dateAfterDate.month.toString().padStart(2,'0')}-${dateAfterDate.day.toString().padStart(2,'0')}`
|
||||||
|
dpAfterElRef.nativeElement.value = dateString
|
||||||
|
} else if (dateBeforeChange && dateBeforeChange.currentValue) {
|
||||||
|
let dateBeforeDate = dateBeforeChange.currentValue as NgbDateStruct
|
||||||
|
dateString = `${dateBeforeChange.currentValue.year}-${dateBeforeChange.currentValue.month.toString().padStart(2,'0')}-${dateBeforeChange.currentValue.day.toString().padStart(2,'0')}`
|
||||||
|
dpBeforeElRef.nativeElement.value = dateString
|
||||||
|
} else {
|
||||||
|
dpAfterElRef.nativeElement.value = dateString
|
||||||
|
dpBeforeElRef.nativeElement.value = dateString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateQuickFilter(range: any) {
|
||||||
|
this._dateAfter = this._dateBefore = undefined
|
||||||
|
let date = new Date()
|
||||||
|
let newDate: NgbDateStruct = { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }
|
||||||
|
switch (typeof range) {
|
||||||
|
case 'number':
|
||||||
|
date.setDate(date.getDate() - range)
|
||||||
|
newDate.year = date.getFullYear()
|
||||||
|
newDate.month = date.getMonth() + 1
|
||||||
|
newDate.day = date.getDate()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'string':
|
||||||
|
newDate.day = 1
|
||||||
|
if (range == 'year') newDate.month = 1
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
this._dateAfter = newDate
|
||||||
|
this.onDateSelected(this._dateAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateSelected(date:NgbDateStruct) {
|
||||||
|
let emitter = this._dateAfter && NgbDate.from(this._dateAfter).equals(date) ? this.dateAfterSet : this.dateBeforeSet
|
||||||
|
emitter.emit(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAfter() {
|
||||||
|
this.dateAfterSet.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBefore() {
|
||||||
|
this.dateBeforeSet.next()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-left-0 border-right-0 border-bottom" role="menuitem" (click)="toggleItem()">
|
||||||
|
<div class="selected-icon mr-1">
|
||||||
|
<svg *ngIf="selected" width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-check" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.236.236 0 0 1 .02-.022z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="mr-1">
|
||||||
|
<app-tag *ngIf="isTag; else displayName" [tag]="item" [clickable]="true" linkTitle="Filter by tag"></app-tag>
|
||||||
|
<ng-template #displayName><small>{{item.name}}</small></ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-light rounded-pill ml-auto mr-1">{{item.document_count}}</div>
|
||||||
|
</button>
|
@ -0,0 +1,4 @@
|
|||||||
|
.selected-icon {
|
||||||
|
min-width: 1em;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { FilterDropodownButtonComponent } from './filter-dropdown-button.component';
|
||||||
|
|
||||||
|
describe('FilterDropodownButtonComponent', () => {
|
||||||
|
let component: FilterDropodownButtonComponent;
|
||||||
|
let fixture: ComponentFixture<FilterDropodownButtonComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ FilterDropodownButtonComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(FilterDropodownButtonComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,32 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output, OnInit } 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';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-filter-dropdown-button',
|
||||||
|
templateUrl: './filter-dropdown-button.component.html',
|
||||||
|
styleUrls: ['./filter-dropdown-button.component.scss']
|
||||||
|
})
|
||||||
|
export class FilterDropdownButtonComponent implements OnInit {
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
item: PaperlessTag | PaperlessDocumentType | PaperlessCorrespondent
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
selected: boolean
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
toggle = new EventEmitter()
|
||||||
|
|
||||||
|
isTag: boolean
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.isTag = 'is_inbox_tag' in this.item // ~ this.item instanceof PaperlessTag
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleItem(): void {
|
||||||
|
this.selected = !this.selected
|
||||||
|
this.toggle.emit(this.item)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
<div class="btn-group" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #filterDropdown="ngbDropdown">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" id="dropdown{{title}}" ngbDropdownToggle>
|
||||||
|
<ng-container *ngIf="itemsSelected?.length > 0">
|
||||||
|
<div class="badge bg-secondary text-light rounded-pill ml-auto">
|
||||||
|
{{itemsSelected?.length}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
{{title}}
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input class="form-control" type="text" [(ngModel)]="filterText" placeholder="Filter {{title}}" (keyup.enter)="listFilterEnter()" #listFilterTextInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="items" class="items">
|
||||||
|
<ng-container *ngFor="let item of items | filter: filterText; let i = index">
|
||||||
|
<app-filter-dropdown-button [item]="item" [selected]="isItemSelected(item)" (toggle)="toggleItem($event)"></app-filter-dropdown-button>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,8 @@
|
|||||||
|
.dropdown-menu {
|
||||||
|
min-width: 250px;
|
||||||
|
|
||||||
|
.items {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { FilterDropodownComponent } from './filter-dropdown.component';
|
||||||
|
|
||||||
|
describe('FilterDropodownComponent', () => {
|
||||||
|
let component: FilterDropodownComponent;
|
||||||
|
let fixture: ComponentFixture<FilterDropodownComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ FilterDropodownComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(FilterDropodownComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,60 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output, ElementRef, ViewChild } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Results } from 'src/app/data/results';
|
||||||
|
import { ObjectWithId } from 'src/app/data/object-with-id';
|
||||||
|
import { FilterPipe } from 'src/app/pipes/filter.pipe';
|
||||||
|
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-filter-dropdown',
|
||||||
|
templateUrl: './filter-dropdown.component.html',
|
||||||
|
styleUrls: ['./filter-dropdown.component.scss']
|
||||||
|
})
|
||||||
|
export class FilterDropdownComponent {
|
||||||
|
|
||||||
|
constructor(private filterPipe: FilterPipe) { }
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
items: ObjectWithId[]
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
itemsSelected: ObjectWithId[]
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
title: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
display: string
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
toggle = new EventEmitter()
|
||||||
|
|
||||||
|
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
||||||
|
@ViewChild('filterDropdown') filterDropdown: NgbDropdown
|
||||||
|
|
||||||
|
filterText: string
|
||||||
|
|
||||||
|
toggleItem(item: ObjectWithId): void {
|
||||||
|
this.toggle.emit(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
isItemSelected(item: ObjectWithId): boolean {
|
||||||
|
return this.itemsSelected?.find(i => i.id == item.id) !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdownOpenChange(open: boolean): void {
|
||||||
|
if (open) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.listFilterTextInput.nativeElement.focus();
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
this.filterText = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listFilterEnter(): void {
|
||||||
|
let filtered = this.filterPipe.transform(this.items, this.filterText)
|
||||||
|
if (filtered.length == 1) this.toggleItem(filtered.shift())
|
||||||
|
this.filterDropdown.close()
|
||||||
|
}
|
||||||
|
}
|
@ -1,52 +1,22 @@
|
|||||||
<div *ngFor="let rule of filterRules" class="form-row form-group">
|
<div class="form-row form-group mb-0">
|
||||||
<div class="col-md-3 col-form-label">
|
<div class="col-auto">
|
||||||
<span>{{rule.type.name}}</span>
|
<div class="text-muted mt-1">Filter by:</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<input *ngIf="rule.type.datatype == 'string'" type="text" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
<input class="form-control form-control-sm" type="text" [(ngModel)]="titleFilter" placeholder="Title">
|
||||||
<input *ngIf="rule.type.datatype == 'number'" type="number" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
|
||||||
<input *ngIf="rule.type.datatype == 'date'" type="date" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
|
||||||
|
|
||||||
<select *ngIf="rule.type.datatype == 'tag'" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
|
||||||
<option *ngFor="let t of tags" [ngValue]="t.id">{{t.name}}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select *ngIf="rule.type.datatype == 'document_type'" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
|
||||||
<option *ngFor="let dt of documentTypes" [ngValue]="dt.id">{{dt.name}}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select *ngIf="rule.type.datatype == 'correspondent'" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
|
||||||
<option *ngFor="let c of correspondents" [ngValue]="c.id">{{c.name}}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select *ngIf="rule.type.datatype == 'boolean'" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
|
||||||
<option [ngValue]="true">Yes</option>
|
|
||||||
<option [ngValue]="false">No</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="removeRuleClicked(rule)">
|
|
||||||
<svg class="toolbaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row form-group">
|
|
||||||
<div class="col">
|
|
||||||
<select [(ngModel)]="selectedRuleType" class="form-control form-control-sm">
|
|
||||||
<option *ngFor="let ruleType of getRuleTypes()" [ngValue]="ruleType">{{ruleType.name}}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<button (click)="newRuleClicked()" class="btn btn-sm btn-outline-secondary">Add</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<button (click)="clearClicked()" class="btn btn-sm btn-outline-secondary">Clear</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<button (click)="applyClicked()" class="btn btn-sm btn-outline-secondary">Apply</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<app-filter-dropdown class="col-auto" [(items)]="filterEditorService.tags" [itemsSelected]="filterEditorService.selectedTags" [title]="'Tags'" (toggle)="onToggleTag($event)"></app-filter-dropdown>
|
||||||
|
<app-filter-dropdown class="col-auto" [(items)]="filterEditorService.correspondents" [itemsSelected]="filterEditorService.selectedCorrespondents" [title]="'Correspondents'" (toggle)="onToggleCorrespondent($event)"></app-filter-dropdown>
|
||||||
|
<app-filter-dropdown class="col-auto" [(items)]="filterEditorService.documentTypes" [itemsSelected]="filterEditorService.selectedDocumentTypes" [title]="'Document Types'" (toggle)="onToggleDocumentType($event)"></app-filter-dropdown>
|
||||||
|
|
||||||
|
<app-filter-dropdown-date class="col-auto" [dateBefore]="filterEditorService.dateCreatedBefore" [dateAfter]="filterEditorService.dateCreatedAfter" [title]="'Created'" (dateBeforeSet)="onDateCreatedBeforeSet($event)" (dateAfterSet)="onDateCreatedAfterSet($event)"></app-filter-dropdown-date>
|
||||||
|
<app-filter-dropdown-date class="col-auto" [dateBefore]="filterEditorService.dateAddedBefore" [dateAfter]="filterEditorService.dateAddedAfter" [title]="'Added'" (dateBeforeSet)="onDateAddedBeforeSet($event)" (dateAfterSet)="onDateAddedAfterSet($event)"></app-filter-dropdown-date>
|
||||||
|
|
||||||
|
<button class="btn btn-link btn-sm" [disabled]="!filterEditorService.hasFilters()" (click)="clearSelected()">
|
||||||
|
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
Clear all filters
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,10 @@
|
|||||||
|
.quick-filter {
|
||||||
|
min-width: 250px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
|
||||||
|
.selected-icon {
|
||||||
|
min-width: 1em;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
}
|
@ -1,67 +1,99 @@
|
|||||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { FilterRule } from 'src/app/data/filter-rule';
|
import { FilterEditorViewService } from 'src/app/services/filter-editor-view.service'
|
||||||
import { FilterRuleType, FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
|
import { PaperlessTag } from 'src/app/data/paperless-tag';
|
||||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
|
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
|
||||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
|
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
|
||||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
|
import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { TagService } from 'src/app/services/rest/tag.service';
|
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-filter-editor',
|
selector: 'app-filter-editor',
|
||||||
templateUrl: './filter-editor.component.html',
|
templateUrl: './filter-editor.component.html',
|
||||||
styleUrls: ['./filter-editor.component.scss']
|
styleUrls: ['./filter-editor.component.scss']
|
||||||
})
|
})
|
||||||
export class FilterEditorComponent implements OnInit {
|
export class FilterEditorComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
constructor(private documentTypeService: DocumentTypeService, private tagService: TagService, private correspondentService: CorrespondentService) { }
|
constructor() { }
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
filterEditorService: FilterEditorViewService
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
clear = new EventEmitter()
|
clear = new EventEmitter()
|
||||||
|
|
||||||
@Input()
|
|
||||||
filterRules: FilterRule[] = []
|
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
apply = new EventEmitter()
|
apply = new EventEmitter()
|
||||||
|
|
||||||
selectedRuleType: FilterRuleType = FILTER_RULE_TYPES[0]
|
get titleFilter() {
|
||||||
|
return this.filterEditorService.titleFilter
|
||||||
correspondents: PaperlessCorrespondent[] = []
|
|
||||||
tags: PaperlessTag[] = []
|
|
||||||
documentTypes: PaperlessDocumentType[] = []
|
|
||||||
|
|
||||||
newRuleClicked() {
|
|
||||||
this.filterRules.push({type: this.selectedRuleType, value: this.selectedRuleType.default})
|
|
||||||
this.selectedRuleType = this.getRuleTypes().length > 0 ? this.getRuleTypes()[0] : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRuleClicked(rule) {
|
set titleFilter(value) {
|
||||||
let index = this.filterRules.findIndex(r => r == rule)
|
this.titleFilterDebounce.next(value)
|
||||||
if (index > -1) {
|
|
||||||
this.filterRules.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyClicked() {
|
titleFilterDebounce: Subject<string>
|
||||||
|
subscription: Subscription
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.titleFilterDebounce = new Subject<string>()
|
||||||
|
this.subscription = this.titleFilterDebounce.pipe(
|
||||||
|
debounceTime(400),
|
||||||
|
distinctUntilChanged()
|
||||||
|
).subscribe(title => {
|
||||||
|
this.filterEditorService.titleFilter = title
|
||||||
|
this.applyFilters()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.titleFilterDebounce.complete()
|
||||||
|
// TODO: not sure if both is necessary
|
||||||
|
this.subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
this.apply.next()
|
this.apply.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
clearClicked() {
|
clearSelected() {
|
||||||
this.filterRules.splice(0,this.filterRules.length)
|
this.filterEditorService.clear()
|
||||||
this.clear.next()
|
this.clear.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
onToggleTag(tag: PaperlessTag) {
|
||||||
this.correspondentService.listAll().subscribe(result => {this.correspondents = result.results})
|
this.filterEditorService.toggleFilterByTag(tag)
|
||||||
this.tagService.listAll().subscribe(result => this.tags = result.results)
|
this.applyFilters()
|
||||||
this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getRuleTypes() {
|
onToggleCorrespondent(correspondent: PaperlessCorrespondent) {
|
||||||
return FILTER_RULE_TYPES.filter(rt => rt.multi || !this.filterRules.find(r => r.type == rt))
|
this.filterEditorService.toggleFilterByCorrespondent(correspondent)
|
||||||
|
this.applyFilters()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onToggleDocumentType(documentType: PaperlessDocumentType) {
|
||||||
|
this.filterEditorService.toggleFilterByDocumentType(documentType)
|
||||||
|
this.applyFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateCreatedBeforeSet(date: NgbDateStruct) {
|
||||||
|
this.filterEditorService.setDateCreatedBefore(date)
|
||||||
|
this.applyFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateCreatedAfterSet(date: NgbDateStruct) {
|
||||||
|
this.filterEditorService.setDateCreatedAfter(date)
|
||||||
|
this.applyFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateAddedBeforeSet(date: NgbDateStruct) {
|
||||||
|
this.filterEditorService.setDateAddedBefore(date)
|
||||||
|
this.applyFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateAddedAfterSet(date: NgbDateStruct) {
|
||||||
|
this.filterEditorService.setDateAddedAfter(date)
|
||||||
|
this.applyFilters()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import { MatchingModel, MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/mat
|
|||||||
import { ObjectWithId } from 'src/app/data/object-with-id';
|
import { ObjectWithId } from 'src/app/data/object-with-id';
|
||||||
import { SortableDirective, SortEvent } from 'src/app/directives/sortable.directive';
|
import { SortableDirective, SortEvent } from 'src/app/directives/sortable.directive';
|
||||||
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service';
|
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service';
|
||||||
import { DeleteDialogComponent } from '../../common/delete-dialog/delete-dialog.component';
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Directive()
|
@Directive()
|
||||||
export abstract class GenericListComponent<T extends ObjectWithId> implements OnInit {
|
export abstract class GenericListComponent<T extends ObjectWithId> implements OnInit {
|
||||||
@ -88,10 +88,13 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On
|
|||||||
}
|
}
|
||||||
|
|
||||||
openDeleteDialog(object: T) {
|
openDeleteDialog(object: T) {
|
||||||
var activeModal = this.modalService.open(DeleteDialogComponent, {backdrop: 'static'})
|
var activeModal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
|
||||||
activeModal.componentInstance.message = `Do you really want to delete ${this.getObjectName(object)}?`
|
activeModal.componentInstance.title = "Confirm delete"
|
||||||
activeModal.componentInstance.message2 = "Associated documents will not be deleted."
|
activeModal.componentInstance.messageBold = `Do you really want to delete ${this.getObjectName(object)}?`
|
||||||
activeModal.componentInstance.deleteClicked.subscribe(() => {
|
activeModal.componentInstance.message = "Associated documents will not be deleted."
|
||||||
|
activeModal.componentInstance.btnClass = "btn-danger"
|
||||||
|
activeModal.componentInstance.btnCaption = "Delete"
|
||||||
|
activeModal.componentInstance.confirmPressed.subscribe(() => {
|
||||||
this.service.delete(object).subscribe(_ => {
|
this.service.delete(object).subscribe(_ => {
|
||||||
activeModal.close()
|
activeModal.close()
|
||||||
this.reloadData()
|
this.reloadData()
|
||||||
|
@ -22,15 +22,15 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
|||||||
|
|
||||||
{id: FILTER_TITLE, name: "Title contains", filtervar: "title__icontains", datatype: "string", multi: false, default: ""},
|
{id: FILTER_TITLE, name: "Title contains", filtervar: "title__icontains", datatype: "string", multi: false, default: ""},
|
||||||
{id: FILTER_CONTENT, name: "Content contains", filtervar: "content__icontains", datatype: "string", multi: false, default: ""},
|
{id: FILTER_CONTENT, name: "Content contains", filtervar: "content__icontains", datatype: "string", multi: false, default: ""},
|
||||||
|
|
||||||
{id: FILTER_ASN, name: "ASN is", filtervar: "archive_serial_number", datatype: "number", multi: false},
|
{id: FILTER_ASN, name: "ASN is", filtervar: "archive_serial_number", datatype: "number", multi: false},
|
||||||
|
|
||||||
{id: FILTER_CORRESPONDENT, name: "Correspondent is", filtervar: "correspondent__id", datatype: "correspondent", multi: false},
|
{id: FILTER_CORRESPONDENT, name: "Correspondent is", filtervar: "correspondent__id", datatype: "correspondent", multi: false},
|
||||||
{id: FILTER_DOCUMENT_TYPE, name: "Document type is", filtervar: "document_type__id", datatype: "document_type", multi: false},
|
{id: FILTER_DOCUMENT_TYPE, name: "Document type is", filtervar: "document_type__id", datatype: "document_type", multi: false},
|
||||||
|
|
||||||
{id: FILTER_IS_IN_INBOX, name: "Is in Inbox", filtervar: "is_in_inbox", datatype: "boolean", multi: false, default: true},
|
{id: FILTER_IS_IN_INBOX, name: "Is in Inbox", filtervar: "is_in_inbox", datatype: "boolean", multi: false, default: true},
|
||||||
{id: FILTER_HAS_TAG, name: "Has tag", filtervar: "tags__id__all", datatype: "tag", multi: true},
|
{id: FILTER_HAS_TAG, name: "Has tag", filtervar: "tags__id__all", datatype: "tag", multi: true},
|
||||||
{id: FILTER_DOES_NOT_HAVE_TAG, name: "Does not have tag", filtervar: "tags__id__none", datatype: "tag", multi: true},
|
{id: FILTER_DOES_NOT_HAVE_TAG, name: "Does not have tag", filtervar: "tags__id__none", datatype: "tag", multi: true},
|
||||||
{id: FILTER_HAS_ANY_TAG, name: "Has any tag", filtervar: "is_tagged", datatype: "boolean", multi: false, default: true},
|
{id: FILTER_HAS_ANY_TAG, name: "Has any tag", filtervar: "is_tagged", datatype: "boolean", multi: false, default: true},
|
||||||
|
|
||||||
{id: FILTER_CREATED_BEFORE, name: "Created before", filtervar: "created__date__lt", datatype: "date", multi: false},
|
{id: FILTER_CREATED_BEFORE, name: "Created before", filtervar: "created__date__lt", datatype: "date", multi: false},
|
||||||
@ -42,7 +42,7 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
|||||||
|
|
||||||
{id: FILTER_ADDED_BEFORE, name: "Added before", filtervar: "added__date__lt", datatype: "date", multi: false},
|
{id: FILTER_ADDED_BEFORE, name: "Added before", filtervar: "added__date__lt", datatype: "date", multi: false},
|
||||||
{id: FILTER_ADDED_AFTER, name: "Added after", filtervar: "added__date__gt", datatype: "date", multi: false},
|
{id: FILTER_ADDED_AFTER, name: "Added after", filtervar: "added__date__gt", datatype: "date", multi: false},
|
||||||
|
|
||||||
{id: FILTER_MODIFIED_BEFORE, name: "Modified before", filtervar: "modified__date__lt", datatype: "date", multi: false},
|
{id: FILTER_MODIFIED_BEFORE, name: "Modified before", filtervar: "modified__date__lt", datatype: "date", multi: false},
|
||||||
{id: FILTER_MODIFIED_AFTER, name: "Modified after", filtervar: "modified__date__gt", datatype: "date", multi: false},
|
{id: FILTER_MODIFIED_AFTER, name: "Modified after", filtervar: "modified__date__gt", datatype: "date", multi: false},
|
||||||
]
|
]
|
||||||
@ -54,4 +54,4 @@ export interface FilterRuleType {
|
|||||||
datatype: string //number, string, boolean, date
|
datatype: string //number, string, boolean, date
|
||||||
multi: boolean
|
multi: boolean
|
||||||
default?: any
|
default?: any
|
||||||
}
|
}
|
||||||
|
17
src-ui/src/app/pipes/filter.pipe.ts
Normal file
17
src-ui/src/app/pipes/filter.pipe.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'filter'
|
||||||
|
})
|
||||||
|
export class FilterPipe implements PipeTransform {
|
||||||
|
transform(items: any[], searchText: string): any[] {
|
||||||
|
if (!items) return [];
|
||||||
|
if (!searchText) return items;
|
||||||
|
|
||||||
|
return items.filter(item => {
|
||||||
|
return Object.keys(item).some(key => {
|
||||||
|
return String(item[key]).toLowerCase().includes(searchText.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +0,0 @@
|
|||||||
import { SafePipe } from './safe.pipe';
|
|
||||||
|
|
||||||
describe('SafePipe', () => {
|
|
||||||
it('create an instance', () => {
|
|
||||||
const pipe = new SafePipe();
|
|
||||||
expect(pipe).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,19 +0,0 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
|
||||||
import { DomSanitizer } from '@angular/platform-browser';
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'safe'
|
|
||||||
})
|
|
||||||
export class SafePipe implements PipeTransform {
|
|
||||||
|
|
||||||
constructor(private sanitizer: DomSanitizer) { }
|
|
||||||
|
|
||||||
transform(url) {
|
|
||||||
if (url == null) {
|
|
||||||
return this.sanitizer.bypassSecurityTrustResourceUrl("")
|
|
||||||
} else {
|
|
||||||
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -9,7 +9,7 @@ import { DocumentService } from './rest/document.service';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* This service manages the document list which is displayed using the document list view.
|
* 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
|
* This service also serves saved views by transparently switching between the document list
|
||||||
* and saved views on request. See below.
|
* and saved views on request. See below.
|
||||||
*/
|
*/
|
||||||
@ -25,7 +25,7 @@ export class DocumentListViewService {
|
|||||||
currentPage = 1
|
currentPage = 1
|
||||||
currentPageSize: number = +localStorage.getItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE) || GENERAL_SETTINGS.DOCUMENT_LIST_SIZE_DEFAULT
|
currentPageSize: number = +localStorage.getItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE) || GENERAL_SETTINGS.DOCUMENT_LIST_SIZE_DEFAULT
|
||||||
collectionSize: number
|
collectionSize: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the current config for the document list. The service will always remember the last settings used for the document list.
|
* This is the current config for the document list. The service will always remember the last settings used for the document list.
|
||||||
*/
|
*/
|
||||||
@ -192,7 +192,7 @@ export class DocumentListViewService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private documentService: DocumentService) {
|
constructor(private documentService: DocumentService) {
|
||||||
let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
|
let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
|
||||||
if (documentListViewConfigJson) {
|
if (documentListViewConfigJson) {
|
||||||
try {
|
try {
|
||||||
|
16
src-ui/src/app/services/filter-editor-view.service.spec.ts
Normal file
16
src-ui/src/app/services/filter-editor-view.service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { FilterEditorViewService } from './filter-editor-view.service';
|
||||||
|
|
||||||
|
describe('FilterEditorViewService', () => {
|
||||||
|
let service: FilterEditorViewService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(FilterEditorViewService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
182
src-ui/src/app/services/filter-editor-view.service.ts
Normal file
182
src-ui/src/app/services/filter-editor-view.service.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { TagService } from 'src/app/services/rest/tag.service';
|
||||||
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
|
||||||
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
|
||||||
|
import { ObjectWithId } from 'src/app/data/object-with-id';
|
||||||
|
import { FilterRule } from 'src/app/data/filter-rule';
|
||||||
|
import { FilterRuleType, FILTER_RULE_TYPES, FILTER_CORRESPONDENT, FILTER_DOCUMENT_TYPE, FILTER_HAS_TAG, FILTER_TITLE, FILTER_ADDED_BEFORE, FILTER_ADDED_AFTER, FILTER_CREATED_BEFORE, FILTER_CREATED_AFTER, FILTER_CREATED_YEAR, FILTER_CREATED_MONTH, FILTER_CREATED_DAY } from 'src/app/data/filter-rule-type';
|
||||||
|
import { Results } from 'src/app/data/results'
|
||||||
|
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 { NgbDate, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FilterEditorViewService {
|
||||||
|
|
||||||
|
tags: PaperlessTag[] = []
|
||||||
|
correspondents: PaperlessCorrespondent[]
|
||||||
|
documentTypes: PaperlessDocumentType[] = []
|
||||||
|
|
||||||
|
filterRules: FilterRule[] = []
|
||||||
|
|
||||||
|
constructor(private tagService: TagService, private documentTypeService: DocumentTypeService, private correspondentService: CorrespondentService) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.filterRules = []
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFilters() {
|
||||||
|
return this.filterRules.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
set titleFilter(title: string) {
|
||||||
|
let existingRule = this.filterRules.find(rule => rule.type.id == FILTER_TITLE)
|
||||||
|
|
||||||
|
if (!existingRule && title) {
|
||||||
|
this.filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_TITLE), value: title})
|
||||||
|
} else if (existingRule && !title) {
|
||||||
|
this.filterRules.splice(this.filterRules.findIndex(rule => rule.type.id == FILTER_TITLE), 1)
|
||||||
|
} else if (existingRule && title) {
|
||||||
|
existingRule.value = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get titleFilter(): string {
|
||||||
|
let existingRule = this.filterRules.find(rule => rule.type.id == FILTER_TITLE)
|
||||||
|
return existingRule ? existingRule.value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedTags(): PaperlessTag[] {
|
||||||
|
let tagRules: FilterRule[] = this.filterRules.filter(fr => fr.type.id == FILTER_HAS_TAG)
|
||||||
|
return this.tags?.filter(t => tagRules.find(tr => tr.value == t.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedCorrespondents(): PaperlessCorrespondent[] {
|
||||||
|
let correspondentRules: FilterRule[] = this.filterRules.filter(fr => fr.type.id == FILTER_CORRESPONDENT)
|
||||||
|
return this.correspondents?.filter(c => correspondentRules.find(cr => cr.value == c.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedDocumentTypes(): PaperlessDocumentType[] {
|
||||||
|
let documentTypeRules: FilterRule[] = this.filterRules.filter(fr => fr.type.id == FILTER_DOCUMENT_TYPE)
|
||||||
|
return this.documentTypes?.filter(dt => documentTypeRules.find(dtr => dtr.value == dt.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFilterByTag(tag: PaperlessTag | number) {
|
||||||
|
if (typeof tag == 'number') tag = this.tags?.find(t => t.id == tag)
|
||||||
|
this.toggleFilterByItem(tag, FILTER_HAS_TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFilterByCorrespondent(correspondent: PaperlessCorrespondent | number) {
|
||||||
|
if (typeof correspondent == 'number') correspondent = this.correspondents?.find(t => t.id == correspondent)
|
||||||
|
this.toggleFilterByItem(correspondent, FILTER_CORRESPONDENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFilterByDocumentType(documentType: PaperlessDocumentType | number) {
|
||||||
|
if (typeof documentType == 'number') documentType = this.documentTypes?.find(t => t.id == documentType)
|
||||||
|
this.toggleFilterByItem(documentType, FILTER_DOCUMENT_TYPE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleFilterByItem(item: ObjectWithId, filterRuleTypeID: number) {
|
||||||
|
let filterRules = this.filterRules
|
||||||
|
let filterRuleType: FilterRuleType = FILTER_RULE_TYPES.find(t => t.id == filterRuleTypeID)
|
||||||
|
let existingRules = filterRules.filter(rule => rule.type.id == filterRuleType.id)
|
||||||
|
let existingItemRule = existingRules?.find(rule => rule.value == item.id)
|
||||||
|
|
||||||
|
if (existingRules && existingItemRule) { // if exact rule exists just remove
|
||||||
|
filterRules.splice(filterRules.indexOf(existingItemRule), 1)
|
||||||
|
} else if (existingRules.length > 0 && filterRuleType.multi) { // e.g. tags can have multiple
|
||||||
|
filterRules.push({type: filterRuleType, value: item.id})
|
||||||
|
} else if (existingRules.length > 0) { // correspondents & documentTypes can only be one
|
||||||
|
filterRules.find(rule => rule.type.id == filterRuleType.id).value = item.id
|
||||||
|
} else {
|
||||||
|
filterRules.push({type: filterRuleType, value: item.id})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filterRules = filterRules
|
||||||
|
}
|
||||||
|
|
||||||
|
get dateCreatedBefore(): NgbDateStruct {
|
||||||
|
let createdBeforeRule: FilterRule = this.filterRules.find(fr => fr.type.id == FILTER_CREATED_BEFORE)
|
||||||
|
return createdBeforeRule ? {
|
||||||
|
year: createdBeforeRule.value.substring(0,4),
|
||||||
|
month: createdBeforeRule.value.substring(5,7),
|
||||||
|
day: createdBeforeRule.value.substring(8,10)
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
get dateCreatedAfter(): NgbDateStruct {
|
||||||
|
let createdAfterRule: FilterRule = this.filterRules.find(fr => fr.type.id == FILTER_CREATED_AFTER)
|
||||||
|
return createdAfterRule ? {
|
||||||
|
year: createdAfterRule.value.substring(0,4),
|
||||||
|
month: createdAfterRule.value.substring(5,7),
|
||||||
|
day: createdAfterRule.value.substring(8,10)
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
get dateAddedBefore(): NgbDateStruct {
|
||||||
|
let addedBeforeRule: FilterRule = this.filterRules.find(fr => fr.type.id == FILTER_ADDED_BEFORE)
|
||||||
|
return addedBeforeRule ? {
|
||||||
|
year: addedBeforeRule.value.substring(0,4),
|
||||||
|
month: addedBeforeRule.value.substring(5,7),
|
||||||
|
day: addedBeforeRule.value.substring(8,10)
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
get dateAddedAfter(): NgbDateStruct {
|
||||||
|
let addedAfterRule: FilterRule = this.filterRules.find(fr => fr.type.id == FILTER_ADDED_AFTER)
|
||||||
|
return addedAfterRule ? {
|
||||||
|
year: addedAfterRule.value.substring(0,4),
|
||||||
|
month: addedAfterRule.value.substring(5,7),
|
||||||
|
day: addedAfterRule.value.substring(8,10)
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateCreatedBefore(date?: NgbDateStruct) {
|
||||||
|
if (date) this.setDateFilter(date, FILTER_CREATED_BEFORE)
|
||||||
|
else this.clearDateFilter(FILTER_CREATED_BEFORE)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateCreatedAfter(date?: NgbDateStruct) {
|
||||||
|
if (date) this.setDateFilter(date, FILTER_CREATED_AFTER)
|
||||||
|
else this.clearDateFilter(FILTER_CREATED_AFTER)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateAddedBefore(date?: NgbDateStruct) {
|
||||||
|
if (date) this.setDateFilter(date, FILTER_ADDED_BEFORE)
|
||||||
|
else this.clearDateFilter(FILTER_ADDED_BEFORE)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateAddedAfter(date?: NgbDateStruct) {
|
||||||
|
if (date) this.setDateFilter(date, FILTER_ADDED_AFTER)
|
||||||
|
else this.clearDateFilter(FILTER_ADDED_AFTER)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateFilter(date: NgbDateStruct, dateRuleTypeID: number) {
|
||||||
|
let filterRules = this.filterRules
|
||||||
|
let existingRule = filterRules.find(rule => rule.type.id == dateRuleTypeID)
|
||||||
|
let newValue = `${date.year}-${date.month.toString().padStart(2,'0')}-${date.day.toString().padStart(2,'0')}` // YYYY-MM-DD
|
||||||
|
|
||||||
|
if (existingRule) {
|
||||||
|
existingRule.value = newValue
|
||||||
|
} else {
|
||||||
|
filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == dateRuleTypeID), value: newValue})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filterRules = filterRules
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDateFilter(dateRuleTypeID: number) {
|
||||||
|
let filterRules = this.filterRules
|
||||||
|
let existingRule = filterRules.find(rule => rule.type.id == dateRuleTypeID)
|
||||||
|
filterRules.splice(filterRules.indexOf(existingRule), 1)
|
||||||
|
this.filterRules = filterRules
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,12 @@ from django.conf import settings
|
|||||||
from django.template.defaultfilters import slugify
|
from django.template.defaultfilters import slugify
|
||||||
|
|
||||||
|
|
||||||
|
class defaultdictNoStr(defaultdict):
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
raise ValueError("Don't use {tags} directly.")
|
||||||
|
|
||||||
|
|
||||||
def create_source_path_directory(source_path):
|
def create_source_path_directory(source_path):
|
||||||
os.makedirs(os.path.dirname(source_path), exist_ok=True)
|
os.makedirs(os.path.dirname(source_path), exist_ok=True)
|
||||||
|
|
||||||
@ -90,8 +96,8 @@ def generate_filename(doc, counter=0):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if settings.PAPERLESS_FILENAME_FORMAT is not None:
|
if settings.PAPERLESS_FILENAME_FORMAT is not None:
|
||||||
tags = defaultdict(lambda: slugify(None),
|
tags = defaultdictNoStr(lambda: slugify(None),
|
||||||
many_to_dictionary(doc.tags))
|
many_to_dictionary(doc.tags))
|
||||||
|
|
||||||
if doc.correspondent:
|
if doc.correspondent:
|
||||||
correspondent = pathvalidate.sanitize_filename(
|
correspondent = pathvalidate.sanitize_filename(
|
||||||
@ -114,14 +120,18 @@ def generate_filename(doc, counter=0):
|
|||||||
document_type=document_type,
|
document_type=document_type,
|
||||||
created=datetime.date.isoformat(doc.created),
|
created=datetime.date.isoformat(doc.created),
|
||||||
created_year=doc.created.year if doc.created else "none",
|
created_year=doc.created.year if doc.created else "none",
|
||||||
created_month=doc.created.month if doc.created else "none",
|
created_month=f"{doc.created.month:02}" if doc.created else "none", # NOQA: E501
|
||||||
created_day=doc.created.day if doc.created else "none",
|
created_day=f"{doc.created.day:02}" if doc.created else "none",
|
||||||
added=datetime.date.isoformat(doc.added),
|
added=datetime.date.isoformat(doc.added),
|
||||||
added_year=doc.added.year if doc.added else "none",
|
added_year=doc.added.year if doc.added else "none",
|
||||||
added_month=doc.added.month if doc.added else "none",
|
added_month=f"{doc.added.month:02}" if doc.added else "none",
|
||||||
added_day=doc.added.day if doc.added else "none",
|
added_day=f"{doc.added.day:02}" if doc.added else "none",
|
||||||
tags=tags,
|
tags=tags,
|
||||||
)
|
tag_list=",".join([tag.name for tag in doc.tags.all()])
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
path = path.strip(os.sep)
|
||||||
|
|
||||||
except (ValueError, KeyError, IndexError):
|
except (ValueError, KeyError, IndexError):
|
||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
f"Invalid PAPERLESS_FILENAME_FORMAT: "
|
f"Invalid PAPERLESS_FILENAME_FORMAT: "
|
||||||
|
@ -2,6 +2,7 @@ import logging
|
|||||||
|
|
||||||
import tqdm
|
import tqdm
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from ...mixins import Renderable
|
from ...mixins import Renderable
|
||||||
@ -24,5 +25,4 @@ class Command(Renderable, BaseCommand):
|
|||||||
logging.getLogger().handlers[0].level = logging.ERROR
|
logging.getLogger().handlers[0].level = logging.ERROR
|
||||||
|
|
||||||
for document in tqdm.tqdm(Document.objects.all()):
|
for document in tqdm.tqdm(Document.objects.all()):
|
||||||
# Saving the document again will generate a new filename and rename
|
post_save.send(Document, instance=document)
|
||||||
document.save()
|
|
||||||
|
@ -13,7 +13,7 @@ from django.test import TestCase, override_settings
|
|||||||
from .utils import DirectoriesMixin
|
from .utils import DirectoriesMixin
|
||||||
from ..file_handling import generate_filename, create_source_path_directory, delete_empty_directories, \
|
from ..file_handling import generate_filename, create_source_path_directory, delete_empty_directories, \
|
||||||
generate_unique_filename
|
generate_unique_filename
|
||||||
from ..models import Document, Correspondent
|
from ..models import Document, Correspondent, Tag
|
||||||
|
|
||||||
|
|
||||||
class TestFileHandling(DirectoriesMixin, TestCase):
|
class TestFileHandling(DirectoriesMixin, TestCase):
|
||||||
@ -267,6 +267,57 @@ class TestFileHandling(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(generate_filename(document),
|
self.assertEqual(generate_filename(document),
|
||||||
"none.pdf")
|
"none.pdf")
|
||||||
|
|
||||||
|
@override_settings(PAPERLESS_FILENAME_FORMAT="{tags}")
|
||||||
|
def test_tags_without_args(self):
|
||||||
|
document = Document()
|
||||||
|
document.mime_type = "application/pdf"
|
||||||
|
document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
|
||||||
|
document.save()
|
||||||
|
|
||||||
|
self.assertEqual(generate_filename(document), f"{document.pk:07}.pdf")
|
||||||
|
|
||||||
|
@override_settings(PAPERLESS_FILENAME_FORMAT="{title} {tag_list}")
|
||||||
|
def test_tag_list(self):
|
||||||
|
doc = Document.objects.create(title="doc1", mime_type="application/pdf")
|
||||||
|
doc.tags.create(name="tag2")
|
||||||
|
doc.tags.create(name="tag1")
|
||||||
|
|
||||||
|
self.assertEqual(generate_filename(doc), "doc1 tag1,tag2.pdf")
|
||||||
|
|
||||||
|
doc = Document.objects.create(title="doc2", checksum="B", mime_type="application/pdf")
|
||||||
|
|
||||||
|
self.assertEqual(generate_filename(doc), "doc2.pdf")
|
||||||
|
|
||||||
|
@override_settings(PAPERLESS_FILENAME_FORMAT="//etc/something/{title}")
|
||||||
|
def test_filename_relative(self):
|
||||||
|
doc = Document.objects.create(title="doc1", mime_type="application/pdf")
|
||||||
|
doc.filename = generate_filename(doc)
|
||||||
|
doc.save()
|
||||||
|
|
||||||
|
self.assertEqual(doc.source_path, os.path.join(settings.ORIGINALS_DIR, "etc", "something", "doc1.pdf"))
|
||||||
|
|
||||||
|
@override_settings(PAPERLESS_FILENAME_FORMAT="{created_year}-{created_month}-{created_day}")
|
||||||
|
def test_created_year_month_day(self):
|
||||||
|
d1 = datetime.datetime(2020, 3, 6, 1, 1, 1)
|
||||||
|
doc1 = Document.objects.create(title="doc1", mime_type="application/pdf", created=d1)
|
||||||
|
|
||||||
|
self.assertEqual(generate_filename(doc1), "2020-03-06.pdf")
|
||||||
|
|
||||||
|
doc1.created = datetime.datetime(2020, 11, 16, 1, 1, 1)
|
||||||
|
|
||||||
|
self.assertEqual(generate_filename(doc1), "2020-11-16.pdf")
|
||||||
|
|
||||||
|
@override_settings(PAPERLESS_FILENAME_FORMAT="{added_year}-{added_month}-{added_day}")
|
||||||
|
def test_added_year_month_day(self):
|
||||||
|
d1 = datetime.datetime(232, 1, 9, 1, 1, 1)
|
||||||
|
doc1 = Document.objects.create(title="doc1", mime_type="application/pdf", added=d1)
|
||||||
|
|
||||||
|
self.assertEqual(generate_filename(doc1), "232-01-09.pdf")
|
||||||
|
|
||||||
|
doc1.added = datetime.datetime(2020, 11, 16, 1, 1, 1)
|
||||||
|
|
||||||
|
self.assertEqual(generate_filename(doc1), "2020-11-16.pdf")
|
||||||
|
|
||||||
@override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}/{correspondent}")
|
@override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}/{correspondent}")
|
||||||
def test_nested_directory_cleanup(self):
|
def test_nested_directory_cleanup(self):
|
||||||
document = Document()
|
document = Document()
|
||||||
|
@ -110,6 +110,24 @@ class RasterisedDocumentParser(DocumentParser):
|
|||||||
f"Error while getting DPI from image {image}: {e}")
|
f"Error while getting DPI from image {image}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def calculate_a4_dpi(self, image):
|
||||||
|
try:
|
||||||
|
with Image.open(image) as im:
|
||||||
|
width, height = im.size
|
||||||
|
# divide image width by A4 width (210mm) in inches.
|
||||||
|
dpi = int(width / (21 / 2.54))
|
||||||
|
self.log(
|
||||||
|
'debug',
|
||||||
|
f"Estimated DPI {dpi} based on image width {width}"
|
||||||
|
)
|
||||||
|
return dpi
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log(
|
||||||
|
'warning',
|
||||||
|
f"Error while calculating DPI for image {image}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def parse(self, document_path, mime_type):
|
def parse(self, document_path, mime_type):
|
||||||
mode = settings.OCR_MODE
|
mode = settings.OCR_MODE
|
||||||
|
|
||||||
@ -162,6 +180,7 @@ class RasterisedDocumentParser(DocumentParser):
|
|||||||
|
|
||||||
if self.is_image(mime_type):
|
if self.is_image(mime_type):
|
||||||
dpi = self.get_dpi(document_path)
|
dpi = self.get_dpi(document_path)
|
||||||
|
a4_dpi = self.calculate_a4_dpi(document_path)
|
||||||
if dpi:
|
if dpi:
|
||||||
self.log(
|
self.log(
|
||||||
"debug",
|
"debug",
|
||||||
@ -170,6 +189,8 @@ class RasterisedDocumentParser(DocumentParser):
|
|||||||
ocr_args['image_dpi'] = dpi
|
ocr_args['image_dpi'] = dpi
|
||||||
elif settings.OCR_IMAGE_DPI:
|
elif settings.OCR_IMAGE_DPI:
|
||||||
ocr_args['image_dpi'] = settings.OCR_IMAGE_DPI
|
ocr_args['image_dpi'] = settings.OCR_IMAGE_DPI
|
||||||
|
elif a4_dpi:
|
||||||
|
ocr_args['image_dpi'] = a4_dpi
|
||||||
else:
|
else:
|
||||||
raise ParseError(
|
raise ParseError(
|
||||||
f"Cannot produce archive PDF for image {document_path}, "
|
f"Cannot produce archive PDF for image {document_path}, "
|
||||||
@ -241,6 +262,9 @@ def strip_excess_whitespace(text):
|
|||||||
|
|
||||||
def get_text_from_pdf(pdf_file):
|
def get_text_from_pdf(pdf_file):
|
||||||
|
|
||||||
|
if not os.path.isfile(pdf_file):
|
||||||
|
return None
|
||||||
|
|
||||||
with open(pdf_file, "rb") as f:
|
with open(pdf_file, "rb") as f:
|
||||||
try:
|
try:
|
||||||
pdf = pdftotext.PDF(f)
|
pdf = pdftotext.PDF(f)
|
||||||
|
@ -164,8 +164,21 @@ class TestParser(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
self.assertRaises(ParseError, f)
|
self.assertRaises(ParseError, f)
|
||||||
|
|
||||||
|
@mock.patch("paperless_tesseract.parsers.ocrmypdf.ocr")
|
||||||
|
def test_image_calc_a4_dpi(self, m):
|
||||||
|
parser = RasterisedDocumentParser(None)
|
||||||
|
|
||||||
def test_image_no_dpi_fail(self):
|
parser.parse(os.path.join(self.SAMPLE_FILES, "simple-no-dpi.png"), "image/png")
|
||||||
|
|
||||||
|
m.assert_called_once()
|
||||||
|
|
||||||
|
args, kwargs = m.call_args
|
||||||
|
|
||||||
|
self.assertEqual(kwargs['image_dpi'], 62)
|
||||||
|
|
||||||
|
@mock.patch("paperless_tesseract.parsers.RasterisedDocumentParser.calculate_a4_dpi")
|
||||||
|
def test_image_dpi_fail(self, m):
|
||||||
|
m.return_value = None
|
||||||
parser = RasterisedDocumentParser(None)
|
parser = RasterisedDocumentParser(None)
|
||||||
|
|
||||||
def f():
|
def f():
|
||||||
|
Loading…
x
Reference in New Issue
Block a user