mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge branch 'feature-websockets-status' into dev
This commit is contained in:
		| @@ -1,17 +1,70 @@ | ||||
| import { Component } from '@angular/core'; | ||||
| import { SettingsService } from './services/settings.service'; | ||||
| import { SettingsService, SETTINGS_KEYS } from './services/settings.service'; | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { ConsumerStatusService } from './services/consumer-status.service'; | ||||
| import { ToastService } from './services/toast.service'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-root', | ||||
|   templateUrl: './app.component.html', | ||||
|   styleUrls: ['./app.component.scss'] | ||||
| }) | ||||
| export class AppComponent { | ||||
| export class AppComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   constructor (private settings: SettingsService) { | ||||
|   newDocumentSubscription: Subscription; | ||||
|   successSubscription: Subscription; | ||||
|   failedSubscription: Subscription; | ||||
|  | ||||
|   constructor (private settings: SettingsService, private consumerStatusService: ConsumerStatusService, private toastService: ToastService, private router: Router) { | ||||
|     let anyWindow = (window as any) | ||||
|     anyWindow.pdfWorkerSrc = '/assets/js/pdf.worker.min.js'; | ||||
|     this.settings.updateDarkModeSettings() | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy(): void { | ||||
|     this.consumerStatusService.disconnect() | ||||
|     if (this.successSubscription) { | ||||
|       this.successSubscription.unsubscribe() | ||||
|     } | ||||
|     if (this.failedSubscription) { | ||||
|       this.failedSubscription.unsubscribe() | ||||
|     } | ||||
|     if (this.newDocumentSubscription) { | ||||
|       this.newDocumentSubscription.unsubscribe() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private showNotification(key) { | ||||
|     if (this.router.url == '/dashboard' && this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD)) { | ||||
|       return false | ||||
|     } | ||||
|     return this.settings.get(key) | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.consumerStatusService.connect() | ||||
|  | ||||
|      | ||||
|     this.successSubscription = this.consumerStatusService.onDocumentConsumptionFinished().subscribe(status => { | ||||
|       if (this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) { | ||||
|         this.toastService.show({title: $localize`Document added`, delay: 10000, content: $localize`Document ${status.filename} was added to paperless.`, actionName: $localize`Open document`, action: () => { | ||||
|           this.router.navigate(['documents', status.documentId]) | ||||
|         }}) | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     this.failedSubscription = this.consumerStatusService.onDocumentConsumptionFailed().subscribe(status => { | ||||
|       if (this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) { | ||||
|         this.toastService.showError($localize`Could not add ${status.filename}\: ${status.message}`) | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     this.newDocumentSubscription = this.consumerStatusService.onDocumentDetected().subscribe(status => { | ||||
|       if (this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)) { | ||||
|         this.toastService.show({title: $localize`New document detected`, delay: 5000, content: $localize`Document ${status.filename} is being processed by paperless.`}) | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -3,5 +3,6 @@ | ||||
|   [header]="toast.title" [autohide]="true" [delay]="toast.delay" | ||||
|   [class]="toast.classname" | ||||
|   (hide)="toastService.closeToast(toast)"> | ||||
|   {{toast.content}} | ||||
| </ngb-toast> | ||||
|   <p>{{toast.content}}</p> | ||||
|   <p *ngIf="toast.action"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p> | ||||
| </ngb-toast> | ||||
|   | ||||
| @@ -19,6 +19,6 @@ | ||||
|     <app-statistics-widget></app-statistics-widget> | ||||
|  | ||||
|     <app-upload-file-widget></app-upload-file-widget> | ||||
|  | ||||
|    | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -18,4 +18,4 @@ | ||||
|     </tbody> | ||||
|   </table> | ||||
|  | ||||
| </app-widget-frame> | ||||
| </app-widget-frame> | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import { Component, Input, OnInit } from '@angular/core'; | ||||
| import { Component, Input, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service'; | ||||
| import { ConsumerStatusService } from 'src/app/services/consumer-status.service'; | ||||
| import { DocumentService } from 'src/app/services/rest/document.service'; | ||||
|  | ||||
| @Component({ | ||||
| @@ -15,14 +17,28 @@ export class SavedViewWidgetComponent implements OnInit { | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private router: Router, | ||||
|     private list: DocumentListViewService) { } | ||||
|     private list: DocumentListViewService, | ||||
|     private consumerStatusService: ConsumerStatusService) { } | ||||
|  | ||||
|   @Input() | ||||
|   savedView: PaperlessSavedView | ||||
|  | ||||
|   documents: PaperlessDocument[] = [] | ||||
|  | ||||
|   subscription: Subscription | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.reload() | ||||
|     this.subscription = this.consumerStatusService.onDocumentConsumptionFinished().subscribe(status => { | ||||
|       this.reload() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy(): void { | ||||
|     this.subscription.unsubscribe() | ||||
|   } | ||||
|  | ||||
|   reload() { | ||||
|     this.documentService.listFiltered(1,10,this.savedView.sort_field, this.savedView.sort_reverse, this.savedView.filter_rules).subscribe(result => { | ||||
|       this.documents = result.results | ||||
|     }) | ||||
|   | ||||
| @@ -3,4 +3,4 @@ | ||||
|     <p class="card-text" i18n>Documents in inbox: {{statistics.documents_inbox}}</p> | ||||
|     <p class="card-text" i18n>Total documents: {{statistics.documents_total}}</p> | ||||
|   </ng-container> | ||||
| </app-widget-frame> | ||||
| </app-widget-frame> | ||||
|   | ||||
| @@ -23,7 +23,7 @@ export class StatisticsWidgetComponent implements OnInit { | ||||
|   getStatistics(): Observable<Statistics> { | ||||
|     return this.http.get(`${environment.apiBaseUrl}statistics/`) | ||||
|   } | ||||
|    | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.getStatistics().subscribe(statistics => { | ||||
|       this.statistics = statistics | ||||
|   | ||||
| @@ -1,18 +1,48 @@ | ||||
| <app-widget-frame title="Upload new documents" i18n-title> | ||||
|  | ||||
|   <div header-buttons> | ||||
|     <a *ngIf="getStatusCompleted().length > 0" (click)="dismissAll()" [routerLink]="" > | ||||
|       <span i18n>Dismiss completed</span>  | ||||
|       <svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-check2-all" viewBox="0 0 16 16"> | ||||
|         <path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7zm-4.208 7l-.896-.897.707-.707.543.543 6.646-6.647a.5.5 0 0 1 .708.708l-7 7a.5.5 0 0 1-.708 0z"/> | ||||
|         <path d="M5.354 7.146l.896.897-.707.707-.897-.896a.5.5 0 1 1 .708-.708z"/> | ||||
|       </svg> | ||||
|     </a> | ||||
|   </div> | ||||
|   <div content> | ||||
|     <form> | ||||
|       <ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)" | ||||
|         (onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card" | ||||
|         multiple="true" contentClassName="justify-content-center d-flex align-items-center p-5" [showBrowseBtn]=true | ||||
|         multiple="true" contentClassName="justify-content-center d-flex align-items-center py-5 px-2" [showBrowseBtn]=true | ||||
|         browseBtnClassName="btn btn-sm btn-outline-primary ml-2" i18n-dropZoneLabel i18n-browseBtnLabel> | ||||
|  | ||||
|       </ngx-file-drop> | ||||
|     </form> | ||||
|     <div *ngIf="uploadVisible" class="mt-3"> | ||||
|       <p i18n>{uploadStatus.length, plural, =1 {Uploading file...} =other {Uploading {{uploadStatus.length}} files...}}</p> | ||||
|       <ngb-progressbar [value]="loadedSum" [max]="totalSum" [striped]="true" [animated]="uploadStatus.length > 0"> | ||||
|       </ngb-progressbar> | ||||
|     <p class="mt-3" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p> | ||||
|     <div *ngFor="let status of getStatus()"> | ||||
|       <ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container> | ||||
|     </div> | ||||
|     <div *ngIf="getStatusHidden().length" class="alerts-hidden"> | ||||
|       <p *ngIf="!alertsExpanded" class="mt-3 mb-0 text-center"><span i18n>{{getStatusHidden().length}} more hidden</span> <button class="btn btn-sm btn-link py-0" (click)="alertsExpanded = !alertsExpanded" aria-controls="hiddenAlerts" [attr.aria-expanded]="alertsExpanded" i18n>Show all</button></p> | ||||
|       <div #hiddenAlerts="ngbCollapse" [(ngbCollapse)]="!alertsExpanded"> | ||||
|         <div *ngFor="let status of getStatusHidden()"> | ||||
|           <ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </app-widget-frame> | ||||
| </app-widget-frame> | ||||
|  | ||||
| <ng-template #consumerAlert let-status> | ||||
|   <ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)"> | ||||
|     <h6 class="alert-heading">{{status.filename}}</h6> | ||||
|     <p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p> | ||||
|     <ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar> | ||||
|     <div *ngIf="isFinished(status)"> | ||||
|       <button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)"> | ||||
|         <small i18n>Open document</small> | ||||
|         <svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16"> | ||||
|           <path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/> | ||||
|         </svg> | ||||
|       </button> | ||||
|     </div> | ||||
|   </ngb-alert> | ||||
| </ng-template> | ||||
|   | ||||
| @@ -0,0 +1,35 @@ | ||||
| @import "/src/theme"; | ||||
|  | ||||
| form { | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .alert-heading { | ||||
|   font-size: 80%; | ||||
|   font-weight: bold; | ||||
| } | ||||
|  | ||||
| .alerts-hidden { | ||||
|   .btn { | ||||
|     line-height: 1; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .btn-open { | ||||
|   line-height: 1; | ||||
|  | ||||
|   svg { | ||||
|     margin-top: -1px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| ::ng-deep .progress { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   left: 0; | ||||
|   height: auto; | ||||
|   mix-blend-mode: soft-light; | ||||
|   pointer-events: none; | ||||
| } | ||||
|   | ||||
| @@ -1,14 +1,10 @@ | ||||
| import { HttpEventType } from '@angular/common/http'; | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop'; | ||||
| import { ConsumerStatusService, FileStatus, FileStatusPhase } from 'src/app/services/consumer-status.service'; | ||||
| import { DocumentService } from 'src/app/services/rest/document.service'; | ||||
| import { ToastService } from 'src/app/services/toast.service'; | ||||
|  | ||||
|  | ||||
| interface UploadStatus { | ||||
|   loaded: number | ||||
|   total: number  | ||||
| } | ||||
| const MAX_ALERTS = 5 | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-upload-file-widget', | ||||
| @@ -16,8 +12,89 @@ interface UploadStatus { | ||||
|   styleUrls: ['./upload-file-widget.component.scss'] | ||||
| }) | ||||
| export class UploadFileWidgetComponent implements OnInit { | ||||
|   alertsExpanded = false | ||||
|  | ||||
|   constructor(private documentService: DocumentService, private toastService: ToastService) { } | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private consumerStatusService: ConsumerStatusService | ||||
|   ) { } | ||||
|  | ||||
|   getStatus() { | ||||
|     return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS) | ||||
|   } | ||||
|  | ||||
|   getStatusSummary() { | ||||
|     let strings = [] | ||||
|     let countUploadingAndProcessing =  this.consumerStatusService.getConsumerStatusNotCompleted().length | ||||
|     let countFailed = this.getStatusFailed().length | ||||
|     let countSuccess = this.getStatusSuccess().length | ||||
|     if (countUploadingAndProcessing > 0) { | ||||
|       strings.push($localize`Processing: ${countUploadingAndProcessing}`) | ||||
|     } | ||||
|     if (countFailed > 0) { | ||||
|       strings.push($localize`Failed: ${countFailed}`) | ||||
|     } | ||||
|     if (countSuccess > 0) { | ||||
|       strings.push($localize`Added: ${countSuccess}`) | ||||
|     } | ||||
|     return strings.join($localize`:this string is used to separate processing, failed and added on the file upload widget:, `) | ||||
|   } | ||||
|  | ||||
|   getStatusHidden() { | ||||
|     if (this.consumerStatusService.getConsumerStatus().length < MAX_ALERTS) return [] | ||||
|     else return this.consumerStatusService.getConsumerStatus().slice(MAX_ALERTS) | ||||
|   } | ||||
|  | ||||
|   getStatusUploading() { | ||||
|     return this.consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) | ||||
|   } | ||||
|  | ||||
|   getStatusFailed() { | ||||
|     return this.consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) | ||||
|   } | ||||
|  | ||||
|   getStatusSuccess() { | ||||
|     return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS) | ||||
|   } | ||||
|  | ||||
|   getStatusCompleted() { | ||||
|     return this.consumerStatusService.getConsumerStatusCompleted() | ||||
|   } | ||||
|   getTotalUploadProgress() { | ||||
|     let current = 0 | ||||
|     let max = 0 | ||||
|  | ||||
|     this.getStatusUploading().forEach(status => { | ||||
|       current += status.currentPhaseProgress | ||||
|       max += status.currentPhaseMaxProgress | ||||
|     }) | ||||
|  | ||||
|     return current / Math.max(max, 1) | ||||
|   } | ||||
|  | ||||
|   isFinished(status: FileStatus) { | ||||
|     return status.phase == FileStatusPhase.FAILED || status.phase == FileStatusPhase.SUCCESS | ||||
|   } | ||||
|  | ||||
|   getStatusColor(status: FileStatus) { | ||||
|     switch (status.phase) { | ||||
|       case FileStatusPhase.PROCESSING: | ||||
|       case FileStatusPhase.UPLOADING: | ||||
|           return "primary" | ||||
|       case FileStatusPhase.FAILED: | ||||
|         return "danger" | ||||
|       case FileStatusPhase.SUCCESS: | ||||
|         return "success" | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   dismiss(status: FileStatus) { | ||||
|     this.consumerStatusService.dismiss(status) | ||||
|   } | ||||
|  | ||||
|   dismissAll() { | ||||
|     this.consumerStatusService.dismissAll() | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
| @@ -28,54 +105,39 @@ export class UploadFileWidgetComponent implements OnInit { | ||||
|   public fileLeave(event){ | ||||
|   } | ||||
|  | ||||
|   uploadStatus: UploadStatus[] = [] | ||||
|   completedFiles = 0 | ||||
|  | ||||
|   uploadVisible = false | ||||
|  | ||||
|   get loadedSum() { | ||||
|     return this.uploadStatus.map(s => s.loaded).reduce((a,b) => a+b, this.completedFiles > 0 ? 1 : 0) | ||||
|   } | ||||
|  | ||||
|   get totalSum() { | ||||
|     return this.uploadStatus.map(s => s.total).reduce((a,b) => a+b, 1) | ||||
|   } | ||||
|  | ||||
|   public dropped(files: NgxFileDropEntry[]) { | ||||
|     for (const droppedFile of files) { | ||||
|       if (droppedFile.fileEntry.isFile) { | ||||
|       let uploadStatusObject: UploadStatus = {loaded: 0, total: 1} | ||||
|       this.uploadStatus.push(uploadStatusObject) | ||||
|       this.uploadVisible = true | ||||
|  | ||||
|       const fileEntry = droppedFile.fileEntry as FileSystemFileEntry; | ||||
|         fileEntry.file((file: File) => { | ||||
|           let formData = new FormData() | ||||
|           formData.append('document', file, file.name) | ||||
|           let status = this.consumerStatusService.newFileUpload(file.name) | ||||
|  | ||||
|           status.message = $localize`Connecting...` | ||||
|  | ||||
|           this.documentService.uploadDocument(formData).subscribe(event => { | ||||
|             if (event.type == HttpEventType.UploadProgress) { | ||||
|               uploadStatusObject.loaded = event.loaded | ||||
|               uploadStatusObject.total = event.total | ||||
|               status.updateProgress(FileStatusPhase.UPLOADING, event.loaded, event.total) | ||||
|               status.message = $localize`Uploading...` | ||||
|             } else if (event.type == HttpEventType.Response) { | ||||
|               this.uploadStatus.splice(this.uploadStatus.indexOf(uploadStatusObject), 1) | ||||
|               this.completedFiles += 1 | ||||
|               this.toastService.showInfo($localize`The document has been uploaded and will be processed by the consumer shortly.`) | ||||
|               status.taskId = event.body["task_id"] | ||||
|               status.message = $localize`Waiting for consumer...` | ||||
|             } | ||||
|              | ||||
|  | ||||
|           }, error => { | ||||
|             this.uploadStatus.splice(this.uploadStatus.indexOf(uploadStatusObject), 1) | ||||
|             this.completedFiles += 1 | ||||
|             switch (error.status) { | ||||
|               case 400: { | ||||
|                 this.toastService.showInfo($localize`There was an error while uploading the document: ${error.error.document}`) | ||||
|                 this.consumerStatusService.fail(status, error.error.document) | ||||
|                 break; | ||||
|               } | ||||
|               default: { | ||||
|                 this.toastService.showInfo($localize`An error has occurred while uploading the document. Sorry!`) | ||||
|                 this.consumerStatusService.fail(status, `${error.status} ${error.statusText}`) | ||||
|                 break; | ||||
|               } | ||||
|             } | ||||
|  | ||||
|           }) | ||||
|         }); | ||||
|       } | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import { AfterViewInit, Component, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; | ||||
| import { AfterViewInit, Component, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; | ||||
| import { SortableDirective, SortEvent } from 'src/app/directives/sortable.directive'; | ||||
| import { ConsumerStatusService } from 'src/app/services/consumer-status.service'; | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service'; | ||||
| import { DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service'; | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service'; | ||||
| @@ -16,7 +18,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi | ||||
|   templateUrl: './document-list.component.html', | ||||
|   styleUrls: ['./document-list.component.scss'] | ||||
| }) | ||||
| export class DocumentListComponent implements OnInit { | ||||
| export class DocumentListComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   constructor( | ||||
|     public list: DocumentListViewService, | ||||
| @@ -24,7 +26,9 @@ export class DocumentListComponent implements OnInit { | ||||
|     public route: ActivatedRoute, | ||||
|     private router: Router, | ||||
|     private toastService: ToastService, | ||||
|     private modalService: NgbModal) { } | ||||
|     private modalService: NgbModal, | ||||
|     private consumerStatusService: ConsumerStatusService | ||||
|   ) { } | ||||
|  | ||||
|   @ViewChild("filterEditor") | ||||
|   private filterEditor: FilterEditorComponent | ||||
| @@ -35,6 +39,8 @@ export class DocumentListComponent implements OnInit { | ||||
|  | ||||
|   filterRulesModified: boolean = false | ||||
|  | ||||
|   private consumptionFinishedSubscription: Subscription | ||||
|  | ||||
|   get isFiltered() { | ||||
|     return this.list.filterRules?.length > 0 | ||||
|   } | ||||
| @@ -63,6 +69,9 @@ export class DocumentListComponent implements OnInit { | ||||
|     if (localStorage.getItem('document-list:displayMode') != null) { | ||||
|       this.displayMode = localStorage.getItem('document-list:displayMode') | ||||
|     } | ||||
|     this.consumptionFinishedSubscription = this.consumerStatusService.onDocumentConsumptionFinished().subscribe(() => { | ||||
|       this.list.reload() | ||||
|     }) | ||||
|     this.route.paramMap.subscribe(params => { | ||||
|       this.list.clear() | ||||
|       if (params.has('id')) { | ||||
| @@ -83,6 +92,12 @@ export class DocumentListComponent implements OnInit { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
|     if (this.consumptionFinishedSubscription) { | ||||
|       this.consumptionFinishedSubscription.unsubscribe() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   loadViewConfig(view: PaperlessSavedView) { | ||||
|     this.list.load(view) | ||||
|     this.list.reload() | ||||
|   | ||||
							
								
								
									
										0
									
								
								src-ui/src/app/components/login/login.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src-ui/src/app/components/login/login.component.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -99,6 +99,20 @@ | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <h4 class="mt-4" i18n>Notifications</h4> | ||||
|  | ||||
|         <div class="form-row form-group"> | ||||
|           <div class="col-md-3 col-form-label"> | ||||
|             <span i18n>Consumer status</span> | ||||
|           </div> | ||||
|           <div class="col"> | ||||
|             <app-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></app-input-check> | ||||
|             <app-input-check i18n-title title="Show notifications when document consumption completes successfully" formControlName="notificationsConsumerSuccess"></app-input-check> | ||||
|             <app-input-check i18n-title title="Show notifications when document consumption fails" formControlName="notificationsConsumerFailed"></app-input-check> | ||||
|             <app-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all consumer related status messages on the dashboard."></app-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <h4 class="mt-4" i18n>Bulk editing</h4> | ||||
|  | ||||
|         <div class="form-row form-group"> | ||||
|   | ||||
| @@ -26,6 +26,10 @@ export class SettingsComponent implements OnInit { | ||||
|     'displayLanguage': new FormControl(this.settings.getLanguage()), | ||||
|     'dateLocale': new FormControl(this.settings.get(SETTINGS_KEYS.DATE_LOCALE)), | ||||
|     'dateFormat': new FormControl(this.settings.get(SETTINGS_KEYS.DATE_FORMAT)), | ||||
|     'notificationsConsumerNewDocument': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)), | ||||
|     'notificationsConsumerSuccess': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)), | ||||
|     'notificationsConsumerFailed': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)), | ||||
|     'notificationsConsumerSuppressOnDashboard': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD)), | ||||
|   }) | ||||
|  | ||||
|   savedViews: PaperlessSavedView[] | ||||
| @@ -73,6 +77,10 @@ export class SettingsComponent implements OnInit { | ||||
|     this.settings.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, this.settingsForm.value.useNativePdfViewer) | ||||
|     this.settings.set(SETTINGS_KEYS.DATE_LOCALE, this.settingsForm.value.dateLocale) | ||||
|     this.settings.set(SETTINGS_KEYS.DATE_FORMAT, this.settingsForm.value.dateFormat) | ||||
|     this.settings.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT, this.settingsForm.value.notificationsConsumerNewDocument) | ||||
|     this.settings.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, this.settingsForm.value.notificationsConsumerSuccess) | ||||
|     this.settings.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED, this.settingsForm.value.notificationsConsumerFailed) | ||||
|     this.settings.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD, this.settingsForm.value.notificationsConsumerSuppressOnDashboard) | ||||
|     this.settings.setLanguage(this.settingsForm.value.displayLanguage) | ||||
|     this.documentListViewService.updatePageSize() | ||||
|     this.settings.updateDarkModeSettings() | ||||
|   | ||||
							
								
								
									
										11
									
								
								src-ui/src/app/data/websocket-consumer-status-message.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src-ui/src/app/data/websocket-consumer-status-message.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export interface WebsocketConsumerStatusMessage { | ||||
|  | ||||
|   filename?: string | ||||
|   task_id?: string | ||||
|   current_progress?: number | ||||
|   max_progress?: number | ||||
|   status?: string | ||||
|   message?: string | ||||
|   document_id: number | ||||
|  | ||||
| } | ||||
							
								
								
									
										0
									
								
								src-ui/src/app/services/auth.interceptor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src-ui/src/app/services/auth.interceptor.ts
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										16
									
								
								src-ui/src/app/services/consumer-status.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src-ui/src/app/services/consumer-status.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import { TestBed } from '@angular/core/testing'; | ||||
|  | ||||
| import { ConsumerStatusService } from './consumer-status.service'; | ||||
|  | ||||
| describe('ConsumerStatusService', () => { | ||||
|   let service: ConsumerStatusService; | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     TestBed.configureTestingModule({}); | ||||
|     service = TestBed.inject(ConsumerStatusService); | ||||
|   }); | ||||
|  | ||||
|   it('should be created', () => { | ||||
|     expect(service).toBeTruthy(); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										193
									
								
								src-ui/src/app/services/consumer-status.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								src-ui/src/app/services/consumer-status.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,193 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { Subject } from 'rxjs'; | ||||
| import { WebsocketConsumerStatusMessage } from '../data/websocket-consumer-status-message'; | ||||
|  | ||||
| export enum FileStatusPhase { | ||||
|   STARTED = 0, | ||||
|   UPLOADING = 1, | ||||
|   PROCESSING = 2, | ||||
|   SUCCESS = 3, | ||||
|   FAILED = 4 | ||||
| } | ||||
|  | ||||
| export const FILE_STATUS_MESSAGES = { | ||||
|   "document_already_exists": $localize`Document already exists.`, | ||||
|   "file_not_found": $localize`File not found.`, | ||||
|   "pre_consume_script_not_found": $localize`Pre-consume script does not exist.`, | ||||
|   "pre_consume_script_error": $localize`Error while executing pre-consume script.`, | ||||
|   "post_consume_script_not_found": $localize`Post-consume script does not exist.`, | ||||
|   "post_consume_script_error": $localize`Error while executing post-consume script.`, | ||||
|   "new_file": $localize`Received new file.`, | ||||
|   "unsupported_type": $localize`File type not supported.`, | ||||
|   "parsing_document": $localize`Processing document...`, | ||||
|   "generating_thumbnail": $localize`Generating thumbnail...`, | ||||
|   "parse_date": $localize`Retrieving date from document...`, | ||||
|   "save_document": $localize`Saving document...`, | ||||
|   "finished": $localize`Finished.` | ||||
| } | ||||
|  | ||||
| export class FileStatus { | ||||
|  | ||||
|   filename: string | ||||
|  | ||||
|   taskId: string | ||||
|  | ||||
|   phase: FileStatusPhase = FileStatusPhase.STARTED | ||||
|  | ||||
|   currentPhaseProgress: number | ||||
|  | ||||
|   currentPhaseMaxProgress: number | ||||
|  | ||||
|   message: string | ||||
|  | ||||
|   documentId: number | ||||
|  | ||||
|   getProgress(): number { | ||||
|     switch (this.phase) { | ||||
|       case FileStatusPhase.STARTED: | ||||
|         return 0.0 | ||||
|       case FileStatusPhase.UPLOADING: | ||||
|         return this.currentPhaseProgress / this.currentPhaseMaxProgress * 0.2 | ||||
|       case FileStatusPhase.PROCESSING: | ||||
|         return (this.currentPhaseProgress / this.currentPhaseMaxProgress * 0.8) + 0.2 | ||||
|       case FileStatusPhase.SUCCESS: | ||||
|       case FileStatusPhase.FAILED: | ||||
|         return 1.0 | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   updateProgress(status: FileStatusPhase, currentProgress?: number, maxProgress?: number) { | ||||
|     if (status >= this.phase) { | ||||
|       this.phase = status | ||||
|       if (currentProgress != null) { | ||||
|         this.currentPhaseProgress = currentProgress | ||||
|       } | ||||
|       if (maxProgress != null) { | ||||
|         this.currentPhaseMaxProgress = maxProgress | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root' | ||||
| }) | ||||
| export class ConsumerStatusService { | ||||
|  | ||||
|   constructor() { } | ||||
|  | ||||
|   private statusWebSocked: WebSocket | ||||
|  | ||||
|   private consumerStatus: FileStatus[] = [] | ||||
|  | ||||
|   private documentDetectedSubject = new Subject<FileStatus>() | ||||
|   private documentConsumptionFinishedSubject = new Subject<FileStatus>() | ||||
|   private documentConsumptionFailedSubject = new Subject<FileStatus>() | ||||
|  | ||||
|   private get(taskId: string, filename?: string) { | ||||
|     let status = this.consumerStatus.find(e => e.taskId == taskId) || this.consumerStatus.find(e => e.filename == filename && e.taskId == null) | ||||
|     let created = false | ||||
|     if (!status) { | ||||
|       status = new FileStatus() | ||||
|       this.consumerStatus.push(status) | ||||
|       created = true | ||||
|     } | ||||
|     status.taskId = taskId | ||||
|     status.filename = filename | ||||
|     return {'status': status, 'created': created} | ||||
|   } | ||||
|  | ||||
|   newFileUpload(filename: string): FileStatus { | ||||
|     let status = new FileStatus() | ||||
|     status.filename = filename | ||||
|     this.consumerStatus.push(status) | ||||
|     return status | ||||
|   } | ||||
|  | ||||
|   getConsumerStatus(phase?: FileStatusPhase) { | ||||
|     if (phase != null) { | ||||
|       return this.consumerStatus.filter(s => s.phase == phase) | ||||
|     } else { | ||||
|       return this.consumerStatus | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   getConsumerStatusNotCompleted() { | ||||
|     return this.consumerStatus.filter(s => s.phase < FileStatusPhase.SUCCESS) | ||||
|   } | ||||
|  | ||||
|   getConsumerStatusCompleted() { | ||||
|     return this.consumerStatus.filter(s => s.phase == FileStatusPhase.FAILED || s.phase == FileStatusPhase.SUCCESS) | ||||
|   } | ||||
|  | ||||
|   connect() { | ||||
|     this.disconnect() | ||||
|     this.statusWebSocked = new WebSocket("ws://localhost:8000/ws/status/"); | ||||
|     this.statusWebSocked.onmessage = (ev) => { | ||||
|       let statusMessage: WebsocketConsumerStatusMessage = JSON.parse(ev['data']) | ||||
|  | ||||
|       let statusMessageGet = this.get(statusMessage.task_id, statusMessage.filename) | ||||
|       let status = statusMessageGet.status | ||||
|       let created = statusMessageGet.created | ||||
|  | ||||
|       status.updateProgress(FileStatusPhase.PROCESSING, statusMessage.current_progress, statusMessage.max_progress) | ||||
|       if (statusMessage.message && statusMessage.message in FILE_STATUS_MESSAGES) { | ||||
|         status.message = FILE_STATUS_MESSAGES[statusMessage.message] | ||||
|       } else if (statusMessage.message) { | ||||
|         status.message = statusMessage.message | ||||
|       } | ||||
|       status.documentId = statusMessage.document_id | ||||
|  | ||||
|       if (created && statusMessage.status == 'STARTING') { | ||||
|         this.documentDetectedSubject.next(status) | ||||
|       } | ||||
|       if (statusMessage.status == "SUCCESS") { | ||||
|         status.phase = FileStatusPhase.SUCCESS | ||||
|         this.documentConsumptionFinishedSubject.next(status) | ||||
|       } | ||||
|       if (statusMessage.status == "FAILED") { | ||||
|         status.phase = FileStatusPhase.FAILED | ||||
|         this.documentConsumptionFailedSubject.next(status) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   fail(status: FileStatus, message: string) { | ||||
|     status.message = message | ||||
|     status.phase = FileStatusPhase.FAILED | ||||
|     this.documentConsumptionFailedSubject.next(status) | ||||
|   } | ||||
|  | ||||
|   disconnect() { | ||||
|     if (this.statusWebSocked) { | ||||
|       this.statusWebSocked.close() | ||||
|       this.statusWebSocked = null | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   dismiss(status: FileStatus) { | ||||
|     let index = this.consumerStatus.findIndex(s => s.filename == status.filename) | ||||
|  | ||||
|     if (index > -1) { | ||||
|       this.consumerStatus.splice(index, 1) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   dismissAll() { | ||||
|     this.consumerStatus = this.consumerStatus.filter(status => status.phase < FileStatusPhase.SUCCESS) | ||||
|   } | ||||
|  | ||||
|   onDocumentConsumptionFinished() { | ||||
|     return this.documentConsumptionFinishedSubject | ||||
|   } | ||||
|  | ||||
|   onDocumentConsumptionFailed() { | ||||
|     return this.documentConsumptionFailedSubject | ||||
|   } | ||||
|  | ||||
|   onDocumentDetected() { | ||||
|     return this.documentDetectedSubject | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -23,7 +23,11 @@ export const SETTINGS_KEYS = { | ||||
|   DARK_MODE_ENABLED: 'general-settings:dark-mode:enabled', | ||||
|   USE_NATIVE_PDF_VIEWER: 'general-settings:document-details:native-pdf-viewer', | ||||
|   DATE_LOCALE: 'general-settings:date-display:date-locale', | ||||
|   DATE_FORMAT: 'general-settings:date-display:date-format' | ||||
|   DATE_FORMAT: 'general-settings:date-display:date-format', | ||||
|   NOTIFICATIONS_CONSUMER_NEW_DOCUMENT: 'general-settings:notifications:consumer-new-documents', | ||||
|   NOTIFICATIONS_CONSUMER_SUCCESS: 'general-settings:notifications:consumer-success', | ||||
|   NOTIFICATIONS_CONSUMER_FAILED: 'general-settings:notifications:consumer-failed', | ||||
|   NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD: 'general-settings:notifications:consumer-suppress-on-dashboard', | ||||
| } | ||||
|  | ||||
| const SETTINGS: PaperlessSettings[] = [ | ||||
| @@ -34,7 +38,11 @@ const SETTINGS: PaperlessSettings[] = [ | ||||
|   {key: SETTINGS_KEYS.DARK_MODE_ENABLED, type: "boolean", default: false}, | ||||
|   {key: SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, type: "boolean", default: false}, | ||||
|   {key: SETTINGS_KEYS.DATE_LOCALE, type: "string", default: ""}, | ||||
|   {key: SETTINGS_KEYS.DATE_FORMAT, type: "string", default: "mediumDate"} | ||||
|   {key: SETTINGS_KEYS.DATE_FORMAT, type: "string", default: "mediumDate"}, | ||||
|   {key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT, type: "boolean", default: true}, | ||||
|   {key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, type: "boolean", default: true}, | ||||
|   {key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED, type: "boolean", default: true}, | ||||
|   {key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD, type: "boolean", default: true}, | ||||
| ] | ||||
|  | ||||
| @Injectable({ | ||||
|   | ||||
| @@ -9,6 +9,10 @@ export interface Toast { | ||||
|  | ||||
|   delay: number | ||||
|  | ||||
|   action?: any | ||||
|  | ||||
|   actionName?: string | ||||
|  | ||||
| } | ||||
|  | ||||
| @Injectable({ | ||||
|   | ||||
| @@ -6,7 +6,8 @@ export const environment = { | ||||
|   production: false, | ||||
|   apiBaseUrl: "http://localhost:8000/api/", | ||||
|   appTitle: "Paperless-ng", | ||||
|   version: "DEVELOPMENT" | ||||
|   version: "DEVELOPMENT", | ||||
|   wsBaseUrl: "ws://localhost:8000/ws/" | ||||
| }; | ||||
|  | ||||
| /* | ||||
|   | ||||
| @@ -111,3 +111,7 @@ body { | ||||
|     font-size: 16px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .ngx-file-drop__drop-zone--over { | ||||
|   background-color: $primaryFaded !important; | ||||
| } | ||||
|   | ||||
| @@ -352,6 +352,20 @@ $border-color-dark-mode: #47494f; | ||||
|   .bg-dark { | ||||
|     background-color: $bg-light-dark-mode !important; | ||||
|   } | ||||
|  | ||||
|   .ngx-file-drop__drop-zone--over { | ||||
|     background-color: darken($primary-dark-mode, 35%) !important; | ||||
|   } | ||||
|  | ||||
|   .alert-secondary { | ||||
|     background-color: $bg-light-dark-mode; | ||||
|     border-color: darken($bg-light-dark-mode, 10%); | ||||
|     color: $text-color-dark-mode-accent; | ||||
|   } | ||||
|  | ||||
|   .progress-bar.bg-primary { | ||||
|     background-color: darken($primary-dark-mode, 5%) !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| body.color-scheme-dark { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 jonaswinkler
					jonaswinkler