Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ba139810 | ||
|
|
27ff38b79e | ||
|
|
d521d7733b | ||
|
|
99bde7b613 | ||
|
|
cc5b7eb4e3 | ||
|
|
2b4a0efef8 | ||
|
|
9502917df6 | ||
|
|
6f3c72ef9a | ||
|
|
3915149ea1 | ||
|
|
f07fb6e1b3 | ||
|
|
e328c439ff | ||
|
|
caa9a340a4 | ||
|
|
b07537aa9a | ||
|
|
8e35e6c726 | ||
|
|
05a43a05a4 | ||
|
|
846a997323 | ||
|
|
e0231a4638 | ||
|
|
b4db388d58 | ||
|
|
e957d9aa53 | ||
|
|
72031824bf | ||
|
|
ae60fc8c2b | ||
|
|
cc3c7ad481 | ||
|
|
484edce5ab | ||
|
|
66aee1446c | ||
|
|
971392345b | ||
|
|
c5ae3377bd | ||
|
|
4ddd031976 | ||
|
|
c18180f344 | ||
|
|
c07bbcb33f | ||
|
|
142c2ac9ff | ||
|
|
88bf679fd9 | ||
|
|
a5de83fe7f | ||
|
|
63b4c33fb9 | ||
|
|
e0a1ba56dd | ||
|
|
aaa913f204 | ||
|
|
cf5747f0bd | ||
|
|
7df9e1f532 | ||
|
|
38d8b34ab4 | ||
|
|
24d07fcc17 | ||
|
|
14116c85e7 | ||
|
|
20b95fb506 | ||
|
|
3c25595fc2 | ||
|
|
af1034f84b | ||
|
|
cfc0925e04 | ||
|
|
746c03c198 | ||
|
|
baae3c2701 | ||
|
|
d88cd05857 | ||
|
|
73c80c3f7a | ||
|
|
35f9989255 | ||
|
|
33ddd033f2 | ||
|
|
b8fabb623e | ||
|
|
37c0fa4fab | ||
|
|
86ca37909b | ||
|
|
ce19525758 | ||
|
|
bb872ab56f | ||
|
|
ea342831da | ||
|
|
8d0ac690fb | ||
|
|
e0f14941e8 | ||
|
|
7cbb50565c | ||
|
|
274df28f39 | ||
|
|
4a6160869c | ||
|
|
87d969f44c | ||
|
|
7746ab4522 | ||
|
|
58cb42b44a | ||
|
|
8e330fc97a | ||
|
|
6a3ad77950 | ||
|
|
3d56621253 | ||
|
|
9e6cb1b868 | ||
|
|
321fad0564 | ||
|
|
eb7b767e35 | ||
|
|
98b26b8e16 | ||
|
|
176d1fa2eb | ||
|
|
072134366c | ||
|
|
afd92af168 | ||
|
|
2d61698509 | ||
|
|
5e286798d5 | ||
|
|
b7a825301a | ||
|
|
02da012603 | ||
|
|
4d99e72a0e | ||
|
|
26c0568b1e | ||
|
|
aa4d9782b7 | ||
|
|
43ccf6682c | ||
|
|
5e096bc8ce | ||
|
|
1e131f0217 | ||
|
|
321869edb0 | ||
|
|
c6e8faa718 | ||
|
|
6696c4d7a5 | ||
|
|
88254bdb79 | ||
|
|
4cc6bf889e |
No files matched your search
Generated
+15
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,12 +11,14 @@ import { SettingsComponent } from './components/manage/settings/settings.compone
|
||||
import { TagListComponent } from './components/manage/tag-list/tag-list.component';
|
||||
import { NotFoundComponent } from './components/not-found/not-found.component';
|
||||
import {DocumentAsnComponent} from "./components/document-asn/document-asn.component";
|
||||
import { SplitMergeComponent } from './components/split-merge/split-merge.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{path: '', redirectTo: 'dashboard', pathMatch: 'full'},
|
||||
{path: '', component: AppFrameComponent, children: [
|
||||
{path: 'dashboard', component: DashboardComponent },
|
||||
{path: 'documents', component: DocumentListComponent },
|
||||
{path: 'split_merge', component: SplitMergeComponent },
|
||||
{path: 'view/:id', component: DocumentListComponent },
|
||||
{path: 'documents/:id', component: DocumentDetailComponent },
|
||||
{path: 'asn/:id', component: DocumentAsnComponent },
|
||||
|
||||
@@ -33,6 +33,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';
|
||||
@@ -64,6 +65,10 @@ 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 { 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';
|
||||
@@ -119,6 +124,7 @@ registerLocaleData(localeSv)
|
||||
DocumentCardSmallComponent,
|
||||
BulkEditorComponent,
|
||||
TextComponent,
|
||||
InputDebounceComponent,
|
||||
SelectComponent,
|
||||
CheckComponent,
|
||||
SaveViewConfigDialogComponent,
|
||||
@@ -140,7 +146,10 @@ registerLocaleData(localeSv)
|
||||
CustomDatePipe,
|
||||
DateComponent,
|
||||
ColorComponent,
|
||||
DocumentAsnComponent
|
||||
DocumentAsnComponent,
|
||||
SplitMergeComponent,
|
||||
DocumentChooserComponent,
|
||||
PageChooserComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
@@ -153,7 +162,8 @@ registerLocaleData(localeSv)
|
||||
InfiniteScrollModule,
|
||||
PdfViewerModule,
|
||||
NgSelectModule,
|
||||
ColorSliderModule
|
||||
ColorSliderModule,
|
||||
DndModule
|
||||
],
|
||||
providers: [
|
||||
DatePipe,
|
||||
|
||||
@@ -68,6 +68,13 @@
|
||||
</svg> <ng-container i18n>Documents</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *ngIf="splitMergeToolVisible">
|
||||
<a class="nav-link" routerLink="split_merge" routerLinkActive="active" (click)="closeMenu()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#scissors"/>
|
||||
</svg> <ng-container i18n>Split & merge</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.sidebarViews.length > 0'>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DocumentDetailComponent } from '../document-detail/document-detail.comp
|
||||
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';
|
||||
import { SplitMergeService } from 'src/app/services/split-merge.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-app-frame',
|
||||
@@ -27,7 +28,8 @@ export class AppFrameComponent implements OnInit {
|
||||
private searchService: SearchService,
|
||||
public savedViewService: SavedViewService,
|
||||
private list: DocumentListViewService,
|
||||
private meta: Meta
|
||||
private meta: Meta,
|
||||
private splitMergeService: SplitMergeService
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -106,6 +108,10 @@ export class AppFrameComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
get splitMergeToolVisible() {
|
||||
return this.splitMergeService.hasDocuments()
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
|
||||
@@ -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">×</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">×</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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-5
@@ -4,11 +4,6 @@ form {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alert-heading {
|
||||
font-size: 80%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alerts-hidden {
|
||||
.btn {
|
||||
line-height: 1;
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
</svg> <span class="d-none d-lg-inline" i18n>More like this</span>
|
||||
</button>
|
||||
|
||||
<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> <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()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x" />
|
||||
|
||||
@@ -21,6 +21,7 @@ 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';
|
||||
import { SplitMergeService } from 'src/app/services/split-merge.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-document-detail',
|
||||
@@ -76,7 +77,8 @@ export class DocumentDetailComponent implements OnInit {
|
||||
private documentListViewService: DocumentListViewService,
|
||||
private documentTitlePipe: DocumentTitlePipe,
|
||||
private toastService: ToastService,
|
||||
private settings: SettingsService) { }
|
||||
private settings: SettingsService,
|
||||
private splitMergeService: SplitMergeService) { }
|
||||
|
||||
get useNativePdfViewer(): boolean {
|
||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
||||
@@ -225,6 +227,11 @@ export class DocumentDetailComponent implements OnInit {
|
||||
this.documentListViewService.quickFilter([{rule_type: FILTER_FULLTEXT_MORELIKE, value: this.documentId.toString()}])
|
||||
}
|
||||
|
||||
splitMerge() {
|
||||
this.splitMergeService.addDocument(this.document)
|
||||
this.router.navigate(["split_merge"])
|
||||
}
|
||||
|
||||
hasNext() {
|
||||
return this.documentListViewService.hasNext(this.documentId)
|
||||
}
|
||||
|
||||
@@ -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> <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"])
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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()">
|
||||
|
||||
+7
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<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> <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> <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">×</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> <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> <ng-container i18n>Cancel</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</app-page-header>
|
||||
|
||||
<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"> <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"> <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"> <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>
|
||||
|
||||
<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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SplitMergeComponent } from './split-merge.component';
|
||||
|
||||
describe('SplitMergeComponent', () => {
|
||||
let component: SplitMergeComponent;
|
||||
let fixture: ComponentFixture<SplitMergeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ SplitMergeComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SplitMergeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
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, OnDestroy {
|
||||
|
||||
public loading: boolean = false
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.previewDebounce$.complete()
|
||||
}
|
||||
|
||||
get documents(): PaperlessDocument[] {
|
||||
return this.splitMergeService.getDocuments()
|
||||
}
|
||||
|
||||
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 => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -52,3 +52,11 @@ export interface PaperlessDocument extends ObjectWithId {
|
||||
__search_hit__?: SearchHit
|
||||
|
||||
}
|
||||
|
||||
export interface PaperlessDocumentPart extends PaperlessDocument {
|
||||
pages: number[]
|
||||
}
|
||||
|
||||
export interface PaperlessDocumentSeparator extends PaperlessDocument {
|
||||
is_separator: boolean
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export enum SplitMergeMetadata {
|
||||
|
||||
REDO = "redo",
|
||||
|
||||
COPY_FIRST = "copy_first"
|
||||
|
||||
}
|
||||
|
||||
export interface SplitMergePlanDocumentPart {
|
||||
|
||||
document: number,
|
||||
|
||||
pages?: string
|
||||
|
||||
}
|
||||
|
||||
export type SplitMergePlan = SplitMergePlanDocumentPart[][]
|
||||
|
||||
export interface SplitMergeRequest {
|
||||
|
||||
split_merge_plan: SplitMergePlan
|
||||
|
||||
delete_source: boolean
|
||||
|
||||
metadata: SplitMergeMetadata
|
||||
|
||||
preview: boolean
|
||||
|
||||
}
|
||||
@@ -189,6 +189,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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SplitMergeService } from './split-merge.service';
|
||||
|
||||
describe('SplitMergeService', () => {
|
||||
let service: SplitMergeService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(SplitMergeService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { PaperlessDocument, PaperlessDocumentPart, PaperlessDocumentSeparator } from '../data/paperless-document';
|
||||
import { SplitMergeMetadata, SplitMergeRequest } from '../data/split-merge-request';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SplitMergeService {
|
||||
|
||||
// this also needs to incorporate pages, if we want to support that.
|
||||
private documents: PaperlessDocument[] = []
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
hasDocuments(): boolean {
|
||||
return this.documents.length > 0
|
||||
}
|
||||
|
||||
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: split_merge_plan
|
||||
}
|
||||
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) {
|
||||
return `${environment.apiBaseUrl}split_merge/${previewKey}/`
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,3 +9,8 @@ $theme-colors: (
|
||||
.bg-body {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.alert-heading {
|
||||
font-size: 80%;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -94,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 {
|
||||
@@ -424,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 {
|
||||
|
||||
@@ -74,6 +74,7 @@ class Consumer(LoggingMixin):
|
||||
self.path = None
|
||||
self.filename = None
|
||||
self.override_title = None
|
||||
self.override_date = None
|
||||
self.override_correspondent_id = None
|
||||
self.override_tag_ids = None
|
||||
self.override_document_type_id = None
|
||||
@@ -167,6 +168,7 @@ class Consumer(LoggingMixin):
|
||||
path,
|
||||
override_filename=None,
|
||||
override_title=None,
|
||||
override_date=None,
|
||||
override_correspondent_id=None,
|
||||
override_document_type_id=None,
|
||||
override_tag_ids=None,
|
||||
@@ -178,6 +180,7 @@ class Consumer(LoggingMixin):
|
||||
self.path = path
|
||||
self.filename = override_filename or os.path.basename(path)
|
||||
self.override_title = override_title
|
||||
self.override_date = override_date
|
||||
self.override_correspondent_id = override_correspondent_id
|
||||
self.override_document_type_id = override_document_type_id
|
||||
self.override_tag_ids = override_tag_ids
|
||||
@@ -399,6 +402,9 @@ class Consumer(LoggingMixin):
|
||||
for tag_id in self.override_tag_ids:
|
||||
document.tags.add(Tag.objects.get(pk=tag_id))
|
||||
|
||||
if self.override_date:
|
||||
document.created = self.override_date
|
||||
|
||||
def _write(self, storage_type, source, target):
|
||||
with open(source, "rb") as read_file:
|
||||
with open(target, "wb") as write_file:
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from django_q.tasks import async_task
|
||||
|
||||
from documents import tasks
|
||||
from documents.consumer import ConsumerError
|
||||
from documents.models import Document
|
||||
|
||||
from pikepdf import Pdf
|
||||
|
||||
|
||||
logger = logging.getLogger("paperless.merge")
|
||||
|
||||
|
||||
class MergeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PdfCache:
|
||||
|
||||
def __init__(self):
|
||||
self.cache = dict()
|
||||
|
||||
def open_from_document(self, document: Document):
|
||||
if document.pk in self.cache:
|
||||
return self.cache[document.pk]
|
||||
|
||||
if document.mime_type == 'application/pdf':
|
||||
filename = document.source_path
|
||||
elif document.has_archive_version:
|
||||
filename = document.archive_path
|
||||
else:
|
||||
raise MergeError(f"Document {document.pk} does not have a PDF.")
|
||||
|
||||
if not os.path.exists(filename):
|
||||
raise MergeError(f"{filename} does not exist.")
|
||||
|
||||
pdf = Pdf.open(filename)
|
||||
self.cache[document.pk] = pdf
|
||||
|
||||
return pdf
|
||||
|
||||
def close_all(self):
|
||||
for pk in self.cache:
|
||||
self.cache[pk].close()
|
||||
|
||||
self.cache.clear()
|
||||
|
||||
|
||||
def parse_page_list(page_list: str):
|
||||
|
||||
if not page_list:
|
||||
return []
|
||||
|
||||
result = []
|
||||
|
||||
re_simple = re.compile(r"^\d+$")
|
||||
re_range = re.compile(r"^(\d+)-(\d+)$")
|
||||
|
||||
for page_list_part in page_list.split(","):
|
||||
match_simple = re_simple.match(page_list_part)
|
||||
match_range = re_range.match(page_list_part)
|
||||
if match_simple:
|
||||
result.append(int(page_list_part))
|
||||
elif match_range:
|
||||
first = int(match_range.group(1))
|
||||
last = int(match_range.group(2))
|
||||
if first <= last:
|
||||
result.extend(range(first, last+1))
|
||||
else:
|
||||
result.extend(reversed(range(last, first+1)))
|
||||
else:
|
||||
raise MergeError(f"Invalid page range: {page_list}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def consume_many_files(kwargs_list, delete_document_ids=None):
|
||||
new_document_ids = []
|
||||
|
||||
try:
|
||||
for kwargs in kwargs_list:
|
||||
new_document_ids.append(tasks.consume_file(**kwargs))
|
||||
|
||||
except ConsumerError:
|
||||
# in case something goes wrong, delete all previously created documents
|
||||
for document_id in new_document_ids:
|
||||
Document.objects.get(id=document_id).delete()
|
||||
raise
|
||||
else:
|
||||
# If everything goes well, optionally delete source documents
|
||||
if delete_document_ids:
|
||||
for document_id in delete_document_ids:
|
||||
Document.objects.get(id=document_id).delete()
|
||||
|
||||
|
||||
def copy_pdf_metadata(source: Pdf, target: Pdf):
|
||||
with source.open_metadata() as source_meta:
|
||||
with target.open_metadata() as target_meta:
|
||||
for k in source_meta:
|
||||
try:
|
||||
target_meta[k] = source_meta[k]
|
||||
except TypeError:
|
||||
# TODO: https://github.com/pikepdf/pikepdf/issues/188
|
||||
logger.warning(f"Could not copy metadata {k} while "
|
||||
f"merging documents", exc_info=True)
|
||||
|
||||
|
||||
def copy_document_metadata(document: Document, consume_task):
|
||||
if document.correspondent:
|
||||
consume_task["override_correspondent_id"] = document.correspondent.id
|
||||
if document.document_type:
|
||||
consume_task["override_document_type_id"] = document.document_type.id
|
||||
if document.tags.count() > 0:
|
||||
consume_task["override_tag_ids"] = [
|
||||
tag.id for tag in document.tags.all()
|
||||
]
|
||||
|
||||
consume_task['override_date'] = document.created
|
||||
|
||||
|
||||
def execute_split_merge_plan(plan,
|
||||
tempdir: str,
|
||||
metadata: str = "redo",
|
||||
delete_source: bool = False,
|
||||
preview: bool = True):
|
||||
|
||||
consume_tasks = []
|
||||
cache = PdfCache()
|
||||
source_documents = set()
|
||||
|
||||
try:
|
||||
for target_doc_spec in plan:
|
||||
# create a new document from documents in target_doc_spec
|
||||
|
||||
target_pdf: Pdf = None
|
||||
try:
|
||||
target_pdf = Pdf.new()
|
||||
target_pdf_filename = tempfile.NamedTemporaryFile(
|
||||
prefix="merge_", suffix="_pdf", dir=tempdir).name
|
||||
version = target_pdf.pdf_version
|
||||
consume_task = {"path": target_pdf_filename}
|
||||
|
||||
for (i, source_doc_spec) in enumerate(target_doc_spec):
|
||||
source_document_id = source_doc_spec['document']
|
||||
source_documents.add(source_document_id)
|
||||
|
||||
if 'pages' in source_doc_spec:
|
||||
pages = parse_page_list(source_doc_spec['pages'])
|
||||
else:
|
||||
pages = None
|
||||
|
||||
try:
|
||||
source_document: Document = Document.objects.get(
|
||||
id=source_document_id)
|
||||
except Document.DoesNotExist:
|
||||
raise MergeError(
|
||||
f"Document {source_document_id} does not exist.")
|
||||
|
||||
source_pdf: Pdf = cache.open_from_document(source_document)
|
||||
version = max(version, source_pdf.pdf_version)
|
||||
|
||||
if i == 0:
|
||||
# first source document for this target
|
||||
consume_task["override_title"] = source_document.title
|
||||
copy_pdf_metadata(source_pdf, target_pdf)
|
||||
if metadata == "copy_first":
|
||||
copy_document_metadata(
|
||||
source_document, consume_task)
|
||||
|
||||
if pages is not None:
|
||||
for page in pages:
|
||||
if page > len(source_pdf.pages) or page < 1:
|
||||
raise MergeError(
|
||||
f"Page {page} is out of range.")
|
||||
target_pdf.pages.append(source_pdf.pages[page - 1])
|
||||
else:
|
||||
target_pdf.pages.extend(source_pdf.pages)
|
||||
|
||||
target_pdf.remove_unreferenced_resources()
|
||||
target_pdf.save(target_pdf_filename, min_version=version)
|
||||
target_pdf.close()
|
||||
|
||||
consume_tasks.append(consume_task)
|
||||
finally:
|
||||
if target_pdf is not None:
|
||||
target_pdf.close()
|
||||
finally:
|
||||
cache.close_all()
|
||||
|
||||
if not preview:
|
||||
async_task(
|
||||
"documents.merge.consume_many_files",
|
||||
kwargs_list=consume_tasks,
|
||||
delete_document_ids=list(source_documents) if delete_source else None # NOQA: E501
|
||||
)
|
||||
|
||||
return [t["path"] for t in consume_tasks]
|
||||
@@ -494,3 +494,94 @@ class BulkDownloadSerializer(DocumentListSerializer):
|
||||
"bzip2": zipfile.ZIP_BZIP2,
|
||||
"lzma": zipfile.ZIP_LZMA
|
||||
}[compression]
|
||||
|
||||
|
||||
class DocumentSplitMergePlanSerializer(serializers.Serializer):
|
||||
"""
|
||||
A split/merge plan describes how a set of documents should be transformed
|
||||
into a new set of documents.
|
||||
|
||||
split_merge_plan is a list. The split and merge tool will create a new
|
||||
document for each element in that list. Each item in split_merge_plan
|
||||
is by itself a list that lists document IDs and pages to take from this
|
||||
document.
|
||||
|
||||
Example:
|
||||
|
||||
split_merge_plan = [
|
||||
[
|
||||
{
|
||||
"document": 1,
|
||||
"pages": "1-3"
|
||||
},
|
||||
{
|
||||
"document": 2,
|
||||
},
|
||||
{
|
||||
"document": 1,
|
||||
"pages": "4"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"document": 1,
|
||||
"pages": "6,5"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
This will split document 1 into two documents. The first document contains
|
||||
pages 1 to 4, the second document contains pages 5 and 6 in reverse.
|
||||
Additionally, all pages from document 2 are inserted into the first
|
||||
document between pages 3 and 4.
|
||||
|
||||
If delete_source is true, the split and merge tool will delete the source
|
||||
files in the plan after all resulting documents have been added
|
||||
successfully to paperless.
|
||||
|
||||
metadata selects how to assign new metadata to the created documents.
|
||||
With 'redo', paperless will run the matching algorithms on the resulting
|
||||
document again. With 'copy_first', each document created by the merge plan
|
||||
will keep the metadata of the first document used to create that new
|
||||
document.
|
||||
|
||||
If preview is true, the split and merge tool will execute the split and
|
||||
merge plan without modifying any data in paperless. The previews are
|
||||
temporarily made available for download. If preview is true,
|
||||
delete_source and metadata is ignored.
|
||||
|
||||
"""
|
||||
|
||||
split_merge_plan = serializers.ListField(
|
||||
required=True,
|
||||
min_length=1,
|
||||
child=serializers.ListField(
|
||||
required=True,
|
||||
min_length=1,
|
||||
child=serializers.DictField()
|
||||
)
|
||||
)
|
||||
|
||||
delete_source = serializers.BooleanField(default=False)
|
||||
|
||||
metadata = serializers.ChoiceField(
|
||||
choices=("redo", "copy_first"),
|
||||
default="copy_first"
|
||||
)
|
||||
|
||||
preview = serializers.BooleanField(default=True)
|
||||
|
||||
def validate_split_merge_plan(self, split_merge_plan):
|
||||
for target_doc in split_merge_plan:
|
||||
for source_doc in target_doc:
|
||||
if "document" not in source_doc:
|
||||
raise serializers.ValidationError(
|
||||
'"document" is required'
|
||||
)
|
||||
try:
|
||||
Document.objects.get(id=source_doc["document"])
|
||||
except Document.DoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
f"document {source_doc['document']} does not exist"
|
||||
)
|
||||
return split_merge_plan
|
||||
@@ -66,6 +66,7 @@ def train_classifier():
|
||||
def consume_file(path,
|
||||
override_filename=None,
|
||||
override_title=None,
|
||||
override_date=None,
|
||||
override_correspondent_id=None,
|
||||
override_document_type_id=None,
|
||||
override_tag_ids=None,
|
||||
@@ -75,6 +76,7 @@ def consume_file(path,
|
||||
path,
|
||||
override_filename=override_filename,
|
||||
override_title=override_title,
|
||||
override_date=override_date,
|
||||
override_correspondent_id=override_correspondent_id,
|
||||
override_document_type_id=override_document_type_id,
|
||||
override_tag_ids=override_tag_ids,
|
||||
@@ -82,9 +84,7 @@ def consume_file(path,
|
||||
)
|
||||
|
||||
if document:
|
||||
return "Success. New document id {} created".format(
|
||||
document.pk
|
||||
)
|
||||
return document.pk
|
||||
else:
|
||||
raise ConsumerError("Unknown error: Returned document was null, but "
|
||||
"no error message was given.")
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from pikepdf import Pdf
|
||||
from rest_framework.test import APITestCase
|
||||
from whoosh.writing import AsyncWriter
|
||||
|
||||
@@ -1372,6 +1373,75 @@ class TestBulkDownload(DirectoriesMixin, APITestCase):
|
||||
"compression": "lzma"
|
||||
}), content_type='application/json')
|
||||
|
||||
|
||||
class TestSplitMerge(DirectoriesMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestSplitMerge, self).setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
self.client.force_login(user=user)
|
||||
|
||||
self.doc1 = Document.objects.create(title="document A", filename="docA.pdf", mime_type="application/pdf", checksum="A")
|
||||
self.doc2 = Document.objects.create(title="document B", filename="docB.jpg", mime_type="image/jpeg", checksum="B", archive_filename="docB.pdf", archive_checksum="D")
|
||||
self.doc3 = Document.objects.create(title="document C", filename="docC.jpg", mime_type="image/jpeg", checksum="C")
|
||||
|
||||
shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), self.doc1.source_path)
|
||||
shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "simple.jpg"), self.doc2.source_path)
|
||||
shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), self.doc2.archive_path)
|
||||
shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "simple.jpg"), self.doc3.source_path)
|
||||
|
||||
@mock.patch("documents.merge.async_task")
|
||||
def test_merge(self, m):
|
||||
result = self.client.post("/api/split_merge/", json.dumps({
|
||||
"split_merge_plan": [
|
||||
[{"document": self.doc1.pk}, {"document": self.doc2.pk}]
|
||||
],
|
||||
"preview": False
|
||||
}), content_type="application/json")
|
||||
self.assertEqual(result.status_code, 200)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertEqual(kwargs["delete_document_ids"], None)
|
||||
|
||||
self.assertEqual(len(result.data), 1)
|
||||
preview_key = result.data[0]
|
||||
|
||||
preview_response = self.client.get(f"/api/split_merge/{preview_key}/")
|
||||
self.assertEqual(200, preview_response.status_code)
|
||||
|
||||
pdf: Pdf = Pdf.open(io.BytesIO(preview_response.content))
|
||||
|
||||
self.assertEqual(2, len(pdf.pages))
|
||||
|
||||
pdf.close()
|
||||
|
||||
@mock.patch("documents.merge.async_task")
|
||||
def test_merge_delete_source(self, m):
|
||||
result = self.client.post("/api/split_merge/", json.dumps({
|
||||
"split_merge_plan": [
|
||||
[{"document": self.doc1.pk}, {"document": self.doc2.pk}]
|
||||
],
|
||||
"preview": False,
|
||||
"delete_source": True
|
||||
}), content_type="application/json")
|
||||
self.assertEqual(result.status_code, 200)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertCountEqual(kwargs["delete_document_ids"], [self.doc1.pk, self.doc2.pk])
|
||||
|
||||
@mock.patch("documents.merge.async_task")
|
||||
def test_merge_invalid_doc(self, m):
|
||||
result = self.client.post("/api/split_merge/", json.dumps({
|
||||
"split_merge_plan": [
|
||||
[{"document": self.doc1.pk}, {"document": self.doc3.pk}]
|
||||
],
|
||||
"preview": False
|
||||
}), content_type="application/json")
|
||||
self.assertEqual(result.status_code, 400)
|
||||
m.assert_not_called()
|
||||
|
||||
|
||||
class TestApiAuth(APITestCase):
|
||||
|
||||
def test_auth_required(self):
|
||||
@@ -1396,6 +1466,7 @@ class TestApiAuth(APITestCase):
|
||||
self.assertEqual(self.client.get("/api/documents/bulk_edit/").status_code, 401)
|
||||
self.assertEqual(self.client.get("/api/documents/bulk_download/").status_code, 401)
|
||||
self.assertEqual(self.client.get("/api/documents/selection_data/").status_code, 401)
|
||||
self.assertEqual(self.client.get("/api/split_merge/").status_code, 401)
|
||||
|
||||
def test_api_version_no_auth(self):
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from documents.consumer import ConsumerError
|
||||
from documents.merge import PdfCache, MergeError, execute_split_merge_plan, consume_many_files, parse_page_list
|
||||
from documents.models import Document, Correspondent, Tag, DocumentType
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
|
||||
class TestPdfCache(DirectoriesMixin, TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super(TestPdfCache, self).setUp()
|
||||
self.cache = PdfCache()
|
||||
|
||||
def test_open_no_pdf(self):
|
||||
doc = Document.objects.create(mime_type="image/jpeg")
|
||||
self.assertRaisesMessage(MergeError, "does not have a PDF.", self.cache.open_from_document, doc)
|
||||
|
||||
def test_open_original_pdf(self):
|
||||
doc = Document.objects.create(mime_type="application/pdf", filename="test.pdf")
|
||||
shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), doc.source_path)
|
||||
self.cache.open_from_document(doc)
|
||||
self.assertIn(doc.pk, self.cache.cache)
|
||||
|
||||
def test_document_does_not_exist(self):
|
||||
doc = Document.objects.create(mime_type="application/pdf", filename="test.pdf")
|
||||
self.assertRaisesMessage(MergeError, "does not exist.", self.cache.open_from_document, doc)
|
||||
|
||||
def test_open_archive_pdf(self):
|
||||
doc = Document.objects.create(mime_type="image/jpef", archive_checksum="B", archive_filename="test.pdf")
|
||||
shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), doc.archive_path)
|
||||
self.cache.open_from_document(doc)
|
||||
self.assertIn(doc.pk, self.cache.cache)
|
||||
|
||||
@mock.patch("documents.merge.Pdf.open")
|
||||
def test_cached(self, pdf_open: MagicMock):
|
||||
pdf_open.return_value = "the_pdf"
|
||||
doc = Document.objects.create(mime_type="application/pdf", filename="test.pdf")
|
||||
shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), doc.source_path)
|
||||
self.assertEqual(self.cache.open_from_document(doc), "the_pdf")
|
||||
pdf_open.assert_called_once()
|
||||
self.assertIn(doc.pk, self.cache.cache)
|
||||
|
||||
pdf_open.reset_mock()
|
||||
self.assertEqual(self.cache.open_from_document(doc), "the_pdf")
|
||||
pdf_open.assert_not_called()
|
||||
|
||||
def test_close(self):
|
||||
self.test_open_original_pdf()
|
||||
|
||||
self.assertEqual(len(self.cache.cache), 1)
|
||||
|
||||
self.cache.close_all()
|
||||
|
||||
self.assertEqual(len(self.cache.cache), 0)
|
||||
|
||||
|
||||
class TestParsePageList(TestCase):
|
||||
|
||||
def test_ranges(self):
|
||||
self.assertListEqual(parse_page_list(""), [])
|
||||
self.assertListEqual(parse_page_list(None), [])
|
||||
self.assertListEqual(parse_page_list("1,2,3"), [1, 2, 3])
|
||||
self.assertListEqual(parse_page_list("1,3,2"), [1, 3, 2])
|
||||
self.assertListEqual(parse_page_list("1-3,5"), [1, 2, 3, 5])
|
||||
self.assertListEqual(parse_page_list("1-1,1,2-2,2,6-6,6"), [1, 1, 2, 2, 6, 6])
|
||||
self.assertListEqual(parse_page_list("5-3,1"), [5, 4, 3, 1])
|
||||
|
||||
def test_error(self):
|
||||
self.assertRaises(MergeError, parse_page_list, "as")
|
||||
self.assertRaises(MergeError, parse_page_list, "1--2")
|
||||
self.assertRaises(MergeError, parse_page_list, "1,2,3,")
|
||||
self.assertRaises(MergeError, parse_page_list, "1-8,1-a")
|
||||
self.assertRaises(MergeError, parse_page_list, "8,1-,8")
|
||||
self.assertRaises(MergeError, parse_page_list, "1,,2")
|
||||
|
||||
|
||||
class FakePdf:
|
||||
|
||||
def __init__(self):
|
||||
self.pages = list()
|
||||
|
||||
def remove_unreferenced_resources(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def save(self, filename, min_version=None):
|
||||
with open(filename, "w") as f:
|
||||
f.write(" ".join(self.pages))
|
||||
|
||||
@property
|
||||
def pdf_version(self):
|
||||
return 0
|
||||
|
||||
@contextmanager
|
||||
def open_metadata(self):
|
||||
yield dict()
|
||||
|
||||
|
||||
def fake_open(self, document):
|
||||
pdf = FakePdf()
|
||||
pdf.pages = document.content.split(" ")
|
||||
return pdf
|
||||
|
||||
|
||||
@mock.patch("documents.merge.Pdf.new", lambda: FakePdf())
|
||||
@mock.patch("documents.merge.PdfCache.open_from_document", fake_open)
|
||||
class TestExecuteSplitMergePlan(DirectoriesMixin, TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super(TestExecuteSplitMergePlan, self).setUp()
|
||||
self.tempdir = os.path.join(settings.SCRATCH_DIR, "split_merge")
|
||||
os.makedirs(self.tempdir, exist_ok=True)
|
||||
|
||||
def _verify_results(self, files, expected_content):
|
||||
self.assertEqual(len(files), len(expected_content))
|
||||
for (file, content) in zip(files, expected_content):
|
||||
with open(file) as f:
|
||||
self.assertEqual(f.read(), content)
|
||||
|
||||
def test_delete_page(self):
|
||||
doc1 = Document.objects.create(content="A B C")
|
||||
result = execute_split_merge_plan(
|
||||
plan=[
|
||||
[{"document": doc1.pk, "pages": "1,3"}]
|
||||
],
|
||||
tempdir=self.tempdir,
|
||||
)
|
||||
self._verify_results(result, ["A C"])
|
||||
|
||||
def test_split(self):
|
||||
doc1 = Document.objects.create(content="A B C")
|
||||
result = execute_split_merge_plan(
|
||||
plan=[
|
||||
[{"document": doc1.pk, "pages": "1,2"}],
|
||||
[{"document": doc1.pk, "pages": "3"}],
|
||||
],
|
||||
tempdir=self.tempdir,
|
||||
)
|
||||
self._verify_results(result, ["A B", "C"])
|
||||
|
||||
def test_merge(self):
|
||||
doc1 = Document.objects.create(content="A B C", checksum="A")
|
||||
doc2 = Document.objects.create(content="D E", checksum="B")
|
||||
result = execute_split_merge_plan(
|
||||
plan=[
|
||||
[{"document": doc2.pk}, {"document": doc1.pk}],
|
||||
],
|
||||
tempdir=self.tempdir,
|
||||
)
|
||||
self._verify_results(result, ["D E A B C"])
|
||||
|
||||
def test_partial_merge_reorder(self):
|
||||
doc1 = Document.objects.create(content="A B C", checksum="A")
|
||||
doc2 = Document.objects.create(content="D E", checksum="B")
|
||||
result = execute_split_merge_plan(
|
||||
plan=[
|
||||
[{"document": doc1.pk, "pages": "3,1"}, {"document": doc2.pk, "pages": "2"}],
|
||||
],
|
||||
tempdir=self.tempdir,
|
||||
)
|
||||
self._verify_results(result, ["C A E"])
|
||||
|
||||
def test_document_does_not_exist(self):
|
||||
self.assertRaisesMessage(
|
||||
MergeError,
|
||||
"Document 3456 does not exist.",
|
||||
execute_split_merge_plan,
|
||||
plan=[[{"document": 3456}]],
|
||||
tempdir=self.tempdir,
|
||||
)
|
||||
|
||||
def test_page_out_of_range(self):
|
||||
doc1 = Document.objects.create(content="A B C")
|
||||
self.assertRaisesMessage(
|
||||
MergeError,
|
||||
"Page 4 is out of range.",
|
||||
execute_split_merge_plan,
|
||||
plan=[[{"document": doc1.pk, "pages": "4"}]],
|
||||
tempdir=self.tempdir,
|
||||
)
|
||||
|
||||
@mock.patch("documents.merge.async_task")
|
||||
def test_no_preview(self, async_task: MagicMock):
|
||||
doc1 = Document.objects.create(content="A B C", checksum="A")
|
||||
doc2 = Document.objects.create(content="D E", checksum="B")
|
||||
result = execute_split_merge_plan(
|
||||
plan=[
|
||||
[{"document": doc2.pk}, {"document": doc1.pk}],
|
||||
],
|
||||
tempdir=self.tempdir,
|
||||
preview=False,
|
||||
delete_source=False
|
||||
)
|
||||
async_task.assert_called_once()
|
||||
args, kwargs = async_task.call_args
|
||||
self.assertListEqual(kwargs['kwargs_list'], [{"path": result[0], "override_title": ""}])
|
||||
self.assertIsNone(kwargs['delete_document_ids'])
|
||||
|
||||
@mock.patch("documents.merge.async_task")
|
||||
def test_no_preview_delete_source(self, async_task: MagicMock):
|
||||
doc1 = Document.objects.create(content="A B C", checksum="A")
|
||||
doc2 = Document.objects.create(content="D E", checksum="B")
|
||||
result = execute_split_merge_plan(
|
||||
plan=[
|
||||
[{"document": doc2.pk}, {"document": doc1.pk}],
|
||||
],
|
||||
tempdir=self.tempdir,
|
||||
preview=False,
|
||||
delete_source=True
|
||||
)
|
||||
async_task.assert_called_once()
|
||||
args, kwargs = async_task.call_args
|
||||
self.assertCountEqual(kwargs['delete_document_ids'], [doc1.pk, doc2.pk])
|
||||
|
||||
@mock.patch("documents.merge.async_task")
|
||||
@override_settings(TIME_ZONE="UTC")
|
||||
def test_metadata_copy_first(self, async_task: MagicMock):
|
||||
c1 = Correspondent.objects.create(name="c1")
|
||||
c2 = Correspondent.objects.create(name="c2")
|
||||
t1 = Tag.objects.create(name="t1")
|
||||
t2 = Tag.objects.create(name="t2")
|
||||
dt1 = DocumentType.objects.create(name="dt1")
|
||||
dt2 = DocumentType.objects.create(name="dt2")
|
||||
doc1 = Document.objects.create(content="A B C", checksum="A", title="titleA", correspondent=c1, document_type=dt1, created=timezone.datetime(2020, 12, 1, 0, 0, 0))
|
||||
doc1.tags.add(t1)
|
||||
doc2 = Document.objects.create(content="D E", checksum="B", title="titleB", correspondent=c2, document_type=dt2, created=timezone.datetime(2020, 2, 20, 0, 0, 0))
|
||||
doc2.tags.add(t2)
|
||||
|
||||
result = execute_split_merge_plan(
|
||||
plan=[
|
||||
[{"document": doc2.pk}, {"document": doc1.pk}],
|
||||
],
|
||||
tempdir=self.tempdir,
|
||||
preview=False,
|
||||
metadata="copy_first",
|
||||
)
|
||||
async_task.assert_called_once()
|
||||
args, kwargs = async_task.call_args
|
||||
self.assertEqual(1, len(kwargs['kwargs_list']))
|
||||
|
||||
kwargs = kwargs['kwargs_list'][0]
|
||||
self.assertEqual(kwargs["override_title"], "titleB")
|
||||
self.assertEqual(kwargs["override_correspondent_id"], c2.pk)
|
||||
self.assertEqual(kwargs["override_tag_ids"], [t2.pk])
|
||||
self.assertEqual(kwargs["override_document_type_id"], dt2.pk)
|
||||
self.assertEqual(kwargs["override_date"], timezone.make_aware(datetime.datetime(2020, 2, 20, 0, 0, 0)))
|
||||
|
||||
|
||||
def fake_consume_file(fail, *args, **kwargs):
|
||||
if fail:
|
||||
raise ConsumerError()
|
||||
return Document.objects.create(checksum=str(Document.objects.count())).pk
|
||||
|
||||
|
||||
class TestConsumeManyFiles(DirectoriesMixin, TestCase):
|
||||
|
||||
@mock.patch("documents.merge.tasks.consume_file")
|
||||
def test_success(self, consume_file: MagicMock):
|
||||
consume_file.side_effect = fake_consume_file
|
||||
consume_many_files([{"fail": False}, {"fail": False}])
|
||||
self.assertEqual(consume_file.call_count, 2)
|
||||
self.assertEqual(Document.objects.count(), 2)
|
||||
|
||||
@mock.patch("documents.merge.tasks.consume_file")
|
||||
def test_fail(self, consume_file: MagicMock):
|
||||
consume_file.side_effect = fake_consume_file
|
||||
self.assertRaises(
|
||||
ConsumerError,
|
||||
consume_many_files,
|
||||
[{"fail": False}, {"fail": True}, {"fail": False}]
|
||||
)
|
||||
self.assertEqual(consume_file.call_count, 2)
|
||||
self.assertEqual(Document.objects.count(), 0)
|
||||
|
||||
@mock.patch("documents.merge.tasks.consume_file")
|
||||
def test_success_delete_source(self, consume_file: MagicMock):
|
||||
doc_id = Document.objects.create().id
|
||||
self.assertTrue(Document.objects.filter(id=doc_id).exists())
|
||||
consume_file.side_effect = fake_consume_file
|
||||
consume_many_files([{"fail": False}, {"fail": False}], delete_document_ids=[doc_id])
|
||||
self.assertEqual(consume_file.call_count, 2)
|
||||
self.assertEqual(Document.objects.count(), 2)
|
||||
self.assertFalse(Document.objects.filter(id=doc_id).exists())
|
||||
|
||||
@mock.patch("documents.merge.tasks.consume_file")
|
||||
def test_fail_no_delete_source(self, consume_file: MagicMock):
|
||||
doc_id = Document.objects.create().id
|
||||
self.assertTrue(Document.objects.filter(id=doc_id).exists())
|
||||
consume_file.side_effect = fake_consume_file
|
||||
self.assertRaises(
|
||||
ConsumerError,
|
||||
consume_many_files,
|
||||
[{"fail": False}, {"fail": True}],
|
||||
delete_document_ids=[doc_id]
|
||||
)
|
||||
self.assertEqual(consume_file.call_count, 2)
|
||||
self.assertEqual(Document.objects.count(), 1)
|
||||
self.assertTrue(Document.objects.filter(id=doc_id).exists())
|
||||
+54
-2
@@ -6,6 +6,7 @@ import zipfile
|
||||
from datetime import datetime
|
||||
from time import mktime
|
||||
|
||||
import pathvalidate
|
||||
from django.conf import settings
|
||||
from django.db.models import Count, Max, Case, When, IntegerField
|
||||
from django.db.models.functions import Lower
|
||||
@@ -57,9 +58,10 @@ from .serialisers import (
|
||||
DocumentTypeSerializer,
|
||||
PostDocumentSerializer,
|
||||
SavedViewSerializer,
|
||||
BulkEditSerializer,
|
||||
DocumentListSerializer,
|
||||
BulkDownloadSerializer
|
||||
BulkDownloadSerializer,
|
||||
BulkEditSerializer,
|
||||
DocumentSplitMergePlanSerializer
|
||||
)
|
||||
|
||||
|
||||
@@ -624,3 +626,53 @@ class BulkDownloadView(GenericAPIView):
|
||||
"attachment", "documents.zip")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class DocumentSplitMergeViewSet(GenericViewSet):
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = DocumentSplitMergePlanSerializer
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(DocumentSplitMergeViewSet, self).__init__(**kwargs)
|
||||
self.tempdir = os.path.join(settings.SCRATCH_DIR, "split-merge")
|
||||
os.makedirs(self.tempdir, exist_ok=True)
|
||||
|
||||
def get_queryset(self):
|
||||
return os.listdir(self.tempdir)
|
||||
|
||||
def retrieve(self, request, pk, *args, **kwargs):
|
||||
filename = os.path.join(
|
||||
self.tempdir, pathvalidate.sanitize_filename(pk))
|
||||
if not os.path.isfile(filename):
|
||||
raise Http404()
|
||||
|
||||
with open(filename, "rb") as f:
|
||||
return HttpResponse(f, content_type="application/pdf")
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
return Response(self.get_queryset())
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
from .merge import execute_split_merge_plan, MergeError
|
||||
|
||||
split_merge_plan = serializer.validated_data.get("split_merge_plan")
|
||||
preview = serializer.validated_data.get("preview")
|
||||
delete_source = serializer.validated_data.get("delete_source")
|
||||
metadata = serializer.validated_data.get("metadata")
|
||||
try:
|
||||
pdf_files = execute_split_merge_plan(
|
||||
plan=split_merge_plan,
|
||||
preview=preview,
|
||||
delete_source=delete_source,
|
||||
metadata=metadata,
|
||||
tempdir=self.tempdir
|
||||
)
|
||||
except MergeError:
|
||||
logger.exception("Error during merge")
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
return Response([os.path.basename(file) for file in pdf_files])
|
||||
@@ -23,7 +23,8 @@ from documents.views import (
|
||||
SavedViewViewSet,
|
||||
BulkEditView,
|
||||
SelectionDataView,
|
||||
BulkDownloadView
|
||||
BulkDownloadView,
|
||||
DocumentSplitMergeViewSet
|
||||
)
|
||||
from paperless.views import FaviconView
|
||||
|
||||
@@ -35,6 +36,9 @@ api_router.register(r"logs", LogViewSet, basename="logs")
|
||||
api_router.register(r"tags", TagViewSet)
|
||||
api_router.register(r"saved_views", SavedViewViewSet)
|
||||
|
||||
api_router.register(
|
||||
r"split_merge", DocumentSplitMergeViewSet, basename="split_merge")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r"^api/", include([
|
||||
|
||||
Reference in new issue
Block a user