Merge pull request #789 from shamoon/feature/merge-tool-ui

Feature: merge tool UI
This commit is contained in:
Jonas Winkler authored and GitHub committed 2021-04-03 22:26:57 +02:00
commit d521d7733b
29 files changed
+1073 -49

No files matched your search

+15
View File
@@ -8361,6 +8361,21 @@
"tslib": "^2.0.0"
}
},
"ngx-drag-drop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ngx-drag-drop/-/ngx-drag-drop-2.0.0.tgz",
"integrity": "sha512-t+4/eiC8zaXKqU1ruNfFEfGs1GpMNwpffD0baopvZFKjQHCb5rhNqFilJ54wO4T0OwGp4/RnsVhlcxe1mX6UJg==",
"requires": {
"tslib": "^1.9.0"
},
"dependencies": {
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
}
}
},
"ngx-file-drop": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/ngx-file-drop/-/ngx-file-drop-10.0.0.tgz",
+1
View File
@@ -28,6 +28,7 @@
"ng2-pdf-viewer": "^6.3.2",
"ngx-color": "^6.2.0",
"ngx-cookie-service": "^10.1.1",
"ngx-drag-drop": "^2.0.0",
"ngx-file-drop": "^10.0.0",
"ngx-infinite-scroll": "^9.1.0",
"rxjs": "~6.6.0",
+10 -2
View File
@@ -35,6 +35,7 @@ import { DocumentCardSmallComponent } from './components/document-list/document-
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component';
import { NgxFileDropModule } from 'ngx-file-drop';
import { TextComponent } from './components/common/input/text/text.component';
import { InputDebounceComponent } from './components/common/input/debounce/input-debounce.component';
import { SelectComponent } from './components/common/input/select/select.component';
import { CheckComponent } from './components/common/input/check/check.component';
import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component';
@@ -66,6 +67,9 @@ import { ApiVersionInterceptor } from './interceptors/api-version.interceptor';
import { ColorSliderModule } from 'ngx-color/slider';
import { ColorComponent } from './components/common/input/color/color.component';
import { SplitMergeComponent } from './components/split-merge/split-merge.component';
import { DndModule } from 'ngx-drag-drop';
import { DocumentChooserComponent } from './components/common/document-chooser/document-chooser.component';
import { PageChooserComponent } from './components/common/page-chooser/page-chooser.component';
import localeFr from '@angular/common/locales/fr';
import localeNl from '@angular/common/locales/nl';
@@ -120,6 +124,7 @@ registerLocaleData(localeEs)
DocumentCardSmallComponent,
BulkEditorComponent,
TextComponent,
InputDebounceComponent,
SelectComponent,
CheckComponent,
SaveViewConfigDialogComponent,
@@ -142,7 +147,9 @@ registerLocaleData(localeEs)
DateComponent,
ColorComponent,
DocumentAsnComponent,
SplitMergeComponent
SplitMergeComponent,
DocumentChooserComponent,
PageChooserComponent
],
imports: [
BrowserModule,
@@ -155,7 +162,8 @@ registerLocaleData(localeEs)
InfiniteScrollModule,
PdfViewerModule,
NgSelectModule,
ColorSliderModule
ColorSliderModule,
DndModule
],
providers: [
DatePipe,
@@ -0,0 +1,15 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i18n>Choose Documents</h4>
<button type="button" class="close" aria-label="Close" (click)="cancelClicked()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="m-n2 row row-cols-paperless-cards">
<app-document-card-small [simpleCard]="true" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents"></app-document-card-small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger" (click)="cancelClicked()" i18n>Cancel</button>
<button type="button" class="btn btn-outline-primary" [disabled]="list.selected.size == 0" (click)="confirmClicked.emit()" i18n>Add Selected</button>
</div>
@@ -0,0 +1,28 @@
@import "/src/theme";
.modal-body {
max-height: 70vh;
overflow-y: scroll;
}
$paperless-card-breakpoints: (
0: 2, // xs
768px: 3, //md
992px: 4, //lg
1200px: 5, //xl
1400px: 6, // xxl
1600px: 7,
1800px: 8,
2000px: 9
);
.row-cols-paperless-cards {
@each $width, $n_cols in $paperless-card-breakpoints {
@media(min-width: $width) {
> * {
flex: 0 0 auto;
width: 100% / $n_cols;
}
}
}
}
@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DocumentChooserComponent } from './document-chooser.component';
describe('DocumentChooserComponent', () => {
let component: DocumentChooserComponent;
let fixture: ComponentFixture<DocumentChooserComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DocumentChooserComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DocumentChooserComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,36 @@
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { PaperlessDocument } from 'src/app/data/paperless-document';
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
@Component({
selector: 'app-document-chooser',
templateUrl: './document-chooser.component.html',
styleUrls: ['./document-chooser.component.scss']
})
export class DocumentChooserComponent implements OnInit {
constructor(
public activeModal: NgbActiveModal,
public list: DocumentListViewService
) { }
@Output()
public confirmClicked = new EventEmitter()
ngOnInit(): void {
this.list.selectNone()
this.list.activateSavedView(null)
this.list.reload()
}
cancelClicked() {
this.list.selectNone()
this.activeModal.close()
}
toggleSelected(d: PaperlessDocument, event: MouseEvent): void {
if (!event.shiftKey) this.list.toggleSelected(d)
else this.list.selectRangeTo(d)
}
}
@@ -0,0 +1,2 @@
<input type="text" [class]="cssClasses" [placeholder]="placeholder" [(ngModel)]="inputValue" (keyup)="keyUp($event)" />
<span *ngIf="allowClear && inputValue?.length > 0" class="clear-wrapper" title="Clear all" (click)="clear()"><span aria-hidden="true" class="clear">×</span></span>
@@ -0,0 +1,23 @@
.clear-wrapper {
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
cursor: pointer;
position: absolute;
top: 5px;
right: 5px;
user-select: none;
width: 17px;
color: #999;
.clear {
display: inline-block;
font-size: 18px;
line-height: 1;
pointer-events: none;
}
&:hover .clear {
color: #D0021B;
}
}
@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { InputDebounceComponent } from './text.component';
describe('InputDebounceComponent', () => {
let component: InputDebounceComponent;
let fixture: ComponentFixture<InputDebounceComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ InputDebounceComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(InputDebounceComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,66 @@
import { Component, Input, Output, ElementRef, EventEmitter } from '@angular/core';
import { fromEvent } from 'rxjs';
import { map, debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-input-debounce',
templateUrl: './input-debounce.component.html',
styleUrls: ['./input-debounce.component.scss']
})
export class InputDebounceComponent {
@Input()
placeholder: string
@Input()
delay: number = 500
@Input()
classes: string
@Input()
allowClear: boolean = true
@Input()
set pattern(regexStr: string) {
this._pattern = new RegExp(regexStr)
}
_pattern: RegExp
@Output()
value: EventEmitter<string> = new EventEmitter<string>()
@Input()
inputValue: string;
constructor(private elementRef: ElementRef) {
fromEvent(elementRef.nativeElement, 'keyup').pipe(
map(() => this.inputValue),
debounceTime(this.delay),
distinctUntilChanged()
).subscribe(input =>
this.value.emit(input)
);
}
keyUp(event: any) {
if (!this._pattern) return
if (event.key.length == 1 && !this._pattern.test(event.key)) { // e.g. dont do for backspace
// invalid character, prevent input
event.preventDefault()
event.stopImmediatePropagation()
this.inputValue = this.inputValue.slice(this.inputValue.length, 1)
}
}
get cssClasses(): string {
return 'form-control ' + this.classes
}
clear() {
this.inputValue = ''
this.value.emit(this.inputValue)
}
}
@@ -0,0 +1,13 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}: {{document.title}}</h4>
<button type="button" class="close" aria-label="Close" (click)="cancelClicked()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<pdf-viewer class="page-chooser-viewer" [class.splitting]="splitting" [src]="previewUrl" [original-size]="false" [zoom]=".25" [autoresize]="true" [show-borders]="true" [show-all]="true" [render-text-mode]="2" (page-rendered)="afterPageRendered($event)" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger" (click)="cancelClicked()" i18n>Cancel</button>
<button type="button" class="btn btn-outline-primary" (click)="confirmClicked()" [disabled]="pages.length == 0">{{title}}</button>
</div>
@@ -0,0 +1,89 @@
@import "src/theme";
.modal-body {
max-height: 70vh;
overflow-y: scroll;
}
::ng-deep .page-chooser-viewer {
min-height: 400px;
width: 100%;
.pdfViewer {
display: flex !important;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
.page:not(.disabled) {
margin: 2px 3% 2rem !important;
cursor: pointer;
.textLayer {
pointer-events: none;
}
&:hover {
.textLayer {
background-color: $primary !important;
opacity: 0.5;
}
}
}
}
&:not(.splitting) {
.pdfViewer {
.page.selected {
&::after {
position: absolute;
top: 0.5rem;
left: 0.5rem;
display: block;
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
content: "";
background: $primary no-repeat 50%/50% 50%;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e");
}
.textLayer {
background-color: $primary !important;
opacity: 0.5;
}
}
}
}
&.splitting {
.pdfViewer {
.page:not(:last-child).selected::after,
.page:not(:last-child):not(.disabled):hover::after {
background-color: $primary;
width: 2rem;
height: 100%;
content: "Split Here";
color: #abb2bf; // dark mode text color
position: absolute;
top: 0;
right: -2rem;
writing-mode: vertical-lr;
padding: 0.5rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.1rem;
font-size: 80%;
font-weight: bold;
}
.page.disabled {
.canvasWrapper,
.textLayer {
opacity: 0.25;
pointer-events: none;
}
}
}
}
}
@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PageChooserComponent } from './page-chooser.component';
describe('PageChooserComponent', () => {
let component: PageChooserComponent;
let fixture: ComponentFixture<PageChooserComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PageChooserComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PageChooserComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,90 @@
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { PaperlessDocument, PaperlessDocumentPart } from 'src/app/data/paperless-document';
import { DocumentService } from 'src/app/services/rest/document.service';
import { PDFDocumentProxy } from 'ng2-pdf-viewer';
@Component({
selector: 'app-page-chooser',
templateUrl: './page-chooser.component.html',
styleUrls: ['./page-chooser.component.scss']
})
export class PageChooserComponent implements OnInit {
constructor(
public activeModal: NgbActiveModal,
private documentService: DocumentService
) { }
get previewUrl(): string {
return this.documentService.getPreviewUrl(this.document.id)
}
@Input()
document: PaperlessDocument
@Input()
splitting: boolean = false
public pages: number[]
public enabledPages: number[]
private nPages: number
@Output()
public confirmPages = new EventEmitter<number[]>()
get title(): string {
return this.splitting ? 'Select Split' : 'Select Pages'
}
ngOnInit(): void {
this.pages = (this.document as PaperlessDocumentPart).pages
if (this.splitting && this.pages?.length) this.enabledPages = this.pages.concat([])
if (!this.pages) this.pages = []
}
cancelClicked() {
this.activeModal.close()
}
confirmClicked() {
if (this.splitting) {
// we need pages as the full range since we wont know length of document later
const startPage = this.pages.pop()
for (let page = startPage + 1; page <= this.nPages; page++) {
if (!this.enabledPages || (this.enabledPages && this.enabledPages.indexOf(page) !== -1)) this.pages.push(page)
}
}
this.confirmPages.emit(this.pages.sort((a, b) => { return a - b }))
}
pdfLoaded(pdf: PDFDocumentProxy) {
this.nPages = pdf.numPages
}
public afterPageRendered(pageRenderedEvent: any) {
const pageView = pageRenderedEvent.source /* as PDFPageView */
const div = pageView.div as HTMLDivElement
if (this.splitting && this.enabledPages?.length) {
div.classList.toggle('disabled', this.enabledPages.indexOf(parseInt(div.dataset.pageNumber)) == -1)
} else {
div.classList.toggle('selected', this.pages.indexOf(parseInt(div.dataset.pageNumber)) !== -1)
}
div.onclick = () => {
let pageNum: number = parseInt(div.dataset.pageNumber)
if (this.splitting) { // only select 1 page
if (pageNum == this.nPages || (this.enabledPages?.length && pageNum == Math.max(...this.enabledPages))) return
this.pages = [pageNum]
div.parentNode.childNodes.forEach(pageEl => {
(pageEl as Element).classList.toggle('selected', false)
})
div.classList.toggle('selected', true)
} else {
this.pages.indexOf(pageNum) !== -1 ? this.pages.splice(this.pages.indexOf(pageNum), 1) : this.pages.push(pageNum)
div.classList.toggle('selected', this.pages.indexOf(pageNum) !== -1)
}
}
}
}
@@ -4,11 +4,6 @@ form {
position: relative;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
.alerts-hidden {
.btn {
line-height: 1;
@@ -41,7 +41,7 @@
<button type="button" class="btn btn-sm btn-outline-primary mr-2" (click)="splitMerge()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#scissors" />
</svg>&nbsp;<span class="d-none d-lg-inline" i18n>Split & merge</span>
</svg>&nbsp;<span class="d-none d-lg-inline" i18n>Add to split/merge</span>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="close()">
@@ -56,6 +56,12 @@
</div>
</div>
<div class="col-auto ml-auto mb-2 mb-xl-0 d-flex">
<button type="button" class="btn btn-sm btn-outline-primary mr-2" (click)="addToSplitMerge()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#scissors" />
</svg>&nbsp;<span class="d-none d-lg-inline" i18n>Add to split/merge</span>
</button>
<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">
@@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, EventEmitter, Output } 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';
@@ -16,6 +16,8 @@ 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';
import { SplitMergeService } from 'src/app/services/split-merge.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-bulk-editor',
@@ -41,7 +43,9 @@ export class BulkEditorComponent {
private modalService: NgbModal,
private openDocumentService: OpenDocumentsService,
private settings: SettingsService,
private toastService: ToastService
private toastService: ToastService,
private splitMergeService: SplitMergeService,
private router: Router
) { }
applyOnClose: boolean = this.settings.get(SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE)
@@ -214,4 +218,9 @@ export class BulkEditorComponent {
saveAs(result, 'documents.zip');
})
}
addToSplitMerge() {
this.splitMergeService.addDocuments(this.list.selectedDocuments)
this.router.navigate(["split_merge"])
}
}
@@ -10,7 +10,7 @@
</div>
</div>
<div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1">
<div *ngIf="!simpleCard" style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1">
<div *ngFor="let t of getTagsLimited$() | async">
<app-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Filter by tag" i18n-linkTitle></app-tag>
</div>
@@ -22,13 +22,13 @@
<div class="card-body p-2">
<p class="card-text">
<ng-container *ngIf="document.correspondent">
<ng-container *ngIf="document.correspondent && !simpleCard">
<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}}
</p>
</div>
<div class="card-footer pt-0 pb-2 px-2">
<div *ngIf="!simpleCard" 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()">
@@ -17,12 +17,15 @@ export class DocumentCardSmallComponent implements OnInit {
@Input()
selected = false
@Output()
toggleSelected = new EventEmitter()
@Input()
document: PaperlessDocument
@Input()
simpleCard: boolean = false
@Output()
toggleSelected = new EventEmitter()
@Output()
clickTag = new EventEmitter<number>()
@@ -93,6 +96,6 @@ export class DocumentCardSmallComponent implements OnInit {
}
mouseLeaveCard() {
this.popover.close()
this.popover?.close()
}
}
@@ -1,8 +1,166 @@
<app-page-header i18n-title title="Split & merge documents">
<app-page-header i18n-title title="Split & Merge Editor">
<div class="btn-group mt-2 mt-sm-0">
<button class="btn btn-sm btn-outline-primary" i18n-title title="Add Documents" (click)="chooseDocuments()">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>&nbsp;<ng-container i18n>Add Documents</ng-container>
</button>
</div>
<div class="btn-group ml-2 mt-2 mt-sm-0">
<button type="button" class="btn btn-sm btn-outline-primary"
[popoverTitle]="optionsTitle" [autoClose]="'outside'" [ngbPopover]="optionsPopoverBody" popoverClass="shadow" #optionsPopover="ngbPopover">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#gear" />
</svg>&nbsp;<ng-container i18n>Options</ng-container>
</button>
<ng-template #optionsTitle>
<ng-container i18n>Options</ng-container>
<button type="button" class="close" aria-label="Close" (click)="optionsPopover.close()">
<span aria-hidden="true">&times;</span>
</button>
</ng-template>
<ng-template #optionsPopoverBody>
<div class="form-group row text-dark">
<label for="source_options" class="col-sm-3 col-form-label pt-0" i18n>Delete source documents</label>
<div class="col-sm-9">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="source_options-delete" [(ngModel)]="delete_source_documents">
<label class="custom-control-label" for="source_options-delete" [class.text-danger]="delete_source_documents"><ng-container i18n>Delete</ng-container><br/><small [class.text-muted]="!delete_source_documents" i18n>Source documents will be immediately deleted.</small></label>
</div>
</div>
</div>
<div class="form-group mt-4 row text-dark">
<label for="metadata_options" class="col-sm-3 col-form-label pt-0" i18n>Output document metadata</label>
<div class="col-sm-9">
<div class="custom-control custom-radio">
<input type="radio" id="metadata_setting-copy" name="metadata_setting" class="custom-control-input" [value]="splitMergeMetadata.COPY_FIRST" [(ngModel)]="metadata_setting">
<label class="custom-control-label" for="metadata_setting-copy"><ng-container i18n>First document</ng-container><br/><small class="text-muted" i18n>Metadata will be copied from first document.</small></label>
</div>
<div class="custom-control custom-radio">
<input type="radio" id="metadata_setting-redo" name="metadata_setting" class="custom-control-input" [value]="splitMergeMetadata.REDO" [(ngModel)]="metadata_setting">
<label class="custom-control-label" for="metadata_setting-redo"><ng-container i18n>Redo</ng-container><br/><small class="text-muted" i18n>Metadata detection will be re-run on new document.</small></label>
</div>
</div>
</div>
</ng-template>
</div>
<div class="btn-group ml-2 mr-2 mr-sm-5 mt-2 mt-sm-0">
<button class="btn btn-sm btn-outline-primary" i18n-title title="Save" (click)="save()" [disabled]="splitMergeService.getDocuments().length < 1">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#check-circle" />
</svg>&nbsp;<ng-container i18n>Save</ng-container>
</button>
</div>
<div class="btn-group ml-sm-5 mt-2 mt-sm-0">
<button class="btn btn-sm btn-outline-danger" i18n-title title="Cancel" (click)="cancel()">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#slash-circle" />
</svg>&nbsp;<ng-container i18n>Cancel</ng-container>
</button>
</div>
</app-page-header>
<p *ngFor="let d of documents">
{{d.title}}
</p>
<div class="row">
<div class="col-md-6 col-lg-5">
<div class="h-100" dndDropzone (dndDrop)="onDrop($event, documents)">
<div class="spacer bg-primary" dndPlaceholderRef>
</div>
<div *ngFor="let d of documents; let i = index" class="card w-100 mb-3" [class.separator-card]="d.is_separator" [dndDraggable]="d" dndEffectAllowed="move" (dndMoved)="onDragged(d, documents)">
<ng-container *ngIf="d.is_separator">
<div class="separator"></div>
<h6 class="text-muted small font-weight-bold d-inline-block py-2 px-3 mb-0 mx-auto bg-body" i18n>Document End</h6>
<button class="btn btn-sm btn-danger" title="Remove" (click)="removeDocument(d, i)">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>
</button>
</ng-container>
<div *ngIf="!d.is_separator" class="row no-gutters">
<div class="col-md-3 bg-light d-flex handle" dndHandle>
<svg width="2em" height="100%" viewBox="0 0 16 16" fill="currentColor" class="mx-auto">
<use xlink:href="assets/bootstrap-icons.svg#grip-horizontal" />
</svg>
<div class="doc-thumb" [style.background-image]="'url(' + getThumbUrl(d.id) + ')'"></div>
</div>
<div class="col-md-9">
<div class="card-body p-3">
<h6 class="card-title mb-2">{{d.title}}</h6>
<div class="card-actions">
<div class="btn-toolbar btn-toolbar-sm justify-content-between w-100">
<button class="btn btn-sm btn-outline-danger flex-fill" i18n-title title="Remove" (click)="removeDocument(d, i)">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg><span class="d-none d-xl-inline">&nbsp;<ng-container i18n>Remove</ng-container></span>
</button>
<button class="btn btn-sm btn-outline-primary ml-3 flex-fill" i18n-title title="Split" (click)="chooseSplit(d, i)">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#scissors" />
</svg><span class="d-none d-xl-inline">&nbsp;<ng-container i18n>Split</ng-container></span>
</button>
</div>
<div class="input-group input-group-sm w-100 mt-3">
<div class="input-group-prepend">
<div class="input-group-text"><ng-container i18n>Pages</ng-container>:</div>
</div>
<app-input-debounce delay="500" classes="form-control-sm" placeholder="All" ngbTooltip="e.g. 1-3,5" pattern="[0-9\,\-]" [inputValue]="formatPages(d.pages)" (value)="pagesFieldChange($event, d, i)"></app-input-debounce>
<div class="input-group-append">
<button class="btn btn-outline-primary" type="button" i18n-title title="Select Pages" (click)="choosePages(d, i)">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
</svg><span class="d-none d-xl-inline">&nbsp;<ng-container i18n>Select Pages</ng-container></span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-lg-7">
<ngb-alert *ngIf="error" type="danger" class="mt-2 mb-0" [dismissible]="false">
<h6 class="alert-heading" i18n>An error occurred during operation</h6>
<p class="mb-0 pb-1" i18n>Please check document settings and parts.</p>
<p class="mb-0 pb-1 small"><ng-container i18n>Message</ng-container>: <span class="font-italic">{{error}}</span></p>
</ngb-alert>
<ng-container *ngIf="!error && previewUrls.length > 1">
<ul ngbNav #previewNav="ngbNav" class="nav-tabs">
<li *ngFor="let previewUrl of previewUrls; let j = index" [ngbNavItem]="j">
<a class="bg-light" ngbNavLink><ng-container i18n>Document</ng-container> {{j + 1}}</a>
<ng-template ngbNavContent>
<div class="preview-container px-2 tabbed text-center bg-light">
<ng-container [ngTemplateOutlet]="previewHeader" [ngTemplateOutletContext]="{ $implicit: j }"></ng-container>
<pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true" [show-all]="true" [render-text-mode]="2" [(page)]="previewCurrentPages[j]" (after-load-complete)="pdfPreviewLoaded($event, j)" (page-rendered)="pdfPageRendered($event, j)"></pdf-viewer>
</div>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="previewNav"></div>
</ng-container>
<div *ngIf="!error && previewUrls.length <= 1" class="preview-container px-2 text-center bg-light">
<ng-container [ngTemplateOutlet]="previewHeader" [ngTemplateOutletContext]="{ $implicit: 0 }"></ng-container>
<pdf-viewer *ngIf="previewUrls.length > 0" [src]="previewUrls[0]" [original-size]="false" [show-borders]="true" [show-all]="true" [render-text-mode]="2" [(page)]="previewCurrentPages[0]" (after-load-complete)="pdfPreviewLoaded($event, 0)" (page-rendered)="pdfPageRendered($event, 0)"></pdf-viewer>
</div>
</div>
</div>
<button class="btn btn-primary" (click)="doIt()">Do it!</button>
<ng-template #previewHeader let-index>
<div class="d-flex mt-2 p-2 w-100 justify-space-between">
<h6 class="text-muted mt-1" [class.mx-auto]="previewNumPages[index] == undefined" [class.mr-auto]="previewNumPages[index] !== undefined" i18n>Preview</h6>
<div *ngIf="previewNumPages[index] !== undefined" class="input-group input-group-sm w-auto">
<div class="input-group-prepend">
<div class="input-group-text" i18n>Page</div>
</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages[index]" [(ngModel)]="previewCurrentPages[index]" />
<div class="input-group-append">
<div class="input-group-text" i18n>of {{previewNumPages[index]}}</div>
</div>
</div>
</div>
<div *ngIf="loading" class="spinner-border" role="status">
<span class="sr-only" i18n>Loading...</span>
</div>
</ng-template>
@@ -0,0 +1,116 @@
@import "/src/theme";
.card {
.handle {
cursor: move;
}
.doc-thumb {
background-position: top center;
background-size: cover;
background-repeat: no-repeat;
min-height: 5rem;
height: 100%;
width: 70%;
}
&.separator-card {
border: none;
background: transparent;
}
}
.spacer {
min-height: 0.25rem;
width: 100%;
margin: 1rem;
}
.preview-container {
height: calc(100vh - 170px);
overflow: hidden;
&.tabbed {
height: calc(100vh - 200px);
}
.spinner-border {
margin-top: 40%;
}
}
.split-document-preview ::ng-deep .ng2-pdf-viewer-container {
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
}
::ng-deep .document-selector {
width: 80vw;
min-width: 50vw;
}
button span {
margin-left: 0.15rem;
}
app-input-debounce {
flex: 1 1;
position: relative;
::ng-deep .form-control {
border-radius: 0;
}
}
.nav-link.bg-light {
border-bottom-color: #f8f9fa;
&:not(.active) {
background-color: #fff !important;
border-bottom-color: #dee2e6;
}
}
.separator-card {
.separator {
border: 0;
height: 20px;
opacity: 0.5;
background-color: transparent !important;
background-image: url("data:image/svg+xml;utf8, <svg viewBox='0 0 200 110' xmlns='http://www.w3.org/2000/svg'><path d='M -15 110 L100 10 L215 110' fill='none' stroke='%23000000' stroke-width='1' vector-effect='non-scaling-stroke'/></svg>");
background-position: bottom left;
background-size: 2% auto;
background-repeat: repeat-x;
}
h6 {
text-transform: uppercase;
text-align: center;
letter-spacing: 0.1em;
margin-top: -20px !important;
z-index: 10;
}
button {
transition: all 0.3s ease;
opacity: 0;
pointer-events: none;
position: absolute;
top: 0;
right: 0;
}
&:hover button {
opacity: 1;
pointer-events: all;
}
}
::ng-deep .popover {
min-width: 30rem;
.close {
line-height: 0.7;
}
}
@@ -1,32 +1,212 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { SplitMergeMetadata } from 'src/app/data/split-merge-request';
import { SplitMergeService } from 'src/app/services/split-merge.service';
import { DocumentService } from 'src/app/services/rest/document.service';
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
import { PaperlessDocument, PaperlessDocumentPart } from 'src/app/data/paperless-document';
import { DndDropEvent } from 'ngx-drag-drop';
import { Subject, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { DocumentChooserComponent } from 'src/app/components/common/document-chooser/document-chooser.component'
import { PageChooserComponent } from 'src/app/components/common/page-chooser/page-chooser.component'
import { PDFDocumentProxy } from 'ng2-pdf-viewer';
@Component({
selector: 'app-split-merge',
templateUrl: './split-merge.component.html',
styleUrls: ['./split-merge.component.scss']
})
export class SplitMergeComponent implements OnInit {
export class SplitMergeComponent implements OnInit, OnDestroy {
constructor(private splitMergeService: SplitMergeService, private router: Router) { }
public loading: boolean = false
ngOnInit(): void {
public previewUrls: string[] = []
public previewNumPages: number[] = []
public previewCurrentPages: number[] = []
public delete_source_documents: boolean = false
public metadata_setting: SplitMergeMetadata = SplitMergeMetadata.COPY_FIRST
private previewDebounce$ = new Subject()
private previewSub: Subscription
public error: string
constructor(
public splitMergeService: SplitMergeService,
private documentService: DocumentService,
private list: DocumentListViewService,
private router: Router,
private modalService: NgbModal
) { }
ngOnInit() {
this.previewSub = this.previewDebounce$.pipe(
debounceTime(400)
).subscribe(() => {
if (this.splitMergeService.hasDocuments()) {
this.save(true)
} else {
this.previewUrls = []
this.error = undefined
}
})
this.previewDebounce$.next()
}
get documents() {
ngOnDestroy() {
this.previewDebounce$.complete()
}
get documents(): PaperlessDocument[] {
return this.splitMergeService.getDocuments()
}
doIt() {
this.splitMergeService.executeSplitMerge(false, false, SplitMergeMetadata.COPY_FIRST).subscribe(
getThumbUrl(documentId: number) {
return this.documentService.getThumbUrl(documentId)
}
get splitMergeMetadata() {
return SplitMergeMetadata
}
chooseDocuments() {
let modal = this.modalService.open(DocumentChooserComponent, { backdrop: 'static', size: 'xl' })
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.splitMergeService.addDocuments(this.list.selectedDocuments)
this.list.selectNone()
modal.close()
this.previewDebounce$.next()
})
}
onDragged(document: PaperlessDocument, documents: PaperlessDocument[]) {
const index = this.documents.indexOf(document)
this.documents.splice(index, 1)
}
onDrop(event: DndDropEvent, documents: PaperlessDocument[]) {
let index = event.index
if (typeof index === "undefined") {
index = this.documents.length
}
this.documents.splice(index, 0, event.data)
this.previewDebounce$.next()
}
cancel() {
this.splitMergeService.clear()
this.router.navigate([""])
}
save(preview: boolean = false) {
this.loading = true
this.previewUrls = []
this.splitMergeService.executeSplitMerge(preview, this.delete_source_documents, this.metadata_setting).subscribe(
result => {
console.log(result)
this.splitMergeService.clear()
this.router.navigate([""])
this.loading = false
this.error = undefined
if (preview) {
this.previewUrls = result.map(r => this.splitMergeService.getPreviewUrl(r))
} else {
this.splitMergeService.clear()
this.router.navigate([""])
}
},
error => {
this.error = error
this.loading = false
}
)
}
removeDocument(d: PaperlessDocument, index: number) {
this.splitMergeService.removeDocument(d, index)
this.previewNumPages[index] = this.previewCurrentPages[index] = undefined
this.previewDebounce$.next()
}
choosePages(d: PaperlessDocument, index: number) {
let modal = this.modalService.open(PageChooserComponent, { backdrop: 'static', size: 'lg' })
modal.componentInstance.document = d
modal.componentInstance.confirmPages.subscribe((pages) => {
this.splitMergeService.setDocumentPages(d, index, pages)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.previewDebounce$.next()
})
}
chooseSplit(d: PaperlessDocument, index: number) {
let modal = this.modalService.open(PageChooserComponent, { backdrop: 'static', size: 'lg' })
modal.componentInstance.document = d
const enabledPages = (d as PaperlessDocumentPart).pages
modal.componentInstance.splitting = true
modal.componentInstance.confirmPages.subscribe((pages) => {
this.splitMergeService.splitDocument(d, index, pages, enabledPages)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.previewDebounce$.next()
})
}
pagesFieldChange(pageStr: string, d: PaperlessDocument, index: number) {
let pages = pageStr.split(',').map(p => {
if (p.indexOf('-') !== -1) {
const minmax = p.split('-')
let range = []
for (let i = parseInt(minmax[0]); i <= parseInt(minmax[1]); i++) {
range.push(i)
}
return range
} else if (p.length > 0) {
return parseInt(p)
} else {
return null
}
})
pages = [].concat.apply([], pages) // e.g. flat()
pages = pages.filter(page => page !== null)
this.splitMergeService.setDocumentPages(d, index, pages as number[])
this.previewDebounce$.next()
}
formatPages(pages: number[]): string {
let pageStrings = []
let rangeStart, rangeEnd
pages?.forEach(page => {
if (rangeStart == undefined) {
rangeStart = page
} else if (page - rangeStart == 1) {
rangeEnd = page
} else if (rangeEnd !== undefined && page - rangeEnd == 1) {
rangeEnd = page
} else {
pageStrings.push(rangeEnd !== undefined ? rangeStart + '-' + rangeEnd : rangeStart)
rangeStart = page
rangeEnd = undefined
}
})
if (rangeEnd !== undefined) {
pageStrings.push(rangeStart + '-' + rangeEnd)
} else if (rangeStart !== undefined) {
pageStrings.push(rangeStart)
}
return pageStrings.join(',')
}
pdfPreviewLoaded(pdf: PDFDocumentProxy, index: number) {
this.previewNumPages[index] = pdf.numPages
}
pdfPageRendered(event: any, index) { // CustomEvent is es6
if (event.pageNumber > 0 && this.previewCurrentPages[index] == undefined) this.previewCurrentPages[index] = 1
}
}
@@ -41,3 +41,11 @@ export interface PaperlessDocument extends ObjectWithId {
archive_serial_number?: number
}
export interface PaperlessDocumentPart extends PaperlessDocument {
pages: number[]
}
export interface PaperlessDocumentSeparator extends PaperlessDocument {
is_separator: boolean
}
@@ -181,6 +181,10 @@ export class DocumentListViewService {
return this.activeListViewState.selected
}
get selectedDocuments(): PaperlessDocument[] {
return this.activeListViewState.documents.filter(d => this.activeListViewState.selected.has(d.id))
}
setSort(field: string, reverse: boolean) {
this.activeListViewState.sortField = field
this.activeListViewState.sortReverse = reverse
+66 -16
View File
@@ -1,8 +1,9 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { PaperlessDocument } from '../data/paperless-document';
import { PaperlessDocument, PaperlessDocumentPart, PaperlessDocumentSeparator } from '../data/paperless-document';
import { SplitMergeMetadata, SplitMergeRequest } from '../data/split-merge-request';
@Injectable({
@@ -15,35 +16,84 @@ export class SplitMergeService {
constructor(private http: HttpClient) { }
// just a demo
addDocument(document: PaperlessDocument) {
this.documents.push(document)
addDocument(document: PaperlessDocument, atIndex?: number) {
if (atIndex !== undefined) this.documents.splice(atIndex, 0, document)
else this.documents.push(document)
}
addDocuments(documents: PaperlessDocument[]) {
this.documents = this.documents.concat(documents)
}
removeDocument(document: PaperlessDocument, atIndex?: number) {
if (!atIndex) atIndex = this.documents.indexOf(document)
this.documents.splice(atIndex, 1)
this.sanitizeStartEndSeparators()
}
getDocuments() {
return this.documents
}
// just a demo
hasDocuments(): boolean {
return this.documents.length > 0
}
// just a demo
getDocuments() {
return this.documents
}
clear() {
this.documents = []
}
setDocumentPages(d: PaperlessDocument, index: number, pages: number[]) {
(this.documents[index] as PaperlessDocumentPart).pages = pages.length > 0 ? pages : null
}
splitDocument(d: PaperlessDocument, index: number, secondPages: number[], enabledPages?: number[]) {
const firstPages = []
for (let page = 1; page < secondPages[0]; page++) {
if (!enabledPages || (enabledPages?.length && enabledPages.indexOf(page) !== -1)) firstPages.push(page)
}
(this.documents[index] as PaperlessDocumentPart).pages = firstPages
this.documents.splice(index + 1, 0, { is_separator: true } as PaperlessDocumentSeparator)
let d2 = { ...d } as PaperlessDocumentPart
d2.pages = secondPages
this.documents.splice(index + 2, 0, d2)
}
sanitizeStartEndSeparators() {
if (this.documents.length > 0 && (this.documents[this.documents.length - 1] as PaperlessDocumentSeparator).is_separator) {
this.documents.pop()
} else if (this.documents.length > 0 && (this.documents[0] as PaperlessDocumentSeparator).is_separator) {
this.documents.shift()
}
}
executeSplitMerge(preview: boolean, delete_source: boolean, metadata: SplitMergeMetadata): Observable<string[]> {
this.sanitizeStartEndSeparators()
let currentDocument = []
let split_merge_plan = [currentDocument]
this.documents.forEach(d => {
if ((d as PaperlessDocumentSeparator).is_separator) {
currentDocument = []
split_merge_plan.push(currentDocument)
} else {
currentDocument.push({document: d.id, pages: (d as PaperlessDocumentPart).pages?.join(',')})
}
})
let request: SplitMergeRequest = {
delete_source: delete_source,
preview: preview,
metadata: metadata,
split_merge_plan: [
this.documents.map(d => { return {document: d.id} })
]
split_merge_plan: split_merge_plan
}
return this.http.post<string[]>(`${environment.apiBaseUrl}split_merge/`, request)
return this.http.post<string[]>(`${environment.apiBaseUrl}split_merge/`, request).pipe(
catchError(this.handleError)
)
}
handleError(error: HttpErrorResponse) {
console.log('API split_merge result error:', error)
return throwError(error.message)
}
getPreviewUrl(previewKey: string) {
+10 -1
View File
@@ -4,4 +4,13 @@ $primaryFaded: #d1ddd2;
$theme-colors: (
"primary": $primary
);
);
.bg-body {
background-color: #fff;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
+25
View File
@@ -37,6 +37,10 @@ $border-color-dark-mode: #47494f;
}
}
.bg-body {
background-color: $bg-dark-mode !important;
}
.text-light {
color: $text-color-dark-mode !important;
}
@@ -90,6 +94,23 @@ $border-color-dark-mode: #47494f;
color: $text-color-dark-mode !important;
}
}
.nav-link.bg-light {
border-bottom-color: $border-color-dark-mode !important;
&:not(.active) {
background-color: $bg-dark-mode !important;
&:hover {
background-color: $bg-dark-mode !important;
}
}
&.active {
background-color: $bg-light-dark-mode !important;
border-bottom-color: $bg-light-dark-mode !important;
}
}
}
.dropdown-menu {
@@ -420,6 +441,10 @@ $border-color-dark-mode: #47494f;
.ngb-dp-month {
background-color: $bg-light-dark-mode;
}
app-split-merge .separator {
filter: invert(1);
}
}
body.color-scheme-dark {