Compare commits

...
89 Commits
Author SHA1 Message Date
jonaswinkler 27ba139810 workaround for issue with pikepdf 2021-05-13 14:15:42 +02:00
jonaswinkler 27ff38b79e Merge branch 'dev' into feature-merge-tool 2021-05-13 14:08:31 +02:00
Jonas Winkler d521d7733b Merge pull request #789 from shamoon/feature/merge-tool-ui
Feature: merge tool UI
2021-04-03 22:26:57 +02:00
Michael Shamoon 99bde7b613 Dont import entire dark mode theme just for 1 color 2021-04-02 14:18:48 -07:00
Michael Shamoon cc5b7eb4e3 Center preview title when empty 2021-04-02 11:10:42 -07:00
Michael Shamoon 2b4a0efef8 Move add to split merge directly into bulk editor 2021-04-02 10:57:06 -07:00
Michael Shamoon 9502917df6 Fix overhang of separator background 2021-04-02 08:05:02 -07:00
Michael Shamoon 6f3c72ef9a Fix options text color not visible in light mode 2021-04-02 07:54:03 -07:00
Michael Shamoon 3915149ea1 Remove separators from first / last position when dragged 2021-04-02 07:51:24 -07:00
Michael Shamoon f07fb6e1b3 Fix tabs in light mode 2021-04-02 07:47:06 -07:00
Michael Shamoon e328c439ff Change to 5/7 column width layout 2021-04-02 07:28:54 -07:00
Michael Shamoon caa9a340a4 Increase height of dropzone 2021-04-02 07:27:42 -07:00
Michael Shamoon b07537aa9a Merge branch 'feature-merge-tool' into feature/merge-tool-ui 2021-04-02 07:11:05 -07:00
jonaswinkler 8e35e6c726 fix test cases 2021-04-02 15:32:00 +02:00
jonaswinkler 05a43a05a4 Merge branch 'dev' into feature-merge-tool 2021-04-02 15:17:44 +02:00
Michael Shamoon 846a997323 Better button responsive layout 2021-03-31 10:35:39 -07:00
Michael Shamoon e0231a4638 Dont allow separator at start of document 2021-03-31 10:24:04 -07:00
Michael Shamoon b4db388d58 Dont initialize page number until page is rendered 2021-03-31 10:23:49 -07:00
Michael Shamoon e957d9aa53 Changes missed from last commit, enables page numbers in previews 2021-03-30 13:56:17 -07:00
Michael Shamoon 72031824bf Preview page numbers 2021-03-25 15:01:05 -07:00
Michael Shamoon ae60fc8c2b Fix popover shadow 2021-03-24 23:23:01 -07:00
Michael Shamoon cc3c7ad481 Apply danger on delete toggle to entire label 2021-03-24 23:21:23 -07:00
Michael Shamoon 484edce5ab Split button should respect pre-selected pages 2021-03-24 23:12:02 -07:00
Michael Shamoon 66aee1446c Light mode fixes, styling tweaks 2021-03-24 22:36:19 -07:00
Michael Shamoon 971392345b Dont allow separator at the end of document parts list 2021-03-24 22:24:18 -07:00
Michael Shamoon c5ae3377bd Basic error handling 2021-03-24 22:16:51 -07:00
Michael Shamoon 4ddd031976 Slightly increasing button spacing on cards 2021-03-24 21:46:18 -07:00
Michael Shamoon c18180f344 Remove debug stuff 2021-03-24 21:46:06 -07:00
Michael Shamoon c07bbcb33f No return type on setter 2021-03-24 21:35:13 -07:00
Michael Shamoon 142c2ac9ff Sanitize pages input 2021-03-24 21:34:43 -07:00
Michael Shamoon 88bf679fd9 Unified button toolbar 2021-03-24 21:08:22 -07:00
Michael Shamoon a5de83fe7f Better clear button 2021-03-24 20:55:19 -07:00
Michael Shamoon 63b4c33fb9 Metadata options & source options merged into options popover panel 2021-03-24 20:37:50 -07:00
Michael Shamoon e0a1ba56dd Typescript fixes 2021-03-24 20:04:16 -07:00
Michael Shamoon aaa913f204 Options popover 2021-03-24 16:16:18 -07:00
Michael Shamoon cf5747f0bd Toggle for source document handling 2021-03-24 13:46:35 -07:00
Michael Shamoon 7df9e1f532 Translation stuff 2021-03-23 14:58:09 -07:00
Michael Shamoon 38d8b34ab4 Clear pages button 2021-03-23 14:57:59 -07:00
Michael Shamoon 24d07fcc17 Missing handle 2021-03-23 14:57:41 -07:00
Michael Shamoon 14116c85e7 Hide preview after all documents removed 2021-03-23 14:28:11 -07:00
Michael Shamoon 20b95fb506 Disable page chooser button if no page chosen 2021-03-23 14:24:42 -07:00
Michael Shamoon 3c25595fc2 Separator styling 2021-03-23 14:21:46 -07:00
Michael Shamoon af1034f84b Visual reorganization 2021-03-23 11:01:10 -07:00
Michael Shamoon cfc0925e04 Split support including tabbed previews 2021-03-23 00:03:21 -07:00
Michael Shamoon 746c03c198 Remove 'mode' concept, basic split UI, many visual changes 2021-03-22 22:31:52 -07:00
Michael Shamoon baae3c2701 Allow removing last document 2021-03-20 22:56:36 -07:00
Michael Shamoon d88cd05857 Allow empty pages field 2021-03-20 22:50:23 -07:00
Michael Shamoon 73c80c3f7a Page field formatting 2021-03-20 22:47:20 -07:00
Michael Shamoon 35f9989255 Allow input-debounce input 2021-03-20 22:04:49 -07:00
Michael Shamoon 33ddd033f2 Allow additional classes for input-debounce & fix field display 2021-03-20 21:45:31 -07:00
Michael Shamoon b8fabb623e InputDebounce component for page fields 2021-03-20 21:32:52 -07:00
Michael Shamoon 37c0fa4fab Merge branch 'feature-merge-tool' into feature/merge-tool-ui 2021-03-18 14:25:24 -07:00
Michael Shamoon 86ca37909b Basic page support 2021-03-16 22:36:59 -07:00
Michael Shamoon ce19525758 Change icons 2021-03-16 20:31:31 -07:00
Michael Shamoon bb872ab56f Tooltip, random styling stuff 2021-03-16 15:31:54 -07:00
Michael Shamoon ea342831da Basic mode toggling 2021-03-16 14:53:23 -07:00
Michael Shamoon 8d0ac690fb Basic page chooser component 2021-03-16 10:29:22 -07:00
jonaswinkler e0f14941e8 added some test cases and validation 2021-03-16 10:20:58 +01:00
Michael Shamoon 7cbb50565c Basic duplicate & delete 2021-03-14 22:12:27 -07:00
Michael Shamoon 274df28f39 Spacer styling 2021-03-14 21:29:47 -07:00
Michael Shamoon 4a6160869c Naming 2021-03-14 21:27:58 -07:00
Michael Shamoon 87d969f44c Basic action buttons 2021-03-14 21:21:13 -07:00
Michael Shamoon 7746ab4522 Basic document chooser 2021-03-14 20:34:32 -07:00
Michael Shamoon 58cb42b44a Typescript 2021-03-14 14:53:32 -07:00
Michael Shamoon 8e330fc97a Basic live preview 2021-03-14 14:40:32 -07:00
Michael Shamoon 6a3ad77950 Skeleton UI for buttons 2021-03-14 14:02:01 -07:00
jonaswinkler 3d56621253 Merge branch 'dev' into feature-merge-tool 2021-03-14 19:22:08 +01:00
Michael Shamoon 9e6cb1b868 Basic layout elements, loading spinner 2021-03-14 09:30:29 -07:00
Michael Shamoon 321fad0564 Basic drag-n-drop 2021-03-13 22:34:47 -08:00
Michael Shamoon eb7b767e35 Basic button for initiating split/merge from bulk editor 2021-03-13 21:00:53 -08:00
jonaswinkler 98b26b8e16 changed page specification 2021-03-13 19:29:11 +01:00
jonaswinkler 176d1fa2eb a couple changes 2021-03-13 18:21:36 +01:00
jonaswinkler 072134366c codestyle 2021-03-13 18:02:52 +01:00
jonaswinkler afd92af168 codestyle 2021-03-13 17:59:56 +01:00
jonaswinkler 2d61698509 couple changes 2021-03-13 17:56:38 +01:00
jonaswinkler 5e286798d5 better error messages 2021-03-13 17:48:51 +01:00
jonaswinkler b7a825301a support for overriding the date created 2021-03-13 17:48:38 +01:00
jonaswinkler 02da012603 added tests for merge 2021-03-13 17:47:54 +01:00
jonaswinkler 4d99e72a0e Merge branch 'dev' into feature-merge-tool 2021-03-12 23:24:16 +01:00
jonaswinkler 26c0568b1e some changes 2021-03-11 22:26:29 +01:00
jonaswinkler aa4d9782b7 initial gui #335 2021-03-11 22:23:01 +01:00
jonaswinkler 43ccf6682c move a couple things around 2021-03-11 22:09:23 +01:00
jonaswinkler 5e096bc8ce Angular interfaces for split+merge API support #335 2021-03-11 18:00:19 +01:00
jonaswinkler 1e131f0217 added a very crude and largely untested API endpoint for document merging #335 2021-03-11 17:58:16 +01:00
jonaswinkler 321869edb0 Merge branch 'dev' into feature-merge-tool 2021-03-11 10:10:31 +01:00
jonaswinkler c6e8faa718 initial implementation of merge tool 2021-03-10 22:26:31 +01:00
jonaswinkler 6696c4d7a5 initial work on merge tool frontend 2021-03-10 21:43:14 +01:00
jonaswinkler 88254bdb79 Merge branch 'dev' into feature-merge-tool 2021-03-10 18:44:55 +01:00
jonaswinkler 4cc6bf889e Added a dummy endpoint with no functionality to the API 2021-02-13 14:56:58 +01:00
44 changed files with 1971 additions and 24 deletions

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",
+2
View File
@@ -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 },
+12 -2
View File
@@ -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>&nbsp;<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>&nbsp;<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">&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;
@@ -38,6 +38,12 @@
</svg>&nbsp;<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>&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()">
<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>&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()
}
}
@@ -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>&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>
<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>
<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}/`
}
}
+5
View File
@@ -9,3 +9,8 @@ $theme-colors: (
.bg-body {
background-color: #fff;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
+21
View File
@@ -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 {
+6
View File
@@ -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:
+201
View 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]
+91
View File
@@ -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
+3 -3
View File
@@ -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.")
+71
View File
@@ -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):
+313
View File
@@ -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
View File
@@ -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])
+5 -1
View File
@@ -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([