mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge branch 'dev' into feature/unsaved-changes
This commit is contained in:
		| @@ -10,6 +10,7 @@ import { LogsComponent } from './components/manage/logs/logs.component'; | ||||
| import { SettingsComponent } from './components/manage/settings/settings.component'; | ||||
| import { TagListComponent } from './components/manage/tag-list/tag-list.component'; | ||||
| import { NotFoundComponent } from './components/not-found/not-found.component'; | ||||
| import {DocumentAsnComponent} from "./components/document-asn/document-asn.component"; | ||||
| import { SearchComponent } from './components/search/search.component'; | ||||
| import { DirtyFormGuard } from './guards/dirty-form.guard'; | ||||
|  | ||||
| @@ -19,8 +20,8 @@ const routes: Routes = [ | ||||
|     {path: 'dashboard', component: DashboardComponent }, | ||||
|     {path: 'documents', component: DocumentListComponent }, | ||||
|     {path: 'view/:id', component: DocumentListComponent }, | ||||
|     {path: 'search', component: SearchComponent }, | ||||
|     {path: 'documents/:id', component: DocumentDetailComponent }, | ||||
|     {path: 'asn/:id', component: DocumentAsnComponent }, | ||||
|     {path: 'tags', component: TagListComponent }, | ||||
|     {path: 'documenttypes', component: DocumentTypeListComponent }, | ||||
|     {path: 'correspondents', component: CorrespondentListComponent }, | ||||
| @@ -33,7 +34,7 @@ const routes: Routes = [ | ||||
| ]; | ||||
|  | ||||
| @NgModule({ | ||||
|   imports: [RouterModule.forRoot(routes)], | ||||
|   imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })], | ||||
|   exports: [RouterModule] | ||||
| }) | ||||
| export class AppRoutingModule { } | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| <app-toasts></app-toasts> | ||||
|  | ||||
| <router-outlet></router-outlet> | ||||
| <router-outlet></router-outlet> | ||||
|   | ||||
| @@ -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'; | ||||
|     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,7 +3,7 @@ import { NgModule } from '@angular/core'; | ||||
|  | ||||
| import { AppRoutingModule } from './app-routing.module'; | ||||
| import { AppComponent } from './app.component'; | ||||
| import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { NgbDateAdapter, NgbDateParserFormatter, NgbModule } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; | ||||
| import { DocumentListComponent } from './components/document-list/document-list.component'; | ||||
| import { DocumentDetailComponent } from './components/document-detail/document-detail.component'; | ||||
| @@ -21,8 +21,6 @@ import { CorrespondentEditDialogComponent } from './components/manage/correspond | ||||
| 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 { TagComponent } from './components/common/tag/tag.component'; | ||||
| import { SearchComponent } from './components/search/search.component'; | ||||
| import { ResultHighlightComponent } from './components/search/result-highlight/result-highlight.component'; | ||||
| import { PageHeaderComponent } from './components/common/page-header/page-header.component'; | ||||
| import { AppFrameComponent } from './components/app-frame/app-frame.component'; | ||||
| import { ToastsComponent } from './components/common/toasts/toasts.component'; | ||||
| @@ -39,7 +37,6 @@ import { SelectComponent } from './components/common/input/select/select.compone | ||||
| import { CheckComponent } from './components/common/input/check/check.component'; | ||||
| import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component'; | ||||
| import { InfiniteScrollModule } from 'ngx-infinite-scroll'; | ||||
| import { DateTimeComponent } from './components/common/input/date-time/date-time.component'; | ||||
| import { TagsComponent } from './components/common/input/tags/tags.component'; | ||||
| import { SortableDirective } from './directives/sortable.directive'; | ||||
| import { CookieService } from 'ngx-cookie-service'; | ||||
| @@ -60,14 +57,41 @@ import { NgSelectModule } from '@ng-select/ng-select'; | ||||
| import { NumberComponent } from './components/common/input/number/number.component'; | ||||
| import { SafePipe } from './pipes/safe.pipe'; | ||||
| import { CustomDatePipe } from './pipes/custom-date.pipe'; | ||||
| import { DateComponent } from './components/common/input/date/date.component'; | ||||
| import { ISODateTimeAdapter } from './utils/ngb-iso-date-time-adapter'; | ||||
| import { LocalizedDateParserFormatter } from './utils/ngb-date-parser-formatter'; | ||||
| import { ApiVersionInterceptor } from './interceptors/api-version.interceptor'; | ||||
| import { ColorSliderModule } from 'ngx-color/slider'; | ||||
| import { ColorComponent } from './components/common/input/color/color.component'; | ||||
| import { DocumentAsnComponent } from './components/document-asn/document-asn.component'; | ||||
|  | ||||
| import localeFr from '@angular/common/locales/fr'; | ||||
| import localeNl from '@angular/common/locales/nl'; | ||||
| import localeDe from '@angular/common/locales/de'; | ||||
| import localePt from '@angular/common/locales/pt'; | ||||
| import localeIt from '@angular/common/locales/it'; | ||||
| import localeEnGb from '@angular/common/locales/en-GB'; | ||||
| import localeRo from '@angular/common/locales/ro'; | ||||
| import localeRu from '@angular/common/locales/ru'; | ||||
| import localeEs from '@angular/common/locales/es'; | ||||
| import localePl from '@angular/common/locales/pl'; | ||||
| import localeSv from '@angular/common/locales/sv'; | ||||
| import localeLb from '@angular/common/locales/lb'; | ||||
|  | ||||
|  | ||||
| registerLocaleData(localeFr) | ||||
| registerLocaleData(localeNl) | ||||
| registerLocaleData(localeDe) | ||||
| registerLocaleData(localePt, "pt-BR") | ||||
| registerLocaleData(localePt, "pt-PT") | ||||
| registerLocaleData(localeIt) | ||||
| registerLocaleData(localeEnGb) | ||||
| registerLocaleData(localeRo) | ||||
| registerLocaleData(localeRu) | ||||
| registerLocaleData(localeEs) | ||||
| registerLocaleData(localePl) | ||||
| registerLocaleData(localeSv) | ||||
| registerLocaleData(localeLb) | ||||
|  | ||||
| @NgModule({ | ||||
|   declarations: [ | ||||
| @@ -86,8 +110,6 @@ registerLocaleData(localeDe) | ||||
|     TagEditDialogComponent, | ||||
|     DocumentTypeEditDialogComponent, | ||||
|     TagComponent, | ||||
|     SearchComponent, | ||||
|     ResultHighlightComponent, | ||||
|     PageHeaderComponent, | ||||
|     AppFrameComponent, | ||||
|     ToastsComponent, | ||||
| @@ -102,7 +124,6 @@ registerLocaleData(localeDe) | ||||
|     SelectComponent, | ||||
|     CheckComponent, | ||||
|     SaveViewConfigDialogComponent, | ||||
|     DateTimeComponent, | ||||
|     TagsComponent, | ||||
|     SortableDirective, | ||||
|     SavedViewWidgetComponent, | ||||
| @@ -118,7 +139,10 @@ registerLocaleData(localeDe) | ||||
|     SelectDialogComponent, | ||||
|     NumberComponent, | ||||
|     SafePipe, | ||||
|     CustomDatePipe | ||||
|     CustomDatePipe, | ||||
|     DateComponent, | ||||
|     ColorComponent, | ||||
|     DocumentAsnComponent | ||||
|   ], | ||||
|   imports: [ | ||||
|     BrowserModule, | ||||
| @@ -130,7 +154,8 @@ registerLocaleData(localeDe) | ||||
|     NgxFileDropModule, | ||||
|     InfiniteScrollModule, | ||||
|     PdfViewerModule, | ||||
|     NgSelectModule | ||||
|     NgSelectModule, | ||||
|     ColorSliderModule | ||||
|   ], | ||||
|   providers: [ | ||||
|     DatePipe, | ||||
| @@ -138,9 +163,15 @@ registerLocaleData(localeDe) | ||||
|       provide: HTTP_INTERCEPTORS, | ||||
|       useClass: CsrfInterceptor, | ||||
|       multi: true | ||||
|     },{ | ||||
|       provide: HTTP_INTERCEPTORS, | ||||
|       useClass: ApiVersionInterceptor, | ||||
|       multi: true | ||||
|     }, | ||||
|     FilterPipe, | ||||
|     DocumentTitlePipe | ||||
|     DocumentTitlePipe, | ||||
|     {provide: NgbDateAdapter, useClass: ISODateTimeAdapter}, | ||||
|     {provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter} | ||||
|   ], | ||||
|   bootstrap: [AppComponent] | ||||
| }) | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|     <span class="navbar-toggler-icon"></span> | ||||
|   </button> | ||||
|   <a class="navbar-brand col-auto col-md-3 col-lg-2 mr-0 px-3 py-3 order-sm-0" routerLink="/dashboard"> | ||||
|     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1rem" class="mr-2" fill="currentColor"> | ||||
|     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" class="mr-2" fill="currentColor"> | ||||
|       <path d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z" transform="translate(0 0)"/> | ||||
|     </svg> | ||||
|     <ng-container i18n="app title">Paperless-ng</ng-container> | ||||
| @@ -31,7 +31,7 @@ | ||||
|       </button> | ||||
|       <div ngbDropdownMenu class="dropdown-menu-right shadow mr-2" aria-labelledby="userDropdown"> | ||||
|         <div *ngIf="displayName" class="d-sm-none"> | ||||
|           <p class="small mb-0 px-3" i18n>Logged in as {{displayName}}</p> | ||||
|           <p class="small mb-0 px-3 text-muted" i18n>Logged in as {{displayName}}</p> | ||||
|           <div class="dropdown-divider"></div> | ||||
|         </div> | ||||
|         <a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"> | ||||
| @@ -52,12 +52,7 @@ | ||||
| <div class="container-fluid"> | ||||
|   <div class="row"> | ||||
|     <nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse" [ngbCollapse]="isMenuCollapsed"> | ||||
|  | ||||
|       <div style="position: absolute; bottom: 0; left: 0;" class="text-muted p-1"> | ||||
|         {{versionString}} | ||||
|       </div> | ||||
|  | ||||
|       <div class="sidebar-sticky pt-3"> | ||||
|       <div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around"> | ||||
|         <ul class="nav flex-column"> | ||||
|           <li class="nav-item"> | ||||
|             <a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"> | ||||
| @@ -75,7 +70,7 @@ | ||||
|           </li> | ||||
|         </ul> | ||||
|  | ||||
|         <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.sidebarViews.length > 0'> | ||||
|         <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.sidebarViews.length > 0'> | ||||
|           <ng-container i18n>Saved views</ng-container> | ||||
|         </h6> | ||||
|         <ul class="nav flex-column mb-2"> | ||||
| @@ -88,7 +83,7 @@ | ||||
|           </li> | ||||
|         </ul> | ||||
|  | ||||
|         <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'> | ||||
|         <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'> | ||||
|           <ng-container i18n>Open documents</ng-container> | ||||
|         </h6> | ||||
|         <ul class="nav flex-column mb-2"> | ||||
| @@ -97,9 +92,14 @@ | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#file-text"/> | ||||
|               </svg> {{d.title | documentTitle}} | ||||
|               <span class="close bg-light" (click)="closeDocument(d); $event.preventDefault()"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16"> | ||||
|                   <use xlink:href="assets/bootstrap-icons.svg#x"/> | ||||
|                 </svg> | ||||
|               </span> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item w-100" *ngIf="openDocuments.length > 1"> | ||||
|           <li class="nav-item w-100" *ngIf="openDocuments.length >= 1"> | ||||
|             <a class="nav-link text-truncate" [routerLink]="" (click)="closeAll()"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#x"/> | ||||
| @@ -108,7 +108,7 @@ | ||||
|           </li> | ||||
|         </ul> | ||||
|  | ||||
|         <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted"> | ||||
|         <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted"> | ||||
|           <ng-container i18n>Manage</ng-container> | ||||
|         </h6> | ||||
|         <ul class="nav flex-column mb-2"> | ||||
| @@ -156,8 +156,8 @@ | ||||
|           </li> | ||||
|         </ul> | ||||
|  | ||||
|         <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted"> | ||||
|           <ng-container i18n>Misc</ng-container> | ||||
|         <h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted"> | ||||
|           <ng-container i18n>Info</ng-container> | ||||
|         </h6> | ||||
|         <ul class="nav flex-column mb-2"> | ||||
|           <li class="nav-item"> | ||||
| @@ -168,11 +168,24 @@ | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item"> | ||||
|             <a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#link"/> | ||||
|               </svg> <ng-container i18n>GitHub</ng-container> | ||||
|             </a> | ||||
|             <div class="d-flex w-100 flex-wrap"> | ||||
|               <a class="nav-link pr-0 pb-0" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="sidebaricon bi bi-github" viewBox="0 0 16 16"> | ||||
|                   <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> | ||||
|                 </svg> <ng-container i18n>GitHub</ng-container> | ||||
|               </a> | ||||
|               <a class="nav-link-additional small text-muted ml-3" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng/discussions/categories/feature-requests" title="Suggest an idea"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" width="1.3em" height="1.3em" fill="currentColor" class="bi bi-lightbulb pr-1" viewBox="0 0 16 16"> | ||||
|                   <path d="M2 6a6 6 0 1 1 10.174 4.31c-.203.196-.359.4-.453.619l-.762 1.769A.5.5 0 0 1 10.5 13a.5.5 0 0 1 0 1 .5.5 0 0 1 0 1l-.224.447a1 1 0 0 1-.894.553H6.618a1 1 0 0 1-.894-.553L5.5 15a.5.5 0 0 1 0-1 .5.5 0 0 1 0-1 .5.5 0 0 1-.46-.302l-.761-1.77a1.964 1.964 0 0 0-.453-.618A5.984 5.984 0 0 1 2 6zm6-5a5 5 0 0 0-3.479 8.592c.263.254.514.564.676.941L5.83 12h4.342l.632-1.467c.162-.377.413-.687.676-.941A5 5 0 0 0 8 1z"/> | ||||
|                 </svg> | ||||
|                 <ng-container i18n>Suggest an idea</ng-container> | ||||
|               </a> | ||||
|             </div> | ||||
|           </li> | ||||
|           <li class="nav-item mt-2"> | ||||
|             <div class="px-3 py-2 text-muted small"> | ||||
|               {{versionString}} | ||||
|             </div> | ||||
|           </li> | ||||
|         </ul> | ||||
|       </div> | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|   top: 0; | ||||
|   bottom: 0; | ||||
|   left: 0; | ||||
|   z-index: 100; /* Behind the navbar */ | ||||
|   z-index: 995; /* Behind the navbar */ | ||||
|   padding: 50px 0 0; /* Height of navbar */ | ||||
|   box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); | ||||
| } | ||||
| @@ -24,6 +24,7 @@ | ||||
|   padding-top: 0.5rem; | ||||
|   overflow-x: hidden; | ||||
|   overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ | ||||
|   min-height: min-content; | ||||
| } | ||||
| @supports ((position: -webkit-sticky) or (position: sticky)) { | ||||
|   .sidebar-sticky { | ||||
| @@ -57,6 +58,49 @@ | ||||
|   text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .nav { | ||||
|   flex-wrap: nowrap; | ||||
| } | ||||
|  | ||||
| .nav-item { | ||||
|   position: relative; | ||||
|  | ||||
|   &:hover .close { | ||||
|     display: block; | ||||
|   } | ||||
|  | ||||
|   .close { | ||||
|     display: none; | ||||
|     position: absolute; | ||||
|     cursor: pointer; | ||||
|     opacity: 1; | ||||
|     top: 0; | ||||
|     padding: .25rem .3rem 0; | ||||
|     right: .4rem; | ||||
|     width: 1.8rem; | ||||
|     height: 100%; | ||||
|  | ||||
|     svg { | ||||
|       opacity: 0.5; | ||||
|     } | ||||
|  | ||||
|     &:hover svg { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .nav-link-additional { | ||||
|     margin-top: 0.2rem; | ||||
|     margin-left: 0.25rem; | ||||
|     padding-top: 0.5rem; | ||||
|  | ||||
|     svg { | ||||
|       margin-bottom: 2px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Navbar | ||||
|  */ | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { FormControl } from '@angular/forms'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { from, Observable, Subscription } from 'rxjs'; | ||||
| import { ActivatedRoute, Router, Params } from '@angular/router'; | ||||
| import { from, Observable, Subscription, BehaviorSubject } from 'rxjs'; | ||||
| import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
| import { OpenDocumentsService } from 'src/app/services/open-documents.service'; | ||||
| @@ -10,13 +10,15 @@ import { SearchService } from 'src/app/services/rest/search.service'; | ||||
| import { environment } from 'src/environments/environment'; | ||||
| import { DocumentDetailComponent } from '../document-detail/document-detail.component'; | ||||
| import { Meta } from '@angular/platform-browser'; | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service'; | ||||
| import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-app-frame', | ||||
|   templateUrl: './app-frame.component.html', | ||||
|   styleUrls: ['./app-frame.component.scss'] | ||||
| }) | ||||
| export class AppFrameComponent implements OnInit, OnDestroy { | ||||
| export class AppFrameComponent implements OnInit { | ||||
|  | ||||
|   constructor ( | ||||
|     public router: Router, | ||||
| @@ -24,6 +26,7 @@ export class AppFrameComponent implements OnInit, OnDestroy { | ||||
|     private openDocumentsService: OpenDocumentsService, | ||||
|     private searchService: SearchService, | ||||
|     public savedViewService: SavedViewService, | ||||
|     private list: DocumentListViewService, | ||||
|     private meta: Meta | ||||
|     ) { } | ||||
|  | ||||
| @@ -37,9 +40,9 @@ export class AppFrameComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   searchField = new FormControl('') | ||||
|  | ||||
|   openDocuments: PaperlessDocument[] = [] | ||||
|  | ||||
|   openDocumentsSubscription: Subscription | ||||
|   get openDocuments(): PaperlessDocument[] { | ||||
|     return this.openDocumentsService.getOpenDocuments() | ||||
|   } | ||||
|  | ||||
|   searchAutoComplete = (text$: Observable<string>) => | ||||
|     text$.pipe( | ||||
| @@ -72,7 +75,20 @@ export class AppFrameComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   search() { | ||||
|     this.closeMenu() | ||||
|     this.router.navigate(['search'], {queryParams: {query: this.searchField.value}}) | ||||
|     this.list.quickFilter([{rule_type: FILTER_FULLTEXT_QUERY, value: this.searchField.value}]) | ||||
|   } | ||||
|  | ||||
|   closeDocument(d: PaperlessDocument) { | ||||
|     this.closeMenu() | ||||
|     this.openDocumentsService.closeDocument(d) | ||||
|  | ||||
|     let route = this.activatedRoute.snapshot | ||||
|     while (route.firstChild) { | ||||
|       route = route.firstChild | ||||
|     } | ||||
|     if (route.component == DocumentDetailComponent && route.params['id'] == d.id) { | ||||
|       this.router.navigate([""]) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   closeAll() { | ||||
| @@ -94,13 +110,6 @@ export class AppFrameComponent implements OnInit, OnDestroy { | ||||
|   } | ||||
|  | ||||
|   ngOnInit() { | ||||
|     this.openDocuments = this.openDocumentsService.getOpenDocuments() | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
|     if (this.openDocumentsSubscription) { | ||||
|       this.openDocumentsSubscription.unsubscribe() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   get displayName() { | ||||
|   | ||||
| @@ -20,8 +20,17 @@ | ||||
|           </div> | ||||
|  | ||||
|           <div class="input-group input-group-sm"> | ||||
|             <input type="date" class="form-control" id="date_after" [(ngModel)]="dateAfter" (change)="onChangeDebounce()"> | ||||
|             <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" | ||||
|                     [(ngModel)]="dateAfter" ngbDatepicker #dateAfterPicker="ngbDatepicker"> | ||||
|             <div class="input-group-append"> | ||||
|               <button class="btn btn-outline-secondary" (click)="dateAfterPicker.toggle()" type="button"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16"> | ||||
|                   <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> | ||||
|                 </svg> | ||||
|               </button> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|         </div> | ||||
|         <div class="list-group-item d-flex flex-column align-items-start" role="menuitem"> | ||||
|  | ||||
| @@ -36,8 +45,17 @@ | ||||
|           </div> | ||||
|  | ||||
|           <div class="input-group input-group-sm"> | ||||
|             <input type="date" class="form-control" id="date_before" [(ngModel)]="dateBefore" (change)="onChangeDebounce()"> | ||||
|             <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" | ||||
|                     [(ngModel)]="dateBefore" ngbDatepicker #dateBeforePicker="ngbDatepicker"> | ||||
|             <div class="input-group-append"> | ||||
|               <button class="btn btn-outline-secondary" (click)="dateBeforePicker.toggle()" type="button"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16"> | ||||
|                   <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> | ||||
|                 </svg> | ||||
|               </button> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|         </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   | ||||
| @@ -1,7 +1,10 @@ | ||||
| import { formatDate } from '@angular/common'; | ||||
| import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core'; | ||||
| import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { Subject, Subscription } from 'rxjs'; | ||||
| import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; | ||||
| import { debounceTime } from 'rxjs/operators'; | ||||
| import { SettingsService } from 'src/app/services/settings.service'; | ||||
| import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'; | ||||
|  | ||||
| export interface DateSelection { | ||||
|   before?: string | ||||
| @@ -16,10 +19,17 @@ const LAST_YEAR = 3 | ||||
| @Component({ | ||||
|   selector: 'app-date-dropdown', | ||||
|   templateUrl: './date-dropdown.component.html', | ||||
|   styleUrls: ['./date-dropdown.component.scss'] | ||||
|   styleUrls: ['./date-dropdown.component.scss'], | ||||
|   providers: [ | ||||
|     {provide: NgbDateAdapter, useClass: ISODateAdapter}, | ||||
|   ] | ||||
| }) | ||||
| export class DateDropdownComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   constructor(settings: SettingsService) { | ||||
|     this.datePlaceHolder = settings.getLocalizedDateInputFormat() | ||||
|   } | ||||
|  | ||||
|   quickFilters = [ | ||||
|     {id: LAST_7_DAYS, name: $localize`Last 7 days`}, | ||||
|     {id: LAST_MONTH, name: $localize`Last month`}, | ||||
| @@ -27,6 +37,8 @@ export class DateDropdownComponent implements OnInit, OnDestroy { | ||||
|     {id: LAST_YEAR, name: $localize`Last year`} | ||||
|   ] | ||||
|  | ||||
|   datePlaceHolder: string | ||||
|  | ||||
|   @Input() | ||||
|   dateBefore: string | ||||
|  | ||||
|   | ||||
| @@ -83,7 +83,7 @@ export class FilterableDropdownSelectionModel { | ||||
|     if (fireEvent) { | ||||
|       this.changed.next(this) | ||||
|     } | ||||
|      | ||||
|  | ||||
|   } | ||||
|  | ||||
|   private getNonTemporary(id: number) { | ||||
|   | ||||
| @@ -10,7 +10,7 @@ | ||||
|         <path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8z"/> | ||||
|       </svg> | ||||
|     </ng-container> | ||||
|      | ||||
|  | ||||
|   </div> | ||||
|   <div class="mr-1"> | ||||
|     <app-tag *ngIf="isTag; else displayName" [tag]="item" [clickable]="true" linkTitle="Filter by tag"></app-tag> | ||||
|   | ||||
| @@ -11,7 +11,7 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor { | ||||
|   constructor() { } | ||||
|  | ||||
|   onChange = (newValue: T) => {}; | ||||
|    | ||||
|  | ||||
|   onTouched = () => {}; | ||||
|  | ||||
|   writeValue(newValue: any): void { | ||||
|   | ||||
| @@ -2,4 +2,4 @@ | ||||
|   <input type="checkbox" class="custom-control-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled"> | ||||
|   <label class="custom-control-label" [for]="inputId">{{title}}</label> | ||||
|   <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> | ||||
| </div> | ||||
| </div> | ||||
|   | ||||
| @@ -15,7 +15,7 @@ import { AbstractInputComponent } from '../abstract-input'; | ||||
| }) | ||||
| export class CheckComponent extends AbstractInputComponent<boolean> { | ||||
|  | ||||
|   constructor() {  | ||||
|   constructor() { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,33 @@ | ||||
| <div class="form-group"> | ||||
|   <label [for]="inputId">{{title}}</label> | ||||
|  | ||||
|   <div class="input-group" [class.is-invalid]="error"> | ||||
|       <div class="input-group-prepend"> | ||||
|         <span class="input-group-text" [style.background-color]="value">   </span> | ||||
|       </div> | ||||
|  | ||||
|       <ng-template #popContent> | ||||
|         <div style="min-width: 200px;" class="pb-3"> | ||||
|           <color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider> | ||||
|         </div> | ||||
|  | ||||
|       </ng-template> | ||||
|  | ||||
|       <input class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow"> | ||||
|  | ||||
|       <div class="input-group-append"> | ||||
|         <button class="btn btn-outline-secondary" type="button" (click)="randomize()"> | ||||
|           <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-dice-5" viewBox="0 0 16 16"> | ||||
|             <path d="M13 1a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h10zM3 0a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V3a3 3 0 0 0-3-3H3z"/> | ||||
|             <path d="M5.5 4a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm8 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-8 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm4-4a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/> | ||||
|           </svg> | ||||
|         </button> | ||||
|       </div> | ||||
|  | ||||
|  | ||||
|     </div> | ||||
|     <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> | ||||
|     <div class="invalid-feedback"> | ||||
|       {{error}} | ||||
|     </div> | ||||
| </div> | ||||
| @@ -1,20 +1,20 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||||
| 
 | ||||
| import { SearchComponent } from './search.component'; | ||||
| import { ColorComponent } from './color.component'; | ||||
| 
 | ||||
| describe('SearchComponent', () => { | ||||
|   let component: SearchComponent; | ||||
|   let fixture: ComponentFixture<SearchComponent>; | ||||
| describe('ColorComponent', () => { | ||||
|   let component: ColorComponent; | ||||
|   let fixture: ComponentFixture<ColorComponent>; | ||||
| 
 | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       declarations: [ SearchComponent ] | ||||
|       declarations: [ ColorComponent ] | ||||
|     }) | ||||
|     .compileComponents(); | ||||
|   }); | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     fixture = TestBed.createComponent(SearchComponent); | ||||
|     fixture = TestBed.createComponent(ColorComponent); | ||||
|     component = fixture.componentInstance; | ||||
|     fixture.detectChanges(); | ||||
|   }); | ||||
| @@ -0,0 +1,30 @@ | ||||
| import { Component, forwardRef } from '@angular/core'; | ||||
| import { NG_VALUE_ACCESSOR } from '@angular/forms'; | ||||
| import { randomColor } from 'src/app/utils/color'; | ||||
| import { AbstractInputComponent } from '../abstract-input'; | ||||
|  | ||||
| @Component({ | ||||
|   providers: [{ | ||||
|     provide: NG_VALUE_ACCESSOR, | ||||
|     useExisting: forwardRef(() => ColorComponent), | ||||
|     multi: true | ||||
|   }], | ||||
|   selector: 'app-input-color', | ||||
|   templateUrl: './color.component.html', | ||||
|   styleUrls: ['./color.component.scss'] | ||||
| }) | ||||
| export class ColorComponent extends AbstractInputComponent<string> { | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   randomize() { | ||||
|     this.colorChanged(randomColor()) | ||||
|   } | ||||
|  | ||||
|   colorChanged(value) { | ||||
|     this.value = value | ||||
|     this.onChange(value) | ||||
|   } | ||||
| } | ||||
| @@ -1,13 +0,0 @@ | ||||
| <div class="form-row"> | ||||
|   <div class="form-group col"> | ||||
|       <label for="created_date">{{titleDate}}</label> | ||||
|       <input type="date" class="form-control" id="created_date" [(ngModel)]="dateValue" (change)="dateOrTimeChanged()"> | ||||
|   </div> | ||||
|   <div class="form-group col" *ngIf="titleTime"> | ||||
|       <label for="created_time">{{titleTime}}</label> | ||||
|       <input type="time" class="form-control" id="created_time" [(ngModel)]="timeValue" (change)="dateOrTimeChanged()"> | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
|  | ||||
| <!-- <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> --> | ||||
| @@ -1,61 +0,0 @@ | ||||
| import { formatDate } from '@angular/common'; | ||||
| import { Component, forwardRef, Input, OnInit } from '@angular/core'; | ||||
| import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; | ||||
|  | ||||
| @Component({ | ||||
|   providers: [{ | ||||
|     provide: NG_VALUE_ACCESSOR, | ||||
|     useExisting: forwardRef(() => DateTimeComponent), | ||||
|     multi: true | ||||
|   }], | ||||
|   selector: 'app-input-date-time', | ||||
|   templateUrl: './date-time.component.html', | ||||
|   styleUrls: ['./date-time.component.scss'] | ||||
| }) | ||||
| export class DateTimeComponent implements OnInit,ControlValueAccessor  { | ||||
|  | ||||
|   constructor() { | ||||
|   } | ||||
|  | ||||
|   onChange = (newValue: any) => {}; | ||||
|    | ||||
|   onTouched = () => {}; | ||||
|  | ||||
|   writeValue(newValue: any): void { | ||||
|     this.dateValue = formatDate(newValue, 'yyyy-MM-dd', "en-US") | ||||
|     this.timeValue = formatDate(newValue, 'HH:mm:ss', 'en-US') | ||||
|   } | ||||
|   registerOnChange(fn: any): void { | ||||
|     this.onChange = fn; | ||||
|   } | ||||
|   registerOnTouched(fn: any): void { | ||||
|     this.onTouched = fn; | ||||
|   } | ||||
|   setDisabledState?(isDisabled: boolean): void { | ||||
|     this.disabled = isDisabled; | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   titleDate: string = "Date" | ||||
|  | ||||
|   @Input() | ||||
|   titleTime: string | ||||
|  | ||||
|   @Input() | ||||
|   disabled: boolean = false | ||||
|  | ||||
|   @Input() | ||||
|   hint: string | ||||
|  | ||||
|   timeValue | ||||
|  | ||||
|   dateValue | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
|  | ||||
|   dateOrTimeChanged() { | ||||
|     this.onChange(formatDate(this.dateValue + "T" + this.timeValue,"yyyy-MM-ddTHH:mm:ssZZZZZ", "en-us", "UTC")) | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| <div class="form-group"> | ||||
|   <label [for]="inputId">{{title}}</label> | ||||
|   <div class="input-group" [class.is-invalid]="error"> | ||||
|     <input class="form-control" [class.is-invalid]="error"  [placeholder]="placeholder" [id]="inputId" (dateSelect)="onChange(value)" (change)="onChange(value)" | ||||
|            name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel"> | ||||
|     <div class="input-group-append"> | ||||
|       <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button"> | ||||
|         <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16"> | ||||
|           <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> | ||||
|         </svg> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="invalid-feedback" i18n>Invalid date.</div> | ||||
|   <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> | ||||
| </div> | ||||
| @@ -1,20 +1,20 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||||
| 
 | ||||
| import { DateTimeComponent } from './date-time.component'; | ||||
| import { DateComponent } from './date.component'; | ||||
| 
 | ||||
| describe('DateTimeComponent', () => { | ||||
|   let component: DateTimeComponent; | ||||
|   let fixture: ComponentFixture<DateTimeComponent>; | ||||
| describe('DateComponent', () => { | ||||
|   let component: DateComponent; | ||||
|   let fixture: ComponentFixture<DateComponent>; | ||||
| 
 | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       declarations: [ DateTimeComponent ] | ||||
|       declarations: [ DateComponent ] | ||||
|     }) | ||||
|     .compileComponents(); | ||||
|   }); | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     fixture = TestBed.createComponent(DateTimeComponent); | ||||
|     fixture = TestBed.createComponent(DateComponent); | ||||
|     component = fixture.componentInstance; | ||||
|     fixture.detectChanges(); | ||||
|   }); | ||||
| @@ -0,0 +1,32 @@ | ||||
| import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; | ||||
| import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; | ||||
| import { NgbDateAdapter, NgbDateParserFormatter, NgbDatepickerContent } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { SettingsService } from 'src/app/services/settings.service'; | ||||
| import { v4 as uuidv4 } from 'uuid'; | ||||
| import { AbstractInputComponent } from '../abstract-input'; | ||||
|  | ||||
|  | ||||
| @Component({ | ||||
|   providers: [{ | ||||
|     provide: NG_VALUE_ACCESSOR, | ||||
|     useExisting: forwardRef(() => DateComponent), | ||||
|     multi: true | ||||
|   }], | ||||
|   selector: 'app-input-date', | ||||
|   templateUrl: './date.component.html', | ||||
|   styleUrls: ['./date.component.scss'] | ||||
| }) | ||||
| export class DateComponent extends AbstractInputComponent<string> implements OnInit { | ||||
|  | ||||
|   constructor(private settings: SettingsService) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     super.ngOnInit() | ||||
|     this.placeholder = this.settings.getLocalizedDateInputFormat() | ||||
|   } | ||||
|  | ||||
|   placeholder: string | ||||
|  | ||||
| } | ||||
| @@ -11,4 +11,4 @@ | ||||
|   </div> | ||||
|   <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> | ||||
|  | ||||
| </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,25 +1,38 @@ | ||||
| <div class="form-group paperless-input-select"> | ||||
|   <label [for]="inputId">{{title}}</label> | ||||
|   <div [class.input-group]="showPlusButton()"> | ||||
|     <ng-select name="inputId" [(ngModel)]="value" | ||||
|       [disabled]="disabled" | ||||
|       [style.color]="textColor" | ||||
|       [style.background]="backgroundColor" | ||||
|       [clearable]="allowNull" | ||||
|       [items]="items" | ||||
|       bindLabel="name" | ||||
|       bindValue="id" | ||||
|       (change)="onChange(value)" | ||||
|       (blur)="onTouched()"> | ||||
|     </ng-select> | ||||
|  | ||||
|     <div *ngIf="showPlusButton()" class="input-group-append"> | ||||
|       <button class="btn btn-outline-secondary" type="button" (click)="createNew.emit()"> | ||||
|         <svg class="buttonicon" fill="currentColor"> | ||||
|           <use xlink:href="assets/bootstrap-icons.svg#plus" /> | ||||
|         </svg> | ||||
|       </button> | ||||
|     <div [class.input-group]="allowCreateNew"> | ||||
|       <ng-select name="inputId" [(ngModel)]="value" | ||||
|         [disabled]="disabled" | ||||
|         [style.color]="textColor" | ||||
|         [style.background]="backgroundColor" | ||||
|         [clearable]="allowNull" | ||||
|         [items]="items" | ||||
|         [addTag]="allowCreateNew && addItemRef" | ||||
|         addTagText="Add item" | ||||
|         i18n-addTagText="Used for both types and correspondents" | ||||
|         bindLabel="name" | ||||
|         bindValue="id" | ||||
|         (change)="onChange(value)" | ||||
|         (search)="onSearch($event)" | ||||
|         (focus)="clearLastSearchTerm()" | ||||
|         (clear)="clearLastSearchTerm()" | ||||
|         (blur)="onBlur()"> | ||||
|       </ng-select> | ||||
|       <div *ngIf="allowCreateNew" class="input-group-append"> | ||||
|         <button class="btn btn-outline-secondary" type="button" (click)="addItem()"> | ||||
|           <svg class="buttonicon" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#plus" /> | ||||
|           </svg> | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> | ||||
|   <small *ngIf="getSuggestions().length > 0"> | ||||
|     <span i18n>Suggestions:</span>  | ||||
|     <ng-container *ngFor="let s of getSuggestions()"> | ||||
|       <a (click)="value = s.id; onChange(value)" [routerLink]="">{{s.name}}</a>  | ||||
|     </ng-container> | ||||
|  | ||||
|  | ||||
|   </small> | ||||
| </div> | ||||
|   | ||||
| @@ -16,6 +16,7 @@ export class SelectComponent extends AbstractInputComponent<number> { | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.addItemRef = this.addItem.bind(this) | ||||
|    } | ||||
|  | ||||
|   @Input() | ||||
| @@ -30,11 +31,51 @@ export class SelectComponent extends AbstractInputComponent<number> { | ||||
|   @Input() | ||||
|   allowNull: boolean = false | ||||
|  | ||||
|   @Input() | ||||
|   suggestions: number[] | ||||
|  | ||||
|   @Output() | ||||
|   createNew = new EventEmitter() | ||||
|    | ||||
|   showPlusButton(): boolean { | ||||
|   createNew = new EventEmitter<string>() | ||||
|  | ||||
|   public addItemRef: (name) => void | ||||
|  | ||||
|   private _lastSearchTerm: string | ||||
|  | ||||
|   get allowCreateNew(): boolean { | ||||
|     return this.createNew.observers.length > 0 | ||||
|   } | ||||
|  | ||||
|   getSuggestions() { | ||||
|     if (this.suggestions && this.items) { | ||||
|       return this.suggestions.filter(id => id != this.value).map(id => this.items.find(item => item.id == id)) | ||||
|     } else { | ||||
|       return [] | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   addItem(name: string) { | ||||
|     if (name) this.createNew.next(name) | ||||
|     else this.createNew.next(this._lastSearchTerm) | ||||
|     this.clearLastSearchTerm() | ||||
|   } | ||||
|  | ||||
|   clickNew() { | ||||
|     this.createNew.next(this._lastSearchTerm) | ||||
|     this.clearLastSearchTerm() | ||||
|   } | ||||
|  | ||||
|   clearLastSearchTerm() { | ||||
|     this._lastSearchTerm = null | ||||
|   } | ||||
|  | ||||
|   onSearch($event) { | ||||
|     this._lastSearchTerm = $event.term | ||||
|   } | ||||
|  | ||||
|   onBlur() { | ||||
|     setTimeout(() => { | ||||
|       this.clearLastSearchTerm() | ||||
|     }, 3000); | ||||
|   } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -2,30 +2,31 @@ | ||||
|   <label for="tags" i18n>Tags</label> | ||||
|  | ||||
|   <div class="input-group flex-nowrap"> | ||||
|     <ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="displayValue" | ||||
|     <ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" | ||||
|       [multiple]="true" | ||||
|       [closeOnSelect]="false" | ||||
|       [clearSearchOnAdd]="true" | ||||
|       [disabled]="disabled" | ||||
|       [hideSelected]="true" | ||||
|       (change)="ngSelectChange()"> | ||||
|       [addTag]="createTagRef" | ||||
|       addTagText="Add tag" | ||||
|       i18n-addTagText | ||||
|       (change)="onChange(value)" | ||||
|       (search)="onSearch($event)" | ||||
|       (focus)="clearLastSearchTerm()" | ||||
|       (clear)="clearLastSearchTerm()" | ||||
|       (blur)="onBlur()"> | ||||
|  | ||||
|       <ng-template ng-label-tmp let-item="item"> | ||||
|         <span class="tag-wrap tag-wrap-delete" (click)="removeTag(item.id)"> | ||||
|           <svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#x"/> | ||||
|           </svg> | ||||
|           <app-tag style="background-color: none;" [tag]="getTag(item.id)"></app-tag> | ||||
|           <app-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></app-tag> | ||||
|         </span> | ||||
|       </ng-template> | ||||
|       <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm"> | ||||
|         <div class="tag-wrap"> | ||||
|           <div class="selected-icon d-inline-block mr-1"> | ||||
|             <svg *ngIf="displayValue.includes(item.id)" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|               <use xlink:href="assets/bootstrap-icons.svg#check"/> | ||||
|             </svg> | ||||
|           </div> | ||||
|           <app-tag class="mr-2" [tag]="getTag(item.id)"></app-tag> | ||||
|           <app-tag *ngIf="item.id && tags" class="mr-2" [tag]="getTag(item.id)"></app-tag> | ||||
|         </div> | ||||
|       </ng-template> | ||||
|     </ng-select> | ||||
| @@ -39,5 +40,13 @@ | ||||
|     </div> | ||||
|   </div> | ||||
|   <small class="form-text text-muted" *ngIf="hint">{{hint}}</small> | ||||
|   <small *ngIf="getSuggestions().length > 0"> | ||||
|     <span i18n>Suggestions:</span>  | ||||
|     <ng-container *ngFor="let tag of getSuggestions()"> | ||||
|       <a (click)="addTag(tag.id)" [routerLink]="">{{tag.name}}</a>  | ||||
|     </ng-container> | ||||
|  | ||||
|  | ||||
|   </small> | ||||
|  | ||||
| </div> | ||||
|   | ||||
| @@ -17,8 +17,9 @@ import { TagService } from 'src/app/services/rest/tag.service'; | ||||
| }) | ||||
| export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|  | ||||
|   constructor(private tagService: TagService, private modalService: NgbModal) { } | ||||
|  | ||||
|   constructor(private tagService: TagService, private modalService: NgbModal) { | ||||
|     this.createTagRef = this.createTag.bind(this) | ||||
|   } | ||||
|  | ||||
|   onChange = (newValue: number[]) => {}; | ||||
|  | ||||
| @@ -26,9 +27,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|  | ||||
|   writeValue(newValue: number[]): void { | ||||
|     this.value = newValue | ||||
|     if (this.tags) { | ||||
|       this.displayValue = newValue | ||||
|     } | ||||
|   } | ||||
|   registerOnChange(fn: any): void { | ||||
|     this.onChange = fn; | ||||
| @@ -43,7 +41,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|   ngOnInit(): void { | ||||
|     this.tagService.listAll().subscribe(result => { | ||||
|       this.tags = result.results | ||||
|       this.displayValue = this.value | ||||
|     }) | ||||
|   } | ||||
|  | ||||
| @@ -53,41 +50,74 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|   @Input() | ||||
|   hint | ||||
|  | ||||
|   value: number[] | ||||
|   @Input() | ||||
|   suggestions: number[] | ||||
|  | ||||
|   displayValue: number[] = [] | ||||
|   value: number[] | ||||
|  | ||||
|   tags: PaperlessTag[] | ||||
|  | ||||
|   getTag(id) { | ||||
|     return this.tags.find(tag => tag.id == id) | ||||
|   } | ||||
|   public createTagRef: (name) => void | ||||
|  | ||||
|   removeTag(id) { | ||||
|     let index = this.displayValue.indexOf(id) | ||||
|     if (index > -1) { | ||||
|       let oldValue = this.displayValue | ||||
|       oldValue.splice(index, 1) | ||||
|       this.displayValue = [...oldValue] | ||||
|       this.onChange(this.displayValue) | ||||
|   private _lastSearchTerm: string | ||||
|  | ||||
|   getTag(id) { | ||||
|     if (this.tags) { | ||||
|       return this.tags.find(tag => tag.id == id) | ||||
|     } else { | ||||
|       return null | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   createTag() { | ||||
|   removeTag(id) { | ||||
|     let index = this.value.indexOf(id) | ||||
|     if (index > -1) { | ||||
|       let oldValue = this.value | ||||
|       oldValue.splice(index, 1) | ||||
|       this.value = [...oldValue] | ||||
|       this.onChange(this.value) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   createTag(name: string = null) { | ||||
|     var modal = this.modalService.open(TagEditDialogComponent, {backdrop: 'static'}) | ||||
|     modal.componentInstance.dialogMode = 'create' | ||||
|     if (name) modal.componentInstance.object = { name: name } | ||||
|     else if (this._lastSearchTerm) modal.componentInstance.object = { name: this._lastSearchTerm } | ||||
|     modal.componentInstance.success.subscribe(newTag => { | ||||
|       this.tagService.listAll().subscribe(tags => { | ||||
|         this.tags = tags.results | ||||
|         this.displayValue = [...this.displayValue, newTag.id] | ||||
|         this.onChange(this.displayValue) | ||||
|         this.value = [...this.value, newTag.id] | ||||
|         this.onChange(this.value) | ||||
|       }) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngSelectChange() { | ||||
|     this.value = this.displayValue | ||||
|     this.onChange(this.displayValue) | ||||
|   getSuggestions() { | ||||
|     if (this.suggestions && this.tags) { | ||||
|       return this.suggestions.filter(id => !this.value.includes(id)).map(id => this.tags.find(tag => tag.id == id)) | ||||
|     } else { | ||||
|       return [] | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   addTag(id) { | ||||
|     this.value = [...this.value, id] | ||||
|     this.onChange(this.value) | ||||
|   } | ||||
|  | ||||
|   clearLastSearchTerm() { | ||||
|     this._lastSearchTerm = null | ||||
|   } | ||||
|  | ||||
|   onSearch($event) { | ||||
|     this._lastSearchTerm = $event.term | ||||
|   } | ||||
|  | ||||
|   onBlur() { | ||||
|     setTimeout(() => { | ||||
|       this.clearLastSearchTerm() | ||||
|     }, 3000); | ||||
|   } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -5,4 +5,4 @@ | ||||
|   <div class="invalid-feedback"> | ||||
|     {{error}} | ||||
|   </div> | ||||
| </div> | ||||
| </div> | ||||
|   | ||||
| @@ -12,4 +12,4 @@ | ||||
| <div class="modal-footer"> | ||||
|   <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" i18n>Cancel</button> | ||||
|   <button type="button" class="btn btn-primary" (click)="selectClicked.emit(selected)" i18n>Select</button> | ||||
| </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,2 +1,2 @@ | ||||
| <span *ngIf="!clickable" class="badge" [style.background]="getColour().value" [style.color]="getColour().textColor">{{tag.name}}</span> | ||||
| <a [routerLink]="" [title]="linkTitle" *ngIf="clickable" class="badge" [style.background]="getColour().value" [style.color]="getColour().textColor">{{tag.name}}</a> | ||||
| <span *ngIf="!clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span> | ||||
| <a [routerLink]="" [title]="linkTitle" *ngIf="clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Component, Input, OnInit } from '@angular/core'; | ||||
| import { TAG_COLOURS, PaperlessTag } from 'src/app/data/paperless-tag'; | ||||
| import { PaperlessTag } from 'src/app/data/paperless-tag'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-tag', | ||||
| @@ -22,8 +22,4 @@ export class TagComponent implements OnInit { | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
|  | ||||
|   getColour() { | ||||
|     return TAG_COLOURS.find(c => c.id == this.tag.colour) | ||||
|   } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -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({ | ||||
| @@ -10,19 +12,33 @@ import { DocumentService } from 'src/app/services/rest/document.service'; | ||||
|   templateUrl: './saved-view-widget.component.html', | ||||
|   styleUrls: ['./saved-view-widget.component.scss'] | ||||
| }) | ||||
| export class SavedViewWidgetComponent implements OnInit { | ||||
| export class SavedViewWidgetComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   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 | ||||
|     }) | ||||
| @@ -32,7 +48,7 @@ export class SavedViewWidgetComponent implements OnInit { | ||||
|     if (this.savedView.show_in_sidebar) { | ||||
|       this.router.navigate(['view', this.savedView.id]) | ||||
|     } else { | ||||
|       this.list.load(this.savedView) | ||||
|       this.list.loadSavedView(this.savedView, true) | ||||
|       this.router.navigate(["documents"]) | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <app-widget-frame title="Statistics" i18n-title> | ||||
|   <ng-container content> | ||||
|     <p class="card-text" i18n>Documents in inbox: {{statistics.documents_inbox}}</p> | ||||
|     <p class="card-text" i18n>Total documents: {{statistics.documents_total}}</p> | ||||
|     <p class="card-text" i18n *ngIf="statistics?.documents_inbox != null">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> | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { HttpClient } from '@angular/common/http'; | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { Observable, Subscription } from 'rxjs'; | ||||
| import { ConsumerStatusService } from 'src/app/services/consumer-status.service'; | ||||
| import { environment } from 'src/environments/environment'; | ||||
|  | ||||
| export interface Statistics { | ||||
| @@ -14,20 +15,34 @@ export interface Statistics { | ||||
|   templateUrl: './statistics-widget.component.html', | ||||
|   styleUrls: ['./statistics-widget.component.scss'] | ||||
| }) | ||||
| export class StatisticsWidgetComponent implements OnInit { | ||||
| export class StatisticsWidgetComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   constructor(private http: HttpClient) { } | ||||
|   constructor(private http: HttpClient, | ||||
|     private consumerStatusService: ConsumerStatusService) { } | ||||
|  | ||||
|   statistics: Statistics = {} | ||||
|  | ||||
|   getStatistics(): Observable<Statistics> { | ||||
|   subscription: Subscription | ||||
|  | ||||
|   private getStatistics(): Observable<Statistics> { | ||||
|     return this.http.get(`${environment.apiBaseUrl}statistics/`) | ||||
|   } | ||||
|    | ||||
|   ngOnInit(): void { | ||||
|  | ||||
|   reload() { | ||||
|     this.getStatistics().subscribe(statistics => { | ||||
|       this.statistics = statistics | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.reload() | ||||
|     this.subscription = this.consumerStatusService.onDocumentConsumptionFinished().subscribe(status => { | ||||
|       this.reload() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy(): void { | ||||
|     this.subscription.unsubscribe() | ||||
|   } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,18 +1,52 @@ | ||||
| <app-widget-frame title="Upload new documents" i18n-title> | ||||
|  | ||||
|   <div header-buttons> | ||||
|     <a *ngIf="getStatusSuccess().length > 0" (click)="dismissCompleted()" [routerLink]="" > | ||||
|       <span i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">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="This is shown as a summary line when there are more than 5 document in the processing pipeline.">{getStatusHidden().length, plural, =1 {One more document} other {{{getStatusHidden().length}} more documents}}</span> | ||||
|          •  | ||||
|         <a [routerLink]="" (click)="alertsExpanded = !alertsExpanded" aria-controls="hiddenAlerts" [attr.aria-expanded]="alertsExpanded" i18n>Show all</a> | ||||
|       </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; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .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) | ||||
|   } | ||||
|  | ||||
|   dismissCompleted() { | ||||
|     this.consumerStatusService.dismissCompleted() | ||||
|   } | ||||
|  | ||||
|   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`Upload complete, waiting...` | ||||
|             } | ||||
|              | ||||
|  | ||||
|           }, 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, $localize`HTTP error: ${error.status} ${error.statusText}`) | ||||
|                 break; | ||||
|               } | ||||
|             } | ||||
|  | ||||
|           }) | ||||
|         }); | ||||
|       } | ||||
|   | ||||
| @@ -13,4 +13,4 @@ | ||||
|     <p i18n>Consult the documentation on how to use these features. The section on basic usage also has some information on how to use paperless in general.</p> | ||||
|   </ng-container> | ||||
|  | ||||
| </app-widget-frame> | ||||
| </app-widget-frame> | ||||
|   | ||||
| @@ -4,9 +4,9 @@ | ||||
|       <h5 class="card-title mb-0">{{title}}</h5> | ||||
|       <ng-content select ="[header-buttons]"></ng-content> | ||||
|     </div> | ||||
|      | ||||
|  | ||||
|   </div> | ||||
|   <div class="card-body text-dark"> | ||||
|     <ng-content select ="[content]"></ng-content> | ||||
|   </div> | ||||
| </div> | ||||
| </div> | ||||
|   | ||||
| @@ -0,0 +1 @@ | ||||
| <p i18n>Searching document with asn {{asn}}</p> | ||||
| @@ -1,20 +1,20 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||||
| 
 | ||||
| import { ResultHighlightComponent } from './result-highlight.component'; | ||||
| import { DocumentAsnComponent } from './document-asn.component'; | ||||
| 
 | ||||
| describe('ResultHighlightComponent', () => { | ||||
|   let component: ResultHighlightComponent; | ||||
|   let fixture: ComponentFixture<ResultHighlightComponent>; | ||||
| describe('DocumentASNComponentComponent', () => { | ||||
|   let component: DocumentAsnComponent; | ||||
|   let fixture: ComponentFixture<DocumentAsnComponent>; | ||||
| 
 | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       declarations: [ ResultHighlightComponent ] | ||||
|       declarations: [ DocumentAsnComponent ] | ||||
|     }) | ||||
|     .compileComponents(); | ||||
|   }); | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     fixture = TestBed.createComponent(ResultHighlightComponent); | ||||
|     fixture = TestBed.createComponent(DocumentAsnComponent); | ||||
|     component = fixture.componentInstance; | ||||
|     fixture.detectChanges(); | ||||
|   }); | ||||
| @@ -0,0 +1,34 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import {DocumentService} from "../../services/rest/document.service"; | ||||
| import {ActivatedRoute, Router} from "@angular/router"; | ||||
| import {FILTER_ASN} from "../../data/filter-rule-type"; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-document-asncomponent', | ||||
|   templateUrl: './document-asn.component.html', | ||||
|   styleUrls: ['./document-asn.component.scss'] | ||||
| }) | ||||
| export class DocumentAsnComponent implements OnInit { | ||||
|  | ||||
|   asn: string | ||||
|   constructor( | ||||
|     private documentsService: DocumentService, | ||||
|     private route: ActivatedRoute, | ||||
|     private router: Router) { } | ||||
|  | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|  | ||||
|     this.route.paramMap.subscribe(paramMap => { | ||||
|       this.asn = paramMap.get('id'); | ||||
|       this.documentsService.listAllFilteredIds([{rule_type: FILTER_ASN, value: this.asn}]).subscribe(documentId => { | ||||
|         if (documentId.length == 1) { | ||||
|           this.router.navigate(['documents', documentId[0]]) | ||||
|         } else { | ||||
|           this.router.navigate(['404']) | ||||
|         } | ||||
|       }) | ||||
|     }) | ||||
|  | ||||
|   } | ||||
| } | ||||
| @@ -58,12 +58,12 @@ | ||||
|  | ||||
|                         <app-input-text #inputTitle i18n-title title="Title" formControlName="title" [error]="error?.title"></app-input-text> | ||||
|                         <app-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></app-input-number> | ||||
|                         <app-input-date-time i18n-titleDate titleDate="Date created" formControlName="created"></app-input-date-time> | ||||
|                         <app-input-date i18n-title title="Date created" formControlName="created" [error]="error?.created"></app-input-date> | ||||
|                         <app-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" | ||||
|                             (createNew)="createCorrespondent()"></app-input-select> | ||||
|                             (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents"></app-input-select> | ||||
|                         <app-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" | ||||
|                             (createNew)="createDocumentType()"></app-input-select> | ||||
|                         <app-input-tags formControlName="tags"></app-input-tags> | ||||
|                             (createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types"></app-input-select> | ||||
|                         <app-input-tags formControlName="tags" [suggestions]="suggestions?.tags"></app-input-tags> | ||||
|  | ||||
|                     </ng-template> | ||||
|                 </li> | ||||
| @@ -123,6 +123,15 @@ | ||||
|  | ||||
|                     </ng-template> | ||||
|                 </li> | ||||
|  | ||||
|                 <li [ngbNavItem]="4" class="d-md-none"> | ||||
|                   <a ngbNavLink>Preview</a> | ||||
|                   <ng-template ngbNavContent *ngIf="pdfPreview.offsetParent == undefined"> | ||||
|                     <div class="pdf-viewer-container" *ngIf="getContentType() == 'application/pdf'"> | ||||
|                       <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true"></pdf-viewer> | ||||
|                     </div> | ||||
|                   </ng-template> | ||||
|                 </li> | ||||
|             </ul> | ||||
|  | ||||
|             <div [ngbNavOutlet]="nav" class="mt-2"></div> | ||||
| @@ -133,17 +142,17 @@ | ||||
|         </form> | ||||
|     </div> | ||||
|  | ||||
|     <div class="col-md-6 col-xl-8 mb-3"> | ||||
|     <div class="col-md-6 col-xl-8 mb-3 d-none d-md-block" #pdfPreview> | ||||
|         <ng-container *ngIf="getContentType() == 'application/pdf'"> | ||||
|             <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer"> | ||||
|                 <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer> | ||||
|             </div> | ||||
|             <ng-template #nativePdfViewer> | ||||
|                 <object [data]="previewUrl | safe" type="application/pdf" class="preview-sticky" width="100%"></object> | ||||
|                 <object [data]="previewUrl | safe" class="preview-sticky" width="100%"></object> | ||||
|             </ng-template> | ||||
|         </ng-container> | ||||
|         <ng-container *ngIf="getContentType() == 'text/plain'"> | ||||
|             <object [data]="previewUrl | safe" type="text/plain" class="preview-sticky" width="100%"></object> | ||||
|             <object [data]="previewUrl | safe" type="text/plain" class="preview-sticky bg-white" width="100%"></object> | ||||
|         </ng-container> | ||||
|  | ||||
|     </div> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { Component, OnInit, ViewChild } from '@angular/core'; | ||||
| import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; | ||||
| import { FormControl, FormGroup } from '@angular/forms'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { NgbModal, NgbNav } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
| import { PaperlessDocumentMetadata } from 'src/app/data/paperless-document-metadata'; | ||||
| @@ -21,6 +21,8 @@ import { TextComponent } from '../common/input/text/text.component'; | ||||
| import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; | ||||
| import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'; | ||||
| import { Observable, Subscription, BehaviorSubject } from 'rxjs'; | ||||
| import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions'; | ||||
| import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-document-detail', | ||||
| @@ -42,6 +44,8 @@ export class DocumentDetailComponent implements OnInit, DirtyComponent { | ||||
|   documentId: number | ||||
|   document: PaperlessDocument | ||||
|   metadata: PaperlessDocumentMetadata | ||||
|   suggestions: PaperlessDocumentSuggestions | ||||
|  | ||||
|   title: string | ||||
|   previewUrl: string | ||||
|   downloadUrl: string | ||||
| @@ -67,6 +71,15 @@ export class DocumentDetailComponent implements OnInit, DirtyComponent { | ||||
|   storeSub: Subscription | ||||
|   isDirty$: Observable<boolean> | ||||
|  | ||||
|   @ViewChild('nav') nav: NgbNav | ||||
|   @ViewChild('pdfPreview') set pdfPreview(element) { | ||||
|     // this gets called when compontent added or removed from DOM | ||||
|     if (element && element.nativeElement.offsetParent !== null) { // its visible | ||||
|  | ||||
|       setTimeout(()=> this.nav?.select(1)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     private documentsService: DocumentService, | ||||
|     private route: ActivatedRoute, | ||||
| @@ -101,6 +114,7 @@ export class DocumentDetailComponent implements OnInit, DirtyComponent { | ||||
|       this.previewUrl = this.documentsService.getPreviewUrl(this.documentId) | ||||
|       this.downloadUrl = this.documentsService.getDownloadUrl(this.documentId) | ||||
|       this.downloadOriginalUrl = this.documentsService.getDownloadUrl(this.documentId, true) | ||||
|       this.suggestions = null | ||||
|       if (this.openDocumentService.getOpenDocument(this.documentId)) { | ||||
|         this.updateComponent(this.openDocumentService.getOpenDocument(this.documentId)) | ||||
|       } | ||||
| @@ -134,14 +148,22 @@ export class DocumentDetailComponent implements OnInit, DirtyComponent { | ||||
|     this.document = doc | ||||
|     this.documentsService.getMetadata(doc.id).subscribe(result => { | ||||
|       this.metadata = result | ||||
|     }, error => { | ||||
|       this.metadata = null | ||||
|     }) | ||||
|     this.documentsService.getSuggestions(doc.id).subscribe(result => { | ||||
|       this.suggestions = result | ||||
|     }, error => { | ||||
|       this.suggestions = null | ||||
|     }) | ||||
|     this.title = this.documentTitlePipe.transform(doc.title) | ||||
|     this.documentForm.patchValue(doc) | ||||
|   } | ||||
|  | ||||
|   createDocumentType() { | ||||
|   createDocumentType(newName: string) { | ||||
|     var modal = this.modalService.open(DocumentTypeEditDialogComponent, {backdrop: 'static'}) | ||||
|     modal.componentInstance.dialogMode = 'create' | ||||
|     if (newName) modal.componentInstance.object = { name: newName } | ||||
|     modal.componentInstance.success.subscribe(newDocumentType => { | ||||
|       this.documentTypeService.listAll().subscribe(documentTypes => { | ||||
|         this.documentTypes = documentTypes.results | ||||
| @@ -150,9 +172,10 @@ export class DocumentDetailComponent implements OnInit, DirtyComponent { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   createCorrespondent() { | ||||
|   createCorrespondent(newName: string) { | ||||
|     var modal = this.modalService.open(CorrespondentEditDialogComponent, {backdrop: 'static'}) | ||||
|     modal.componentInstance.dialogMode = 'create' | ||||
|     if (newName) modal.componentInstance.object = { name: newName } | ||||
|     modal.componentInstance.success.subscribe(newCorrespondent => { | ||||
|       this.correspondentService.listAll().subscribe(correspondents => { | ||||
|         this.correspondents = correspondents.results | ||||
| @@ -225,8 +248,8 @@ export class DocumentDetailComponent implements OnInit, DirtyComponent { | ||||
|  | ||||
|   close() { | ||||
|     this.openDocumentService.closeDocument(this.document) | ||||
|     if (this.documentListViewService.savedViewId) { | ||||
|       this.router.navigate(['view', this.documentListViewService.savedViewId]) | ||||
|     if (this.documentListViewService.activeSavedViewId) { | ||||
|       this.router.navigate(['view', this.documentListViewService.activeSavedViewId]) | ||||
|     } else { | ||||
|       this.router.navigate(['documents']) | ||||
|     } | ||||
| @@ -253,7 +276,7 @@ export class DocumentDetailComponent implements OnInit, DirtyComponent { | ||||
|   } | ||||
|  | ||||
|   moreLike() { | ||||
|     this.router.navigate(["search"], {queryParams: {more_like:this.document.id}}) | ||||
|     this.documentListViewService.quickFilter([{rule_type: FILTER_FULLTEXT_MORELIKE, value: this.documentId.toString()}]) | ||||
|   } | ||||
|  | ||||
|   hasNext() { | ||||
|   | ||||
| @@ -20,4 +20,4 @@ | ||||
|           </tr> | ||||
|       </tbody> | ||||
|   </table> | ||||
| </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| .metadata-column { | ||||
|   overflow-wrap: anywhere; | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -56,6 +56,20 @@ | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="col-auto ml-auto mb-2 mb-xl-0 d-flex"> | ||||
|     <div class="btn-group btn-group-sm mr-2"> | ||||
|       <button type="button" class="btn btn-outline-primary btn-sm" (click)="downloadSelected()"> | ||||
|         <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> | ||||
|           <use xlink:href="assets/bootstrap-icons.svg#download" /> | ||||
|         </svg> <ng-container i18n>Download</ng-container> | ||||
|       </button> | ||||
|       <div class="btn-group" ngbDropdown role="group" aria-label="Button group with nested dropdown"> | ||||
|         <button class="btn btn-outline-primary btn-sm dropdown-toggle-split" ngbDropdownToggle></button> | ||||
|         <div class="dropdown-menu shadow" ngbDropdownMenu> | ||||
|           <button ngbDropdownItem i18n (click)="downloadSelected('originals')">Download originals</button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()"> | ||||
|       <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable | ||||
| import { MatchingModel } from 'src/app/data/matching-model'; | ||||
| import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; | ||||
| import { ToastService } from 'src/app/services/toast.service'; | ||||
| import { saveAs } from 'file-saver'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-bulk-editor', | ||||
| @@ -109,7 +110,7 @@ export class BulkEditorComponent { | ||||
|     if (items.length == 0) { | ||||
|       return "" | ||||
|     } else if (items.length == 1) { | ||||
|       return items[0].name | ||||
|       return $localize`"${items[0].name}"` | ||||
|     } else if (items.length == 2) { | ||||
|       return $localize`:This is for messages like 'modify "tag1" and "tag2"':"${items[0].name}" and "${items[1].name}"` | ||||
|     } else { | ||||
| @@ -137,7 +138,7 @@ export class BulkEditorComponent { | ||||
|       } else { | ||||
|         modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(changedTags.itemsToAdd)} and remove the tags ${this._localizeList(changedTags.itemsToRemove)} on ${this.list.selected.size} selected document(s).` | ||||
|       } | ||||
|        | ||||
|  | ||||
|       modal.componentInstance.btnClass = "btn-warning" | ||||
|       modal.componentInstance.btnCaption = $localize`Confirm` | ||||
|       modal.componentInstance.confirmClicked.subscribe(() => { | ||||
| @@ -207,4 +208,10 @@ export class BulkEditorComponent { | ||||
|       this.executeBulkOperation(modal, "delete", {}) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   downloadSelected(content = "archive") { | ||||
|     this.documentService.bulkDownload(Array.from(this.list.selected), content).subscribe((result: any) => { | ||||
|       saveAs(result, 'documents.zip'); | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <div class="card mb-3 shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable"> | ||||
| <div class="card mb-3 shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()"> | ||||
|   <div class="row no-gutters"> | ||||
|     <div class="col-md-2 d-none d-lg-block doc-img-background rounded-left" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)"> | ||||
|       <img [src]="getThumbUrl()" class="card-img doc-img border-right rounded-left"> | ||||
|       <img [src]="getThumbUrl()" class="card-img doc-img border-right rounded-left" [class.inverted]="getIsThumbInverted()"> | ||||
|  | ||||
|       <div style="top: 0; left: 0" class="position-absolute border-right border-bottom bg-light p-1" [class.document-card-check]="!selected"> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
| @@ -23,17 +23,16 @@ | ||||
|             {{document.title | documentTitle}} | ||||
|             <app-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></app-tag> | ||||
|           </h5> | ||||
|           <h5 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5> | ||||
|         </div> | ||||
|         <p class="card-text"> | ||||
|           <app-result-highlight *ngIf="getDetailsAsHighlight()" class="result-content" [highlights]="getDetailsAsHighlight()"></app-result-highlight> | ||||
|           <span *ngIf="getDetailsAsString()" class="result-content">{{getDetailsAsString()}}</span> | ||||
|           <span *ngIf="document.__search_hit__" [innerHtml]="document.__search_hit__.highlights"></span> | ||||
|           <span *ngIf="!document.__search_hit__" class="result-content">{{contentTrimmed}}</span> | ||||
|         </p> | ||||
|  | ||||
|  | ||||
|         <div class="d-flex flex-column flex-md-row align-items-md-center"> | ||||
|           <div class="btn-group"> | ||||
|             <a routerLink="/search" [queryParams]="{'more_like': document.id}" class="btn btn-sm btn-outline-secondary" *ngIf="moreLikeThis"> | ||||
|             <a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()"> | ||||
|               <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16"> | ||||
|                 <path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/> | ||||
|               </svg> <span class="d-block d-md-inline" i18n>More like this</span> | ||||
| @@ -43,28 +42,52 @@ | ||||
|                 <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> | ||||
|               </svg> <span class="d-block d-md-inline" i18n>Edit</span> | ||||
|             </a> | ||||
|             <a class="btn btn-sm btn-outline-secondary" [href]="getPreviewUrl()"> | ||||
|               <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|                 <path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/> | ||||
|                 <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/> | ||||
|             <a class="btn btn-sm btn-outline-secondary" [href]="previewUrl" | ||||
|             [ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" | ||||
|             autoClose="true" popoverClass="shadow" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover"> | ||||
|               <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16"> | ||||
|                 <path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/> | ||||
|                 <path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/> | ||||
|               </svg> <span class="d-block d-md-inline" i18n>View</span> | ||||
|             </a> | ||||
|             <ng-template #previewContent> | ||||
|               <object [data]="previewUrl | safe" class="preview" width="100%"></object> | ||||
|             </ng-template> | ||||
|             <a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()"> | ||||
|               <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|                 <path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/> | ||||
|                 <path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/> | ||||
|               </svg> <span class="d-block d-md-inline" i18n>Download</span> | ||||
|             </a> | ||||
|  | ||||
|           </div> | ||||
|  | ||||
|           <div *ngIf="searchScore" class="d-flex align-items-center ml-md-auto mt-2 mt-md-0"> | ||||
|             <small class="text-muted" i18n>Score:</small> | ||||
|           <div class="list-group list-group-horizontal border-0 card-info ml-md-auto mt-2 mt-md-0"> | ||||
|             <button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 mr-2" title="Filter by document type" | ||||
|              (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> | ||||
|               <svg class="metadata-icon mr-2 text-muted bi bi-file-earmark" viewBox="0 0 16 16" fill="currentColor"> | ||||
|                 <path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/> | ||||
|               </svg> | ||||
|               <small>{{(document.document_type$ | async)?.name}}</small> | ||||
|             </button> | ||||
|             <div *ngIf="document.archive_serial_number" class="list-group-item mr-2 bg-light text-dark p-1 border-0"> | ||||
|               <svg class="metadata-icon mr-2 text-muted bi bi-upc-scan" viewBox="0 0 16 16" fill="currentColor"> | ||||
|                 <path d="M1.5 1a.5.5 0 0 0-.5.5v3a.5.5 0 0 1-1 0v-3A1.5 1.5 0 0 1 1.5 0h3a.5.5 0 0 1 0 1h-3zM11 .5a.5.5 0 0 1 .5-.5h3A1.5 1.5 0 0 1 16 1.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 1-.5-.5zM.5 11a.5.5 0 0 1 .5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 1 0 1h-3A1.5 1.5 0 0 1 0 14.5v-3a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v3a1.5 1.5 0 0 1-1.5 1.5h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 1 .5-.5zM3 4.5a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-7zm3 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7z"/> | ||||
|               </svg> | ||||
|               <small>#{{document.archive_serial_number}}</small> | ||||
|             </div> | ||||
|             <div class="list-group-item bg-light text-dark p-1 border-0" ngbTooltip="Added: {{document.added | customDate:'shortDate'}} Created: {{document.created | customDate:'shortDate'}}"> | ||||
|               <svg class="metadata-icon mr-2 text-muted bi bi-calendar-event" viewBox="0 0 16 16" fill="currentColor"> | ||||
|                 <path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1z"/> | ||||
|                 <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> | ||||
|               </svg> | ||||
|               <small>{{document.created | customDate:'mediumDate'}}</small> | ||||
|             </div> | ||||
|  | ||||
|             <ngb-progressbar [type]="searchScoreClass" [value]="searchScore" class="search-score-bar mx-2" [max]="1"></ngb-progressbar> | ||||
|             <div *ngIf="document.__search_hit__?.score" class="list-group-item bg-light text-dark border-0 d-flex p-0 pl-4 search-score"> | ||||
|               <small class="text-muted" i18n>Score:</small> | ||||
|               <ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <small class="text-muted" [class.ml-auto]="!searchScore" i18n>Created: {{document.created | customDate}}</small> | ||||
|         </div> | ||||
|  | ||||
|       </div> | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
|  | ||||
| .doc-img { | ||||
|   object-fit: cover; | ||||
|   object-position: top; | ||||
|   object-position: top left; | ||||
|   height: 100%; | ||||
|   position: absolute; | ||||
|   mix-blend-mode: multiply; | ||||
| @@ -37,3 +37,31 @@ | ||||
| .doc-img-background-selected { | ||||
|   background-color: $primaryFaded; | ||||
| } | ||||
|  | ||||
| .card-info { | ||||
|   line-height: 1; | ||||
|  | ||||
|   button { | ||||
|     line-height: 1; | ||||
|  | ||||
|     &:hover, | ||||
|     &:focus { | ||||
|       background-color: transparent !important; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .metadata-icon { | ||||
|     width: 0.9rem; | ||||
|     height: 0.9rem; | ||||
|     padding: 0.05rem; | ||||
|   } | ||||
|  | ||||
|   .search-score { | ||||
|     padding-top: 0.35rem !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| span ::ng-deep .match { | ||||
|   color: black; | ||||
|   background-color: rgb(255, 211, 66); | ||||
| } | ||||
|   | ||||
| @@ -1,16 +1,20 @@ | ||||
| import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; | ||||
| import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; | ||||
| import { DomSanitizer } from '@angular/platform-browser'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
| import { DocumentService } from 'src/app/services/rest/document.service'; | ||||
| import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; | ||||
| import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service'; | ||||
| import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-document-card-large', | ||||
|   templateUrl: './document-card-large.component.html', | ||||
|   styleUrls: ['./document-card-large.component.scss'] | ||||
|   styleUrls: ['./document-card-large.component.scss', '../popover-preview/popover-preview.scss'] | ||||
| }) | ||||
| export class DocumentCardLargeComponent implements OnInit { | ||||
|  | ||||
|   constructor(private documentService: DocumentService, private sanitizer: DomSanitizer) { } | ||||
|   constructor(private documentService: DocumentService, private sanitizer: DomSanitizer, private settingsService: SettingsService) { } | ||||
|  | ||||
|   @Input() | ||||
|   selected = false | ||||
| @@ -22,48 +26,43 @@ export class DocumentCardLargeComponent implements OnInit { | ||||
|     return this.toggleSelected.observers.length > 0 | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   moreLikeThis: boolean = false | ||||
|  | ||||
|   @Input() | ||||
|   document: PaperlessDocument | ||||
|  | ||||
|   @Input() | ||||
|   details: any | ||||
|  | ||||
|   @Output() | ||||
|   clickTag = new EventEmitter<number>() | ||||
|  | ||||
|   @Output() | ||||
|   clickCorrespondent = new EventEmitter<number>() | ||||
|  | ||||
|   @Input() | ||||
|   searchScore: number | ||||
|   @Output() | ||||
|   clickDocumentType = new EventEmitter<number>() | ||||
|  | ||||
|   @Output() | ||||
|   clickMoreLike= new EventEmitter() | ||||
|  | ||||
|   @ViewChild('popover') popover: NgbPopover | ||||
|  | ||||
|   mouseOnPreview = false | ||||
|   popoverHidden = true | ||||
|  | ||||
|   get searchScoreClass() { | ||||
|     if (this.searchScore > 0.7) { | ||||
|       return "success" | ||||
|     } else if (this.searchScore > 0.3) { | ||||
|       return "warning" | ||||
|     } else { | ||||
|       return "danger" | ||||
|     if (this.document.__search_hit__) { | ||||
|       if (this.document.__search_hit__.score > 0.7) { | ||||
|         return "success" | ||||
|       } else if (this.document.__search_hit__.score > 0.3) { | ||||
|         return "warning" | ||||
|       } else { | ||||
|         return "danger" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
|  | ||||
|   getDetailsAsString() { | ||||
|     if (typeof this.details === 'string') { | ||||
|       return this.details.substring(0, 500) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   getDetailsAsHighlight() { | ||||
|     //TODO: this is not an exact typecheck, can we do better | ||||
|     if (this.details instanceof Array) { | ||||
|       return this.details | ||||
|     } | ||||
|   getIsThumbInverted() { | ||||
|     return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) | ||||
|   } | ||||
|  | ||||
|   getThumbUrl() { | ||||
| @@ -74,7 +73,36 @@ export class DocumentCardLargeComponent implements OnInit { | ||||
|     return this.documentService.getDownloadUrl(this.document.id) | ||||
|   } | ||||
|  | ||||
|   getPreviewUrl() { | ||||
|   get previewUrl() { | ||||
|     return this.documentService.getPreviewUrl(this.document.id) | ||||
|   } | ||||
|  | ||||
|   mouseEnterPreview() { | ||||
|     this.mouseOnPreview = true | ||||
|     if (!this.popover.isOpen()) { | ||||
|       // we're going to open but hide to pre-load content during hover delay | ||||
|       this.popover.open() | ||||
|       this.popoverHidden = true | ||||
|       setTimeout(() => { | ||||
|         if (this.mouseOnPreview) { | ||||
|           // show popover | ||||
|           this.popoverHidden = false | ||||
|         } else { | ||||
|           this.popover.close() | ||||
|         } | ||||
|       }, 600); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   mouseLeavePreview() { | ||||
|     this.mouseOnPreview = false | ||||
|   } | ||||
|  | ||||
|   mouseLeaveCard() { | ||||
|     this.popover.close() | ||||
|   } | ||||
|  | ||||
|   get contentTrimmed() { | ||||
|     return this.document.content.substr(0, 500) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <div class="col p-2 h-100"> | ||||
|   <div class="card h-100 shadow-sm document-card" [class.card-selected]="selected"> | ||||
|   <div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()"> | ||||
|     <div class="border-bottom doc-img-container" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)"> | ||||
|       <img class="card-img doc-img rounded-top" [src]="getThumbUrl()"> | ||||
|       <img class="card-img doc-img rounded-top" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()"> | ||||
|  | ||||
|       <div class="border-right border-bottom bg-light p-1 rounded document-card-check"> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
| @@ -25,24 +25,60 @@ | ||||
|         <ng-container *ngIf="document.correspondent"> | ||||
|           <a [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>: | ||||
|         </ng-container> | ||||
|         {{document.title | documentTitle}} <span *ngIf="document.archive_serial_number">(#{{document.archive_serial_number}})</span> | ||||
|         {{document.title | documentTitle}} | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="card-footer"> | ||||
|     <div class="card-footer pt-0 pb-2 px-2"> | ||||
|       <div class="list-group list-group-flush border-0 pt-1 pb-2 card-info"> | ||||
|         <button *ngIf="document.document_type" type="button" class="list-group-item list-group-item-action bg-transparent pl-0 p-1 border-0" title="Filter by document type" | ||||
|          (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> | ||||
|           <svg class="metadata-icon mr-2 text-muted bi bi-file-earmark" viewBox="0 0 16 16" fill="currentColor"> | ||||
|             <path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/> | ||||
|           </svg> | ||||
|           <small>{{(document.document_type$ | async)?.name}}</small> | ||||
|         </button> | ||||
|         <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> | ||||
|           <ng-template #dateTooltip> | ||||
|             <div class="d-flex flex-column"> | ||||
|               <span i18n>Created: {{ document.created | customDate}}</span> | ||||
|               <span i18n>Added: {{ document.added | customDate}}</span> | ||||
|               <span i18n>Modified: {{ document.modified | customDate}}</span> | ||||
|             </div> | ||||
|           </ng-template> | ||||
|  | ||||
|       <div class="d-flex justify-content-between align-items-center mx-n2"> | ||||
|         <div class="btn-group"> | ||||
|           <div class="pl-0 p-1" placement="top" [ngbTooltip]="dateTooltip"> | ||||
|             <svg class="metadata-icon mr-2 text-muted bi bi-calendar-event" viewBox="0 0 16 16" fill="currentColor"> | ||||
|               <path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1z"/> | ||||
|               <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> | ||||
|             </svg> | ||||
|             <small>{{document.created | customDate:'mediumDate'}}</small> | ||||
|           </div> | ||||
|           <div *ngIf="document.archive_serial_number" class="pl-0 p-1"> | ||||
|             <svg class="metadata-icon mr-2 text-muted bi bi-upc-scan" viewBox="0 0 16 16" fill="currentColor"> | ||||
|               <path d="M1.5 1a.5.5 0 0 0-.5.5v3a.5.5 0 0 1-1 0v-3A1.5 1.5 0 0 1 1.5 0h3a.5.5 0 0 1 0 1h-3zM11 .5a.5.5 0 0 1 .5-.5h3A1.5 1.5 0 0 1 16 1.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 1-.5-.5zM.5 11a.5.5 0 0 1 .5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 1 0 1h-3A1.5 1.5 0 0 1 0 14.5v-3a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v3a1.5 1.5 0 0 1-1.5 1.5h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 1 .5-.5zM3 4.5a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-7zm3 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7z"/> | ||||
|             </svg> | ||||
|             <small>#{{document.archive_serial_number}}</small> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="d-flex justify-content-between align-items-center"> | ||||
|         <div class="btn-group w-100"> | ||||
|           <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title> | ||||
|             <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|               <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> | ||||
|             </svg> | ||||
|           </a> | ||||
|           <a [href]="getPreviewUrl()" class="btn btn-sm btn-outline-secondary" title="View in browser" i18n-title> | ||||
|             <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|               <path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/> | ||||
|               <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/> | ||||
|           <a [href]="previewUrl" target="_blank" class="btn btn-sm btn-outline-secondary" | ||||
|           [ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" | ||||
|           autoClose="true" popoverClass="shadow" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover"> | ||||
|             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16"> | ||||
|               <path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/> | ||||
|               <path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/> | ||||
|             </svg> | ||||
|           </a> | ||||
|           <ng-template #previewContent> | ||||
|             <object [data]="previewUrl | safe" class="preview" width="100%"></object> | ||||
|           </ng-template> | ||||
|           <a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" (click)="$event.stopPropagation()" i18n-title> | ||||
|             <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|               <path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/> | ||||
| @@ -50,9 +86,7 @@ | ||||
|             </svg> | ||||
|           </a> | ||||
|         </div> | ||||
|         <small class="text-muted pl-1">{{document.created | customDate:'shortDate'}}</small> | ||||
|       </div> | ||||
|  | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,9 +1,13 @@ | ||||
| @import "/src/theme"; | ||||
|  | ||||
| .card-text { | ||||
|   font-size: 90%; | ||||
| } | ||||
|  | ||||
| .doc-img { | ||||
|   object-fit: cover; | ||||
|   object-position: top; | ||||
|   height: 200px; | ||||
|   object-position: top left; | ||||
|   height: 175px; | ||||
|   mix-blend-mode: multiply; | ||||
| } | ||||
|  | ||||
| @@ -34,3 +38,32 @@ | ||||
| .doc-img-background-selected { | ||||
|   background-color: $primaryFaded; | ||||
| } | ||||
|  | ||||
| .card-info { | ||||
|   line-height: 1; | ||||
|  | ||||
|   button { | ||||
|     line-height: 1; | ||||
|  | ||||
|     &:hover, | ||||
|     &:focus { | ||||
|       background-color: transparent !important; | ||||
|       color: $primary; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .metadata-icon { | ||||
|     width: 0.9rem; | ||||
|     height: 0.9rem; | ||||
|     padding: 0.05rem; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .card-footer .btn { | ||||
|   padding-top: .10rem; | ||||
| } | ||||
|  | ||||
| ::ng-deep .tooltip-inner { | ||||
|   text-align: left !important; | ||||
|   font-size: 90%; | ||||
| } | ||||
|   | ||||
| @@ -1,20 +1,22 @@ | ||||
| import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; | ||||
| import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; | ||||
| import { map } from 'rxjs/operators'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
| import { DocumentService } from 'src/app/services/rest/document.service'; | ||||
| import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; | ||||
| import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-document-card-small', | ||||
|   templateUrl: './document-card-small.component.html', | ||||
|   styleUrls: ['./document-card-small.component.scss'] | ||||
|   styleUrls: ['./document-card-small.component.scss', '../popover-preview/popover-preview.scss'] | ||||
| }) | ||||
| export class DocumentCardSmallComponent implements OnInit { | ||||
|  | ||||
|   constructor(private documentService: DocumentService) { } | ||||
|   constructor(private documentService: DocumentService, private settingsService: SettingsService) { } | ||||
|  | ||||
|   @Input() | ||||
|   selected = false | ||||
|    | ||||
|  | ||||
|   @Output() | ||||
|   toggleSelected = new EventEmitter() | ||||
|  | ||||
| @@ -27,11 +29,23 @@ export class DocumentCardSmallComponent implements OnInit { | ||||
|   @Output() | ||||
|   clickCorrespondent = new EventEmitter<number>() | ||||
|  | ||||
|   @Output() | ||||
|   clickDocumentType = new EventEmitter<number>() | ||||
|  | ||||
|   moreTags: number = null | ||||
|  | ||||
|   @ViewChild('popover') popover: NgbPopover | ||||
|  | ||||
|   mouseOnPreview = false | ||||
|   popoverHidden = true | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
|  | ||||
|   getIsThumbInverted() { | ||||
|     return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) | ||||
|   } | ||||
|  | ||||
|   getThumbUrl() { | ||||
|     return this.documentService.getThumbUrl(this.document.id) | ||||
|   } | ||||
| @@ -40,7 +54,7 @@ export class DocumentCardSmallComponent implements OnInit { | ||||
|     return this.documentService.getDownloadUrl(this.document.id) | ||||
|   } | ||||
|  | ||||
|   getPreviewUrl() { | ||||
|   get previewUrl() { | ||||
|     return this.documentService.getPreviewUrl(this.document.id) | ||||
|   } | ||||
|  | ||||
| @@ -57,4 +71,28 @@ export class DocumentCardSmallComponent implements OnInit { | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   mouseEnterPreview() { | ||||
|     this.mouseOnPreview = true | ||||
|     if (!this.popover.isOpen()) { | ||||
|       // we're going to open but hide to pre-load content during hover delay | ||||
|       this.popover.open() | ||||
|       this.popoverHidden = true | ||||
|       setTimeout(() => { | ||||
|         if (this.mouseOnPreview) { | ||||
|           // show popover | ||||
|           this.popoverHidden = false | ||||
|         } else { | ||||
|           this.popover.close() | ||||
|         } | ||||
|       }, 600); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   mouseLeavePreview() { | ||||
|     this.mouseOnPreview = false | ||||
|   } | ||||
|  | ||||
|   mouseLeaveCard() { | ||||
|     this.popover.close() | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -63,112 +63,127 @@ | ||||
|   <div class="btn-group ml-2 flex-fill" ngbDropdown role="group"> | ||||
|     <button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" ngbDropdownToggle i18n>Views</button> | ||||
|     <div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu> | ||||
|       <ng-container *ngIf="!list.savedViewId"> | ||||
|       <ng-container *ngIf="!list.activeSavedViewId"> | ||||
|         <button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view)">{{view.name}}</button> | ||||
|         <div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div> | ||||
|       </ng-container> | ||||
|  | ||||
|       <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId" i18n>Save "{{list.savedViewTitle}}"</button> | ||||
|       <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.activeSavedViewId" i18n>Save "{{list.activeSavedViewTitle}}"</button> | ||||
|       <button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
| </app-page-header> | ||||
|  | ||||
| <div class="w-100 mb-2 mb-sm-4"> | ||||
|   <app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [rulesModified]="filterRulesModified" (filterRulesChange)="rulesChanged()" (reset)="resetFilters()" #filterEditor></app-filter-editor> | ||||
| <div class="sticky-top py-2 mt-n2 mt-sm-n3 py-sm-4 bg-body mx-n3 px-3"> | ||||
|   <app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" #filterEditor></app-filter-editor> | ||||
|   <app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor> | ||||
| </div> | ||||
|  | ||||
| <div class="d-flex justify-content-between align-items-center"> | ||||
|   <p> | ||||
|     <ng-container *ngIf="list.isReloading"> | ||||
|       <div class="spinner-border spinner-border-sm mr-2" role="status"></div> | ||||
|       <ng-container i18n>Loading...</ng-container> | ||||
|     </ng-container> | ||||
|     <span i18n *ngIf="list.selected.size > 0">{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span> | ||||
|     <span i18n *ngIf="list.selected.size == 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span> <span i18n *ngIf="isFiltered">(filtered)</span> | ||||
|     <ng-container *ngIf="!list.isReloading"> | ||||
|       <span i18n *ngIf="list.selected.size == 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span> <span i18n *ngIf="isFiltered">(filtered)</span> | ||||
|     </ng-container> | ||||
|   </p> | ||||
|   <ngb-pagination [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" | ||||
|   [rotate]="true" (pageChange)="list.reload()" aria-label="Default pagination"></ngb-pagination> | ||||
|   [rotate]="true" aria-label="Default pagination"></ngb-pagination> | ||||
| </div> | ||||
|  | ||||
| <div *ngIf="displayMode == 'largeCards'"> | ||||
|   <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"> | ||||
|   </app-document-card-large> | ||||
| </div> | ||||
| <ng-container *ngIf="list.error ; else documentListNoError"> | ||||
|   <div class="alert alert-danger" role="alert">Error while loading documents: {{list.error}}</div> | ||||
| </ng-container> | ||||
|  | ||||
| <table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'"> | ||||
|   <thead> | ||||
|     <th></th> | ||||
|     <th class="d-none d-lg-table-cell" | ||||
|       sortable="archive_serial_number" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n>ASN</th> | ||||
|     <th class="d-none d-md-table-cell" | ||||
|       sortable="correspondent__name" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n>Correspondent</th> | ||||
|     <th | ||||
|       sortable="title" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n>Title</th> | ||||
|     <th class="d-none d-xl-table-cell" | ||||
|       sortable="document_type__name" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n>Document type</th> | ||||
|     <th | ||||
|       sortable="created" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n>Created</th> | ||||
|     <th class="d-none d-xl-table-cell" | ||||
|       sortable="added" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n>Added</th> | ||||
|   </thead> | ||||
|   <tbody> | ||||
|     <tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" (click)="toggleSelected(d, $event)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''"> | ||||
|       <td> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
|           <input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (click)="toggleSelected(d, $event)"> | ||||
|           <label class="custom-control-label" for="docCheck{{d.id}}"></label> | ||||
|         </div> | ||||
|       </td> | ||||
|       <td class="d-none d-lg-table-cell"> | ||||
|         {{d.archive_serial_number}} | ||||
|       </td> | ||||
|       <td class="d-none d-md-table-cell"> | ||||
|         <ng-container *ngIf="d.correspondent"> | ||||
|           <a [routerLink]="" (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a> | ||||
|         </ng-container> | ||||
|       </td> | ||||
|       <td> | ||||
|         <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.id);$event.stopPropagation()"></app-tag> | ||||
|       </td> | ||||
|       <td class="d-none d-xl-table-cell"> | ||||
|         <ng-container *ngIf="d.document_type"> | ||||
|           <a [routerLink]="" (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> | ||||
|         </ng-container> | ||||
|       </td> | ||||
|       <td> | ||||
|         {{d.created | customDate}} | ||||
|       </td> | ||||
|       <td class="d-none d-xl-table-cell"> | ||||
|         {{d.added | customDate}} | ||||
|       </td> | ||||
|     </tr> | ||||
|   </tbody> | ||||
| </table> | ||||
| <ng-template #documentListNoError> | ||||
|  | ||||
| <div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> | ||||
|   <app-document-card-small [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)"  [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small> | ||||
| </div> | ||||
|   <div *ngIf="displayMode == 'largeCards'"> | ||||
|     <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickMoreLike)="clickMoreLike(d.id)"> | ||||
|     </app-document-card-large> | ||||
|   </div> | ||||
|  | ||||
|   <table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'"> | ||||
|     <thead> | ||||
|       <th></th> | ||||
|       <th class="d-none d-lg-table-cell" | ||||
|         sortable="archive_serial_number" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>ASN</th> | ||||
|       <th class="d-none d-md-table-cell" | ||||
|         sortable="correspondent__name" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Correspondent</th> | ||||
|       <th | ||||
|         sortable="title" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Title</th> | ||||
|       <th class="d-none d-xl-table-cell" | ||||
|         sortable="document_type__name" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Document type</th> | ||||
|       <th | ||||
|         sortable="created" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Created</th> | ||||
|       <th class="d-none d-xl-table-cell" | ||||
|         sortable="added" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Added</th> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" (click)="toggleSelected(d, $event)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''"> | ||||
|         <td> | ||||
|           <div class="custom-control custom-checkbox"> | ||||
|             <input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (click)="toggleSelected(d, $event)"> | ||||
|             <label class="custom-control-label" for="docCheck{{d.id}}"></label> | ||||
|           </div> | ||||
|         </td> | ||||
|         <td class="d-none d-lg-table-cell"> | ||||
|           {{d.archive_serial_number}} | ||||
|         </td> | ||||
|         <td class="d-none d-md-table-cell"> | ||||
|           <ng-container *ngIf="d.correspondent"> | ||||
|             <a [routerLink]="" (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a> | ||||
|           </ng-container> | ||||
|         </td> | ||||
|         <td> | ||||
|           <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.id);$event.stopPropagation()"></app-tag> | ||||
|         </td> | ||||
|         <td class="d-none d-xl-table-cell"> | ||||
|           <ng-container *ngIf="d.document_type"> | ||||
|             <a [routerLink]="" (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> | ||||
|           </ng-container> | ||||
|         </td> | ||||
|         <td> | ||||
|           {{d.created | customDate}} | ||||
|         </td> | ||||
|         <td class="d-none d-xl-table-cell"> | ||||
|           {{d.added | customDate}} | ||||
|         </td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
|  | ||||
|   <div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> | ||||
|     <app-document-card-small [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small> | ||||
|   </div> | ||||
|  | ||||
|  | ||||
| </ng-template> | ||||
|   | ||||
| @@ -34,3 +34,12 @@ $paperless-card-breakpoints: ( | ||||
|   right: 0 !important; | ||||
|   left: auto !important; | ||||
| } | ||||
|  | ||||
| .sticky-top { | ||||
|   z-index: 990; // below main navbar | ||||
|   top: calc(7rem - 2px); // height of navbar (mobile) | ||||
|  | ||||
|   @media (min-width: 580px) { | ||||
|     top: 3.5rem; // height of navbar | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,17 @@ | ||||
| import { AfterViewInit, Component, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; | ||||
| import { 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 { FilterRule, isFullTextFilterRule } from 'src/app/data/filter-rule'; | ||||
| import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; | ||||
| import { 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 { DOCUMENT_SORT_FIELDS, DOCUMENT_SORT_FIELDS_FULLTEXT } from 'src/app/services/rest/document.service'; | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service'; | ||||
| import { Toast, ToastService } from 'src/app/services/toast.service'; | ||||
| import { ToastService } from 'src/app/services/toast.service'; | ||||
| import { FilterEditorComponent } from './filter-editor/filter-editor.component'; | ||||
| import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component'; | ||||
|  | ||||
| @@ -16,7 +20,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 +28,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 | ||||
| @@ -33,18 +39,20 @@ export class DocumentListComponent implements OnInit { | ||||
|  | ||||
|   displayMode = 'smallCards' // largeCards, smallCards, details | ||||
|  | ||||
|   filterRulesModified: boolean = false | ||||
|   unmodifiedFilterRules: FilterRule[] = [] | ||||
|  | ||||
|   private consumptionFinishedSubscription: Subscription | ||||
|  | ||||
|   get isFiltered() { | ||||
|     return this.list.filterRules?.length > 0 | ||||
|   } | ||||
|  | ||||
|   getTitle() { | ||||
|     return this.list.savedViewTitle || $localize`Documents` | ||||
|     return this.list.activeSavedViewTitle || $localize`Documents` | ||||
|   } | ||||
|  | ||||
|   getSortFields() { | ||||
|     return DOCUMENT_SORT_FIELDS | ||||
|     return isFullTextFilterRule(this.list.filterRules) ? DOCUMENT_SORT_FIELDS_FULLTEXT : DOCUMENT_SORT_FIELDS | ||||
|   } | ||||
|  | ||||
|   onSort(event: SortEvent) { | ||||
| @@ -63,37 +71,52 @@ 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')) { | ||||
|         this.savedViewService.getCached(+params.get('id')).subscribe(view => { | ||||
|           if (!view) { | ||||
|             this.router.navigate(["404"]) | ||||
|             return | ||||
|           } | ||||
|           this.list.savedView = view | ||||
|           this.list.activateSavedView(view) | ||||
|           this.list.reload() | ||||
|           this.rulesChanged() | ||||
|           this.unmodifiedFilterRules = view.filter_rules | ||||
|         }) | ||||
|       } else { | ||||
|         this.list.savedView = null | ||||
|         this.list.activateSavedView(null) | ||||
|         this.list.reload() | ||||
|         this.rulesChanged() | ||||
|         this.unmodifiedFilterRules = [] | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
|     if (this.consumptionFinishedSubscription) { | ||||
|       this.consumptionFinishedSubscription.unsubscribe() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   loadViewConfig(view: PaperlessSavedView) { | ||||
|     this.list.load(view) | ||||
|     this.list.loadSavedView(view) | ||||
|     this.list.reload() | ||||
|     this.rulesChanged() | ||||
|   } | ||||
|  | ||||
|   saveViewConfig() { | ||||
|     this.savedViewService.update(this.list.savedView).subscribe(result => { | ||||
|       this.toastService.showInfo($localize`View "${this.list.savedView.name}" saved successfully.`) | ||||
|     }) | ||||
|  | ||||
|     if (this.list.activeSavedViewId != null) { | ||||
|       let savedView: PaperlessSavedView = { | ||||
|         id: this.list.activeSavedViewId, | ||||
|         filter_rules: this.list.filterRules, | ||||
|         sort_field: this.list.sortField, | ||||
|         sort_reverse: this.list.sortReverse | ||||
|       } | ||||
|       this.savedViewService.patch(savedView).subscribe(result => { | ||||
|         this.toastService.showInfo($localize`View "${this.list.activeSavedViewTitle}" saved successfully.`) | ||||
|         this.unmodifiedFilterRules = this.list.filterRules | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   saveViewConfigAs() { | ||||
| @@ -101,7 +124,7 @@ export class DocumentListComponent implements OnInit { | ||||
|     modal.componentInstance.defaultName = this.filterEditor.generateFilterName() | ||||
|     modal.componentInstance.saveClicked.subscribe(formValue => { | ||||
|       modal.componentInstance.buttonsEnabled = false | ||||
|       let savedView = { | ||||
|       let savedView: PaperlessSavedView = { | ||||
|         name: formValue.name, | ||||
|         show_on_dashboard: formValue.showOnDashboard, | ||||
|         show_in_sidebar: formValue.showInSideBar, | ||||
| @@ -120,46 +143,6 @@ export class DocumentListComponent implements OnInit { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   resetFilters(): void { | ||||
|     this.filterRulesModified = false | ||||
|     if (this.list.savedViewId) { | ||||
|       this.savedViewService.getCached(this.list.savedViewId).subscribe(viewUntouched => { | ||||
|         this.list.filterRules = viewUntouched.filter_rules | ||||
|         this.list.reload() | ||||
|       }) | ||||
|     } else { | ||||
|       this.list.filterRules = [] | ||||
|       this.list.reload() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   rulesChanged() { | ||||
|     let modified = false | ||||
|     if (this.list.savedView == null) { | ||||
|       modified = this.list.filterRules.length > 0 // documents list is modified if it has any filters | ||||
|     } else { | ||||
|       // compare savedView current filters vs original | ||||
|       this.savedViewService.getCached(this.list.savedViewId).subscribe(view => { | ||||
|         let filterRulesInitial = view.filter_rules | ||||
|  | ||||
|         if (this.list.filterRules.length !== filterRulesInitial.length) modified = true | ||||
|         else { | ||||
|           modified = this.list.filterRules.some(rule => { | ||||
|             return (filterRulesInitial.find(fri => fri.rule_type == rule.rule_type && fri.value == rule.value) == undefined) | ||||
|           }) | ||||
|  | ||||
|           if (!modified) { | ||||
|             // only check other direction if we havent already determined is modified | ||||
|             modified = filterRulesInitial.some(rule => { | ||||
|               this.list.filterRules.find(fr => fr.rule_type == rule.rule_type && fr.value == rule.value) == undefined | ||||
|             }) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
|     } | ||||
|     this.filterRulesModified = modified | ||||
|   } | ||||
|  | ||||
|   toggleSelected(document: PaperlessDocument, event: MouseEvent): void { | ||||
|     if (!event.shiftKey) this.list.toggleSelected(document) | ||||
|     else this.list.selectRangeTo(document) | ||||
| @@ -186,6 +169,10 @@ export class DocumentListComponent implements OnInit { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   clickMoreLike(documentID: number) { | ||||
|     this.list.quickFilter([{rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString()}]) | ||||
|   } | ||||
|  | ||||
|   trackByDocumentId(index, item: PaperlessDocument) { | ||||
|     return item.id | ||||
|   } | ||||
|   | ||||
| @@ -1,8 +1,15 @@ | ||||
| <div class="row"> | ||||
|    <div class="col mb-2 mb-xl-0"> | ||||
|      <div class="form-inline d-flex align-items-center"> | ||||
|          <label class="text-muted mr-2 mb-0" i18n>Filter by:</label> | ||||
|          <input class="form-control form-control-sm flex-fill w-auto" type="text" [(ngModel)]="titleFilter" placeholder="Title" i18n-placeholder> | ||||
|          <div class="input-group input-group-sm flex-fill w-auto"> | ||||
|            <div class="input-group-prepend" ngbDropdown> | ||||
|             <button class="btn btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button> | ||||
|             <div class="dropdown-menu shadow" ngbDropdownMenu> | ||||
|               <button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget == t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button> | ||||
|             </div> | ||||
|           </div> | ||||
|           <input #textFilterInput class="form-control form-control-sm" type="text" [(ngModel)]="textFilter" [readonly]="textFilterTarget == 'fulltext-morelike'"> | ||||
|          </div> | ||||
|      </div> | ||||
|   </div> | ||||
|   <div class="w-100 d-xl-none"></div> | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core'; | ||||
| import { Component, EventEmitter, Input, Output, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; | ||||
| import { PaperlessTag } from 'src/app/data/paperless-tag'; | ||||
| import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'; | ||||
| import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; | ||||
| @@ -8,9 +8,17 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service | ||||
| import { TagService } from 'src/app/services/rest/tag.service'; | ||||
| import { CorrespondentService } from 'src/app/services/rest/correspondent.service'; | ||||
| import { FilterRule } from 'src/app/data/filter-rule'; | ||||
| import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE } from 'src/app/data/filter-rule-type'; | ||||
| import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_ASN, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_QUERY, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE, FILTER_TITLE_CONTENT } from 'src/app/data/filter-rule-type'; | ||||
| import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component'; | ||||
| import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'; | ||||
| import { DocumentService } from 'src/app/services/rest/document.service'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
|  | ||||
| const TEXT_FILTER_TARGET_TITLE = "title" | ||||
| const TEXT_FILTER_TARGET_TITLE_CONTENT = "title-content" | ||||
| const TEXT_FILTER_TARGET_ASN = "asn" | ||||
| const TEXT_FILTER_TARGET_FULLTEXT_QUERY = "fulltext-query" | ||||
| const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = "fulltext-morelike" | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-filter-editor', | ||||
| @@ -46,6 +54,11 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|             return $localize`Without any tag` | ||||
|           } | ||||
|  | ||||
|         case FILTER_TITLE: | ||||
|           return $localize`Title: ${rule.value}` | ||||
|  | ||||
|         case FILTER_ASN: | ||||
|           return $localize`ASN: ${rule.value}` | ||||
|       } | ||||
|     } | ||||
|  | ||||
| @@ -55,14 +68,40 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|   constructor( | ||||
|     private documentTypeService: DocumentTypeService, | ||||
|     private tagService: TagService, | ||||
|     private correspondentService: CorrespondentService | ||||
|     private correspondentService: CorrespondentService, | ||||
|     private documentService: DocumentService | ||||
|   ) { } | ||||
|  | ||||
|   @ViewChild("textFilterInput") | ||||
|   textFilterInput: ElementRef | ||||
|  | ||||
|   tags: PaperlessTag[] = [] | ||||
|   correspondents: PaperlessCorrespondent[] = [] | ||||
|   documentTypes: PaperlessDocumentType[] = [] | ||||
|  | ||||
|   _titleFilter = "" | ||||
|   _textFilter = "" | ||||
|   _moreLikeId: number | ||||
|   _moreLikeDoc: PaperlessDocument | ||||
|  | ||||
|   get textFilterTargets() { | ||||
|     let targets = [ | ||||
|       {id: TEXT_FILTER_TARGET_TITLE, name: $localize`Title`}, | ||||
|       {id: TEXT_FILTER_TARGET_TITLE_CONTENT, name: $localize`Title & content`}, | ||||
|       {id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN`}, | ||||
|       {id: TEXT_FILTER_TARGET_FULLTEXT_QUERY, name: $localize`Advanced search`} | ||||
|     ] | ||||
|     if (this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) { | ||||
|       targets.push({id: TEXT_FILTER_TARGET_FULLTEXT_MORELIKE, name: $localize`More like`}) | ||||
|     } | ||||
|     return targets | ||||
|   } | ||||
|  | ||||
|   textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT | ||||
|  | ||||
|   get textFilterTargetName() { | ||||
|     return this.textFilterTargets.find(t => t.id == this.textFilterTarget)?.name | ||||
|   } | ||||
|  | ||||
|  | ||||
|   tagSelectionModel = new FilterableDropdownSelectionModel() | ||||
|   correspondentSelectionModel = new FilterableDropdownSelectionModel() | ||||
| @@ -73,12 +112,28 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|   dateAddedBefore: string | ||||
|   dateAddedAfter: string | ||||
|  | ||||
|   _unmodifiedFilterRules: FilterRule[] = [] | ||||
|   _filterRules: FilterRule[] = [] | ||||
|  | ||||
|   @Input() | ||||
|   set unmodifiedFilterRules(value: FilterRule[]) { | ||||
|     this._unmodifiedFilterRules = value | ||||
|     this.checkIfRulesHaveChanged() | ||||
|   } | ||||
|  | ||||
|   get unmodifiedFilterRules(): FilterRule[] { | ||||
|     return this._unmodifiedFilterRules | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   set filterRules (value: FilterRule[]) { | ||||
|     this._filterRules = value | ||||
|  | ||||
|     this.documentTypeSelectionModel.clear(false) | ||||
|     this.tagSelectionModel.clear(false) | ||||
|     this.correspondentSelectionModel.clear(false) | ||||
|     this._titleFilter = null | ||||
|     this._textFilter = null | ||||
|     this._moreLikeId = null | ||||
|     this.dateAddedBefore = null | ||||
|     this.dateAddedAfter = null | ||||
|     this.dateCreatedBefore = null | ||||
| @@ -87,7 +142,28 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     value.forEach(rule => { | ||||
|       switch (rule.rule_type) { | ||||
|         case FILTER_TITLE: | ||||
|           this._titleFilter = rule.value | ||||
|           this._textFilter = rule.value | ||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_TITLE | ||||
|           break | ||||
|         case FILTER_TITLE_CONTENT: | ||||
|           this._textFilter = rule.value | ||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT | ||||
|           break | ||||
|         case FILTER_ASN: | ||||
|           this._textFilter = rule.value | ||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_ASN | ||||
|           break | ||||
|         case FILTER_FULLTEXT_QUERY: | ||||
|           this._textFilter = rule.value | ||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_QUERY | ||||
|           break | ||||
|         case FILTER_FULLTEXT_MORELIKE: | ||||
|           this._moreLikeId = +rule.value | ||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_MORELIKE | ||||
|           this.documentService.get(this._moreLikeId).subscribe(result => { | ||||
|             this._moreLikeDoc = result | ||||
|             this._textFilter = result.title | ||||
|           }) | ||||
|           break | ||||
|         case FILTER_CREATED_AFTER: | ||||
|           this.dateCreatedAfter = rule.value | ||||
| @@ -115,12 +191,25 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|           break | ||||
|       } | ||||
|     }) | ||||
|     this.checkIfRulesHaveChanged() | ||||
|   } | ||||
|  | ||||
|   get filterRules() { | ||||
|   get filterRules(): FilterRule[] { | ||||
|     let filterRules: FilterRule[] = [] | ||||
|     if (this._titleFilter) { | ||||
|       filterRules.push({rule_type: FILTER_TITLE, value: this._titleFilter}) | ||||
|     if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT) { | ||||
|       filterRules.push({rule_type: FILTER_TITLE_CONTENT, value: this._textFilter}) | ||||
|     } | ||||
|     if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) { | ||||
|       filterRules.push({rule_type: FILTER_TITLE, value: this._textFilter}) | ||||
|     } | ||||
|     if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_ASN) { | ||||
|       filterRules.push({rule_type: FILTER_ASN, value: this._textFilter}) | ||||
|     } | ||||
|     if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY) { | ||||
|       filterRules.push({rule_type: FILTER_FULLTEXT_QUERY, value: this._textFilter}) | ||||
|     } | ||||
|     if (this._moreLikeId && this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) { | ||||
|       filterRules.push({rule_type: FILTER_FULLTEXT_MORELIKE, value: this._moreLikeId?.toString()}) | ||||
|     } | ||||
|     if (this.tagSelectionModel.isNoneSelected()) { | ||||
|       filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"}) | ||||
| @@ -153,25 +242,40 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|   @Output() | ||||
|   filterRulesChange = new EventEmitter<FilterRule[]>() | ||||
|  | ||||
|   @Output() | ||||
|   reset = new EventEmitter() | ||||
|  | ||||
|   @Input() | ||||
|   rulesModified: boolean = false | ||||
|  | ||||
|   private checkIfRulesHaveChanged() { | ||||
|     let modified = false | ||||
|     if (this._unmodifiedFilterRules.length != this._filterRules.length) { | ||||
|       modified = true | ||||
|     } else { | ||||
|       modified = this._unmodifiedFilterRules.some(rule => { | ||||
|         return (this._filterRules.find(fri => fri.rule_type == rule.rule_type && fri.value == rule.value) == undefined) | ||||
|       }) | ||||
|  | ||||
|       if (!modified) { | ||||
|         // only check other direction if we havent already determined is modified | ||||
|         modified = this._filterRules.some(rule => { | ||||
|           this._unmodifiedFilterRules.find(fr => fr.rule_type == rule.rule_type && fr.value == rule.value) == undefined | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|     this.rulesModified = modified | ||||
|   } | ||||
|  | ||||
|   updateRules() { | ||||
|     this.filterRulesChange.next(this.filterRules) | ||||
|   } | ||||
|  | ||||
|   get titleFilter() { | ||||
|     return this._titleFilter | ||||
|   get textFilter() { | ||||
|     return this._textFilter | ||||
|   } | ||||
|  | ||||
|   set titleFilter(value) { | ||||
|     this.titleFilterDebounce.next(value) | ||||
|   set textFilter(value) { | ||||
|     this.textFilterDebounce.next(value) | ||||
|   } | ||||
|  | ||||
|   titleFilterDebounce: Subject<string> | ||||
|   textFilterDebounce: Subject<string> | ||||
|   subscription: Subscription | ||||
|  | ||||
|   ngOnInit() { | ||||
| @@ -179,23 +283,29 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     this.correspondentService.listAll().subscribe(result => this.correspondents = result.results) | ||||
|     this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results) | ||||
|  | ||||
|     this.titleFilterDebounce = new Subject<string>() | ||||
|     this.textFilterDebounce = new Subject<string>() | ||||
|  | ||||
|     this.subscription = this.titleFilterDebounce.pipe( | ||||
|     this.subscription = this.textFilterDebounce.pipe( | ||||
|       debounceTime(400), | ||||
|       distinctUntilChanged() | ||||
|     ).subscribe(title => { | ||||
|       this._titleFilter = title | ||||
|     ).subscribe(text => { | ||||
|       this._textFilter = text | ||||
|       this.documentService.searchQuery = text | ||||
|       this.updateRules() | ||||
|     }) | ||||
|  | ||||
|     if (this._textFilter) this.documentService.searchQuery = this._textFilter | ||||
|  | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
|     this.titleFilterDebounce.complete() | ||||
|     this.textFilterDebounce.complete() | ||||
|   } | ||||
|  | ||||
|   resetSelected() { | ||||
|     this.reset.next() | ||||
|     this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT | ||||
|     this.filterRules = this._unmodifiedFilterRules | ||||
|     this.updateRules() | ||||
|   } | ||||
|  | ||||
|   toggleTag(tagId: number) { | ||||
| @@ -221,4 +331,13 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|   onDocumentTypeDropdownOpen() { | ||||
|     this.documentTypeSelectionModel.apply() | ||||
|   } | ||||
|  | ||||
|   changeTextFilterTarget(target) { | ||||
|     if (this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE && target != TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) { | ||||
|       this._textFilter = "" | ||||
|     } | ||||
|     this.textFilterTarget = target | ||||
|     this.textFilterInput.nativeElement.focus() | ||||
|     this.updateRules() | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,22 @@ | ||||
| ::ng-deep .popover { | ||||
|   max-width: 40rem; | ||||
|  | ||||
|   .preview { | ||||
|     min-width: 30rem; | ||||
|     min-height: 18rem; | ||||
|     max-height: 35rem; | ||||
|     overflow-y: scroll; | ||||
|   } | ||||
|  | ||||
|   .spinner-border { | ||||
|     position: absolute; | ||||
|     top: 4rem; | ||||
|     left: calc(50% - 0.5rem); | ||||
|     z-index: 0; | ||||
|   } | ||||
| } | ||||
|  | ||||
|  ::ng-deep .popover-hidden .popover { | ||||
|   opacity: 0; | ||||
|   pointer-events: none; | ||||
| } | ||||
| @@ -8,7 +8,7 @@ | ||||
|   <div class="modal-body"> | ||||
|     <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||
|     <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> | ||||
|     <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match"></app-input-text> | ||||
|     <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text> | ||||
|     <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive" novalidate></app-input-check> | ||||
|   </div> | ||||
|   <div class="modal-footer"> | ||||
|   | ||||
| @@ -32,6 +32,6 @@ export class CorrespondentEditDialogComponent extends EditDialogComponent<Paperl | ||||
|       match: new FormControl(""), | ||||
|       is_insensitive: new FormControl(true) | ||||
|     }) | ||||
|   }   | ||||
|   } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -18,7 +18,7 @@ export class CorrespondentListComponent extends GenericListComponent<PaperlessCo | ||||
|   constructor(correspondentsService: CorrespondentService, modalService: NgbModal, | ||||
|     private list: DocumentListViewService, | ||||
|     toastService: ToastService | ||||
|   ) {  | ||||
|   ) { | ||||
|     super(correspondentsService,modalService,CorrespondentEditDialogComponent, toastService) | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ | ||||
|  | ||||
|       <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||
|       <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> | ||||
|       <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match"></app-input-text> | ||||
|       <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text> | ||||
|       <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check> | ||||
|  | ||||
|     </div> | ||||
|   | ||||
| @@ -13,7 +13,7 @@ import { ToastService } from 'src/app/services/toast.service'; | ||||
| }) | ||||
| export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> { | ||||
|  | ||||
|   constructor(service: DocumentTypeService, activeModal: NgbActiveModal, toastService: ToastService) {  | ||||
|   constructor(service: DocumentTypeService, activeModal: NgbActiveModal, toastService: ToastService) { | ||||
|     super(service, activeModal, toastService) | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -49,4 +49,4 @@ | ||||
|       </td> | ||||
|     </tr> | ||||
|   </tbody> | ||||
| </table> | ||||
| </table> | ||||
|   | ||||
| @@ -1,27 +1,18 @@ | ||||
| <app-page-header title="Logs" i18n-title> | ||||
|  | ||||
|   <div ngbDropdown class="btn-group"> | ||||
|     <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle> | ||||
|       <svg class="toolbaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#funnel" /> | ||||
|       </svg> <ng-container i18n>Filter</ng-container> | ||||
|  | ||||
|  | ||||
|     </button> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownBasic1"> | ||||
|       <button *ngFor="let f of getLevels()" ngbDropdownItem (click)="setLevel(f.id)" | ||||
|         [class.active]="level == f.id">{{f.name}}</button> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
| </app-page-header> | ||||
|  | ||||
| <div class="bg-dark p-3 mb-3 text-light text-monospace" infiniteScroll (scrolled)="onScroll()"> | ||||
|  | ||||
| <ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs"> | ||||
|   <li *ngFor="let logFile of logFiles" [ngbNavItem]="logFile"> | ||||
|     <a ngbNavLink>{{logFile}}.log</a> | ||||
|   </li> | ||||
| </ul> | ||||
|  | ||||
| <div [ngbNavOutlet]="nav" class="mt-2"></div> | ||||
|  | ||||
| <div class="bg-dark p-3 text-light text-monospace log-container" #logContainer> | ||||
|   <p | ||||
|     class="m-0 p-0 log-entry-{{log.level}}" | ||||
|     *ngFor="let log of logs"> | ||||
|       {{log.created | customDate:'short'}} | ||||
|       {{getLevelText(log.level)}} | ||||
|       {{log.message}} | ||||
|   </p> | ||||
|     class="m-0 p-0 log-entry-{{getLogLevel(log)}}" | ||||
|     *ngFor="let log of logs">{{log}}</p> | ||||
| </div> | ||||
|   | ||||
| @@ -13,4 +13,14 @@ | ||||
| .log-entry-50 { | ||||
|   color: lightcoral !important; | ||||
|   font-weight: bold; | ||||
| } | ||||
| } | ||||
|  | ||||
| .log-container { | ||||
|   overflow-y: scroll; | ||||
|   height: calc(100vh - 200px); | ||||
|   top: 70px; | ||||
|  | ||||
|   p { | ||||
|     white-space: pre-wrap; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { LOG_LEVELS, LOG_LEVEL_INFO, PaperlessLog } from 'src/app/data/paperless-log'; | ||||
| import { Component, ElementRef, OnInit, AfterViewChecked, ViewChild } from '@angular/core'; | ||||
| import { LogService } from 'src/app/services/rest/log.service'; | ||||
|  | ||||
| @Component({ | ||||
| @@ -7,42 +6,60 @@ import { LogService } from 'src/app/services/rest/log.service'; | ||||
|   templateUrl: './logs.component.html', | ||||
|   styleUrls: ['./logs.component.scss'] | ||||
| }) | ||||
| export class LogsComponent implements OnInit { | ||||
| export class LogsComponent implements OnInit, AfterViewChecked { | ||||
|  | ||||
|   constructor(private logService: LogService) { } | ||||
|  | ||||
|   logs: PaperlessLog[] = [] | ||||
|   level: number = LOG_LEVEL_INFO | ||||
|   logs: string[] = [] | ||||
|  | ||||
|   logFiles: string[] = [] | ||||
|  | ||||
|   activeLog: string | ||||
|  | ||||
|   @ViewChild('logContainer') logContainer: ElementRef | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.reload() | ||||
|   } | ||||
|  | ||||
|   reload() { | ||||
|     this.logService.list(1, 50, 'created', true, {'level__gte': this.level}).subscribe(result => this.logs = result.results) | ||||
|   } | ||||
|  | ||||
|   getLevelText(level: number) { | ||||
|     return LOG_LEVELS.find(l => l.id == level)?.name | ||||
|   } | ||||
|  | ||||
|   onScroll() { | ||||
|     let lastCreated = null | ||||
|     if (this.logs.length > 0) { | ||||
|       lastCreated = new Date(this.logs[this.logs.length-1].created).toISOString() | ||||
|     } | ||||
|     this.logService.list(1, 25, 'created', true, {'created__lt': lastCreated, 'level__gte': this.level}).subscribe(result => { | ||||
|       this.logs.push(...result.results) | ||||
|     this.logService.list().subscribe(result => { | ||||
|       this.logFiles = result | ||||
|       if (this.logFiles.length > 0) { | ||||
|         this.activeLog = this.logFiles[0] | ||||
|         this.reloadLogs() | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   getLevels() { | ||||
|     return LOG_LEVELS | ||||
|   ngAfterViewChecked() { | ||||
|     this.scrollToBottom(); | ||||
|   } | ||||
|  | ||||
|   setLevel(id) { | ||||
|     this.level = id | ||||
|     this.reload() | ||||
|   reloadLogs() { | ||||
|     this.logService.get(this.activeLog).subscribe(result => { | ||||
|       this.logs = result | ||||
|     }, error => { | ||||
|       this.logs = [] | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   getLogLevel(log: string) { | ||||
|     if (log.indexOf("[DEBUG]") != -1) { | ||||
|       return 10 | ||||
|     } else if (log.indexOf("[WARNING]") != -1) { | ||||
|       return 30 | ||||
|     } else if (log.indexOf("[ERROR]") != -1) { | ||||
|       return 40 | ||||
|     } else if (log.indexOf("[CRITICAL]") != -1) { | ||||
|       return 50 | ||||
|     } else { | ||||
|       return 20 | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   scrollToBottom(): void { | ||||
|     this.logContainer?.nativeElement.scroll({ | ||||
|       top: this.logContainer.nativeElement.scrollHeight, | ||||
|       left: 0, | ||||
|       behavior: 'auto' | ||||
|     }); | ||||
|   } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -34,7 +34,7 @@ | ||||
|           <div class="col"> | ||||
|  | ||||
|             <select class="form-control" formControlName="dateLocale"> | ||||
|               <option *ngFor="let lang of dateLocaleOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code"> - {{today | date:'shortDate':null:lang.code}}</span></option> | ||||
|               <option *ngFor="let lang of dateLocaleOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code"> - {{today | customDate:'shortDate':null:lang.code}}</span></option> | ||||
|             </select> | ||||
|  | ||||
|           </div> | ||||
| @@ -96,6 +96,7 @@ | ||||
|           <div class="col"> | ||||
|             <app-input-check i18n-title title="Use system settings" formControlName="darkModeUseSystem"></app-input-check> | ||||
|             <app-input-check [hidden]="settingsForm.value.darkModeUseSystem" i18n-title title="Enable dark mode" formControlName="darkModeEnabled"></app-input-check> | ||||
|             <app-input-check i18n-title title="Invert thumbnails in dark mode" formControlName="darkModeInvertThumbs"></app-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
| @@ -110,7 +111,26 @@ | ||||
|  | ||||
|       </ng-template> | ||||
|     </li> | ||||
|  | ||||
|     <li [ngbNavItem]="2"> | ||||
|       <a ngbNavLink i18n>Notifications</a> | ||||
|       <ng-template ngbNavContent> | ||||
|  | ||||
|         <h4 i18n>Document processing</h4> | ||||
|  | ||||
|         <div class="form-row form-group"> | ||||
|           <div class="offset-md-3 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 processing completes successfully" formControlName="notificationsConsumerSuccess"></app-input-check> | ||||
|             <app-input-check i18n-title title="Show notifications when document processing 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 messages about document processing status on the dashboard."></app-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|       </ng-template> | ||||
|     </li> | ||||
|  | ||||
|     <li [ngbNavItem]="3"> | ||||
|       <a ngbNavLink i18n>Saved views</a> | ||||
|       <ng-template ngbNavContent> | ||||
|  | ||||
| @@ -148,7 +168,7 @@ | ||||
|     </li> | ||||
|   </ul> | ||||
|  | ||||
|   <div [ngbNavOutlet]="nav" class="border-left border-right border-bottom p-3 mb-3 shadow"></div> | ||||
|   <div [ngbNavOutlet]="nav" class="border-left border-right border-bottom p-3 mb-3 shadow-sm"></div> | ||||
|  | ||||
|   <button type="submit" class="btn btn-primary" [disabled]="!(isDirty$ | async)" i18n>Save</button> | ||||
| </form> | ||||
|   | ||||
| @@ -28,6 +28,10 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent { | ||||
|     'displayLanguage': new FormControl(null), | ||||
|     'dateLocale': new FormControl(null), | ||||
|     'dateFormat': new FormControl(null), | ||||
|     'notificationsConsumerNewDocument': new FormControl(null), | ||||
|     'notificationsConsumerSuccess': new FormControl(null), | ||||
|     'notificationsConsumerFailed': new FormControl(null), | ||||
|     'notificationsConsumerSuppressOnDashboard': new FormControl(null), | ||||
|   }) | ||||
|  | ||||
|   savedViews: PaperlessSavedView[] | ||||
| @@ -37,7 +41,7 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent { | ||||
|   isDirty$: Observable<boolean> | ||||
|  | ||||
|   get computedDateLocale(): string { | ||||
|     return this.settingsForm.value.dateLocale || this.settingsForm.value.displayLanguage | ||||
|     return this.settingsForm.value.dateLocale || this.settingsForm.value.displayLanguage || this.currentLocale | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
| @@ -62,6 +66,10 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent { | ||||
|         'displayLanguage': this.settings.getLanguage(), | ||||
|         'dateLocale': this.settings.get(SETTINGS_KEYS.DATE_LOCALE), | ||||
|         'dateFormat': this.settings.get(SETTINGS_KEYS.DATE_FORMAT), | ||||
|         'notificationsConsumerNewDocument': this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT), | ||||
|         'notificationsConsumerSuccess': this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS), | ||||
|         'notificationsConsumerFailed': this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED), | ||||
|         'notificationsConsumerSuppressOnDashboard': this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD), | ||||
|       } | ||||
|  | ||||
|       for (let view of this.savedViews) { | ||||
| @@ -108,9 +116,14 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent { | ||||
|     this.settings.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage) | ||||
|     this.settings.set(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM, this.settingsForm.value.darkModeUseSystem) | ||||
|     this.settings.set(SETTINGS_KEYS.DARK_MODE_ENABLED, (this.settingsForm.value.darkModeEnabled == true).toString()) | ||||
|     this.settings.set(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED, (this.settingsForm.value.darkModeInvertThumbs == true).toString()) | ||||
|     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.store.next(this.settingsForm.value) | ||||
|     this.documentListViewService.updatePageSize() | ||||
| @@ -119,11 +132,15 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent { | ||||
|   } | ||||
|  | ||||
|   get displayLanguageOptions(): LanguageOption[] { | ||||
|     return [{code: "", name: $localize`Use system language`}].concat(this.settings.getLanguageOptions()) | ||||
|     return [ | ||||
|       {code: "", name: $localize`Use system language`} | ||||
|     ].concat(this.settings.getLanguageOptions()) | ||||
|   } | ||||
|  | ||||
|   get dateLocaleOptions(): LanguageOption[] { | ||||
|     return [{code: "", name: $localize`Use date format of display language`}].concat(this.settings.getLanguageOptions()) | ||||
|     return [ | ||||
|       {code: "", name: $localize`Use date format of display language`} | ||||
|     ].concat(this.settings.getDateLocaleOptions()) | ||||
|   } | ||||
|  | ||||
|   get today() { | ||||
|   | ||||
| @@ -8,19 +8,11 @@ | ||||
|     <div class="modal-body"> | ||||
|       <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||
|  | ||||
|  | ||||
|       <div class="form-group paperless-input-select"> | ||||
|         <label for="colour" i18n>Color</label> | ||||
|         <ng-select name="colour" formControlName="colour" [items]="getColours()" bindValue="id" bindLabel="name" [clearable]="false"> | ||||
|           <ng-template ng-option-tmp ng-label-tmp let-item="item"> | ||||
|             <span class="badge" [style.background]="item.value" [style.color]="item.textColor">{{item.name}}</span> | ||||
|           </ng-template> | ||||
|         </ng-select> | ||||
|       </div> | ||||
|       <app-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></app-input-color> | ||||
|  | ||||
|       <app-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></app-input-check> | ||||
|       <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> | ||||
|       <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match"></app-input-text> | ||||
|       <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text> | ||||
|       <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check> | ||||
|     </div> | ||||
|     <div class="modal-footer"> | ||||
|   | ||||
| @@ -2,9 +2,10 @@ import { Component } from '@angular/core'; | ||||
| import { FormControl, FormGroup } from '@angular/forms'; | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'; | ||||
| import { TAG_COLOURS, PaperlessTag } from 'src/app/data/paperless-tag'; | ||||
| import { PaperlessTag } from 'src/app/data/paperless-tag'; | ||||
| import { TagService } from 'src/app/services/rest/tag.service'; | ||||
| import { ToastService } from 'src/app/services/toast.service'; | ||||
| import { randomColor } from 'src/app/utils/color'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-tag-edit-dialog', | ||||
| @@ -13,7 +14,7 @@ import { ToastService } from 'src/app/services/toast.service'; | ||||
| }) | ||||
| export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { | ||||
|  | ||||
|   constructor(service: TagService, activeModal: NgbActiveModal, toastService: ToastService) {  | ||||
|   constructor(service: TagService, activeModal: NgbActiveModal, toastService: ToastService) { | ||||
|     super(service, activeModal, toastService) | ||||
|   } | ||||
|  | ||||
| @@ -28,7 +29,7 @@ export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { | ||||
|   getForm(): FormGroup { | ||||
|     return new FormGroup({ | ||||
|       name: new FormControl(''), | ||||
|       colour: new FormControl(1), | ||||
|       color: new FormControl(randomColor()), | ||||
|       is_inbox_tag: new FormControl(false), | ||||
|       matching_algorithm: new FormControl(1), | ||||
|       match: new FormControl(""), | ||||
| @@ -36,12 +37,4 @@ export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   getColours() { | ||||
|     return TAG_COLOURS | ||||
|   } | ||||
|  | ||||
|   getColor(id: number) { | ||||
|     return TAG_COLOURS.find(c => c.id == id) | ||||
|   } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -26,8 +26,8 @@ | ||||
|   <tbody> | ||||
|     <tr *ngFor="let tag of data"> | ||||
|       <td scope="row">{{ tag.name }}</td> | ||||
|       <td scope="row"><span class="badge" [style.color]="getColor(tag.colour).textColor" | ||||
|           [style.background-color]="getColor(tag.colour).value">{{ getColor(tag.colour).name }}</span></td> | ||||
|       <td scope="row"><span class="badge" [style.color]="tag.text_color" | ||||
|           [style.background-color]="tag.color">{{tag.color}}</span></td> | ||||
|       <td scope="row">{{ getMatching(tag) }}</td> | ||||
|       <td scope="row">{{ tag.document_count }}</td> | ||||
|       <td scope="row"> | ||||
| @@ -52,4 +52,4 @@ | ||||
|       </td> | ||||
|     </tr> | ||||
|   </tbody> | ||||
| </table> | ||||
| </table> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { Component } from '@angular/core'; | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { FILTER_HAS_TAG } from 'src/app/data/filter-rule-type'; | ||||
| import { TAG_COLOURS, PaperlessTag } from 'src/app/data/paperless-tag'; | ||||
| import { PaperlessTag } from 'src/app/data/paperless-tag'; | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service'; | ||||
| import { TagService } from 'src/app/services/rest/tag.service'; | ||||
| import { ToastService } from 'src/app/services/toast.service'; | ||||
| @@ -22,10 +22,6 @@ export class TagListComponent extends GenericListComponent<PaperlessTag> { | ||||
|     super(tagService, modalService, TagEditDialogComponent, toastService) | ||||
|   } | ||||
|  | ||||
|   getColor(id) { | ||||
|     return TAG_COLOURS.find(c => c.id == id) | ||||
|   } | ||||
|  | ||||
|   getDeleteMessage(object: PaperlessTag) { | ||||
|     return $localize`Do you really want to delete the tag "${object.name}"?` | ||||
|   } | ||||
|   | ||||
| @@ -5,4 +5,4 @@ | ||||
|         <path d="M7 6.5C7 7.328 6.552 8 6 8s-1-.672-1-1.5S5.448 5 6 5s1 .672 1 1.5zm4 0c0 .828-.448 1.5-1 1.5s-1-.672-1-1.5S9.448 5 10 5s1 .672 1 1.5z"/> | ||||
|       </svg> | ||||
|     <h1 i18n>404 Not Found</h1> | ||||
| </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,3 +0,0 @@ | ||||
| ... <span *ngFor="let fragment of highlights"> | ||||
|     <span *ngFor="let token of fragment" [class.match]="token.highlight">{{token.text}}</span> ...  | ||||
| </span> | ||||
| @@ -1,4 +0,0 @@ | ||||
| .match { | ||||
|     color: black; | ||||
|     background-color: rgb(255, 211, 66); | ||||
| } | ||||
| @@ -1,19 +0,0 @@ | ||||
| import { Component, Input, OnInit } from '@angular/core'; | ||||
| import { SearchHitHighlight } from 'src/app/data/search-result'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-result-highlight', | ||||
|   templateUrl: './result-highlight.component.html', | ||||
|   styleUrls: ['./result-highlight.component.scss'] | ||||
| }) | ||||
| export class ResultHighlightComponent implements OnInit { | ||||
|  | ||||
|   constructor() { } | ||||
|  | ||||
|   @Input() | ||||
|   highlights: SearchHitHighlight[][] | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -1,28 +0,0 @@ | ||||
| <app-page-header i18n-title title="Search results"> | ||||
| </app-page-header> | ||||
|  | ||||
| <div *ngIf="errorMessage" class="alert alert-danger" i18n>Invalid search query: {{errorMessage}}</div> | ||||
|  | ||||
| <p *ngIf="more_like" i18n> | ||||
|     Showing documents similar to <a routerLink="/documents/{{more_like}}">{{more_like_doc?.original_file_name}}</a> | ||||
| </p> | ||||
|  | ||||
| <p *ngIf="query"> | ||||
|     <ng-container i18n>Search query: <i>{{query}}</i></ng-container> | ||||
|     <ng-container *ngIf="correctedQuery"> | ||||
|         - <ng-container i18n>Did you mean "<a [routerLink]="" (click)="searchCorrectedQuery()">{{correctedQuery}}</a>"?</ng-container> | ||||
|     </ng-container> | ||||
| </p> | ||||
|  | ||||
| <div *ngIf="!errorMessage" [class.result-content-searching]="searching" infiniteScroll (scrolled)="onScroll()"> | ||||
|     <p i18n>{resultCount, plural, =0 {No results} =1 {One result} other {{{resultCount}} results}}</p> | ||||
|     <ng-container *ngFor="let result of results"> | ||||
|         <app-document-card-large *ngIf="result.document" | ||||
|             [document]="result.document" | ||||
|             [details]="result.highlights" | ||||
|             [searchScore]="result.score / maxScore" | ||||
|             [moreLikeThis]="true"> | ||||
|         </app-document-card-large> | ||||
|     </ng-container> | ||||
|  | ||||
| </div> | ||||
| @@ -1,15 +0,0 @@ | ||||
| .result-content { | ||||
|     color: darkgray; | ||||
| } | ||||
|  | ||||
| .doc-img { | ||||
|     object-fit: cover; | ||||
|     object-position: top; | ||||
|     height: 100%; | ||||
|     position: absolute; | ||||
|  | ||||
| } | ||||
|  | ||||
| .result-content-searching { | ||||
|     opacity: 0.3; | ||||
| } | ||||
| @@ -1,95 +0,0 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
| import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; | ||||
| import { SearchHit } from 'src/app/data/search-result'; | ||||
| import { DocumentService } from 'src/app/services/rest/document.service'; | ||||
| import { SearchService } from 'src/app/services/rest/search.service'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-search', | ||||
|   templateUrl: './search.component.html', | ||||
|   styleUrls: ['./search.component.scss'] | ||||
| }) | ||||
| export class SearchComponent implements OnInit { | ||||
|  | ||||
|   results: SearchHit[] = [] | ||||
|  | ||||
|   query: string = "" | ||||
|  | ||||
|   more_like: number | ||||
|  | ||||
|   more_like_doc: PaperlessDocument | ||||
|  | ||||
|   searching = false | ||||
|  | ||||
|   currentPage = 1 | ||||
|  | ||||
|   pageCount = 1 | ||||
|  | ||||
|   resultCount | ||||
|  | ||||
|   correctedQuery: string = null | ||||
|  | ||||
|   errorMessage: string | ||||
|  | ||||
|   get maxScore() { | ||||
|     return this.results?.length > 0 ? this.results[0].score : 100 | ||||
|   } | ||||
|  | ||||
|   constructor(private searchService: SearchService, private route: ActivatedRoute, private router: Router, private documentService: DocumentService) { } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.route.queryParamMap.subscribe(paramMap => { | ||||
|       window.scrollTo(0, 0) | ||||
|       this.query = paramMap.get('query') | ||||
|       this.more_like = paramMap.has('more_like') ? +paramMap.get('more_like') : null | ||||
|       if (this.more_like) { | ||||
|         this.documentService.get(this.more_like).subscribe(r => { | ||||
|           this.more_like_doc = r | ||||
|         }) | ||||
|       } else { | ||||
|         this.more_like_doc = null | ||||
|       } | ||||
|       this.searching = true | ||||
|       this.currentPage = 1 | ||||
|       this.loadPage() | ||||
|     }) | ||||
|  | ||||
|   } | ||||
|  | ||||
|   searchCorrectedQuery() { | ||||
|     this.router.navigate(["search"], {queryParams: {query: this.correctedQuery, more_like: this.more_like}}) | ||||
|   } | ||||
|  | ||||
|   loadPage(append: boolean = false) { | ||||
|     this.errorMessage = null | ||||
|     this.correctedQuery = null | ||||
|  | ||||
|     this.searchService.search(this.query, this.currentPage, this.more_like).subscribe(result => { | ||||
|       if (append) { | ||||
|         this.results.push(...result.results) | ||||
|       } else { | ||||
|         this.results = result.results | ||||
|       } | ||||
|       this.pageCount = result.page_count | ||||
|       this.searching = false | ||||
|       this.resultCount = result.count | ||||
|       this.correctedQuery = result.corrected_query | ||||
|     }, error => { | ||||
|       this.searching = false | ||||
|       this.resultCount = 1 | ||||
|       this.pageCount = 1 | ||||
|       this.results = [] | ||||
|       this.errorMessage = error.error | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   onScroll() { | ||||
|     if (this.currentPage < this.pageCount) { | ||||
|       this.currentPage += 1 | ||||
|       this.loadPage(true) | ||||
|     } | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -20,6 +20,11 @@ export const FILTER_DOES_NOT_HAVE_TAG = 17 | ||||
|  | ||||
| export const FILTER_ASN_ISNULL = 18 | ||||
|  | ||||
| export const FILTER_TITLE_CONTENT = 19 | ||||
|  | ||||
| export const FILTER_FULLTEXT_QUERY = 20 | ||||
| export const FILTER_FULLTEXT_MORELIKE = 21 | ||||
|  | ||||
| export const FILTER_RULE_TYPES: FilterRuleType[] = [ | ||||
|  | ||||
|   {id: FILTER_TITLE, filtervar: "title__icontains", datatype: "string", multi: false, default: ""}, | ||||
| @@ -47,7 +52,13 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [ | ||||
|  | ||||
|   {id: FILTER_MODIFIED_BEFORE, filtervar: "modified__date__lt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_MODIFIED_AFTER, filtervar: "modified__date__gt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_ASN_ISNULL, filtervar: "archive_serial_number__isnull", datatype: "boolean", multi: false} | ||||
|   {id: FILTER_ASN_ISNULL, filtervar: "archive_serial_number__isnull", datatype: "boolean", multi: false}, | ||||
|  | ||||
|   {id: FILTER_TITLE_CONTENT, filtervar: "title_content", datatype: "string", multi: false}, | ||||
|  | ||||
|   {id: FILTER_FULLTEXT_QUERY, filtervar: "query", datatype: "string", multi: false}, | ||||
|  | ||||
|   {id: FILTER_FULLTEXT_MORELIKE, filtervar: "more_like_id", datatype: "number", multi: false}, | ||||
| ] | ||||
|  | ||||
| export interface FilterRuleType { | ||||
|   | ||||
| @@ -1,16 +1,22 @@ | ||||
| import { FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_QUERY } from "./filter-rule-type" | ||||
|  | ||||
| export function cloneFilterRules(filterRules: FilterRule[]): FilterRule[] { | ||||
|   if (filterRules) { | ||||
|     let newRules: FilterRule[] = [] | ||||
|     for (let rule of filterRules) { | ||||
|       newRules.push({rule_type: rule.rule_type, value: rule.value}) | ||||
|     } | ||||
|     return newRules       | ||||
|     return newRules | ||||
|   } else { | ||||
|     return null | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function isFullTextFilterRule(filterRules: FilterRule[]): boolean { | ||||
|   return filterRules.find(r => r.rule_type == FILTER_FULLTEXT_QUERY || r.rule_type == FILTER_FULLTEXT_MORELIKE) != null | ||||
| } | ||||
|  | ||||
| export interface FilterRule { | ||||
|   rule_type: number | ||||
|   value: string | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { MatchingModel } from './matching-model'; | ||||
|  | ||||
| export interface PaperlessCorrespondent extends MatchingModel { | ||||
|    | ||||
|  | ||||
|   last_correspondence?: Date | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| export interface PaperlessDocumentMetadata { | ||||
|      | ||||
|  | ||||
|   original_checksum?: string | ||||
|  | ||||
|   archived_checksum?: string | ||||
| @@ -10,4 +10,4 @@ export interface PaperlessDocumentMetadata { | ||||
|  | ||||
|   has_archive_version?: boolean | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
							
								
								
									
										9
									
								
								src-ui/src/app/data/paperless-document-suggestions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src-ui/src/app/data/paperless-document-suggestions.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| export interface PaperlessDocumentSuggestions { | ||||
|  | ||||
|   tags?: number[] | ||||
|  | ||||
|   correspondents?: number[] | ||||
|  | ||||
|   document_types?: number[] | ||||
|  | ||||
| } | ||||
| @@ -4,6 +4,15 @@ import { PaperlessTag } from './paperless-tag' | ||||
| import { PaperlessDocumentType } from './paperless-document-type' | ||||
| import { Observable } from 'rxjs' | ||||
|  | ||||
| export interface SearchHit { | ||||
|  | ||||
|   score?: number | ||||
|   rank?: number | ||||
|  | ||||
|   highlights?: string | ||||
|  | ||||
| } | ||||
|  | ||||
| export interface PaperlessDocument extends ObjectWithId { | ||||
|  | ||||
|     correspondent$?: Observable<PaperlessCorrespondent> | ||||
| @@ -40,4 +49,6 @@ export interface PaperlessDocument extends ObjectWithId { | ||||
|  | ||||
|     archive_serial_number?: number | ||||
|  | ||||
|     __search_hit__?: SearchHit | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,27 +0,0 @@ | ||||
| export const LOG_LEVEL_DEBUG = 10 | ||||
| export const LOG_LEVEL_INFO = 20 | ||||
| export const LOG_LEVEL_WARNING = 30 | ||||
| export const LOG_LEVEL_ERROR = 40 | ||||
| export const LOG_LEVEL_CRITICAL = 50 | ||||
|  | ||||
| export const LOG_LEVELS = [ | ||||
|   {id: LOG_LEVEL_DEBUG, name: "DEBUG"}, | ||||
|   {id: LOG_LEVEL_INFO, name: "INFO"}, | ||||
|   {id: LOG_LEVEL_WARNING, name: "WARNING"}, | ||||
|   {id: LOG_LEVEL_ERROR, name: "ERROR"}, | ||||
|   {id: LOG_LEVEL_CRITICAL, name: "CRITICAL"} | ||||
| ] | ||||
|  | ||||
| export interface PaperlessLog { | ||||
|  | ||||
|   id?: number | ||||
|  | ||||
|   group?: string | ||||
|  | ||||
|   message?: string | ||||
|  | ||||
|   created?: Date | ||||
|  | ||||
|   level?: number | ||||
|  | ||||
| } | ||||
| @@ -15,4 +15,4 @@ export interface PaperlessSavedView extends ObjectWithId { | ||||
|  | ||||
|   filter_rules: FilterRule[] | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,26 +1,10 @@ | ||||
| import { MatchingModel } from './matching-model'; | ||||
| import { ObjectWithId } from './object-with-id'; | ||||
|  | ||||
|  | ||||
| export const TAG_COLOURS = [ | ||||
|     {id: 1, value: "#a6cee3", name: $localize`Light blue`, textColor: "#000000"}, | ||||
|     {id: 2, value: "#1f78b4", name: $localize`Blue`, textColor: "#ffffff"}, | ||||
|     {id: 3, value: "#b2df8a", name: $localize`Light green`, textColor: "#000000"}, | ||||
|     {id: 4, value: "#33a02c", name: $localize`Green`, textColor: "#ffffff"}, | ||||
|     {id: 5, value: "#fb9a99", name: $localize`Light red`, textColor: "#000000"}, | ||||
|     {id: 6, value: "#e31a1c", name: $localize`Red `, textColor: "#ffffff"}, | ||||
|     {id: 7, value: "#fdbf6f", name: $localize`Light orange`, textColor: "#000000"}, | ||||
|     {id: 8, value: "#ff7f00", name: $localize`Orange`, textColor: "#000000"}, | ||||
|     {id: 9, value: "#cab2d6", name: $localize`Light violet`, textColor: "#000000"}, | ||||
|     {id: 10, value: "#6a3d9a", name: $localize`Violet`, textColor: "#ffffff"}, | ||||
|     {id: 11, value: "#b15928", name: $localize`Brown`, textColor: "#ffffff"}, | ||||
|     {id: 12, value: "#000000", name: $localize`Black`, textColor: "#ffffff"}, | ||||
|     {id: 13, value: "#cccccc", name: $localize`Light grey`, textColor: "#000000"} | ||||
| ] | ||||
| import { MatchingModel } from "./matching-model"; | ||||
|  | ||||
| export interface PaperlessTag extends MatchingModel { | ||||
|  | ||||
|     colour?: number | ||||
|     color?: string | ||||
|  | ||||
|     text_color?: string | ||||
|  | ||||
|     is_inbox_tag?: boolean | ||||
|  | ||||
|   | ||||
| @@ -3,5 +3,5 @@ export interface Results<T> { | ||||
|   count: number | ||||
|  | ||||
|   results: T[] | ||||
|    | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,29 +0,0 @@ | ||||
| import { PaperlessDocument } from './paperless-document' | ||||
|  | ||||
| export class SearchHitHighlight { | ||||
|   text?: string | ||||
|   term?: number | ||||
| } | ||||
|  | ||||
| export interface SearchHit { | ||||
|   id?: number | ||||
|   title?: string | ||||
|   score?: number | ||||
|   rank?: number | ||||
|  | ||||
|   highlights?: SearchHitHighlight[][] | ||||
|   document?: PaperlessDocument | ||||
| } | ||||
|  | ||||
| export interface SearchResult { | ||||
|  | ||||
|   count?: number | ||||
|   page?: number | ||||
|   page_count?: number | ||||
|  | ||||
|   corrected_query?: string | ||||
|  | ||||
|   results?: SearchHit[] | ||||
|  | ||||
|  | ||||
| } | ||||
							
								
								
									
										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 | ||||
|  | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 Michael Shamoon
					Michael Shamoon