Merge branch 'dev' into feature/any-all-filtering

This commit is contained in:
Michael Shamoon
2022-02-14 22:23:31 -08:00
323 changed files with 85068 additions and 12189 deletions

View File

@@ -10,7 +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 { SearchComponent } from './components/search/search.component';
import {DocumentAsnComponent} from "./components/document-asn/document-asn.component";
const routes: Routes = [
{path: '', redirectTo: 'dashboard', pathMatch: 'full'},
@@ -18,22 +18,22 @@ 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 },
{path: 'logs', component: LogsComponent },
{path: 'settings', component: SettingsComponent },
]},
]},
{path: '404', component: NotFoundComponent},
{path: '**', redirectTo: '/404', pathMatch: 'full'}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@@ -1,3 +1,3 @@
<app-toasts></app-toasts>
<router-outlet></router-outlet>
<router-outlet></router-outlet>

View File

@@ -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.`})
}
})
}
}

View File

@@ -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]
})

View File

@@ -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>&nbsp;{{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>&nbsp;<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>&nbsp;<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>

View File

@@ -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
*/

View File

@@ -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,9 +26,10 @@ export class AppFrameComponent implements OnInit, OnDestroy {
private openDocumentsService: OpenDocumentsService,
private searchService: SearchService,
public savedViewService: SavedViewService,
private list: DocumentListViewService,
private meta: Meta
) {
}
versionString = `${environment.appTitle} ${environment.version}`
@@ -39,9 +42,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(
@@ -74,15 +77,27 @@ 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() {
this.closeMenu()
this.openDocumentsService.closeAll()
// TODO: is there a better way to do this?
let route = this.activatedRoute
let route = this.activatedRoute.snapshot
while (route.firstChild) {
route = route.firstChild
}
@@ -92,13 +107,6 @@ export class AppFrameComponent implements OnInit, OnDestroy {
}
ngOnInit() {
this.openDocuments = this.openDocumentsService.getOpenDocuments()
}
ngOnDestroy() {
if (this.openDocumentsSubscription) {
this.openDocumentsSubscription.unsubscribe()
}
}
get displayName() {

View File

@@ -30,7 +30,7 @@ export class ConfirmDialogComponent implements OnInit {
@Input()
buttonsEnabled = true
confirmButtonEnabled = true
seconds = 0

View File

@@ -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>

View File

@@ -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

View File

@@ -11,7 +11,7 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
constructor() { }
onChange = (newValue: T) => {};
onTouched = () => {};
writeValue(newValue: any): void {

View File

@@ -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>

View File

@@ -15,7 +15,7 @@ import { AbstractInputComponent } from '../abstract-input';
})
export class CheckComponent extends AbstractInputComponent<boolean> {
constructor() {
constructor() {
super()
}

View File

@@ -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">&nbsp;&nbsp;&nbsp;</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>

View File

@@ -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();
});

View File

@@ -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)
}
}

View File

@@ -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> -->

View File

@@ -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"))
}
}

View File

@@ -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>

View File

@@ -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();
});

View File

@@ -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
}

View File

@@ -11,4 +11,4 @@
</div>
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
</div>
</div>

View File

@@ -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>&nbsp;
<ng-container *ngFor="let s of getSuggestions()">
<a (click)="value = s.id; onChange(value)" [routerLink]="">{{s.name}}</a>&nbsp;
</ng-container>
</small>
</div>

View File

@@ -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);
}
}

View File

@@ -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>&nbsp;
<ng-container *ngFor="let tag of getSuggestions()">
<a (click)="addTag(tag.id)" [routerLink]="">{{tag.name}}</a>&nbsp;
</ng-container>
</small>
</div>

View File

@@ -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);
}
}

View File

@@ -5,4 +5,4 @@
<div class="invalid-feedback">
{{error}}
</div>
</div>
</div>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -18,4 +18,4 @@
</tbody>
</table>
</app-widget-frame>
</app-widget-frame>

View File

@@ -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"])
}
}

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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>&nbsp;
<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>
&nbsp;&bull;&nbsp;
<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>

View File

@@ -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;
}

View File

@@ -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;
}
}
})
});
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1 @@
<p i18n>Searching document with asn {{asn}}</p>

View File

@@ -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();
});

View File

@@ -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'])
}
})
})
}
}

View File

@@ -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>
@@ -139,12 +139,12 @@
<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>
</ng-container>
</div>
</div>

View File

@@ -19,6 +19,8 @@ import { PDFDocumentProxy } from 'ng2-pdf-viewer';
import { ToastService } from 'src/app/services/toast.service';
import { TextComponent } from '../common/input/text/text.component';
import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service';
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',
@@ -40,6 +42,8 @@ export class DocumentDetailComponent implements OnInit {
documentId: number
document: PaperlessDocument
metadata: PaperlessDocumentMetadata
suggestions: PaperlessDocumentSuggestions
title: string
previewUrl: string
downloadUrl: string
@@ -95,6 +99,7 @@ export class DocumentDetailComponent implements OnInit {
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))
} else {
@@ -111,14 +116,22 @@ export class DocumentDetailComponent implements OnInit {
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
@@ -127,9 +140,10 @@ export class DocumentDetailComponent implements OnInit {
})
}
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
@@ -180,8 +194,8 @@ export class DocumentDetailComponent implements OnInit {
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'])
}
@@ -208,7 +222,7 @@ export class DocumentDetailComponent implements OnInit {
}
moreLike() {
this.router.navigate(["search"], {queryParams: {more_like:this.document.id}})
this.documentListViewService.quickFilter([{rule_type: FILTER_FULLTEXT_MORELIKE, value: this.documentId.toString()}])
}
hasNext() {

View File

@@ -20,4 +20,4 @@
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -1,3 +1,3 @@
.metadata-column {
overflow-wrap: anywhere;
}
}

View File

@@ -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>&nbsp;<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" />

View File

@@ -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');
})
}
}

View File

@@ -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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<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:&nbsp;{{document.added | customDate:'shortDate'}} Created:&nbsp;{{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>

View File

@@ -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);
}

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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()
}
}

View File

@@ -63,20 +63,20 @@
<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>
@@ -86,89 +86,98 @@
<span i18n *ngIf="list.selected.size == 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>&nbsp;<span i18n *ngIf="isFiltered">(filtered)</span>
</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>

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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_TAGS_ALL, FILTER_HAS_TAGS_ANY, FILTER_DOES_NOT_HAVE_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_TAGS_ALL, FILTER_HAS_TAGS_ANY, FILTER_DOES_NOT_HAVE_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
@@ -122,12 +198,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"})
@@ -164,25 +253,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() {
@@ -190,23 +294,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()
}
addTag(tagId: number) {
@@ -232,4 +342,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()
}
}

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -32,6 +32,6 @@ export class CorrespondentEditDialogComponent extends EditDialogComponent<Paperl
match: new FormControl(""),
is_insensitive: new FormControl(true)
})
}
}
}

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -49,4 +49,4 @@
</td>
</tr>
</tbody>
</table>
</table>

View File

@@ -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>&nbsp;<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>

View File

@@ -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;
}
}

View File

@@ -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'
});
}
}

View File

@@ -19,7 +19,7 @@
<div class="col">
<select class="form-control" formControlName="displayLanguage">
<option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code"> ({{lang.englishName}})</span></option>
<option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale != 'en-US'"> - {{lang.englishName}}</span></option>
</select>
<small class="form-text text-muted" i18n>You need to reload the page after applying a new language.</small>
@@ -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" i18n>Save</button>
</form>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, Renderer2 } from '@angular/core';
import { Component, Inject, LOCALE_ID, OnInit, Renderer2 } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
@@ -21,24 +21,30 @@ export class SettingsComponent implements OnInit {
'documentListItemPerPage': new FormControl(this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)),
'darkModeUseSystem': new FormControl(this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM)),
'darkModeEnabled': new FormControl(this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED)),
'darkModeInvertThumbs': new FormControl(this.settings.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)),
'useNativePdfViewer': new FormControl(this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)),
'savedViews': this.savedViewGroup,
'displayLanguage': new FormControl(this.settings.getLanguage()),
'dateLocale': new FormControl(this.settings.get(SETTINGS_KEYS.DATE_LOCALE)),
'dateFormat': new FormControl(this.settings.get(SETTINGS_KEYS.DATE_FORMAT)),
'notificationsConsumerNewDocument': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)),
'notificationsConsumerSuccess': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)),
'notificationsConsumerFailed': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)),
'notificationsConsumerSuppressOnDashboard': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD)),
})
savedViews: PaperlessSavedView[]
get computedDateLocale(): string {
return this.settingsForm.value.dateLocale || this.settingsForm.value.displayLanguage
return this.settingsForm.value.dateLocale || this.settingsForm.value.displayLanguage || this.currentLocale
}
constructor(
public savedViewService: SavedViewService,
private documentListViewService: DocumentListViewService,
private toastService: ToastService,
private settings: SettingsService
private settings: SettingsService,
@Inject(LOCALE_ID) public currentLocale: string
) { }
ngOnInit() {
@@ -69,9 +75,14 @@ export class SettingsComponent implements OnInit {
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.documentListViewService.updatePageSize()
this.settings.updateDarkModeSettings()
@@ -79,11 +90,15 @@ export class SettingsComponent implements OnInit {
}
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() {

View File

@@ -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">

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { FILTER_HAS_TAGS_ALL } 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}"?`
}

