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

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