mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge branch 'dev' into quick-filters
This commit is contained in:
		| @@ -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. | ||||||
|   | |||||||
| @@ -25,6 +25,11 @@ wait_for_postgres() { | |||||||
| 	host="${PAPERLESS_DBHOST}" | 	host="${PAPERLESS_DBHOST}" | ||||||
| 	port="${PAPERLESS_DBPORT}" | 	port="${PAPERLESS_DBPORT}" | ||||||
|  |  | ||||||
|  | 	if [[ -z $port ]] ; | ||||||
|  | 	then | ||||||
|  | 		port="5432" | ||||||
|  | 	fi | ||||||
|  |  | ||||||
| 	while !</dev/tcp/$host/$port ; | 	while !</dev/tcp/$host/$port ; | ||||||
| 	do | 	do | ||||||
|  |  | ||||||
| @@ -114,13 +119,13 @@ install_languages() { | |||||||
|     done |     done | ||||||
| } | } | ||||||
|  |  | ||||||
| initialize |  | ||||||
|  |  | ||||||
| # Install additional languages if specified | # Install additional languages if specified | ||||||
| if [[ ! -z "$PAPERLESS_OCR_LANGUAGES"  ]]; then | if [[ ! -z "$PAPERLESS_OCR_LANGUAGES"  ]]; then | ||||||
| 		install_languages "$PAPERLESS_OCR_LANGUAGES" | 		install_languages "$PAPERLESS_OCR_LANGUAGES" | ||||||
| fi | fi | ||||||
|  |  | ||||||
|  | initialize | ||||||
|  |  | ||||||
| if [[ "$1" != "/"* ]]; then | if [[ "$1" != "/"* ]]; then | ||||||
| 	exec sudo -HEu paperless python3 manage.py "$@" | 	exec sudo -HEu paperless python3 manage.py "$@" | ||||||
| else | else | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ services: | |||||||
|       POSTGRES_PASSWORD: paperless |       POSTGRES_PASSWORD: paperless | ||||||
|  |  | ||||||
|   webserver: |   webserver: | ||||||
|     image: jonaswinkler/paperless-ng:0.9.5 |     image: jonaswinkler/paperless-ng:0.9.6 | ||||||
|     restart: always |     restart: always | ||||||
|     depends_on: |     depends_on: | ||||||
|       - db |       - db | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ services: | |||||||
|     restart: always |     restart: always | ||||||
|  |  | ||||||
|   webserver: |   webserver: | ||||||
|     image: jonaswinkler/paperless-ng:0.9.5 |     image: jonaswinkler/paperless-ng:0.9.6 | ||||||
|     restart: always |     restart: always | ||||||
|     depends_on: |     depends_on: | ||||||
|       - broker |       - broker | ||||||
|   | |||||||
| @@ -32,6 +32,7 @@ This release focusses primarily on many small issues with the UI. | |||||||
| * Other | * Other | ||||||
|  |  | ||||||
|   * Fixed an issue with the docker image when a non-standard PostgreSQL port was used. |   * Fixed an issue with the docker image when a non-standard PostgreSQL port was used. | ||||||
|  |   * The docker image was trying check for installed languages before actually installing them. | ||||||
|   * ``FILENAME_FORMAT`` placeholder for document types. |   * ``FILENAME_FORMAT`` placeholder for document types. | ||||||
|   * The filename formatter is now less restrictive with file names and tries to |   * The filename formatter is now less restrictive with file names and tries to | ||||||
|     conserve the original correspondents, types and titles as much as possible. |     conserve the original correspondents, types and titles as much as possible. | ||||||
|   | |||||||
| @@ -152,6 +152,16 @@ PAPERLESS_AUTO_LOGIN_USERNAME=<username> | |||||||
|  |  | ||||||
|     Defaults to none, which disables this feature. |     Defaults to none, which disables this feature. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | PAPERLESS_COOKIE_PREFIX=<str> | ||||||
|  |     Specify a prefix that is added to the cookies used by paperless to identify | ||||||
|  |     the currently logged in user. This is useful for when you're running two | ||||||
|  |     instances of paperless on the same host. | ||||||
|  |  | ||||||
|  |     After changing this, you will have to login again. | ||||||
|  |  | ||||||
|  |     Defaults to ``""``, which does not alter the cookie names. | ||||||
|  |  | ||||||
| .. _configuration-ocr: | .. _configuration-ocr: | ||||||
|  |  | ||||||
| OCR settings | OCR settings | ||||||
|   | |||||||
| @@ -30,6 +30,7 @@ | |||||||
| #PAPERLESS_FORCE_SCRIPT_NAME= | #PAPERLESS_FORCE_SCRIPT_NAME= | ||||||
| #PAPERLESS_STATIC_URL=/static/ | #PAPERLESS_STATIC_URL=/static/ | ||||||
| #PAPERLESS_AUTO_LOGIN_USERNAME= | #PAPERLESS_AUTO_LOGIN_USERNAME= | ||||||
|  | #PAPERLESS_COOKIE_PREFIX= | ||||||
|  |  | ||||||
| # OCR settings | # OCR settings | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										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'; | ||||||
| @@ -48,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 { FilterPipe } from './pipes/filter.pipe'; | ||||||
|  | import { DocumentTitlePipe } from './pipes/document-title.pipe'; | ||||||
|  | import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component'; | ||||||
|  |  | ||||||
| @NgModule({ | @NgModule({ | ||||||
|   declarations: [ |   declarations: [ | ||||||
| @@ -64,10 +66,9 @@ import { FilterPipe } from './pipes/filter.pipe'; | |||||||
|     DocumentTypeListComponent, |     DocumentTypeListComponent, | ||||||
|     LogsComponent, |     LogsComponent, | ||||||
|     SettingsComponent, |     SettingsComponent, | ||||||
|     SafePipe, |  | ||||||
|     NotFoundComponent, |     NotFoundComponent, | ||||||
|     CorrespondentEditDialogComponent, |     CorrespondentEditDialogComponent, | ||||||
|     DeleteDialogComponent, |     ConfirmDialogComponent, | ||||||
|     TagEditDialogComponent, |     TagEditDialogComponent, | ||||||
|     DocumentTypeEditDialogComponent, |     DocumentTypeEditDialogComponent, | ||||||
|     TagComponent, |     TagComponent, | ||||||
| @@ -97,6 +98,8 @@ import { FilterPipe } from './pipes/filter.pipe'; | |||||||
|     YesNoPipe, |     YesNoPipe, | ||||||
|     FileSizePipe, |     FileSizePipe, | ||||||
|     FilterPipe |     FilterPipe | ||||||
|  |     DocumentTitlePipe, | ||||||
|  |     MetadataCollapseComponent | ||||||
|   ], |   ], | ||||||
|   imports: [ |   imports: [ | ||||||
|     BrowserModule, |     BrowserModule, | ||||||
| @@ -106,7 +109,8 @@ import { FilterPipe } from './pipes/filter.pipe'; | |||||||
|     FormsModule, |     FormsModule, | ||||||
|     ReactiveFormsModule, |     ReactiveFormsModule, | ||||||
|     NgxFileDropModule, |     NgxFileDropModule, | ||||||
|     InfiniteScrollModule |     InfiniteScrollModule, | ||||||
|  |     PdfViewerModule | ||||||
|   ], |   ], | ||||||
|   providers: [ |   providers: [ | ||||||
|     DatePipe, |     DatePipe, | ||||||
|   | |||||||
| @@ -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() |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -8,7 +8,7 @@ | |||||||
|  |  | ||||||
|     <div class="input-group-append" ngbDropdown placement="top-right"> |     <div class="input-group-append" ngbDropdown placement="top-right"> | ||||||
|       <button class="btn btn-outline-secondary" type="button" ngbDropdownToggle></button> |       <button class="btn btn-outline-secondary" type="button" ngbDropdownToggle></button> | ||||||
|       <div ngbDropdownMenu class="scrollable-menu"> |       <div ngbDropdownMenu class="scrollable-menu shadow"> | ||||||
|         <button type="button" *ngFor="let tag of tags" ngbDropdownItem (click)="addTag(tag.id)"> |         <button type="button" *ngFor="let tag of tags" ngbDropdownItem (click)="addTag(tag.id)"> | ||||||
|           <app-tag [tag]="tag"></app-tag> |           <app-tag [tag]="tag"></app-tag> | ||||||
|         </button> |         </button> | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| <div class="card mb-3 shadow"> | <div class="card mb-3 shadow-sm"> | ||||||
|   <div class="card-header"> |   <div class="card-header"> | ||||||
|     <div class="d-flex justify-content-between align-items-center"> |     <div class="d-flex justify-content-between align-items-center"> | ||||||
|       <h5 class="card-title mb-0">{{title}}</h5> |       <h5 class="card-title mb-0">{{title}}</h5> | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ | |||||||
|  |  | ||||||
|         <div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version"> |         <div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version"> | ||||||
|             <button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button> |             <button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button> | ||||||
|             <div class="dropdown-menu" ngbDropdownMenu> |             <div class="dropdown-menu shadow" ngbDropdownMenu> | ||||||
|                 <a ngbDropdownItem [href]="downloadOriginalUrl">Download original</a> |                 <a ngbDropdownItem [href]="downloadOriginalUrl">Download original</a> | ||||||
|             </div> |             </div> | ||||||
|         </div> |         </div> | ||||||
| @@ -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 { | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -12,7 +12,7 @@ | |||||||
|               <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>: | ||||||
|             </ng-container> |             </ng-container> | ||||||
|             {{document.title}} |             {{document.title | documentTitle}} | ||||||
|             <app-tag [tag]="t" linkTitle="Filter by tag" *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id)" [clickable]="clickTag.observers.length"></app-tag> |             <app-tag [tag]="t" linkTitle="Filter by tag" *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id)" [clickable]="clickTag.observers.length"></app-tag> | ||||||
|           </h5> |           </h5> | ||||||
|           <h5 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5> |           <h5 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5> | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <div class="col p-2 h-100" style="width: 16rem;"> | <div class="col p-2 h-100" style="width: 16rem;"> | ||||||
|   <div class="card h-100 shadow-sm"> |   <div class="card h-100 shadow-sm"> | ||||||
|     <div class=" border-bottom pr-1"> |     <div class="border-bottom"> | ||||||
|       <img class="card-img doc-img" [src]="getThumbUrl()"> |       <img class="card-img doc-img" [src]="getThumbUrl()"> | ||||||
|       <div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1"> |       <div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1"> | ||||||
|         <div *ngFor="let t of getTagsLimited$() | async"> |         <div *ngFor="let t of getTagsLimited$() | async"> | ||||||
| @@ -17,7 +17,7 @@ | |||||||
|         <ng-container *ngIf="document.correspondent"> |         <ng-container *ngIf="document.correspondent"> | ||||||
|           <a [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>: |           <a [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>: | ||||||
|         </ng-container> |         </ng-container> | ||||||
|         {{document.title}} |         {{document.title | documentTitle}} | ||||||
|       </p> |       </p> | ||||||
|     </div> |     </div> | ||||||
|     <div class="card-footer"> |     <div class="card-footer"> | ||||||
|   | |||||||
| @@ -24,7 +24,7 @@ | |||||||
|   <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> | ||||||
|       <div ngbDropdownMenu aria-labelledby="dropdownBasic1"> |       <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow"> | ||||||
|         <button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="list.sortField = f.field" |         <button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="list.sortField = f.field" | ||||||
|           [class.active]="list.sortField == f.field">{{f.name}}</button> |           [class.active]="list.sortField == f.field">{{f.name}}</button> | ||||||
|       </div> |       </div> | ||||||
| @@ -79,7 +79,7 @@ | |||||||
|   </app-document-card-large> |   </app-document-card-large> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <table class="table table-sm border shadow" *ngIf="displayMode == 'details'"> | <table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'"> | ||||||
|   <thead> |   <thead> | ||||||
|     <th class="d-none d-lg-table-cell">ASN</th> |     <th class="d-none d-lg-table-cell">ASN</th> | ||||||
|     <th class="d-none d-md-table-cell">Correspondent</th> |     <th class="d-none d-md-table-cell">Correspondent</th> | ||||||
| @@ -99,7 +99,7 @@ | |||||||
|         </ng-container> |         </ng-container> | ||||||
|       </td> |       </td> | ||||||
|       <td> |       <td> | ||||||
|         <a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title}}</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)="clickTag(t)"></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"> | ||||||
|   | |||||||
| @@ -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() | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								src-ui/src/app/pipes/document-title.pipe.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src-ui/src/app/pipes/document-title.pipe.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | import { DocumentTitlePipe } from './document-title.pipe'; | ||||||
|  |  | ||||||
|  | describe('DocumentTitlePipe', () => { | ||||||
|  |   it('create an instance', () => { | ||||||
|  |     const pipe = new DocumentTitlePipe(); | ||||||
|  |     expect(pipe).toBeTruthy(); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										16
									
								
								src-ui/src/app/pipes/document-title.pipe.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src-ui/src/app/pipes/document-title.pipe.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | import { Pipe, PipeTransform } from '@angular/core'; | ||||||
|  |  | ||||||
|  | @Pipe({ | ||||||
|  |   name: 'documentTitle' | ||||||
|  | }) | ||||||
|  | export class DocumentTitlePipe implements PipeTransform { | ||||||
|  |  | ||||||
|  |   transform(value: string): unknown { | ||||||
|  |     if (value) { | ||||||
|  |       return value | ||||||
|  |     } else { | ||||||
|  |       return "(no title)" | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -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); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @@ -17,8 +17,6 @@ class CorrespondentAdmin(admin.ModelAdmin): | |||||||
|     list_filter = ("matching_algorithm",) |     list_filter = ("matching_algorithm",) | ||||||
|     list_editable = ("match", "matching_algorithm") |     list_editable = ("match", "matching_algorithm") | ||||||
|  |  | ||||||
|     readonly_fields = ("slug",) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TagAdmin(admin.ModelAdmin): | class TagAdmin(admin.ModelAdmin): | ||||||
|  |  | ||||||
| @@ -31,8 +29,6 @@ class TagAdmin(admin.ModelAdmin): | |||||||
|     list_filter = ("colour", "matching_algorithm") |     list_filter = ("colour", "matching_algorithm") | ||||||
|     list_editable = ("colour", "match", "matching_algorithm") |     list_editable = ("colour", "match", "matching_algorithm") | ||||||
|  |  | ||||||
|     readonly_fields = ("slug", ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DocumentTypeAdmin(admin.ModelAdmin): | class DocumentTypeAdmin(admin.ModelAdmin): | ||||||
|  |  | ||||||
| @@ -44,8 +40,6 @@ class DocumentTypeAdmin(admin.ModelAdmin): | |||||||
|     list_filter = ("matching_algorithm",) |     list_filter = ("matching_algorithm",) | ||||||
|     list_editable = ("match", "matching_algorithm") |     list_editable = ("match", "matching_algorithm") | ||||||
|  |  | ||||||
|     readonly_fields = ("slug",) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DocumentAdmin(admin.ModelAdmin): | class DocumentAdmin(admin.ModelAdmin): | ||||||
|  |  | ||||||
| @@ -106,7 +100,7 @@ class DocumentAdmin(admin.ModelAdmin): | |||||||
|         for tag in obj.tags.all(): |         for tag in obj.tags.all(): | ||||||
|             r += self._html_tag( |             r += self._html_tag( | ||||||
|                 "span", |                 "span", | ||||||
|                 tag.slug + ", " |                 tag.name + ", " | ||||||
|             ) |             ) | ||||||
|         return r |         return r | ||||||
|  |  | ||||||
|   | |||||||
| @@ -248,7 +248,7 @@ class Consumer(LoggingMixin): | |||||||
|         with open(self.path, "rb") as f: |         with open(self.path, "rb") as f: | ||||||
|             document = Document.objects.create( |             document = Document.objects.create( | ||||||
|                 correspondent=file_info.correspondent, |                 correspondent=file_info.correspondent, | ||||||
|                 title=file_info.title, |                 title=(self.override_title or file_info.title)[:127], | ||||||
|                 content=text, |                 content=text, | ||||||
|                 mime_type=mime_type, |                 mime_type=mime_type, | ||||||
|                 checksum=hashlib.md5(f.read()).hexdigest(), |                 checksum=hashlib.md5(f.read()).hexdigest(), | ||||||
| @@ -259,18 +259,17 @@ class Consumer(LoggingMixin): | |||||||
|  |  | ||||||
|         relevant_tags = set(file_info.tags) |         relevant_tags = set(file_info.tags) | ||||||
|         if relevant_tags: |         if relevant_tags: | ||||||
|             tag_names = ", ".join([t.slug for t in relevant_tags]) |             tag_names = ", ".join([t.name for t in relevant_tags]) | ||||||
|             self.log("debug", "Tagging with {}".format(tag_names)) |             self.log("debug", "Tagging with {}".format(tag_names)) | ||||||
|             document.tags.add(*relevant_tags) |             document.tags.add(*relevant_tags) | ||||||
|  |  | ||||||
|         self.apply_overrides(document) |         self.apply_overrides(document) | ||||||
|  |  | ||||||
|  |         document.save() | ||||||
|  |  | ||||||
|         return document |         return document | ||||||
|  |  | ||||||
|     def apply_overrides(self, document): |     def apply_overrides(self, document): | ||||||
|         if self.override_title: |  | ||||||
|             document.title = self.override_title |  | ||||||
|  |  | ||||||
|         if self.override_correspondent_id: |         if self.override_correspondent_id: | ||||||
|             document.correspondent = Correspondent.objects.get( |             document.correspondent = Correspondent.objects.get( | ||||||
|                 pk=self.override_correspondent_id) |                 pk=self.override_correspondent_id) | ||||||
|   | |||||||
| @@ -51,7 +51,6 @@ class TagsFilter(Filter): | |||||||
|             return qs |             return qs | ||||||
|  |  | ||||||
|         for tag_id in tag_ids: |         for tag_id in tag_ids: | ||||||
|             print(self.exclude, tag_id) |  | ||||||
|             if self.exclude: |             if self.exclude: | ||||||
|                 qs = qs.exclude(tags__id=tag_id) |                 qs = qs.exclude(tags__id=tag_id) | ||||||
|             else: |             else: | ||||||
|   | |||||||
| @@ -82,7 +82,8 @@ class Command(BaseCommand): | |||||||
|             with open(document.thumbnail_path, "wb") as f: |             with open(document.thumbnail_path, "wb") as f: | ||||||
|                 f.write(raw_thumb) |                 f.write(raw_thumb) | ||||||
|  |  | ||||||
|             document.save(update_fields=("storage_type", "filename")) |             Document.objects.filter(id=document.id).update( | ||||||
|  |                 storage_type=document.storage_type, filename=document.filename) | ||||||
|  |  | ||||||
|             for path in old_paths: |             for path in old_paths: | ||||||
|                 os.unlink(path) |                 os.unlink(path) | ||||||
|   | |||||||
| @@ -29,10 +29,9 @@ def _tags_from_path(filepath): | |||||||
|     path_parts = Path(filepath).relative_to( |     path_parts = Path(filepath).relative_to( | ||||||
|                 settings.CONSUMPTION_DIR).parent.parts |                 settings.CONSUMPTION_DIR).parent.parts | ||||||
|     for part in path_parts: |     for part in path_parts: | ||||||
|         tag_ids.add(Tag.objects.get_or_create( |         tag_ids.add(Tag.objects.get_or_create(name__iexact=part, defaults={ | ||||||
|             slug=slugify(part), |             "name": part | ||||||
|             defaults={"name": part}, |         })[0].pk) | ||||||
|         )[0].pk) |  | ||||||
|  |  | ||||||
|     return tag_ids |     return tag_ids | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,8 @@ | |||||||
|  | import logging | ||||||
|  |  | ||||||
|  | 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 | ||||||
| @@ -18,6 +22,7 @@ class Command(Renderable, BaseCommand): | |||||||
|  |  | ||||||
|         self.verbosity = options["verbosity"] |         self.verbosity = options["verbosity"] | ||||||
|  |  | ||||||
|         for document in Document.objects.all(): |         logging.getLogger().handlers[0].level = logging.ERROR | ||||||
|             # Saving the document again will generate a new filename and rename |  | ||||||
|             document.save() |         for document in tqdm.tqdm(Document.objects.all()): | ||||||
|  |             post_save.send(Document, instance=document) | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								src/documents/migrations/1006_auto_20201208_2209.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/documents/migrations/1006_auto_20201208_2209.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | # Generated by Django 3.1.4 on 2020-12-08 22:09 | ||||||
|  |  | ||||||
|  | from django.db import migrations | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ('documents', '1005_checksums'), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.RemoveField( | ||||||
|  |             model_name='correspondent', | ||||||
|  |             name='slug', | ||||||
|  |         ), | ||||||
|  |         migrations.RemoveField( | ||||||
|  |             model_name='documenttype', | ||||||
|  |             name='slug', | ||||||
|  |         ), | ||||||
|  |         migrations.RemoveField( | ||||||
|  |             model_name='tag', | ||||||
|  |             name='slug', | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -36,7 +36,6 @@ class MatchingModel(models.Model): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     name = models.CharField(max_length=128, unique=True) |     name = models.CharField(max_length=128, unique=True) | ||||||
|     slug = models.SlugField(blank=True, editable=False) |  | ||||||
|  |  | ||||||
|     match = models.CharField(max_length=256, blank=True) |     match = models.CharField(max_length=256, blank=True) | ||||||
|     matching_algorithm = models.PositiveIntegerField( |     matching_algorithm = models.PositiveIntegerField( | ||||||
| @@ -69,7 +68,6 @@ class MatchingModel(models.Model): | |||||||
|     def save(self, *args, **kwargs): |     def save(self, *args, **kwargs): | ||||||
|  |  | ||||||
|         self.match = self.match.lower() |         self.match = self.match.lower() | ||||||
|         self.slug = slugify(self.name) |  | ||||||
|  |  | ||||||
|         models.Model.save(self, *args, **kwargs) |         models.Model.save(self, *args, **kwargs) | ||||||
|  |  | ||||||
| @@ -384,9 +382,7 @@ class FileInfo: | |||||||
|     def _get_correspondent(cls, name): |     def _get_correspondent(cls, name): | ||||||
|         if not name: |         if not name: | ||||||
|             return None |             return None | ||||||
|         return Correspondent.objects.get_or_create(name=name, defaults={ |         return Correspondent.objects.get_or_create(name=name)[0] | ||||||
|             "slug": slugify(name) |  | ||||||
|         })[0] |  | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def _get_title(cls, title): |     def _get_title(cls, title): | ||||||
| @@ -396,10 +392,7 @@ class FileInfo: | |||||||
|     def _get_tags(cls, tags): |     def _get_tags(cls, tags): | ||||||
|         r = [] |         r = [] | ||||||
|         for t in tags.split(","): |         for t in tags.split(","): | ||||||
|             r.append(Tag.objects.get_or_create( |             r.append(Tag.objects.get_or_create(name=t)[0]) | ||||||
|                 slug=slugify(t), |  | ||||||
|                 defaults={"name": t} |  | ||||||
|             )[0]) |  | ||||||
|         return tuple(r) |         return tuple(r) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|   | |||||||
| @@ -210,6 +210,7 @@ class DocumentParser(LoggingMixin): | |||||||
|     def __init__(self, logging_group): |     def __init__(self, logging_group): | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.logging_group = logging_group |         self.logging_group = logging_group | ||||||
|  |         os.makedirs(settings.SCRATCH_DIR, exist_ok=True) | ||||||
|         self.tempdir = tempfile.mkdtemp( |         self.tempdir = tempfile.mkdtemp( | ||||||
|             prefix="paperless-", dir=settings.SCRATCH_DIR) |             prefix="paperless-", dir=settings.SCRATCH_DIR) | ||||||
|  |  | ||||||
| @@ -217,6 +218,9 @@ class DocumentParser(LoggingMixin): | |||||||
|         self.text = None |         self.text = None | ||||||
|         self.date = None |         self.date = None | ||||||
|  |  | ||||||
|  |     def extract_metadata(self, document_path, mime_type): | ||||||
|  |         return [] | ||||||
|  |  | ||||||
|     def parse(self, document_path, mime_type): |     def parse(self, document_path, mime_type): | ||||||
|         raise NotImplementedError() |         raise NotImplementedError() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -46,6 +46,10 @@ def check_sanity(): | |||||||
|         for f in files: |         for f in files: | ||||||
|             present_files.append(os.path.normpath(os.path.join(root, f))) |             present_files.append(os.path.normpath(os.path.join(root, f))) | ||||||
|  |  | ||||||
|  |     lockfile = os.path.normpath(settings.MEDIA_LOCK) | ||||||
|  |     if lockfile in present_files: | ||||||
|  |         present_files.remove(lockfile) | ||||||
|  |  | ||||||
|     for doc in Document.objects.all(): |     for doc in Document.objects.all(): | ||||||
|         # Check sanity of the thumbnail |         # Check sanity of the thumbnail | ||||||
|         if not os.path.isfile(doc.thumbnail_path): |         if not os.path.isfile(doc.thumbnail_path): | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import magic | import magic | ||||||
| from pathvalidate import validate_filename, ValidationError | from django.utils.text import slugify | ||||||
| from rest_framework import serializers | from rest_framework import serializers | ||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
|  |  | ||||||
| @@ -7,12 +7,16 @@ from .models import Correspondent, Tag, Document, Log, DocumentType | |||||||
| from .parsers import is_mime_type_supported | from .parsers import is_mime_type_supported | ||||||
|  |  | ||||||
|  |  | ||||||
| class CorrespondentSerializer(serializers.HyperlinkedModelSerializer): | class CorrespondentSerializer(serializers.ModelSerializer): | ||||||
|  |  | ||||||
|     document_count = serializers.IntegerField(read_only=True) |     document_count = serializers.IntegerField(read_only=True) | ||||||
|  |  | ||||||
|     last_correspondence = serializers.DateTimeField(read_only=True) |     last_correspondence = serializers.DateTimeField(read_only=True) | ||||||
|  |  | ||||||
|  |     def get_slug(self, obj): | ||||||
|  |         return slugify(obj.name) | ||||||
|  |     slug = SerializerMethodField() | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = Correspondent |         model = Correspondent | ||||||
|         fields = ( |         fields = ( | ||||||
| @@ -27,10 +31,14 @@ class CorrespondentSerializer(serializers.HyperlinkedModelSerializer): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class DocumentTypeSerializer(serializers.HyperlinkedModelSerializer): | class DocumentTypeSerializer(serializers.ModelSerializer): | ||||||
|  |  | ||||||
|     document_count = serializers.IntegerField(read_only=True) |     document_count = serializers.IntegerField(read_only=True) | ||||||
|  |  | ||||||
|  |     def get_slug(self, obj): | ||||||
|  |         return slugify(obj.name) | ||||||
|  |     slug = SerializerMethodField() | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = DocumentType |         model = DocumentType | ||||||
|         fields = ( |         fields = ( | ||||||
| @@ -44,10 +52,14 @@ class DocumentTypeSerializer(serializers.HyperlinkedModelSerializer): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TagSerializer(serializers.HyperlinkedModelSerializer): | class TagSerializer(serializers.ModelSerializer): | ||||||
|  |  | ||||||
|     document_count = serializers.IntegerField(read_only=True) |     document_count = serializers.IntegerField(read_only=True) | ||||||
|  |  | ||||||
|  |     def get_slug(self, obj): | ||||||
|  |         return slugify(obj.name) | ||||||
|  |     slug = SerializerMethodField() | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = Tag |         model = Tag | ||||||
|         fields = ( |         fields = ( | ||||||
| @@ -166,12 +178,6 @@ class PostDocumentSerializer(serializers.Serializer): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     def validate_document(self, document): |     def validate_document(self, document): | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             validate_filename(document.name) |  | ||||||
|         except ValidationError: |  | ||||||
|             raise serializers.ValidationError("Invalid filename.") |  | ||||||
|  |  | ||||||
|         document_data = document.file.read() |         document_data = document.file.read() | ||||||
|         mime_type = magic.from_buffer(document_data, mime=True) |         mime_type = magic.from_buffer(document_data, mime=True) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ from django.contrib.admin.models import ADDITION, LogEntry | |||||||
| from django.contrib.auth.models import User | from django.contrib.auth.models import User | ||||||
| from django.contrib.contenttypes.models import ContentType | from django.contrib.contenttypes.models import ContentType | ||||||
| from django.db import models, DatabaseError | from django.db import models, DatabaseError | ||||||
|  | from django.db.models import Q | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| from filelock import FileLock | from filelock import FileLock | ||||||
| @@ -121,11 +122,14 @@ def set_tags(sender, | |||||||
|              classifier=None, |              classifier=None, | ||||||
|              replace=False, |              replace=False, | ||||||
|              **kwargs): |              **kwargs): | ||||||
|  |  | ||||||
|     if replace: |     if replace: | ||||||
|         document.tags.clear() |         Document.tags.through.objects.filter(document=document).exclude( | ||||||
|         current_tags = set([]) |             Q(tag__is_inbox_tag=True)).exclude( | ||||||
|     else: |             Q(tag__match="") & ~Q(tag__matching_algorithm=Tag.MATCH_AUTO) | ||||||
|         current_tags = set(document.tags.all()) |         ).delete() | ||||||
|  |  | ||||||
|  |     current_tags = set(document.tags.all()) | ||||||
|  |  | ||||||
|     matched_tags = matching.match_tags(document.content, classifier) |     matched_tags = matching.match_tags(document.content, classifier) | ||||||
|  |  | ||||||
| @@ -136,7 +140,7 @@ def set_tags(sender, | |||||||
|  |  | ||||||
|     message = 'Tagging "{}" with "{}"' |     message = 'Tagging "{}" with "{}"' | ||||||
|     logger( |     logger( | ||||||
|         message.format(document, ", ".join([t.slug for t in relevant_tags])), |         message.format(document, ", ".join([t.name for t in relevant_tags])), | ||||||
|         logging_group |         logging_group | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
| @@ -165,35 +169,36 @@ def run_post_consume_script(sender, document, **kwargs): | |||||||
|         reverse("document-download", kwargs={"pk": document.pk}), |         reverse("document-download", kwargs={"pk": document.pk}), | ||||||
|         reverse("document-thumb", kwargs={"pk": document.pk}), |         reverse("document-thumb", kwargs={"pk": document.pk}), | ||||||
|         str(document.correspondent), |         str(document.correspondent), | ||||||
|         str(",".join(document.tags.all().values_list("slug", flat=True))) |         str(",".join(document.tags.all().values_list("name", flat=True))) | ||||||
|     )).wait() |     )).wait() | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(models.signals.post_delete, sender=Document) | @receiver(models.signals.post_delete, sender=Document) | ||||||
| def cleanup_document_deletion(sender, instance, using, **kwargs): | def cleanup_document_deletion(sender, instance, using, **kwargs): | ||||||
|     for f in (instance.source_path, |     with FileLock(settings.MEDIA_LOCK): | ||||||
|               instance.archive_path, |         for f in (instance.source_path, | ||||||
|               instance.thumbnail_path): |                   instance.archive_path, | ||||||
|         if os.path.isfile(f): |                   instance.thumbnail_path): | ||||||
|             try: |             if os.path.isfile(f): | ||||||
|                 os.unlink(f) |                 try: | ||||||
|                 logging.getLogger(__name__).debug( |                     os.unlink(f) | ||||||
|                     f"Deleted file {f}.") |                     logging.getLogger(__name__).debug( | ||||||
|             except OSError as e: |                         f"Deleted file {f}.") | ||||||
|                 logging.getLogger(__name__).warning( |                 except OSError as e: | ||||||
|                     f"While deleting document {str(instance)}, the file " |                     logging.getLogger(__name__).warning( | ||||||
|                     f"{f} could not be deleted: {e}" |                         f"While deleting document {str(instance)}, the file " | ||||||
|                 ) |                         f"{f} could not be deleted: {e}" | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|     delete_empty_directories( |         delete_empty_directories( | ||||||
|         os.path.dirname(instance.source_path), |             os.path.dirname(instance.source_path), | ||||||
|         root=settings.ORIGINALS_DIR |             root=settings.ORIGINALS_DIR | ||||||
|     ) |         ) | ||||||
|  |  | ||||||
|     delete_empty_directories( |         delete_empty_directories( | ||||||
|         os.path.dirname(instance.archive_path), |             os.path.dirname(instance.archive_path), | ||||||
|         root=settings.ARCHIVE_DIR |             root=settings.ARCHIVE_DIR | ||||||
|     ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def validate_move(instance, old_path, new_path): | def validate_move(instance, old_path, new_path): | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import logging | import logging | ||||||
|  |  | ||||||
|  | import tqdm | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from whoosh.writing import AsyncWriter | from whoosh.writing import AsyncWriter | ||||||
|  |  | ||||||
| @@ -23,7 +24,7 @@ def index_reindex(): | |||||||
|     ix = index.open_index(recreate=True) |     ix = index.open_index(recreate=True) | ||||||
|  |  | ||||||
|     with AsyncWriter(ix) as writer: |     with AsyncWriter(ix) as writer: | ||||||
|         for document in documents: |         for document in tqdm.tqdm(documents): | ||||||
|             index.update_document(writer, document) |             index.update_document(writer, document) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -403,16 +403,6 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | |||||||
|         self.assertEqual(response.status_code, 400) |         self.assertEqual(response.status_code, 400) | ||||||
|         m.assert_not_called() |         m.assert_not_called() | ||||||
|  |  | ||||||
|     @mock.patch("documents.views.async_task") |  | ||||||
|     @mock.patch("documents.serialisers.validate_filename") |  | ||||||
|     def test_upload_invalid_filename(self, validate_filename, async_task): |  | ||||||
|         validate_filename.side_effect = ValidationError() |  | ||||||
|         with open(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), "rb") as f: |  | ||||||
|             response = self.client.post("/api/documents/post_document/", {"document": f}) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|  |  | ||||||
|         async_task.assert_not_called() |  | ||||||
|  |  | ||||||
|     @mock.patch("documents.views.async_task") |     @mock.patch("documents.views.async_task") | ||||||
|     def test_upload_with_title(self, async_task): |     def test_upload_with_title(self, async_task): | ||||||
|         with open(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), "rb") as f: |         with open(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), "rb") as f: | ||||||
|   | |||||||
| @@ -27,7 +27,7 @@ class TestAttributes(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(file_info.title, title, filename) |         self.assertEqual(file_info.title, title, filename) | ||||||
|  |  | ||||||
|         self.assertEqual(tuple([t.slug for t in file_info.tags]), tags, filename) |         self.assertEqual(tuple([t.name for t in file_info.tags]), tags, filename) | ||||||
|  |  | ||||||
|     def test_guess_attributes_from_name0(self): |     def test_guess_attributes_from_name0(self): | ||||||
|         self._test_guess_attributes_from_name( |         self._test_guess_attributes_from_name( | ||||||
| @@ -188,7 +188,7 @@ class TestFieldPermutations(TestCase): | |||||||
|             self.assertEqual(info.tags, (), filename) |             self.assertEqual(info.tags, (), filename) | ||||||
|         else: |         else: | ||||||
|             self.assertEqual( |             self.assertEqual( | ||||||
|                 [t.slug for t in info.tags], tags.split(','), |                 [t.name for t in info.tags], tags.split(','), | ||||||
|                 filename |                 filename | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
| @@ -342,8 +342,8 @@ class TestFieldPermutations(TestCase): | |||||||
|             info = FileInfo.from_filename(filename) |             info = FileInfo.from_filename(filename) | ||||||
|             self.assertEqual(info.title, "0001") |             self.assertEqual(info.title, "0001") | ||||||
|             self.assertEqual(len(info.tags), 2) |             self.assertEqual(len(info.tags), 2) | ||||||
|             self.assertEqual(info.tags[0].slug, "tag1") |             self.assertEqual(info.tags[0].name, "tag1") | ||||||
|             self.assertEqual(info.tags[1].slug, "tag2") |             self.assertEqual(info.tags[1].name, "tag2") | ||||||
|             self.assertIsNone(info.created) |             self.assertIsNone(info.created) | ||||||
|  |  | ||||||
|         # Complex transformation with date in replacement string |         # Complex transformation with date in replacement string | ||||||
| @@ -356,8 +356,8 @@ class TestFieldPermutations(TestCase): | |||||||
|             info = FileInfo.from_filename(filename) |             info = FileInfo.from_filename(filename) | ||||||
|             self.assertEqual(info.title, "0001") |             self.assertEqual(info.title, "0001") | ||||||
|             self.assertEqual(len(info.tags), 2) |             self.assertEqual(len(info.tags), 2) | ||||||
|             self.assertEqual(info.tags[0].slug, "tag1") |             self.assertEqual(info.tags[0].name, "tag1") | ||||||
|             self.assertEqual(info.tags[1].slug, "tag2") |             self.assertEqual(info.tags[1].name, "tag2") | ||||||
|             self.assertEqual(info.created.year, 2019) |             self.assertEqual(info.created.year, 2019) | ||||||
|             self.assertEqual(info.created.month, 9) |             self.assertEqual(info.created.month, 9) | ||||||
|             self.assertEqual(info.created.day, 8) |             self.assertEqual(info.created.day, 8) | ||||||
|   | |||||||
| @@ -3,7 +3,6 @@ import hashlib | |||||||
| import os | import os | ||||||
| import random | import random | ||||||
| import uuid | import uuid | ||||||
| from concurrent.futures.thread import ThreadPoolExecutor |  | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from unittest import mock | from unittest import mock | ||||||
|  |  | ||||||
| @@ -15,7 +14,6 @@ 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 | ||||||
| from ..sanity_checker import check_sanity |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestFileHandling(DirectoriesMixin, TestCase): | class TestFileHandling(DirectoriesMixin, TestCase): | ||||||
| @@ -573,21 +571,3 @@ def run(): | |||||||
|     for i in range(30): |     for i in range(30): | ||||||
|         doc.title = str(random.randrange(1, 5)) |         doc.title = str(random.randrange(1, 5)) | ||||||
|         doc.save() |         doc.save() | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestSuperMassive(DirectoriesMixin, TestCase): |  | ||||||
|  |  | ||||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") |  | ||||||
|     def test_super_massive(self): |  | ||||||
|         # try to save as many documents in parallel as possible. |  | ||||||
|         # try to make the system fail. |  | ||||||
|  |  | ||||||
|         with ThreadPoolExecutor(max_workers=16) as executor: |  | ||||||
|             results = [executor.submit(run) for i in range(16)] |  | ||||||
|  |  | ||||||
|         for r in results: |  | ||||||
|             if r.exception(): |  | ||||||
|                 raise r.exception() |  | ||||||
|  |  | ||||||
|         # nope, everything still good. Thank you, lockfiles. |  | ||||||
|         self.assertEqual(len(check_sanity()), 0) |  | ||||||
|   | |||||||
| @@ -16,25 +16,23 @@ sample_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf") | |||||||
| class TestArchiver(DirectoriesMixin, TestCase): | class TestArchiver(DirectoriesMixin, TestCase): | ||||||
|  |  | ||||||
|     def make_models(self): |     def make_models(self): | ||||||
|         self.d1 = Document.objects.create(checksum="A", title="A", content="first document", pk=1, mime_type="application/pdf") |         return Document.objects.create(checksum="A", title="A", content="first document", mime_type="application/pdf") | ||||||
|         #self.d2 = Document.objects.create(checksum="B", title="B", content="second document") |  | ||||||
|         #self.d3 = Document.objects.create(checksum="C", title="C", content="unrelated document") |  | ||||||
|  |  | ||||||
|     def test_archiver(self): |     def test_archiver(self): | ||||||
|  |  | ||||||
|         shutil.copy(sample_file, os.path.join(self.dirs.originals_dir, "0000001.pdf")) |         doc = self.make_models() | ||||||
|         self.make_models() |         shutil.copy(sample_file, os.path.join(self.dirs.originals_dir, f"{doc.id:07}.pdf")) | ||||||
|  |  | ||||||
|         call_command('document_archiver') |         call_command('document_archiver') | ||||||
|  |  | ||||||
|     def test_handle_document(self): |     def test_handle_document(self): | ||||||
|  |  | ||||||
|         shutil.copy(sample_file, os.path.join(self.dirs.originals_dir, "0000001.pdf")) |         doc = self.make_models() | ||||||
|         self.make_models() |         shutil.copy(sample_file, os.path.join(self.dirs.originals_dir, f"{doc.id:07}.pdf")) | ||||||
|  |  | ||||||
|         handle_document(self.d1.pk) |         handle_document(doc.pk) | ||||||
|  |  | ||||||
|         doc = Document.objects.get(id=self.d1.id) |         doc = Document.objects.get(id=doc.id) | ||||||
|  |  | ||||||
|         self.assertIsNotNone(doc.checksum) |         self.assertIsNotNone(doc.checksum) | ||||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) |         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||||
|   | |||||||
| @@ -230,7 +230,7 @@ class TestConsumerTags(DirectoriesMixin, ConsumerMixin, TransactionTestCase): | |||||||
|  |  | ||||||
|         tag_names = ("existingTag", "Space Tag") |         tag_names = ("existingTag", "Space Tag") | ||||||
|         # Create a Tag prior to consuming a file using it in path |         # Create a Tag prior to consuming a file using it in path | ||||||
|         tag_ids = [Tag.objects.create(name=tag_names[0]).pk,] |         tag_ids = [Tag.objects.create(name="existingtag").pk,] | ||||||
|  |  | ||||||
|         self.t_start() |         self.t_start() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -35,20 +35,20 @@ class TestDecryptDocuments(TestCase): | |||||||
|             PASSPHRASE="test" |             PASSPHRASE="test" | ||||||
|         ).enable() |         ).enable() | ||||||
|  |  | ||||||
|         shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "documents", "originals", "0000002.pdf.gpg"), os.path.join(originals_dir, "0000002.pdf.gpg")) |         doc = Document.objects.create(checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg",  mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG) | ||||||
|         shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "documents", "thumbnails", "0000002.png.gpg"), os.path.join(thumb_dir, "0000002.png.gpg")) |  | ||||||
|  |  | ||||||
|         Document.objects.create(checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg", id=2, mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG) |         shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "documents", "originals", "0000002.pdf.gpg"), os.path.join(originals_dir, "0000002.pdf.gpg")) | ||||||
|  |         shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "documents", "thumbnails", f"0000002.png.gpg"), os.path.join(thumb_dir, f"{doc.id:07}.png.gpg")) | ||||||
|  |  | ||||||
|         call_command('decrypt_documents') |         call_command('decrypt_documents') | ||||||
|  |  | ||||||
|         doc = Document.objects.get(id=2) |         doc.refresh_from_db() | ||||||
|  |  | ||||||
|         self.assertEqual(doc.storage_type, Document.STORAGE_TYPE_UNENCRYPTED) |         self.assertEqual(doc.storage_type, Document.STORAGE_TYPE_UNENCRYPTED) | ||||||
|         self.assertEqual(doc.filename, "0000002.pdf") |         self.assertEqual(doc.filename, "0000002.pdf") | ||||||
|         self.assertTrue(os.path.isfile(os.path.join(originals_dir, "0000002.pdf"))) |         self.assertTrue(os.path.isfile(os.path.join(originals_dir, "0000002.pdf"))) | ||||||
|         self.assertTrue(os.path.isfile(doc.source_path)) |         self.assertTrue(os.path.isfile(doc.source_path)) | ||||||
|         self.assertTrue(os.path.isfile(os.path.join(thumb_dir, "0000002.png"))) |         self.assertTrue(os.path.isfile(os.path.join(thumb_dir, f"{doc.id:07}.png"))) | ||||||
|         self.assertTrue(os.path.isfile(doc.thumbnail_path)) |         self.assertTrue(os.path.isfile(doc.thumbnail_path)) | ||||||
|  |  | ||||||
|         with doc.source_file as f: |         with doc.source_file as f: | ||||||
|   | |||||||
| @@ -24,13 +24,14 @@ class TestExportImport(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         file = os.path.join(self.dirs.originals_dir, "0000001.pdf") |         file = os.path.join(self.dirs.originals_dir, "0000001.pdf") | ||||||
|  |  | ||||||
|         Document.objects.create(content="Content", checksum="42995833e01aea9b3edee44bbfdd7ce1", archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b", title="wow", filename="0000001.pdf", id=1, mime_type="application/pdf") |         Document.objects.create(content="Content", checksum="42995833e01aea9b3edee44bbfdd7ce1", archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b", title="wow", filename="0000001.pdf", mime_type="application/pdf") | ||||||
|         Document.objects.create(content="Content", checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg", id=2, mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG) |         Document.objects.create(content="Content", checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg", mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG) | ||||||
|         Tag.objects.create(name="t") |         Tag.objects.create(name="t") | ||||||
|         DocumentType.objects.create(name="dt") |         DocumentType.objects.create(name="dt") | ||||||
|         Correspondent.objects.create(name="c") |         Correspondent.objects.create(name="c") | ||||||
|  |  | ||||||
|         target = tempfile.mkdtemp() |         target = tempfile.mkdtemp() | ||||||
|  |         self.addCleanup(shutil.rmtree, target) | ||||||
|  |  | ||||||
|         call_command('document_exporter', target) |         call_command('document_exporter', target) | ||||||
|  |  | ||||||
| @@ -66,9 +67,6 @@ class TestExportImport(DirectoriesMixin, TestCase): | |||||||
|     def test_export_missing_files(self): |     def test_export_missing_files(self): | ||||||
|  |  | ||||||
|         target = tempfile.mkdtemp() |         target = tempfile.mkdtemp() | ||||||
|         Document.objects.create(checksum="AAAAAAAAAAAAAAAAA", title="wow", filename="0000004.pdf", id=3, mime_type="application/pdf") |         self.addCleanup(shutil.rmtree, target) | ||||||
|  |         Document.objects.create(checksum="AAAAAAAAAAAAAAAAA", title="wow", filename="0000004.pdf", mime_type="application/pdf") | ||||||
|         self.assertRaises(FileNotFoundError, call_command, 'document_exporter', target) |         self.assertRaises(FileNotFoundError, call_command, 'document_exporter', target) | ||||||
|  |  | ||||||
|     def test_duplicate_titles(self): |  | ||||||
|         # TODO |  | ||||||
|         pass |  | ||||||
|   | |||||||
| @@ -14,6 +14,12 @@ class TestRetagger(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self.tag_first = Tag.objects.create(name="tag1", match="first", matching_algorithm=Tag.MATCH_ANY) |         self.tag_first = Tag.objects.create(name="tag1", match="first", matching_algorithm=Tag.MATCH_ANY) | ||||||
|         self.tag_second = Tag.objects.create(name="tag2", match="second", matching_algorithm=Tag.MATCH_ANY) |         self.tag_second = Tag.objects.create(name="tag2", match="second", matching_algorithm=Tag.MATCH_ANY) | ||||||
|  |         self.tag_inbox = Tag.objects.create(name="test", is_inbox_tag=True) | ||||||
|  |         self.tag_no_match = Tag.objects.create(name="test2") | ||||||
|  |  | ||||||
|  |         self.d3.tags.add(self.tag_inbox) | ||||||
|  |         self.d3.tags.add(self.tag_no_match) | ||||||
|  |  | ||||||
|  |  | ||||||
|         self.correspondent_first = Correspondent.objects.create( |         self.correspondent_first = Correspondent.objects.create( | ||||||
|             name="c1", match="first", matching_algorithm=Correspondent.MATCH_ANY) |             name="c1", match="first", matching_algorithm=Correspondent.MATCH_ANY) | ||||||
| @@ -38,7 +44,7 @@ class TestRetagger(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(d_first.tags.count(), 1) |         self.assertEqual(d_first.tags.count(), 1) | ||||||
|         self.assertEqual(d_second.tags.count(), 1) |         self.assertEqual(d_second.tags.count(), 1) | ||||||
|         self.assertEqual(d_unrelated.tags.count(), 0) |         self.assertEqual(d_unrelated.tags.count(), 2) | ||||||
|  |  | ||||||
|         self.assertEqual(d_first.tags.first(), self.tag_first) |         self.assertEqual(d_first.tags.first(), self.tag_first) | ||||||
|         self.assertEqual(d_second.tags.first(), self.tag_second) |         self.assertEqual(d_second.tags.first(), self.tag_second) | ||||||
| @@ -56,3 +62,17 @@ class TestRetagger(DirectoriesMixin, TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(d_first.correspondent, self.correspondent_first) |         self.assertEqual(d_first.correspondent, self.correspondent_first) | ||||||
|         self.assertEqual(d_second.correspondent, self.correspondent_second) |         self.assertEqual(d_second.correspondent, self.correspondent_second) | ||||||
|  |  | ||||||
|  |     def test_overwrite_preserve_inbox(self): | ||||||
|  |         self.d1.tags.add(self.tag_second) | ||||||
|  |  | ||||||
|  |         call_command('document_retagger', '--tags', '--overwrite') | ||||||
|  |  | ||||||
|  |         d_first, d_second, d_unrelated = self.get_updated_docs() | ||||||
|  |  | ||||||
|  |         self.assertIsNotNone(Tag.objects.get(id=self.tag_second.id)) | ||||||
|  |  | ||||||
|  |         self.assertCountEqual([tag.id for tag in d_first.tags.all()], [self.tag_first.id]) | ||||||
|  |         self.assertCountEqual([tag.id for tag in d_second.tags.all()], [self.tag_second.id]) | ||||||
|  |         self.assertCountEqual([tag.id for tag in d_unrelated.tags.all()], [self.tag_inbox.id, self.tag_no_match.id]) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,11 +1,8 @@ | |||||||
| import logging |  | ||||||
| import os | import os | ||||||
| import re |  | ||||||
| import tempfile | import tempfile | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from time import mktime | from time import mktime | ||||||
|  |  | ||||||
| import pikepdf |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.db.models import Count, Max | from django.db.models import Count, Max | ||||||
| from django.http import HttpResponse, HttpResponseBadRequest, Http404 | from django.http import HttpResponse, HttpResponseBadRequest, Http404 | ||||||
| @@ -42,6 +39,7 @@ from .filters import ( | |||||||
|     LogFilterSet |     LogFilterSet | ||||||
| ) | ) | ||||||
| from .models import Correspondent, Document, Log, Tag, DocumentType | from .models import Correspondent, Document, Log, Tag, DocumentType | ||||||
|  | from .parsers import get_parser_class_for_mime_type | ||||||
| from .serialisers import ( | from .serialisers import ( | ||||||
|     CorrespondentSerializer, |     CorrespondentSerializer, | ||||||
|     DocumentSerializer, |     DocumentSerializer, | ||||||
| @@ -163,34 +161,16 @@ class DocumentViewSet(RetrieveModelMixin, | |||||||
|             disposition, filename) |             disposition, filename) | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     def get_metadata(self, file, type): |     def get_metadata(self, file, mime_type): | ||||||
|         if not os.path.isfile(file): |         if not os.path.isfile(file): | ||||||
|             return None |             return None | ||||||
|  |  | ||||||
|         namespace_pattern = re.compile(r"\{(.*)\}(.*)") |         parser_class = get_parser_class_for_mime_type(mime_type) | ||||||
|  |         if parser_class: | ||||||
|         result = [] |             parser = parser_class(logging_group=None) | ||||||
|         if type == 'application/pdf': |             return parser.extract_metadata(file, mime_type) | ||||||
|             pdf = pikepdf.open(file) |         else: | ||||||
|             meta = pdf.open_metadata() |             return [] | ||||||
|             for key, value in meta.items(): |  | ||||||
|                 if isinstance(value, list): |  | ||||||
|                     value = " ".join([str(e) for e in value]) |  | ||||||
|                 value = str(value) |  | ||||||
|                 try: |  | ||||||
|                     m = namespace_pattern.match(key) |  | ||||||
|                     result.append({ |  | ||||||
|                         "namespace": m.group(1), |  | ||||||
|                         "prefix": meta.REVERSE_NS[m.group(1)], |  | ||||||
|                         "key": m.group(2), |  | ||||||
|                         "value": value |  | ||||||
|                     }) |  | ||||||
|                 except Exception as e: |  | ||||||
|                     logging.getLogger(__name__).warning( |  | ||||||
|                         f"Error while reading metadata {key}: {value}. Error: " |  | ||||||
|                         f"{e}" |  | ||||||
|                     ) |  | ||||||
|         return result |  | ||||||
|  |  | ||||||
|     @action(methods=['get'], detail=True) |     @action(methods=['get'], detail=True) | ||||||
|     def metadata(self, request, pk=None): |     def metadata(self, request, pk=None): | ||||||
|   | |||||||
| @@ -210,6 +210,12 @@ AUTH_PASSWORD_VALIDATORS = [ | |||||||
|  |  | ||||||
| DATA_UPLOAD_MAX_NUMBER_FIELDS = None | DATA_UPLOAD_MAX_NUMBER_FIELDS = None | ||||||
|  |  | ||||||
|  | COOKIE_PREFIX = os.getenv("PAPERLESS_COOKIE_PREFIX", "") | ||||||
|  |  | ||||||
|  | CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken" | ||||||
|  | SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid" | ||||||
|  | LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language" | ||||||
|  |  | ||||||
| ############################################################################### | ############################################################################### | ||||||
| # Database                                                                    # | # Database                                                                    # | ||||||
| ############################################################################### | ############################################################################### | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| __version__ = (0, 9, 5) | __version__ = (0, 9, 6) | ||||||
|   | |||||||
| @@ -103,10 +103,7 @@ class MailAccountHandler(LoggingMixin): | |||||||
|  |  | ||||||
|     def _correspondent_from_name(self, name): |     def _correspondent_from_name(self, name): | ||||||
|         try: |         try: | ||||||
|             return Correspondent.objects.get_or_create( |             return Correspondent.objects.get_or_create(name=name)[0] | ||||||
|                 name=name, defaults={ |  | ||||||
|                     "slug": slugify(name) |  | ||||||
|                 })[0] |  | ||||||
|         except DatabaseError as e: |         except DatabaseError as e: | ||||||
|             self.log( |             self.log( | ||||||
|                 "error", |                 "error", | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import subprocess | |||||||
|  |  | ||||||
| import ocrmypdf | import ocrmypdf | ||||||
| import pdftotext | import pdftotext | ||||||
|  | import pikepdf | ||||||
| from PIL import Image | from PIL import Image | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from ocrmypdf import InputFileError, EncryptedPdfError | from ocrmypdf import InputFileError, EncryptedPdfError | ||||||
| @@ -18,6 +19,33 @@ class RasterisedDocumentParser(DocumentParser): | |||||||
|     image, whether it's a PDF, or other graphical format (JPEG, TIFF, etc.) |     image, whether it's a PDF, or other graphical format (JPEG, TIFF, etc.) | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |     def extract_metadata(self, document_path, mime_type): | ||||||
|  |         namespace_pattern = re.compile(r"\{(.*)\}(.*)") | ||||||
|  |  | ||||||
|  |         result = [] | ||||||
|  |         if mime_type == 'application/pdf': | ||||||
|  |             pdf = pikepdf.open(document_path) | ||||||
|  |             meta = pdf.open_metadata() | ||||||
|  |             for key, value in meta.items(): | ||||||
|  |                 if isinstance(value, list): | ||||||
|  |                     value = " ".join([str(e) for e in value]) | ||||||
|  |                 value = str(value) | ||||||
|  |                 try: | ||||||
|  |                     m = namespace_pattern.match(key) | ||||||
|  |                     result.append({ | ||||||
|  |                         "namespace": m.group(1), | ||||||
|  |                         "prefix": meta.REVERSE_NS[m.group(1)], | ||||||
|  |                         "key": m.group(2), | ||||||
|  |                         "value": value | ||||||
|  |                     }) | ||||||
|  |                 except Exception as e: | ||||||
|  |                     self.log( | ||||||
|  |                         "warning", | ||||||
|  |                         f"Error while reading metadata {key}: {value}. Error: " | ||||||
|  |                         f"{e}" | ||||||
|  |                     ) | ||||||
|  |         return result | ||||||
|  |  | ||||||
|     def get_thumbnail(self, document_path, mime_type): |     def get_thumbnail(self, document_path, mime_type): | ||||||
|         """ |         """ | ||||||
|         The thumbnail of a PDF is just a 500px wide image of the first page. |         The thumbnail of a PDF is just a 500px wide image of the first page. | ||||||
| @@ -82,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 | ||||||
|  |  | ||||||
| @@ -134,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", | ||||||
| @@ -142,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}, " | ||||||
| @@ -213,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(): | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Michael Shamoon
					Michael Shamoon