View File

@@ -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>

View File

@@ -1,3 +0,0 @@
... <span *ngFor="let fragment of highlights">
<span *ngFor="let token of fragment" [class.match]="token.highlight">{{token.text}}</span> ...
</span>

View File

@@ -1,4 +0,0 @@
.match {
color: black;
background-color: rgb(255, 211, 66);
}

View File

@@ -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 {
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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)
}
}
}

View File

@@ -21,6 +21,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: ""},
@@ -49,7 +54,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 {

View File

@@ -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
}
}

View File

@@ -1,7 +1,7 @@
import { MatchingModel } from './matching-model';
export interface PaperlessCorrespondent extends MatchingModel {
last_correspondence?: Date
}

View File

@@ -1,5 +1,5 @@
export interface PaperlessDocumentMetadata {
original_checksum?: string
archived_checksum?: string
@@ -10,4 +10,4 @@ export interface PaperlessDocumentMetadata {
has_archive_version?: boolean
}
}

View File

@@ -0,0 +1,9 @@
export interface PaperlessDocumentSuggestions {
tags?: number[]
correspondents?: number[]
document_types?: number[]
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -15,4 +15,4 @@ export interface PaperlessSavedView extends ObjectWithId {
filter_rules: FilterRule[]
}
}

View File

@@ -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

View File

@@ -3,5 +3,5 @@ export interface Results<T> {
count: number
results: T[]
}

View File

@@ -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[]
}

View 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
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ApiVersionInterceptor } from './api-version.interceptor';
describe('ApiVersionInterceptor', () => {
beforeEach(() => TestBed.configureTestingModule({
providers: [
ApiVersionInterceptor
]
}));
it('should be created', () => {
const interceptor: ApiVersionInterceptor = TestBed.inject(ApiVersionInterceptor);
expect(interceptor).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More