mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Compare commits
	
		
			22 Commits
		
	
	
		
			v2.18.0
			...
			fff7a9229c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					fff7a9229c | ||
| 
						 | 
					d4f35c23ce | ||
| 
						 | 
					f96523a638 | ||
| 
						 | 
					ce403730ca | ||
| 
						 | 
					0c724e3590 | ||
| 
						 | 
					3c1073aaef | ||
| 
						 | 
					d922ec4d6e | ||
| 
						 | 
					a7cdecd5a2 | ||
| 
						 | 
					de9cc7420d | ||
| 
						 | 
					a3b6de99fd | ||
| 
						 | 
					855b128213 | ||
| 
						 | 
					45e70fc99d | ||
| 
						 | 
					d439b2bb17 | ||
| 
						 | 
					ae174613ff | ||
| 
						 | 
					8ee8f2d4c5 | ||
| 
						 | 
					3dece8f872 | ||
| 
						 | 
					dd1cd5524a | ||
| 
						 | 
					52a3884fc4 | ||
| 
						 | 
					401e5d68d8 | ||
| 
						 | 
					ef764065b8 | ||
| 
						 | 
					0418bc58b5 | ||
| 
						 | 
					d3644463cc | 
@@ -573,12 +573,14 @@ The following custom field types are supported:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
## PDF Actions
 | 
					## PDF Actions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
 | 
					Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
 | 
				
			||||||
 | 
					open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-   Merging documents: available when selecting multiple documents for 'bulk editing'.
 | 
					-   Merging documents: available when selecting multiple documents for 'bulk editing'.
 | 
				
			||||||
-   Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
 | 
					-   Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
 | 
				
			||||||
-   Splitting documents: available from an individual document's details page.
 | 
					-   Splitting documents: via the pdf editor on an individual document's details page.
 | 
				
			||||||
-   Deleting pages: available from an individual document's details page.
 | 
					-   Deleting pages: via the pdf editor on an individual document's details page.
 | 
				
			||||||
 | 
					-   Re-arranging pages: via the pdf editor on an individual document's details page.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
!!! important
 | 
					!!! important
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -119,6 +119,26 @@ Object.defineProperty(window, 'location', {
 | 
				
			|||||||
  value: { reload: jest.fn() },
 | 
					  value: { reload: jest.fn() },
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if (typeof IntersectionObserver === 'undefined') {
 | 
				
			||||||
 | 
					  class MockIntersectionObserver {
 | 
				
			||||||
 | 
					    constructor(
 | 
				
			||||||
 | 
					      public callback: IntersectionObserverCallback,
 | 
				
			||||||
 | 
					      public options?: IntersectionObserverInit
 | 
				
			||||||
 | 
					    ) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    observe = jest.fn()
 | 
				
			||||||
 | 
					    unobserve = jest.fn()
 | 
				
			||||||
 | 
					    disconnect = jest.fn()
 | 
				
			||||||
 | 
					    takeRecords = jest.fn()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Object.defineProperty(window, 'IntersectionObserver', {
 | 
				
			||||||
 | 
					    writable: true,
 | 
				
			||||||
 | 
					    configurable: true,
 | 
				
			||||||
 | 
					    value: MockIntersectionObserver,
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
HTMLCanvasElement.prototype.getContext = <
 | 
					HTMLCanvasElement.prototype.getContext = <
 | 
				
			||||||
  typeof HTMLCanvasElement.prototype.getContext
 | 
					  typeof HTMLCanvasElement.prototype.getContext
 | 
				
			||||||
>jest.fn()
 | 
					>jest.fn()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,54 +0,0 @@
 | 
				
			|||||||
<div class="modal-header">
 | 
					 | 
				
			||||||
    <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
 | 
					 | 
				
			||||||
    <button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
 | 
					 | 
				
			||||||
    </button>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
<div class="modal-body">
 | 
					 | 
				
			||||||
    <div class="row">
 | 
					 | 
				
			||||||
        <div class="col">
 | 
					 | 
				
			||||||
            <div class="btn-toolbar flex-nowrap">
 | 
					 | 
				
			||||||
                <div class="input-group input-group-sm">
 | 
					 | 
				
			||||||
                    <div class="input-group-text" i18n>Page</div>
 | 
					 | 
				
			||||||
                    <input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" />
 | 
					 | 
				
			||||||
                    <div class="input-group-text" i18n>of {{totalPages}}</div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div class="input-group input-group-sm ms-auto">
 | 
					 | 
				
			||||||
                    <span class="input-group-text" i18n>Pages to remove</span>
 | 
					 | 
				
			||||||
                    <input [ngModel]="pagesString" class="form-control" disabled />
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div class="pdf-viewer-container w-100 mt-3">
 | 
					 | 
				
			||||||
                <pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage"
 | 
					 | 
				
			||||||
                [original-size]="false"
 | 
					 | 
				
			||||||
                [zoom]="1"
 | 
					 | 
				
			||||||
                zoom-scale="page-fit"
 | 
					 | 
				
			||||||
                [render-text]="false"
 | 
					 | 
				
			||||||
                (pagerendered)="pageRendered($event)"
 | 
					 | 
				
			||||||
                (after-load-complete)="pdfPreviewLoaded($event)">
 | 
					 | 
				
			||||||
                </pdf-viewer>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
<div class="modal-footer flex-nowrap">
 | 
					 | 
				
			||||||
    <div>
 | 
					 | 
				
			||||||
        @if (message) {
 | 
					 | 
				
			||||||
            <p [innerHTML]="message | safeHtml"></p>
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        @if (messageBold) {
 | 
					 | 
				
			||||||
            <p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
    <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
 | 
					 | 
				
			||||||
            <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
 | 
					 | 
				
			||||||
        </button>
 | 
					 | 
				
			||||||
    <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
 | 
					 | 
				
			||||||
        {{btnCaption}}
 | 
					 | 
				
			||||||
    </button>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<ng-template #pageCheckOverlay let-page="page" let-pages="pages">
 | 
					 | 
				
			||||||
    <div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)">
 | 
					 | 
				
			||||||
        <input type="checkbox" class="form-check-input" />
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
</ng-template>
 | 
					 | 
				
			||||||
@@ -1,28 +0,0 @@
 | 
				
			|||||||
.pdf-viewer-container {
 | 
					 | 
				
			||||||
  background-color: gray;
 | 
					 | 
				
			||||||
  height: 550px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  pdf-viewer {
 | 
					 | 
				
			||||||
    width: 100%;
 | 
					 | 
				
			||||||
    height: 100%;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.mw-60 {
 | 
					 | 
				
			||||||
  max-width: 60px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
div.position-absolute:has(.form-check-input:checked) {
 | 
					 | 
				
			||||||
  background-color: rgba(var(--bs-dark-rgb), 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form-check-input {
 | 
					 | 
				
			||||||
  &:checked {
 | 
					 | 
				
			||||||
    background-color: var(--bs-danger);
 | 
					 | 
				
			||||||
    border-color: var(--bs-danger);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  &:focus {
 | 
					 | 
				
			||||||
    box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha));
 | 
					 | 
				
			||||||
    border-color: var(--bs-danger);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,60 +0,0 @@
 | 
				
			|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
 | 
					 | 
				
			||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
 | 
					 | 
				
			||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
 | 
					 | 
				
			||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
					 | 
				
			||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
					 | 
				
			||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
					 | 
				
			||||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
 | 
					 | 
				
			||||||
import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
describe('DeletePagesConfirmDialogComponent', () => {
 | 
					 | 
				
			||||||
  let component: DeletePagesConfirmDialogComponent
 | 
					 | 
				
			||||||
  let fixture: ComponentFixture<DeletePagesConfirmDialogComponent>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  beforeEach(async () => {
 | 
					 | 
				
			||||||
    await TestBed.configureTestingModule({
 | 
					 | 
				
			||||||
      declarations: [],
 | 
					 | 
				
			||||||
      imports: [
 | 
					 | 
				
			||||||
        NgxBootstrapIconsModule.pick(allIcons),
 | 
					 | 
				
			||||||
        FormsModule,
 | 
					 | 
				
			||||||
        ReactiveFormsModule,
 | 
					 | 
				
			||||||
        DeletePagesConfirmDialogComponent,
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
      providers: [
 | 
					 | 
				
			||||||
        NgbActiveModal,
 | 
					 | 
				
			||||||
        SafeHtmlPipe,
 | 
					 | 
				
			||||||
        provideHttpClient(withInterceptorsFromDi()),
 | 
					 | 
				
			||||||
        provideHttpClientTesting(),
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
    }).compileComponents()
 | 
					 | 
				
			||||||
    fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent)
 | 
					 | 
				
			||||||
    component = fixture.componentInstance
 | 
					 | 
				
			||||||
    fixture.detectChanges()
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should return a string with comma-separated pages', () => {
 | 
					 | 
				
			||||||
    component.pages = [1, 2, 3, 4]
 | 
					 | 
				
			||||||
    expect(component.pagesString).toEqual('1, 2, 3, 4')
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should update totalPages when pdf is loaded', () => {
 | 
					 | 
				
			||||||
    component.pdfPreviewLoaded({ numPages: 5 } as any)
 | 
					 | 
				
			||||||
    expect(component.totalPages).toEqual(5)
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should update checks when page is rendered', () => {
 | 
					 | 
				
			||||||
    const event = {
 | 
					 | 
				
			||||||
      target: document.createElement('div'),
 | 
					 | 
				
			||||||
      detail: { pageNumber: 1 },
 | 
					 | 
				
			||||||
    } as any
 | 
					 | 
				
			||||||
    component.pageRendered(event)
 | 
					 | 
				
			||||||
    expect(component['checks'].length).toEqual(1)
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should update pages when page check is changed', () => {
 | 
					 | 
				
			||||||
    component.pageCheckChanged(1)
 | 
					 | 
				
			||||||
    expect(component.pages).toEqual([1])
 | 
					 | 
				
			||||||
    component.pageCheckChanged(1)
 | 
					 | 
				
			||||||
    expect(component.pages).toEqual([])
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
@@ -1,69 +0,0 @@
 | 
				
			|||||||
import { Component, TemplateRef, ViewChild, inject } from '@angular/core'
 | 
					 | 
				
			||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  PDFDocumentProxy,
 | 
					 | 
				
			||||||
  PdfViewerComponent,
 | 
					 | 
				
			||||||
  PdfViewerModule,
 | 
					 | 
				
			||||||
} from 'ng2-pdf-viewer'
 | 
					 | 
				
			||||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
 | 
					 | 
				
			||||||
import { DocumentService } from 'src/app/services/rest/document.service'
 | 
					 | 
				
			||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@Component({
 | 
					 | 
				
			||||||
  selector: 'pngx-delete-pages-confirm-dialog',
 | 
					 | 
				
			||||||
  templateUrl: './delete-pages-confirm-dialog.component.html',
 | 
					 | 
				
			||||||
  styleUrl: './delete-pages-confirm-dialog.component.scss',
 | 
					 | 
				
			||||||
  imports: [PdfViewerModule, FormsModule, ReactiveFormsModule, SafeHtmlPipe],
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent {
 | 
					 | 
				
			||||||
  private documentService = inject(DocumentService)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public documentID: number
 | 
					 | 
				
			||||||
  public pages: number[] = []
 | 
					 | 
				
			||||||
  public currentPage: number = 1
 | 
					 | 
				
			||||||
  public totalPages: number
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
 | 
					 | 
				
			||||||
  @ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any>
 | 
					 | 
				
			||||||
  private checks: HTMLElement[] = []
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public get pagesString(): string {
 | 
					 | 
				
			||||||
    return this.pages.join(', ')
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public get pdfSrc(): string {
 | 
					 | 
				
			||||||
    return this.documentService.getPreviewUrl(this.documentID)
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor() {
 | 
					 | 
				
			||||||
    super()
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public pdfPreviewLoaded(pdf: PDFDocumentProxy) {
 | 
					 | 
				
			||||||
    this.totalPages = pdf.numPages
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  pageRendered(event: CustomEvent) {
 | 
					 | 
				
			||||||
    const pageDiv = event.target as HTMLDivElement
 | 
					 | 
				
			||||||
    const check = this.pageCheckOverlay.createEmbeddedView({
 | 
					 | 
				
			||||||
      page: event.detail.pageNumber,
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
    this.checks[event.detail.pageNumber - 1] = check.rootNodes[0]
 | 
					 | 
				
			||||||
    pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild)
 | 
					 | 
				
			||||||
    this.updateChecks()
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  pageCheckChanged(pageNumber: number) {
 | 
					 | 
				
			||||||
    if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber)
 | 
					 | 
				
			||||||
    else if (this.pages.includes(pageNumber))
 | 
					 | 
				
			||||||
      this.pages.splice(this.pages.indexOf(pageNumber), 1)
 | 
					 | 
				
			||||||
    this.updateChecks()
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private updateChecks() {
 | 
					 | 
				
			||||||
    this.checks.forEach((check, i) => {
 | 
					 | 
				
			||||||
      const input = check.getElementsByTagName('input')[0]
 | 
					 | 
				
			||||||
      input.checked = this.pages.includes(i + 1)
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,59 +0,0 @@
 | 
				
			|||||||
<div class="modal-header">
 | 
					 | 
				
			||||||
    <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
 | 
					 | 
				
			||||||
    <button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
 | 
					 | 
				
			||||||
    </button>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
<div class="modal-body">
 | 
					 | 
				
			||||||
    <p>{{message}}</p>
 | 
					 | 
				
			||||||
    <div class="row mb-2">
 | 
					 | 
				
			||||||
        <div class="col-7">
 | 
					 | 
				
			||||||
            <div class="input-group input-group-sm">
 | 
					 | 
				
			||||||
                <div class="input-group-text" i18n>Page</div>
 | 
					 | 
				
			||||||
                <input class="form-control" type="number" min="1" [(ngModel)]="page" />
 | 
					 | 
				
			||||||
                <div class="input-group-text" i18n>of {{totalPages}}</div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div class="pdf-viewer-container w-100 mt-3">
 | 
					 | 
				
			||||||
                <pdf-viewer [src]="pdfSrc" [(page)]="page"
 | 
					 | 
				
			||||||
                [original-size]="false"
 | 
					 | 
				
			||||||
                [zoom]="1"
 | 
					 | 
				
			||||||
                zoom-scale="page-fit"
 | 
					 | 
				
			||||||
                (after-load-complete)="pdfPreviewLoaded($event)">
 | 
					 | 
				
			||||||
                </pdf-viewer>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        <div class="col-5">
 | 
					 | 
				
			||||||
            <div class="d-grid">
 | 
					 | 
				
			||||||
                <button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
 | 
					 | 
				
			||||||
                    <i-bs name="plus-circle"></i-bs> 
 | 
					 | 
				
			||||||
                    <span i18n>Add Split</span>
 | 
					 | 
				
			||||||
                </button>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <ul class="list-group mt-3">
 | 
					 | 
				
			||||||
                @for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
 | 
					 | 
				
			||||||
                    <li class="list-group-item d-flex align-items-center">
 | 
					 | 
				
			||||||
                        {{pageStr}}
 | 
					 | 
				
			||||||
                        @if (pagesString.split(',').length > 1) {
 | 
					 | 
				
			||||||
                             
 | 
					 | 
				
			||||||
                            <button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
 | 
					 | 
				
			||||||
                                <i-bs name="trash"></i-bs>
 | 
					 | 
				
			||||||
                            </button>
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
                    </li>
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            </ul>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
<div class="modal-footer">
 | 
					 | 
				
			||||||
    <div class="form-check form-switch me-auto">
 | 
					 | 
				
			||||||
       <input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
 | 
					 | 
				
			||||||
       <label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
 | 
					 | 
				
			||||||
     </div>
 | 
					 | 
				
			||||||
    <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
 | 
					 | 
				
			||||||
            <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
 | 
					 | 
				
			||||||
        </button>
 | 
					 | 
				
			||||||
    <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
 | 
					 | 
				
			||||||
        {{btnCaption}}
 | 
					 | 
				
			||||||
    </button>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
@@ -1,9 +0,0 @@
 | 
				
			|||||||
.pdf-viewer-container {
 | 
					 | 
				
			||||||
    background-color: gray;
 | 
					 | 
				
			||||||
    height: 500px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    pdf-viewer {
 | 
					 | 
				
			||||||
      width: 100%;
 | 
					 | 
				
			||||||
      height: 100%;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
@@ -1,107 +0,0 @@
 | 
				
			|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
 | 
					 | 
				
			||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
 | 
					 | 
				
			||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
					 | 
				
			||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
					 | 
				
			||||||
import { PdfViewerModule } from 'ng2-pdf-viewer'
 | 
					 | 
				
			||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
					 | 
				
			||||||
import { of } from 'rxjs'
 | 
					 | 
				
			||||||
import { DocumentService } from 'src/app/services/rest/document.service'
 | 
					 | 
				
			||||||
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
describe('SplitConfirmDialogComponent', () => {
 | 
					 | 
				
			||||||
  let component: SplitConfirmDialogComponent
 | 
					 | 
				
			||||||
  let fixture: ComponentFixture<SplitConfirmDialogComponent>
 | 
					 | 
				
			||||||
  let documentService: DocumentService
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  beforeEach(async () => {
 | 
					 | 
				
			||||||
    await TestBed.configureTestingModule({
 | 
					 | 
				
			||||||
      imports: [
 | 
					 | 
				
			||||||
        NgxBootstrapIconsModule.pick(allIcons),
 | 
					 | 
				
			||||||
        ReactiveFormsModule,
 | 
					 | 
				
			||||||
        FormsModule,
 | 
					 | 
				
			||||||
        PdfViewerModule,
 | 
					 | 
				
			||||||
        SplitConfirmDialogComponent,
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
      providers: [
 | 
					 | 
				
			||||||
        NgbActiveModal,
 | 
					 | 
				
			||||||
        provideHttpClient(withInterceptorsFromDi()),
 | 
					 | 
				
			||||||
        provideHttpClientTesting(),
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
    }).compileComponents()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    fixture = TestBed.createComponent(SplitConfirmDialogComponent)
 | 
					 | 
				
			||||||
    documentService = TestBed.inject(DocumentService)
 | 
					 | 
				
			||||||
    component = fixture.componentInstance
 | 
					 | 
				
			||||||
    fixture.detectChanges()
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should load document on init', () => {
 | 
					 | 
				
			||||||
    const getSpy = jest.spyOn(documentService, 'get')
 | 
					 | 
				
			||||||
    component.documentID = 1
 | 
					 | 
				
			||||||
    getSpy.mockReturnValue(of({ id: 1 } as any))
 | 
					 | 
				
			||||||
    component.ngOnInit()
 | 
					 | 
				
			||||||
    expect(documentService.get).toHaveBeenCalledWith(1)
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should update pagesString when pages are added', () => {
 | 
					 | 
				
			||||||
    component.totalPages = 5
 | 
					 | 
				
			||||||
    component.page = 2
 | 
					 | 
				
			||||||
    component.addSplit()
 | 
					 | 
				
			||||||
    expect(component.pagesString).toEqual('1-2,3-5')
 | 
					 | 
				
			||||||
    component.page = 4
 | 
					 | 
				
			||||||
    component.addSplit()
 | 
					 | 
				
			||||||
    expect(component.pagesString).toEqual('1-2,3-4,5')
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should update pagesString when pages are removed', () => {
 | 
					 | 
				
			||||||
    component.totalPages = 5
 | 
					 | 
				
			||||||
    component.page = 2
 | 
					 | 
				
			||||||
    component.addSplit()
 | 
					 | 
				
			||||||
    component.page = 4
 | 
					 | 
				
			||||||
    component.addSplit()
 | 
					 | 
				
			||||||
    expect(component.pagesString).toEqual('1-2,3-4,5')
 | 
					 | 
				
			||||||
    component.removeSplit(0)
 | 
					 | 
				
			||||||
    expect(component.pagesString).toEqual('1-4,5')
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should enable confirm button when pages are added', () => {
 | 
					 | 
				
			||||||
    component.totalPages = 5
 | 
					 | 
				
			||||||
    component.page = 2
 | 
					 | 
				
			||||||
    component.addSplit()
 | 
					 | 
				
			||||||
    expect(component.confirmButtonEnabled).toBeTruthy()
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should disable confirm button when all pages are removed', () => {
 | 
					 | 
				
			||||||
    component.totalPages = 5
 | 
					 | 
				
			||||||
    component.page = 2
 | 
					 | 
				
			||||||
    component.addSplit()
 | 
					 | 
				
			||||||
    component.removeSplit(0)
 | 
					 | 
				
			||||||
    expect(component.confirmButtonEnabled).toBeFalsy()
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should not add split if page is the last page', () => {
 | 
					 | 
				
			||||||
    component.totalPages = 5
 | 
					 | 
				
			||||||
    component.page = 5
 | 
					 | 
				
			||||||
    component.addSplit()
 | 
					 | 
				
			||||||
    expect(component.pagesString).toEqual('1-5')
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should update totalPages when pdf is loaded', () => {
 | 
					 | 
				
			||||||
    component.pdfPreviewLoaded({ numPages: 5 } as any)
 | 
					 | 
				
			||||||
    expect(component.totalPages).toEqual(5)
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should correctly disable split button', () => {
 | 
					 | 
				
			||||||
    component.totalPages = 5
 | 
					 | 
				
			||||||
    component.page = 1
 | 
					 | 
				
			||||||
    expect(component.canSplit).toBeTruthy()
 | 
					 | 
				
			||||||
    component.page = 5
 | 
					 | 
				
			||||||
    expect(component.canSplit).toBeFalsy()
 | 
					 | 
				
			||||||
    component.page = 4
 | 
					 | 
				
			||||||
    expect(component.canSplit).toBeTruthy()
 | 
					 | 
				
			||||||
    component['pages'] = new Set([1, 2, 3, 4])
 | 
					 | 
				
			||||||
    expect(component.canSplit).toBeFalsy()
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
@@ -1,98 +0,0 @@
 | 
				
			|||||||
import { Component, OnInit, inject } from '@angular/core'
 | 
					 | 
				
			||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
					 | 
				
			||||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
 | 
					 | 
				
			||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
					 | 
				
			||||||
import { Document } from 'src/app/data/document'
 | 
					 | 
				
			||||||
import { PermissionsService } from 'src/app/services/permissions.service'
 | 
					 | 
				
			||||||
import { DocumentService } from 'src/app/services/rest/document.service'
 | 
					 | 
				
			||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@Component({
 | 
					 | 
				
			||||||
  selector: 'pngx-split-confirm-dialog',
 | 
					 | 
				
			||||||
  templateUrl: './split-confirm-dialog.component.html',
 | 
					 | 
				
			||||||
  styleUrl: './split-confirm-dialog.component.scss',
 | 
					 | 
				
			||||||
  imports: [
 | 
					 | 
				
			||||||
    FormsModule,
 | 
					 | 
				
			||||||
    ReactiveFormsModule,
 | 
					 | 
				
			||||||
    NgxBootstrapIconsModule,
 | 
					 | 
				
			||||||
    PdfViewerModule,
 | 
					 | 
				
			||||||
  ],
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
export class SplitConfirmDialogComponent
 | 
					 | 
				
			||||||
  extends ConfirmDialogComponent
 | 
					 | 
				
			||||||
  implements OnInit
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
  private documentService = inject(DocumentService)
 | 
					 | 
				
			||||||
  private permissionService = inject(PermissionsService)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public get pagesString(): string {
 | 
					 | 
				
			||||||
    let pagesStr = ''
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let lastPage = 1
 | 
					 | 
				
			||||||
    for (let i = 1; i <= this.totalPages; i++) {
 | 
					 | 
				
			||||||
      if (this.pages.has(i) || i === this.totalPages) {
 | 
					 | 
				
			||||||
        if (lastPage === i) {
 | 
					 | 
				
			||||||
          pagesStr += `${i},`
 | 
					 | 
				
			||||||
          lastPage = Math.min(i + 1, this.totalPages)
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          pagesStr += `${lastPage}-${i},`
 | 
					 | 
				
			||||||
          lastPage = Math.min(i + 1, this.totalPages)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return pagesStr.replace(/,$/, '')
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private pages: Set<number> = new Set()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public documentID: number
 | 
					 | 
				
			||||||
  private document: Document
 | 
					 | 
				
			||||||
  public page: number = 1
 | 
					 | 
				
			||||||
  public totalPages: number
 | 
					 | 
				
			||||||
  public deleteOriginal: boolean = false
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public get canSplit(): boolean {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      this.page < this.totalPages &&
 | 
					 | 
				
			||||||
      this.pages.size < this.totalPages - 1 &&
 | 
					 | 
				
			||||||
      !this.pages.has(this.page)
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public get pdfSrc(): string {
 | 
					 | 
				
			||||||
    return this.documentService.getPreviewUrl(this.documentID)
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor() {
 | 
					 | 
				
			||||||
    super()
 | 
					 | 
				
			||||||
    this.confirmButtonEnabled = this.pages.size > 0
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  ngOnInit(): void {
 | 
					 | 
				
			||||||
    this.documentService.get(this.documentID).subscribe((r) => {
 | 
					 | 
				
			||||||
      this.document = r
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  pdfPreviewLoaded(pdf: PDFDocumentProxy) {
 | 
					 | 
				
			||||||
    this.totalPages = pdf.numPages
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  addSplit() {
 | 
					 | 
				
			||||||
    if (this.page === this.totalPages) return
 | 
					 | 
				
			||||||
    this.pages.add(this.page)
 | 
					 | 
				
			||||||
    this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
 | 
					 | 
				
			||||||
    this.confirmButtonEnabled = this.pages.size > 0
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  removeSplit(i: number) {
 | 
					 | 
				
			||||||
    let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
 | 
					 | 
				
			||||||
    this.pages.delete(page)
 | 
					 | 
				
			||||||
    this.confirmButtonEnabled = this.pages.size > 0
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  get userOwnsDocument(): boolean {
 | 
					 | 
				
			||||||
    return this.permissionService.currentUserOwnsObject(this.document)
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -0,0 +1,103 @@
 | 
				
			|||||||
 | 
					<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
 | 
				
			||||||
 | 
					<div class="modal-header">
 | 
				
			||||||
 | 
					  <h4 class="modal-title">{{ title }}</h4>
 | 
				
			||||||
 | 
					  <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					<div class="modal-body">
 | 
				
			||||||
 | 
					  <div class="btn-toolbar mb-2">
 | 
				
			||||||
 | 
					    <div class="btn-group me-3">
 | 
				
			||||||
 | 
					      <button class="btn btn-sm btn-secondary" (click)="selectAll()" title="Select all pages" i18n-title>
 | 
				
			||||||
 | 
					        <i-bs name="check-all"></i-bs>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					      <button class="btn btn-sm btn-secondary" (click)="deselectAll()" [disabled]="!hasSelection()" title="Deselect all pages" i18n-title>
 | 
				
			||||||
 | 
					        <i-bs name="x"></i-bs>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="btn-group">
 | 
				
			||||||
 | 
					      <button class="btn btn-sm btn-secondary" (click)="rotateSelected(-90)" [disabled]="!hasSelection()" title="Rotate selected pages counter-clockwise" i18n-title>
 | 
				
			||||||
 | 
					        <i-bs name="arrow-counterclockwise"></i-bs>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					      <button class="btn btn-sm btn-secondary" (click)="rotateSelected(90)" [disabled]="!hasSelection()" title="Rotate selected pages clockwise" i18n-title>
 | 
				
			||||||
 | 
					        <i-bs name="arrow-clockwise"></i-bs>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					      <button class="btn btn-sm btn-danger" (click)="deleteSelected()" [disabled]="!hasSelection()" title="Delete selected pages" i18n-title>
 | 
				
			||||||
 | 
					        <i-bs name="trash"></i-bs>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  <div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-5">
 | 
				
			||||||
 | 
					    @for (p of pages; track p.page; let i = $index) {
 | 
				
			||||||
 | 
					      <div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected">
 | 
				
			||||||
 | 
					        <div class="btn-toolbar hover-actions z-10">
 | 
				
			||||||
 | 
					          <div class="btn-group me-2">
 | 
				
			||||||
 | 
					            <button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
 | 
				
			||||||
 | 
					              <i-bs name="arrow-counterclockwise"></i-bs>
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title>
 | 
				
			||||||
 | 
					              <i-bs name="arrow-clockwise"></i-bs>
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="btn-group">
 | 
				
			||||||
 | 
					            <button class="btn btn-sm btn-dark text-danger" (click)="remove(i); $event.stopPropagation()" title="Delete page" i18n-title>
 | 
				
			||||||
 | 
					              <i-bs name="trash"></i-bs>
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <button class="btn btn-sm btn-dark" (click)="toggleSplit(i); $event.stopPropagation()" title="Add / remove document split here" i18n-title>
 | 
				
			||||||
 | 
					              <i-bs name="scissors"></i-bs>
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="border-end border-bottom bg-light py-1 px-2 document-check z-10">
 | 
				
			||||||
 | 
					          <div class="form-check">
 | 
				
			||||||
 | 
					            <input type="checkbox" class="form-check-input" id="page{{i}}" [checked]="p.selected" (click)="toggleSelection(i); $event.stopPropagation()">
 | 
				
			||||||
 | 
					            <label class="form-check-label" for="page{{i}}"></label>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="pdf-viewer-container w-100" [class.selected]="p.selected">
 | 
				
			||||||
 | 
					          @defer (on viewport) {
 | 
				
			||||||
 | 
					            @if (!p.loaded) {
 | 
				
			||||||
 | 
					              <div class="placeholder-glow w-100 h-100 z-10">
 | 
				
			||||||
 | 
					                <span class="placeholder w-100 h-100"></span>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            <pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
 | 
				
			||||||
 | 
					          } @placeholder {
 | 
				
			||||||
 | 
					            <div class="placeholder-glow w-100 h-100 z-10">
 | 
				
			||||||
 | 
					              <span class="placeholder w-100 h-100"></span>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        @if (p.splitAfter) {
 | 
				
			||||||
 | 
					          <div class="split-after rounded position-absolute top-0 end-0 bg-dark text-uppercase text-center h-100 px-1 small fw-bold">— <span i18n>Split here</span> —</div>
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					<div class="modal-footer flex-column">
 | 
				
			||||||
 | 
					  <div class="d-flex w-100 justify-content-between align-items-center">
 | 
				
			||||||
 | 
					    <div class="btn-group" role="group">
 | 
				
			||||||
 | 
					      <input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Create" id="editModeCreate" name="editmode">
 | 
				
			||||||
 | 
					      <label for="editModeCreate" class="btn btn-outline-primary btn-sm">
 | 
				
			||||||
 | 
					        <i-bs name="plus"></i-bs>
 | 
				
			||||||
 | 
					        <span class="form-check-label ms-1" i18n>Create new document(s)</span>
 | 
				
			||||||
 | 
					      </label>
 | 
				
			||||||
 | 
					      <input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()">
 | 
				
			||||||
 | 
					      <label for="editModeUpdate" class="btn btn-outline-primary btn-sm">
 | 
				
			||||||
 | 
					        <i-bs name="pencil"></i-bs>
 | 
				
			||||||
 | 
					        <span class="form-check-label ms-2" i18n>Update existing document</span>
 | 
				
			||||||
 | 
					      </label>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    @if (editMode === PdfEditorEditMode.Create) {
 | 
				
			||||||
 | 
					      <div class="form-check ms-3">
 | 
				
			||||||
 | 
					        <input class="form-check-input" type="checkbox" id="copyMeta" [(ngModel)]="includeMetadata">
 | 
				
			||||||
 | 
					        <label class="form-check-label" for="copyMeta" i18n>Copy metadata</label>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="form-check ms-3">
 | 
				
			||||||
 | 
					        <input class="form-check-input" type="checkbox" id="deleteOriginal" [(ngModel)]="deleteOriginal">
 | 
				
			||||||
 | 
					        <label class="form-check-label" for="deleteOriginal" i18n>Delete original</label>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    <button type="button" class="btn ms-auto me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
 | 
				
			||||||
 | 
					    <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@@ -0,0 +1,70 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.page-item {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  border: 1px solid transparent;
 | 
				
			||||||
 | 
					  background-origin: border-box;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &.selected {
 | 
				
			||||||
 | 
					    background-color: var(--pngx-primary-darken-5);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.pdf-viewer-container {
 | 
				
			||||||
 | 
					  background-color: gray;
 | 
				
			||||||
 | 
					  height: 240px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  pdf-viewer {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					::ng-deep .ng2-pdf-viewer-container {
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.hover-actions {
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  top: 0;
 | 
				
			||||||
 | 
					  right: 0;
 | 
				
			||||||
 | 
					  display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.page-item:hover .hover-actions {
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.document-check {
 | 
				
			||||||
 | 
					  display: none;
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  top: 0;
 | 
				
			||||||
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  padding: 0.5rem;
 | 
				
			||||||
 | 
					  border-top-left-radius: 0.25rem;
 | 
				
			||||||
 | 
					  border-bottom-right-radius: 0.25rem;
 | 
				
			||||||
 | 
					  pointer-events: none;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .form-check {
 | 
				
			||||||
 | 
					    padding: 0;
 | 
				
			||||||
 | 
					    min-height: 0;
 | 
				
			||||||
 | 
					    margin-bottom: 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .form-check-input {
 | 
				
			||||||
 | 
					      margin-left: 0;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.page-item:hover .document-check, .selected .document-check {
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.z-10 {
 | 
				
			||||||
 | 
					    z-index: 10;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.split-after {
 | 
				
			||||||
 | 
					  writing-mode: vertical-rl;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,142 @@
 | 
				
			|||||||
 | 
					import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
 | 
				
			||||||
 | 
					import { provideHttpClientTesting } from '@angular/common/http/testing'
 | 
				
			||||||
 | 
					import { ComponentFixture, TestBed } from '@angular/core/testing'
 | 
				
			||||||
 | 
					import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
				
			||||||
 | 
					import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
				
			||||||
 | 
					import { PDFEditorComponent } from './pdf-editor.component'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('PDFEditorComponent', () => {
 | 
				
			||||||
 | 
					  let component: PDFEditorComponent
 | 
				
			||||||
 | 
					  let fixture: ComponentFixture<PDFEditorComponent>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  beforeEach(async () => {
 | 
				
			||||||
 | 
					    await TestBed.configureTestingModule({
 | 
				
			||||||
 | 
					      imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)],
 | 
				
			||||||
 | 
					      providers: [
 | 
				
			||||||
 | 
					        provideHttpClient(withInterceptorsFromDi()),
 | 
				
			||||||
 | 
					        provideHttpClientTesting(),
 | 
				
			||||||
 | 
					        { provide: NgbActiveModal, useValue: {} },
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    }).compileComponents()
 | 
				
			||||||
 | 
					    fixture = TestBed.createComponent(PDFEditorComponent)
 | 
				
			||||||
 | 
					    component = fixture.componentInstance
 | 
				
			||||||
 | 
					    fixture.detectChanges()
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('should return correct operations with no changes', () => {
 | 
				
			||||||
 | 
					    component.pages = [
 | 
				
			||||||
 | 
					      { page: 1, rotate: 0, splitAfter: false },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 0, splitAfter: false },
 | 
				
			||||||
 | 
					      { page: 3, rotate: 0, splitAfter: false },
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    const ops = component.getOperations()
 | 
				
			||||||
 | 
					    expect(ops).toEqual([
 | 
				
			||||||
 | 
					      { page: 1, rotate: 0, doc: 0 },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 0, doc: 0 },
 | 
				
			||||||
 | 
					      { page: 3, rotate: 0, doc: 0 },
 | 
				
			||||||
 | 
					    ])
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('should rotate, delete and reorder pages', () => {
 | 
				
			||||||
 | 
					    component.pages = [
 | 
				
			||||||
 | 
					      { page: 1, rotate: 0, splitAfter: false, selected: false },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 0, splitAfter: false, selected: false },
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    component.toggleSelection(0)
 | 
				
			||||||
 | 
					    component.rotateSelected(90)
 | 
				
			||||||
 | 
					    expect(component.pages[0].rotate).toBe(90)
 | 
				
			||||||
 | 
					    component.toggleSelection(0) // deselect
 | 
				
			||||||
 | 
					    component.toggleSelection(1)
 | 
				
			||||||
 | 
					    component.deleteSelected()
 | 
				
			||||||
 | 
					    expect(component.pages.length).toBe(1)
 | 
				
			||||||
 | 
					    component.pages.push({ page: 2, rotate: 0, splitAfter: false })
 | 
				
			||||||
 | 
					    component.drop({ previousIndex: 0, currentIndex: 1 } as any)
 | 
				
			||||||
 | 
					    expect(component.pages[0].page).toBe(2)
 | 
				
			||||||
 | 
					    component.rotate(0)
 | 
				
			||||||
 | 
					    expect(component.pages[0].rotate).toBe(90)
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('should handle empty pages array', () => {
 | 
				
			||||||
 | 
					    component.pages = []
 | 
				
			||||||
 | 
					    expect(component.getOperations()).toEqual([])
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('should increment doc index after splitAfter', () => {
 | 
				
			||||||
 | 
					    component.pages = [
 | 
				
			||||||
 | 
					      { page: 1, rotate: 0, splitAfter: true },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 0, splitAfter: false },
 | 
				
			||||||
 | 
					      { page: 3, rotate: 0, splitAfter: true },
 | 
				
			||||||
 | 
					      { page: 4, rotate: 0, splitAfter: false },
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    const ops = component.getOperations()
 | 
				
			||||||
 | 
					    expect(ops).toEqual([
 | 
				
			||||||
 | 
					      { page: 1, rotate: 0, doc: 0 },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 0, doc: 1 },
 | 
				
			||||||
 | 
					      { page: 3, rotate: 0, doc: 1 },
 | 
				
			||||||
 | 
					      { page: 4, rotate: 0, doc: 2 },
 | 
				
			||||||
 | 
					    ])
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('should include rotations in operations', () => {
 | 
				
			||||||
 | 
					    component.pages = [
 | 
				
			||||||
 | 
					      { page: 1, rotate: 90, splitAfter: false },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 180, splitAfter: true },
 | 
				
			||||||
 | 
					      { page: 3, rotate: 270, splitAfter: false },
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    const ops = component.getOperations()
 | 
				
			||||||
 | 
					    expect(ops).toEqual([
 | 
				
			||||||
 | 
					      { page: 1, rotate: 90, doc: 0 },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 180, doc: 0 },
 | 
				
			||||||
 | 
					      { page: 3, rotate: 270, doc: 1 },
 | 
				
			||||||
 | 
					    ])
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('should handle remove operation', () => {
 | 
				
			||||||
 | 
					    component.pages = [
 | 
				
			||||||
 | 
					      { page: 1, rotate: 0, splitAfter: false, selected: false },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 0, splitAfter: false, selected: true },
 | 
				
			||||||
 | 
					      { page: 3, rotate: 0, splitAfter: false, selected: false },
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    component.remove(1) // remove page 2
 | 
				
			||||||
 | 
					    expect(component.pages.length).toBe(2)
 | 
				
			||||||
 | 
					    expect(component.pages[0].page).toBe(1)
 | 
				
			||||||
 | 
					    expect(component.pages[1].page).toBe(3)
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('should toggle splitAfter correctly', () => {
 | 
				
			||||||
 | 
					    component.pages = [
 | 
				
			||||||
 | 
					      { page: 1, rotate: 0, splitAfter: false },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 0, splitAfter: false },
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    component.toggleSplit(0)
 | 
				
			||||||
 | 
					    expect(component.pages[0].splitAfter).toBeTruthy()
 | 
				
			||||||
 | 
					    component.toggleSplit(1)
 | 
				
			||||||
 | 
					    expect(component.pages[1].splitAfter).toBeTruthy()
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('should select and deselect all pages', () => {
 | 
				
			||||||
 | 
					    component.pages = [
 | 
				
			||||||
 | 
					      { page: 1, rotate: 0, splitAfter: false, selected: false },
 | 
				
			||||||
 | 
					      { page: 2, rotate: 0, splitAfter: false, selected: false },
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    component.selectAll()
 | 
				
			||||||
 | 
					    expect(component.pages.every((p) => p.selected)).toBeTruthy()
 | 
				
			||||||
 | 
					    expect(component.hasSelection()).toBeTruthy()
 | 
				
			||||||
 | 
					    component.deselectAll()
 | 
				
			||||||
 | 
					    expect(component.pages.every((p) => !p.selected)).toBeTruthy()
 | 
				
			||||||
 | 
					    expect(component.hasSelection()).toBeFalsy()
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('should handle pdf loading and page generation', () => {
 | 
				
			||||||
 | 
					    const mockPdf = {
 | 
				
			||||||
 | 
					      numPages: 3,
 | 
				
			||||||
 | 
					      getPage: (pageNum: number) => Promise.resolve({ pageNumber: pageNum }),
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    component.pdfLoaded(mockPdf as any)
 | 
				
			||||||
 | 
					    expect(component.totalPages).toBe(3)
 | 
				
			||||||
 | 
					    expect(component.pages.length).toBe(3)
 | 
				
			||||||
 | 
					    expect(component.pages[0].page).toBe(1)
 | 
				
			||||||
 | 
					    expect(component.pages[1].page).toBe(2)
 | 
				
			||||||
 | 
					    expect(component.pages[2].page).toBe(3)
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
@@ -0,0 +1,133 @@
 | 
				
			|||||||
 | 
					import {
 | 
				
			||||||
 | 
					  CdkDragDrop,
 | 
				
			||||||
 | 
					  DragDropModule,
 | 
				
			||||||
 | 
					  moveItemInArray,
 | 
				
			||||||
 | 
					} from '@angular/cdk/drag-drop'
 | 
				
			||||||
 | 
					import { Component, inject } from '@angular/core'
 | 
				
			||||||
 | 
					import { FormsModule } from '@angular/forms'
 | 
				
			||||||
 | 
					import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
				
			||||||
 | 
					import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
 | 
				
			||||||
 | 
					import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
				
			||||||
 | 
					import { DocumentService } from 'src/app/services/rest/document.service'
 | 
				
			||||||
 | 
					import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface PageOperation {
 | 
				
			||||||
 | 
					  page: number
 | 
				
			||||||
 | 
					  rotate: number
 | 
				
			||||||
 | 
					  splitAfter: boolean
 | 
				
			||||||
 | 
					  selected?: boolean
 | 
				
			||||||
 | 
					  loaded?: boolean
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export enum PdfEditorEditMode {
 | 
				
			||||||
 | 
					  Update = 'update',
 | 
				
			||||||
 | 
					  Create = 'create',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'pngx-pdf-editor',
 | 
				
			||||||
 | 
					  templateUrl: './pdf-editor.component.html',
 | 
				
			||||||
 | 
					  styleUrl: './pdf-editor.component.scss',
 | 
				
			||||||
 | 
					  imports: [
 | 
				
			||||||
 | 
					    DragDropModule,
 | 
				
			||||||
 | 
					    FormsModule,
 | 
				
			||||||
 | 
					    PdfViewerModule,
 | 
				
			||||||
 | 
					    NgxBootstrapIconsModule,
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class PDFEditorComponent extends ConfirmDialogComponent {
 | 
				
			||||||
 | 
					  public PdfEditorEditMode = PdfEditorEditMode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private documentService = inject(DocumentService)
 | 
				
			||||||
 | 
					  activeModal: NgbActiveModal = inject(NgbActiveModal)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  documentID: number
 | 
				
			||||||
 | 
					  pages: PageOperation[] = []
 | 
				
			||||||
 | 
					  totalPages = 0
 | 
				
			||||||
 | 
					  editMode: PdfEditorEditMode = PdfEditorEditMode.Create
 | 
				
			||||||
 | 
					  deleteOriginal: boolean = false
 | 
				
			||||||
 | 
					  includeMetadata: boolean = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  get pdfSrc(): string {
 | 
				
			||||||
 | 
					    return this.documentService.getPreviewUrl(this.documentID)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  pdfLoaded(pdf: PDFDocumentProxy) {
 | 
				
			||||||
 | 
					    this.totalPages = pdf.numPages
 | 
				
			||||||
 | 
					    this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
 | 
				
			||||||
 | 
					      page: i + 1,
 | 
				
			||||||
 | 
					      rotate: 0,
 | 
				
			||||||
 | 
					      splitAfter: false,
 | 
				
			||||||
 | 
					      selected: false,
 | 
				
			||||||
 | 
					      loaded: false,
 | 
				
			||||||
 | 
					    }))
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  toggleSelection(i: number) {
 | 
				
			||||||
 | 
					    this.pages[i].selected = !this.pages[i].selected
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  rotate(i: number) {
 | 
				
			||||||
 | 
					    this.pages[i].rotate = (this.pages[i].rotate + 90) % 360
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  rotateSelected(dir: number) {
 | 
				
			||||||
 | 
					    for (let p of this.pages) {
 | 
				
			||||||
 | 
					      if (p.selected) {
 | 
				
			||||||
 | 
					        p.rotate = (p.rotate + dir + 360) % 360
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  remove(i: number) {
 | 
				
			||||||
 | 
					    this.pages.splice(i, 1)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  toggleSplit(i: number) {
 | 
				
			||||||
 | 
					    this.pages[i].splitAfter = !this.pages[i].splitAfter
 | 
				
			||||||
 | 
					    if (this.pages[i].splitAfter) {
 | 
				
			||||||
 | 
					      // force create mode
 | 
				
			||||||
 | 
					      this.editMode = PdfEditorEditMode.Create
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  selectAll() {
 | 
				
			||||||
 | 
					    this.pages.forEach((p) => (p.selected = true))
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  deselectAll() {
 | 
				
			||||||
 | 
					    this.pages.forEach((p) => (p.selected = false))
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  deleteSelected() {
 | 
				
			||||||
 | 
					    this.pages = this.pages.filter((p) => !p.selected)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  hasSelection(): boolean {
 | 
				
			||||||
 | 
					    return this.pages.some((p) => p.selected)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  hasSplit(): boolean {
 | 
				
			||||||
 | 
					    return this.pages.some((p) => p.splitAfter)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  drop(event: CdkDragDrop<PageOperation[]>) {
 | 
				
			||||||
 | 
					    moveItemInArray(this.pages, event.previousIndex, event.currentIndex)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getOperations() {
 | 
				
			||||||
 | 
					    return this.pages.map((p, idx) => ({
 | 
				
			||||||
 | 
					      page: p.page,
 | 
				
			||||||
 | 
					      rotate: p.rotate,
 | 
				
			||||||
 | 
					      doc: this.computeDocIndex(idx),
 | 
				
			||||||
 | 
					    }))
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private computeDocIndex(index: number): number {
 | 
				
			||||||
 | 
					    let docIndex = 0
 | 
				
			||||||
 | 
					    for (let i = 0; i <= index; i++) {
 | 
				
			||||||
 | 
					      if (this.pages[i].splitAfter && i < index) docIndex++
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return docIndex
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -58,16 +58,8 @@
 | 
				
			|||||||
        <i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
 | 
					        <i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <button ngbDropdownItem (click)="splitDocument()" [disabled]="!userCanAdd || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
 | 
					      <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
 | 
				
			||||||
        <i-bs width="1em" height="1em" name="scissors"></i-bs> <span i18n>Split</span>
 | 
					        <i-bs name="pencil"></i-bs> <ng-container i18n>Edit PDF</ng-container>
 | 
				
			||||||
      </button>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
 | 
					 | 
				
			||||||
        <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
 | 
					 | 
				
			||||||
      </button>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
 | 
					 | 
				
			||||||
        <i-bs name="file-earmark-minus"></i-bs> <ng-container i18n>Delete page(s)</ng-container>
 | 
					 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1142,81 +1142,40 @@ describe('DocumentDetailComponent', () => {
 | 
				
			|||||||
    ).not.toBeUndefined()
 | 
					    ).not.toBeUndefined()
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should support split', () => {
 | 
					  it('should support pdf editor, handle error', () => {
 | 
				
			||||||
    let modal: NgbModalRef
 | 
					    let modal: NgbModalRef
 | 
				
			||||||
    modalService.activeInstances.subscribe((m) => (modal = m[0]))
 | 
					    modalService.activeInstances.subscribe((m) => (modal = m[0]))
 | 
				
			||||||
    initNormally()
 | 
					    initNormally()
 | 
				
			||||||
    component.splitDocument()
 | 
					    component.editPdf()
 | 
				
			||||||
    expect(modal).not.toBeUndefined()
 | 
					    expect(modal).not.toBeUndefined()
 | 
				
			||||||
    modal.componentInstance.documentID = doc.id
 | 
					    modal.componentInstance.documentID = doc.id
 | 
				
			||||||
    modal.componentInstance.totalPages = 5
 | 
					    modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
 | 
				
			||||||
    modal.componentInstance.page = 2
 | 
					 | 
				
			||||||
    modal.componentInstance.addSplit()
 | 
					 | 
				
			||||||
    modal.componentInstance.confirm()
 | 
					    modal.componentInstance.confirm()
 | 
				
			||||||
    let req = httpTestingController.expectOne(
 | 
					    let req = httpTestingController.expectOne(
 | 
				
			||||||
      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
					      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    expect(req.request.body).toEqual({
 | 
					    expect(req.request.body).toEqual({
 | 
				
			||||||
      documents: [doc.id],
 | 
					      documents: [doc.id],
 | 
				
			||||||
      method: 'split',
 | 
					      method: 'edit_pdf',
 | 
				
			||||||
      parameters: { pages: '1-2,3-5', delete_originals: false },
 | 
					      parameters: {
 | 
				
			||||||
 | 
					        operations: [{ page: 1, rotate: 0, doc: 0 }],
 | 
				
			||||||
 | 
					        delete_original: false,
 | 
				
			||||||
 | 
					        update_document: false,
 | 
				
			||||||
 | 
					        include_metadata: true,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
    req.error(new ProgressEvent('failed'))
 | 
					 | 
				
			||||||
    modal.componentInstance.confirm()
 | 
					 | 
				
			||||||
    req = httpTestingController.expectOne(
 | 
					 | 
				
			||||||
      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    req.flush(true)
 | 
					    req.flush(true)
 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should support rotate', () => {
 | 
					    component.editPdf()
 | 
				
			||||||
    let modal: NgbModalRef
 | 
					 | 
				
			||||||
    modalService.activeInstances.subscribe((m) => (modal = m[0]))
 | 
					 | 
				
			||||||
    initNormally()
 | 
					 | 
				
			||||||
    component.rotateDocument()
 | 
					 | 
				
			||||||
    expect(modal).not.toBeUndefined()
 | 
					 | 
				
			||||||
    modal.componentInstance.documentID = doc.id
 | 
					    modal.componentInstance.documentID = doc.id
 | 
				
			||||||
    modal.componentInstance.rotate()
 | 
					    modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: true }]
 | 
				
			||||||
    modal.componentInstance.confirm()
 | 
					 | 
				
			||||||
    let req = httpTestingController.expectOne(
 | 
					 | 
				
			||||||
      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    expect(req.request.body).toEqual({
 | 
					 | 
				
			||||||
      documents: [doc.id],
 | 
					 | 
				
			||||||
      method: 'rotate',
 | 
					 | 
				
			||||||
      parameters: { degrees: 90 },
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
    req.error(new ProgressEvent('failed'))
 | 
					 | 
				
			||||||
    modal.componentInstance.confirm()
 | 
					    modal.componentInstance.confirm()
 | 
				
			||||||
 | 
					    const errorSpy = jest.spyOn(toastService, 'showError')
 | 
				
			||||||
    req = httpTestingController.expectOne(
 | 
					    req = httpTestingController.expectOne(
 | 
				
			||||||
      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
					      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    req.flush(true)
 | 
					    req.error(new ErrorEvent('failed'))
 | 
				
			||||||
  })
 | 
					    expect(errorSpy).toHaveBeenCalled()
 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should support delete pages', () => {
 | 
					 | 
				
			||||||
    let modal: NgbModalRef
 | 
					 | 
				
			||||||
    modalService.activeInstances.subscribe((m) => (modal = m[0]))
 | 
					 | 
				
			||||||
    initNormally()
 | 
					 | 
				
			||||||
    component.deletePages()
 | 
					 | 
				
			||||||
    expect(modal).not.toBeUndefined()
 | 
					 | 
				
			||||||
    modal.componentInstance.documentID = doc.id
 | 
					 | 
				
			||||||
    modal.componentInstance.pages = [1, 2]
 | 
					 | 
				
			||||||
    modal.componentInstance.confirm()
 | 
					 | 
				
			||||||
    let req = httpTestingController.expectOne(
 | 
					 | 
				
			||||||
      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    expect(req.request.body).toEqual({
 | 
					 | 
				
			||||||
      documents: [doc.id],
 | 
					 | 
				
			||||||
      method: 'delete_pages',
 | 
					 | 
				
			||||||
      parameters: { pages: [1, 2] },
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
    req.error(new ProgressEvent('failed'))
 | 
					 | 
				
			||||||
    modal.componentInstance.confirm()
 | 
					 | 
				
			||||||
    req = httpTestingController.expectOne(
 | 
					 | 
				
			||||||
      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    req.flush(true)
 | 
					 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should support keyboard shortcuts', () => {
 | 
					  it('should support keyboard shortcuts', () => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -81,9 +81,6 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http'
 | 
				
			|||||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
 | 
					import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
 | 
				
			||||||
import * as UTIF from 'utif'
 | 
					import * as UTIF from 'utif'
 | 
				
			||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
 | 
					import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
 | 
				
			||||||
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
 | 
					 | 
				
			||||||
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
 | 
					 | 
				
			||||||
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
 | 
					 | 
				
			||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
 | 
					import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
 | 
				
			||||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
 | 
					import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
 | 
				
			||||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
 | 
					import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
 | 
				
			||||||
@@ -101,6 +98,10 @@ import { TagsComponent } from '../common/input/tags/tags.component'
 | 
				
			|||||||
import { TextComponent } from '../common/input/text/text.component'
 | 
					import { TextComponent } from '../common/input/text/text.component'
 | 
				
			||||||
import { UrlComponent } from '../common/input/url/url.component'
 | 
					import { UrlComponent } from '../common/input/url/url.component'
 | 
				
			||||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
 | 
					import { PageHeaderComponent } from '../common/page-header/page-header.component'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  PDFEditorComponent,
 | 
				
			||||||
 | 
					  PdfEditorEditMode,
 | 
				
			||||||
 | 
					} from '../common/pdf-editor/pdf-editor.component'
 | 
				
			||||||
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
 | 
					import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
 | 
				
			||||||
import { DocumentHistoryComponent } from '../document-history/document-history.component'
 | 
					import { DocumentHistoryComponent } from '../document-history/document-history.component'
 | 
				
			||||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
 | 
					import { DocumentNotesComponent } from '../document-notes/document-notes.component'
 | 
				
			||||||
@@ -1336,13 +1337,13 @@ export class DocumentDetailComponent
 | 
				
			|||||||
    this.documentForm.updateValueAndValidity()
 | 
					    this.documentForm.updateValueAndValidity()
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  splitDocument() {
 | 
					  editPdf() {
 | 
				
			||||||
    let modal = this.modalService.open(SplitConfirmDialogComponent, {
 | 
					    let modal = this.modalService.open(PDFEditorComponent, {
 | 
				
			||||||
      backdrop: 'static',
 | 
					      backdrop: 'static',
 | 
				
			||||||
      size: 'lg',
 | 
					      size: 'xl',
 | 
				
			||||||
 | 
					      scrollable: true,
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
    modal.componentInstance.title = $localize`Split confirm`
 | 
					    modal.componentInstance.title = $localize`Edit PDF`
 | 
				
			||||||
    modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
 | 
					 | 
				
			||||||
    modal.componentInstance.btnCaption = $localize`Proceed`
 | 
					    modal.componentInstance.btnCaption = $localize`Proceed`
 | 
				
			||||||
    modal.componentInstance.documentID = this.document.id
 | 
					    modal.componentInstance.documentID = this.document.id
 | 
				
			||||||
    modal.componentInstance.confirmClicked
 | 
					    modal.componentInstance.confirmClicked
 | 
				
			||||||
@@ -1350,15 +1351,18 @@ export class DocumentDetailComponent
 | 
				
			|||||||
      .subscribe(() => {
 | 
					      .subscribe(() => {
 | 
				
			||||||
        modal.componentInstance.buttonsEnabled = false
 | 
					        modal.componentInstance.buttonsEnabled = false
 | 
				
			||||||
        this.documentsService
 | 
					        this.documentsService
 | 
				
			||||||
          .bulkEdit([this.document.id], 'split', {
 | 
					          .bulkEdit([this.document.id], 'edit_pdf', {
 | 
				
			||||||
            pages: modal.componentInstance.pagesString,
 | 
					            operations: modal.componentInstance.getOperations(),
 | 
				
			||||||
            delete_originals: modal.componentInstance.deleteOriginal,
 | 
					            delete_original: modal.componentInstance.deleteOriginal,
 | 
				
			||||||
 | 
					            update_document:
 | 
				
			||||||
 | 
					              modal.componentInstance.editMode == PdfEditorEditMode.Update,
 | 
				
			||||||
 | 
					            include_metadata: modal.componentInstance.includeMetadata,
 | 
				
			||||||
          })
 | 
					          })
 | 
				
			||||||
          .pipe(first(), takeUntil(this.unsubscribeNotifier))
 | 
					          .pipe(first(), takeUntil(this.unsubscribeNotifier))
 | 
				
			||||||
          .subscribe({
 | 
					          .subscribe({
 | 
				
			||||||
            next: () => {
 | 
					            next: () => {
 | 
				
			||||||
              this.toastService.showInfo(
 | 
					              this.toastService.showInfo(
 | 
				
			||||||
                $localize`Split operation for "${this.document.title}" will begin in the background.`
 | 
					                $localize`PDF edit operation for "${this.document.title}" will begin in the background.`
 | 
				
			||||||
              )
 | 
					              )
 | 
				
			||||||
              modal.close()
 | 
					              modal.close()
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
@@ -1367,86 +1371,7 @@ export class DocumentDetailComponent
 | 
				
			|||||||
                modal.componentInstance.buttonsEnabled = true
 | 
					                modal.componentInstance.buttonsEnabled = true
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
              this.toastService.showError(
 | 
					              this.toastService.showError(
 | 
				
			||||||
                $localize`Error executing split operation`,
 | 
					                $localize`Error executing PDF edit operation`,
 | 
				
			||||||
                error
 | 
					 | 
				
			||||||
              )
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
          })
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  rotateDocument() {
 | 
					 | 
				
			||||||
    let modal = this.modalService.open(RotateConfirmDialogComponent, {
 | 
					 | 
				
			||||||
      backdrop: 'static',
 | 
					 | 
				
			||||||
      size: 'lg',
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
    modal.componentInstance.title = $localize`Rotate confirm`
 | 
					 | 
				
			||||||
    modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
 | 
					 | 
				
			||||||
    modal.componentInstance.btnCaption = $localize`Proceed`
 | 
					 | 
				
			||||||
    modal.componentInstance.documentID = this.document.id
 | 
					 | 
				
			||||||
    modal.componentInstance.showPDFNote = false
 | 
					 | 
				
			||||||
    modal.componentInstance.confirmClicked
 | 
					 | 
				
			||||||
      .pipe(takeUntil(this.unsubscribeNotifier))
 | 
					 | 
				
			||||||
      .subscribe(() => {
 | 
					 | 
				
			||||||
        modal.componentInstance.buttonsEnabled = false
 | 
					 | 
				
			||||||
        this.documentsService
 | 
					 | 
				
			||||||
          .bulkEdit([this.document.id], 'rotate', {
 | 
					 | 
				
			||||||
            degrees: modal.componentInstance.degrees,
 | 
					 | 
				
			||||||
          })
 | 
					 | 
				
			||||||
          .pipe(first(), takeUntil(this.unsubscribeNotifier))
 | 
					 | 
				
			||||||
          .subscribe({
 | 
					 | 
				
			||||||
            next: () => {
 | 
					 | 
				
			||||||
              this.toastService.show({
 | 
					 | 
				
			||||||
                content: $localize`Rotation of "${this.document.title}" will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
 | 
					 | 
				
			||||||
                delay: 8000,
 | 
					 | 
				
			||||||
                action: this.close.bind(this),
 | 
					 | 
				
			||||||
                actionName: $localize`Close`,
 | 
					 | 
				
			||||||
              })
 | 
					 | 
				
			||||||
              modal.close()
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            error: (error) => {
 | 
					 | 
				
			||||||
              if (modal) {
 | 
					 | 
				
			||||||
                modal.componentInstance.buttonsEnabled = true
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
              this.toastService.showError(
 | 
					 | 
				
			||||||
                $localize`Error executing rotate operation`,
 | 
					 | 
				
			||||||
                error
 | 
					 | 
				
			||||||
              )
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
          })
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  deletePages() {
 | 
					 | 
				
			||||||
    let modal = this.modalService.open(DeletePagesConfirmDialogComponent, {
 | 
					 | 
				
			||||||
      backdrop: 'static',
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
    modal.componentInstance.title = $localize`Delete pages confirm`
 | 
					 | 
				
			||||||
    modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected pages from the original document.`
 | 
					 | 
				
			||||||
    modal.componentInstance.btnCaption = $localize`Proceed`
 | 
					 | 
				
			||||||
    modal.componentInstance.documentID = this.document.id
 | 
					 | 
				
			||||||
    modal.componentInstance.confirmClicked
 | 
					 | 
				
			||||||
      .pipe(takeUntil(this.unsubscribeNotifier))
 | 
					 | 
				
			||||||
      .subscribe(() => {
 | 
					 | 
				
			||||||
        modal.componentInstance.buttonsEnabled = false
 | 
					 | 
				
			||||||
        this.documentsService
 | 
					 | 
				
			||||||
          .bulkEdit([this.document.id], 'delete_pages', {
 | 
					 | 
				
			||||||
            pages: modal.componentInstance.pages,
 | 
					 | 
				
			||||||
          })
 | 
					 | 
				
			||||||
          .pipe(first(), takeUntil(this.unsubscribeNotifier))
 | 
					 | 
				
			||||||
          .subscribe({
 | 
					 | 
				
			||||||
            next: () => {
 | 
					 | 
				
			||||||
              this.toastService.showInfo(
 | 
					 | 
				
			||||||
                $localize`Delete pages operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
 | 
					 | 
				
			||||||
              )
 | 
					 | 
				
			||||||
              modal.close()
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            error: (error) => {
 | 
					 | 
				
			||||||
              if (modal) {
 | 
					 | 
				
			||||||
                modal.componentInstance.buttonsEnabled = true
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
              this.toastService.showError(
 | 
					 | 
				
			||||||
                $localize`Error executing delete pages operation`,
 | 
					 | 
				
			||||||
                error
 | 
					                error
 | 
				
			||||||
              )
 | 
					              )
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -497,6 +497,96 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
 | 
				
			|||||||
    return "OK"
 | 
					    return "OK"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def edit_pdf(
 | 
				
			||||||
 | 
					    doc_ids: list[int],
 | 
				
			||||||
 | 
					    operations: list[dict],
 | 
				
			||||||
 | 
					    *,
 | 
				
			||||||
 | 
					    delete_original: bool = False,
 | 
				
			||||||
 | 
					    update_document: bool = False,
 | 
				
			||||||
 | 
					    include_metadata: bool = True,
 | 
				
			||||||
 | 
					    user: User | None = None,
 | 
				
			||||||
 | 
					) -> Literal["OK"]:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Operations is a list of dictionaries describing the final PDF pages.
 | 
				
			||||||
 | 
					    Each entry must contain the original page number in `page` and may
 | 
				
			||||||
 | 
					    specify `rotate` in degrees and `doc` indicating the output
 | 
				
			||||||
 | 
					    document index (for splitting). Pages omitted from the list are
 | 
				
			||||||
 | 
					    discarded.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    logger.info(
 | 
				
			||||||
 | 
					        f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    doc = Document.objects.get(id=doc_ids[0])
 | 
				
			||||||
 | 
					    import pikepdf
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    pdf_docs: list[pikepdf.Pdf] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        with pikepdf.open(doc.source_path) as src:
 | 
				
			||||||
 | 
					            # prepare output documents
 | 
				
			||||||
 | 
					            max_idx = max(op.get("doc", 0) for op in operations)
 | 
				
			||||||
 | 
					            pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for op in operations:
 | 
				
			||||||
 | 
					                dst = pdf_docs[op.get("doc", 0)]
 | 
				
			||||||
 | 
					                page = src.pages[op["page"] - 1]
 | 
				
			||||||
 | 
					                dst.pages.append(page)
 | 
				
			||||||
 | 
					                if op.get("rotate"):
 | 
				
			||||||
 | 
					                    dst.pages[-1].rotate(op["rotate"], relative=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if update_document:
 | 
				
			||||||
 | 
					            if len(pdf_docs) != 1:
 | 
				
			||||||
 | 
					                logger.error(
 | 
				
			||||||
 | 
					                    "Update requested but multiple output documents specified",
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                return "ERROR"
 | 
				
			||||||
 | 
					            pdf = pdf_docs[0]
 | 
				
			||||||
 | 
					            pdf.remove_unreferenced_resources()
 | 
				
			||||||
 | 
					            pdf.save(doc.source_path)
 | 
				
			||||||
 | 
					            doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
 | 
				
			||||||
 | 
					            doc.page_count = len(pdf.pages)
 | 
				
			||||||
 | 
					            doc.save()
 | 
				
			||||||
 | 
					            update_document_content_maybe_archive_file.delay(document_id=doc.id)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            consume_tasks = []
 | 
				
			||||||
 | 
					            overrides = (
 | 
				
			||||||
 | 
					                DocumentMetadataOverrides().from_document(doc)
 | 
				
			||||||
 | 
					                if include_metadata
 | 
				
			||||||
 | 
					                else DocumentMetadataOverrides()
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            if user is not None:
 | 
				
			||||||
 | 
					                overrides.owner_id = user.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for idx, pdf in enumerate(pdf_docs, start=1):
 | 
				
			||||||
 | 
					                filepath: Path = (
 | 
				
			||||||
 | 
					                    Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
 | 
				
			||||||
 | 
					                    / f"{doc.id}_edit_{idx}.pdf"
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                pdf.remove_unreferenced_resources()
 | 
				
			||||||
 | 
					                pdf.save(filepath)
 | 
				
			||||||
 | 
					                consume_tasks.append(
 | 
				
			||||||
 | 
					                    consume_file.s(
 | 
				
			||||||
 | 
					                        ConsumableDocument(
 | 
				
			||||||
 | 
					                            source=DocumentSource.ConsumeFolder,
 | 
				
			||||||
 | 
					                            original_file=filepath,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        overrides,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if delete_original:
 | 
				
			||||||
 | 
					                chord(header=consume_tasks, body=delete.si([doc.id])).delay()
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                group(consume_tasks).delay()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logger.exception(f"Error editing document {doc.id}: {e}")
 | 
				
			||||||
 | 
					        return "ERROR"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return "OK"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def reflect_doclinks(
 | 
					def reflect_doclinks(
 | 
				
			||||||
    document: Document,
 | 
					    document: Document,
 | 
				
			||||||
    field: CustomField,
 | 
					    field: CustomField,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1293,6 +1293,7 @@ class BulkEditSerializer(
 | 
				
			|||||||
            "merge",
 | 
					            "merge",
 | 
				
			||||||
            "split",
 | 
					            "split",
 | 
				
			||||||
            "delete_pages",
 | 
					            "delete_pages",
 | 
				
			||||||
 | 
					            "edit_pdf",
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
        label="Method",
 | 
					        label="Method",
 | 
				
			||||||
        write_only=True,
 | 
					        write_only=True,
 | 
				
			||||||
@@ -1366,7 +1367,10 @@ class BulkEditSerializer(
 | 
				
			|||||||
            return bulk_edit.split
 | 
					            return bulk_edit.split
 | 
				
			||||||
        elif method == "delete_pages":
 | 
					        elif method == "delete_pages":
 | 
				
			||||||
            return bulk_edit.delete_pages
 | 
					            return bulk_edit.delete_pages
 | 
				
			||||||
        else:
 | 
					        elif method == "edit_pdf":
 | 
				
			||||||
 | 
					            return bulk_edit.edit_pdf
 | 
				
			||||||
 | 
					        else:  # pragma: no cover
 | 
				
			||||||
 | 
					            # This will never happen as it is handled by the ChoiceField
 | 
				
			||||||
            raise serializers.ValidationError("Unsupported method.")
 | 
					            raise serializers.ValidationError("Unsupported method.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _validate_parameters_tags(self, parameters):
 | 
					    def _validate_parameters_tags(self, parameters):
 | 
				
			||||||
@@ -1520,6 +1524,38 @@ class BulkEditSerializer(
 | 
				
			|||||||
        else:
 | 
					        else:
 | 
				
			||||||
            parameters["archive_fallback"] = False
 | 
					            parameters["archive_fallback"] = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _validate_parameters_edit_pdf(self, parameters):
 | 
				
			||||||
 | 
					        if "operations" not in parameters:
 | 
				
			||||||
 | 
					            raise serializers.ValidationError("operations not specified")
 | 
				
			||||||
 | 
					        if not isinstance(parameters["operations"], list):
 | 
				
			||||||
 | 
					            raise serializers.ValidationError("operations must be a list")
 | 
				
			||||||
 | 
					        for op in parameters["operations"]:
 | 
				
			||||||
 | 
					            if not isinstance(op, dict):
 | 
				
			||||||
 | 
					                raise serializers.ValidationError("invalid operation entry")
 | 
				
			||||||
 | 
					            if "page" not in op or not isinstance(op["page"], int):
 | 
				
			||||||
 | 
					                raise serializers.ValidationError("page must be an integer")
 | 
				
			||||||
 | 
					            if "rotate" in op and not isinstance(op["rotate"], int):
 | 
				
			||||||
 | 
					                raise serializers.ValidationError("rotate must be an integer")
 | 
				
			||||||
 | 
					            if "doc" in op and not isinstance(op["doc"], int):
 | 
				
			||||||
 | 
					                raise serializers.ValidationError("doc must be an integer")
 | 
				
			||||||
 | 
					        if "update_document" in parameters:
 | 
				
			||||||
 | 
					            if not isinstance(parameters["update_document"], bool):
 | 
				
			||||||
 | 
					                raise serializers.ValidationError("update_document must be a boolean")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            parameters["update_document"] = False
 | 
				
			||||||
 | 
					        if "include_metadata" in parameters:
 | 
				
			||||||
 | 
					            if not isinstance(parameters["include_metadata"], bool):
 | 
				
			||||||
 | 
					                raise serializers.ValidationError("include_metadata must be a boolean")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            parameters["include_metadata"] = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if parameters["update_document"]:
 | 
				
			||||||
 | 
					            max_idx = max(op.get("doc", 0) for op in parameters["operations"])
 | 
				
			||||||
 | 
					            if max_idx > 0:
 | 
				
			||||||
 | 
					                raise serializers.ValidationError(
 | 
				
			||||||
 | 
					                    "update_document only allowed with a single output document",
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def validate(self, attrs):
 | 
					    def validate(self, attrs):
 | 
				
			||||||
        method = attrs["method"]
 | 
					        method = attrs["method"]
 | 
				
			||||||
        parameters = attrs["parameters"]
 | 
					        parameters = attrs["parameters"]
 | 
				
			||||||
@@ -1554,6 +1590,12 @@ class BulkEditSerializer(
 | 
				
			|||||||
            self._validate_parameters_delete_pages(parameters)
 | 
					            self._validate_parameters_delete_pages(parameters)
 | 
				
			||||||
        elif method == bulk_edit.merge:
 | 
					        elif method == bulk_edit.merge:
 | 
				
			||||||
            self._validate_parameters_merge(parameters)
 | 
					            self._validate_parameters_merge(parameters)
 | 
				
			||||||
 | 
					        elif method == bulk_edit.edit_pdf:
 | 
				
			||||||
 | 
					            if len(attrs["documents"]) > 1:
 | 
				
			||||||
 | 
					                raise serializers.ValidationError(
 | 
				
			||||||
 | 
					                    "Edit PDF method only supports one document",
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            self._validate_parameters_edit_pdf(parameters)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return attrs
 | 
					        return attrs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1369,6 +1369,192 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
 | 
				
			|||||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
        self.assertIn(b"pages must be a list of integers", response.content)
 | 
					        self.assertIn(b"pages must be a list of integers", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch("documents.serialisers.bulk_edit.edit_pdf")
 | 
				
			||||||
 | 
					    def test_edit_pdf(self, m):
 | 
				
			||||||
 | 
					        self.setup_mock(m, "edit_pdf")
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {"operations": [{"page": 1}]},
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        m.assert_called_once()
 | 
				
			||||||
 | 
					        args, kwargs = m.call_args
 | 
				
			||||||
 | 
					        self.assertCountEqual(args[0], [self.doc2.id])
 | 
				
			||||||
 | 
					        self.assertEqual(kwargs["operations"], [{"page": 1}])
 | 
				
			||||||
 | 
					        self.assertEqual(kwargs["user"], self.user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_edit_pdf_invalid_params(self):
 | 
				
			||||||
 | 
					        # multiple documents
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id, self.doc3.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {"operations": [{"page": 1}]},
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(b"Edit PDF method only supports one document", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # no operations specified
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {},
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(b"operations not specified", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # operations not a list
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {"operations": "not_a_list"},
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(b"operations must be a list", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # invalid operation
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {"operations": ["invalid_operation"]},
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(b"invalid operation entry", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # page not an int
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {"operations": [{"page": "not_an_int"}]},
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(b"page must be an integer", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # rotate not an int
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {"operations": [{"page": 1, "rotate": "not_an_int"}]},
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(b"rotate must be an integer", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # doc not an int
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {"operations": [{"page": 1, "doc": "not_an_int"}]},
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(b"doc must be an integer", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # update_document not a boolean
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {
 | 
				
			||||||
 | 
					                        "update_document": "not_a_bool",
 | 
				
			||||||
 | 
					                        "operations": [{"page": 1}],
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(b"update_document must be a boolean", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # include_metadata not a boolean
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {
 | 
				
			||||||
 | 
					                        "include_metadata": "not_a_bool",
 | 
				
			||||||
 | 
					                        "operations": [{"page": 1}],
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(b"include_metadata must be a boolean", response.content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # update_document True but output would be multiple documents
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            "/api/documents/bulk_edit/",
 | 
				
			||||||
 | 
					            json.dumps(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "documents": [self.doc2.id],
 | 
				
			||||||
 | 
					                    "method": "edit_pdf",
 | 
				
			||||||
 | 
					                    "parameters": {
 | 
				
			||||||
 | 
					                        "update_document": True,
 | 
				
			||||||
 | 
					                        "operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}],
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            content_type="application/json",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					        self.assertIn(
 | 
				
			||||||
 | 
					            b"update_document only allowed with a single output document",
 | 
				
			||||||
 | 
					            response.content,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @override_settings(AUDIT_LOG_ENABLED=True)
 | 
					    @override_settings(AUDIT_LOG_ENABLED=True)
 | 
				
			||||||
    def test_bulk_edit_audit_log_enabled_simple_field(self):
 | 
					    def test_bulk_edit_audit_log_enabled_simple_field(self):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -909,3 +909,156 @@ class TestPDFActions(DirectoriesMixin, TestCase):
 | 
				
			|||||||
            expected_str = "Error deleting pages from document"
 | 
					            expected_str = "Error deleting pages from document"
 | 
				
			||||||
            self.assertIn(expected_str, error_str)
 | 
					            self.assertIn(expected_str, error_str)
 | 
				
			||||||
            mock_update_archive_file.assert_not_called()
 | 
					            mock_update_archive_file.assert_not_called()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch("documents.bulk_edit.group")
 | 
				
			||||||
 | 
					    @mock.patch("documents.tasks.consume_file.s")
 | 
				
			||||||
 | 
					    def test_edit_pdf_basic_operations(self, mock_consume_file, mock_group):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        GIVEN:
 | 
				
			||||||
 | 
					            - Existing document
 | 
				
			||||||
 | 
					        WHEN:
 | 
				
			||||||
 | 
					            - edit_pdf is called with two operations to split the doc and rotate pages
 | 
				
			||||||
 | 
					        THEN:
 | 
				
			||||||
 | 
					            - A grouped task is generated and delay() is called
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        mock_group.return_value.delay.return_value = None
 | 
				
			||||||
 | 
					        doc_ids = [self.doc2.id]
 | 
				
			||||||
 | 
					        operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1, "rotate": 90}]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = bulk_edit.edit_pdf(doc_ids, operations)
 | 
				
			||||||
 | 
					        self.assertEqual(result, "OK")
 | 
				
			||||||
 | 
					        mock_group.return_value.delay.assert_called_once()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch("documents.bulk_edit.group")
 | 
				
			||||||
 | 
					    @mock.patch("documents.tasks.consume_file.s")
 | 
				
			||||||
 | 
					    def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        GIVEN:
 | 
				
			||||||
 | 
					            - Existing document
 | 
				
			||||||
 | 
					        WHEN:
 | 
				
			||||||
 | 
					            - edit_pdf is called with user override
 | 
				
			||||||
 | 
					        THEN:
 | 
				
			||||||
 | 
					            - Task is created with user context
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        mock_group.return_value.delay.return_value = None
 | 
				
			||||||
 | 
					        doc_ids = [self.doc2.id]
 | 
				
			||||||
 | 
					        operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}]
 | 
				
			||||||
 | 
					        user = User.objects.create(username="editor")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = bulk_edit.edit_pdf(doc_ids, operations, user=user)
 | 
				
			||||||
 | 
					        self.assertEqual(result, "OK")
 | 
				
			||||||
 | 
					        mock_group.return_value.delay.assert_called_once()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch("documents.bulk_edit.chord")
 | 
				
			||||||
 | 
					    @mock.patch("documents.tasks.consume_file.s")
 | 
				
			||||||
 | 
					    def test_edit_pdf_with_delete_original(self, mock_consume_file, mock_chord):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        GIVEN:
 | 
				
			||||||
 | 
					            - Existing document
 | 
				
			||||||
 | 
					        WHEN:
 | 
				
			||||||
 | 
					            - edit_pdf is called with delete_original=True
 | 
				
			||||||
 | 
					        THEN:
 | 
				
			||||||
 | 
					            - Task group is triggered
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        mock_chord.return_value.delay.return_value = None
 | 
				
			||||||
 | 
					        doc_ids = [self.doc2.id]
 | 
				
			||||||
 | 
					        operations = [{"page": 1}, {"page": 2}]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
 | 
				
			||||||
 | 
					        self.assertEqual(result, "OK")
 | 
				
			||||||
 | 
					        mock_chord.assert_called_once()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
 | 
				
			||||||
 | 
					    def test_edit_pdf_with_update_document(self, mock_update_document):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        GIVEN:
 | 
				
			||||||
 | 
					            - A single existing PDF document
 | 
				
			||||||
 | 
					        WHEN:
 | 
				
			||||||
 | 
					            - edit_pdf is called with update_document=True and a single output
 | 
				
			||||||
 | 
					        THEN:
 | 
				
			||||||
 | 
					            - The original document is updated in-place
 | 
				
			||||||
 | 
					            - The update_document_content_maybe_archive_file task is triggered
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        doc_ids = [self.doc2.id]
 | 
				
			||||||
 | 
					        operations = [{"page": 1}, {"page": 2}]
 | 
				
			||||||
 | 
					        original_checksum = self.doc2.checksum
 | 
				
			||||||
 | 
					        original_page_count = self.doc2.page_count
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = bulk_edit.edit_pdf(
 | 
				
			||||||
 | 
					            doc_ids,
 | 
				
			||||||
 | 
					            operations=operations,
 | 
				
			||||||
 | 
					            update_document=True,
 | 
				
			||||||
 | 
					            delete_original=False,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(result, "OK")
 | 
				
			||||||
 | 
					        self.doc2.refresh_from_db()
 | 
				
			||||||
 | 
					        self.assertNotEqual(self.doc2.checksum, original_checksum)
 | 
				
			||||||
 | 
					        self.assertNotEqual(self.doc2.page_count, original_page_count)
 | 
				
			||||||
 | 
					        mock_update_document.assert_called_once_with(document_id=self.doc2.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch("documents.bulk_edit.group")
 | 
				
			||||||
 | 
					    @mock.patch("documents.tasks.consume_file.s")
 | 
				
			||||||
 | 
					    def test_edit_pdf_without_metadata(self, mock_consume_file, mock_group):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        GIVEN:
 | 
				
			||||||
 | 
					            - Existing document
 | 
				
			||||||
 | 
					        WHEN:
 | 
				
			||||||
 | 
					            - edit_pdf is called with include_metadata=False
 | 
				
			||||||
 | 
					        THEN:
 | 
				
			||||||
 | 
					            - Tasks are created with empty metadata
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        mock_group.return_value.delay.return_value = None
 | 
				
			||||||
 | 
					        doc_ids = [self.doc2.id]
 | 
				
			||||||
 | 
					        operations = [{"page": 1}]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = bulk_edit.edit_pdf(doc_ids, operations, include_metadata=False)
 | 
				
			||||||
 | 
					        self.assertEqual(result, "OK")
 | 
				
			||||||
 | 
					        mock_group.return_value.delay.assert_called_once()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch("documents.bulk_edit.group")
 | 
				
			||||||
 | 
					    @mock.patch("documents.tasks.consume_file.s")
 | 
				
			||||||
 | 
					    def test_edit_pdf_open_failure(self, mock_consume_file, mock_group):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        GIVEN:
 | 
				
			||||||
 | 
					            - Existing document
 | 
				
			||||||
 | 
					        WHEN:
 | 
				
			||||||
 | 
					            - edit_pdf fails to open PDF
 | 
				
			||||||
 | 
					        THEN:
 | 
				
			||||||
 | 
					            - Task group is not called
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        doc_ids = [self.doc2.id]
 | 
				
			||||||
 | 
					        operations = [
 | 
				
			||||||
 | 
					            {"page": 9999},  # invalid page, forces error during PDF load
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        with self.assertLogs("paperless.bulk_edit", level="ERROR"):
 | 
				
			||||||
 | 
					            result = bulk_edit.edit_pdf(doc_ids, operations)
 | 
				
			||||||
 | 
					            self.assertEqual(result, "ERROR")
 | 
				
			||||||
 | 
					        mock_group.assert_not_called()
 | 
				
			||||||
 | 
					        mock_consume_file.assert_not_called()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch("documents.bulk_edit.group")
 | 
				
			||||||
 | 
					    @mock.patch("documents.tasks.consume_file.s")
 | 
				
			||||||
 | 
					    def test_edit_pdf_multiple_outputs_with_update_flag_errors(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        mock_consume_file,
 | 
				
			||||||
 | 
					        mock_group,
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        GIVEN:
 | 
				
			||||||
 | 
					            - Existing document
 | 
				
			||||||
 | 
					        WHEN:
 | 
				
			||||||
 | 
					            - edit_pdf is called with multiple outputs and update_document=True
 | 
				
			||||||
 | 
					        THEN:
 | 
				
			||||||
 | 
					            - An error is logged and task group is not called
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        doc_ids = [self.doc2.id]
 | 
				
			||||||
 | 
					        operations = [
 | 
				
			||||||
 | 
					            {"page": 1, "doc": 0},
 | 
				
			||||||
 | 
					            {"page": 2, "doc": 1},
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        with self.assertLogs("paperless.bulk_edit", level="ERROR"):
 | 
				
			||||||
 | 
					            result = bulk_edit.edit_pdf(doc_ids, operations, update_document=True)
 | 
				
			||||||
 | 
					            self.assertEqual(result, "ERROR")
 | 
				
			||||||
 | 
					        mock_group.assert_not_called()
 | 
				
			||||||
 | 
					        mock_consume_file.assert_not_called()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1314,6 +1314,7 @@ class BulkEditView(PassUserMixin):
 | 
				
			|||||||
        "delete_pages": "checksum",
 | 
					        "delete_pages": "checksum",
 | 
				
			||||||
        "split": None,
 | 
					        "split": None,
 | 
				
			||||||
        "merge": None,
 | 
					        "merge": None,
 | 
				
			||||||
 | 
					        "edit_pdf": None,
 | 
				
			||||||
        "reprocess": "checksum",
 | 
					        "reprocess": "checksum",
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1332,6 +1333,7 @@ class BulkEditView(PassUserMixin):
 | 
				
			|||||||
        if method in [
 | 
					        if method in [
 | 
				
			||||||
            bulk_edit.split,
 | 
					            bulk_edit.split,
 | 
				
			||||||
            bulk_edit.merge,
 | 
					            bulk_edit.merge,
 | 
				
			||||||
 | 
					            bulk_edit.edit_pdf,
 | 
				
			||||||
        ]:
 | 
					        ]:
 | 
				
			||||||
            parameters["user"] = user
 | 
					            parameters["user"] = user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1351,27 +1353,36 @@ class BulkEditView(PassUserMixin):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
            # check ownership for methods that change original document
 | 
					            # check ownership for methods that change original document
 | 
				
			||||||
            if (
 | 
					            if (
 | 
				
			||||||
                has_perms
 | 
					                (
 | 
				
			||||||
                and method
 | 
					                    has_perms
 | 
				
			||||||
                in [
 | 
					                    and method
 | 
				
			||||||
                    bulk_edit.set_permissions,
 | 
					                    in [
 | 
				
			||||||
                    bulk_edit.delete,
 | 
					                        bulk_edit.set_permissions,
 | 
				
			||||||
                    bulk_edit.rotate,
 | 
					                        bulk_edit.delete,
 | 
				
			||||||
                    bulk_edit.delete_pages,
 | 
					                        bulk_edit.rotate,
 | 
				
			||||||
                ]
 | 
					                        bulk_edit.delete_pages,
 | 
				
			||||||
            ) or (
 | 
					                        bulk_edit.edit_pdf,
 | 
				
			||||||
                method in [bulk_edit.merge, bulk_edit.split]
 | 
					                    ]
 | 
				
			||||||
                and parameters["delete_originals"]
 | 
					                )
 | 
				
			||||||
 | 
					                or (
 | 
				
			||||||
 | 
					                    method in [bulk_edit.merge, bulk_edit.split]
 | 
				
			||||||
 | 
					                    and parameters["delete_originals"]
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                or (method == bulk_edit.edit_pdf and parameters["update_document"])
 | 
				
			||||||
            ):
 | 
					            ):
 | 
				
			||||||
                has_perms = user_is_owner_of_all_documents
 | 
					                has_perms = user_is_owner_of_all_documents
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # check global add permissions for methods that create documents
 | 
					            # check global add permissions for methods that create documents
 | 
				
			||||||
            if (
 | 
					            if (
 | 
				
			||||||
                has_perms
 | 
					                has_perms
 | 
				
			||||||
                and method in [bulk_edit.split, bulk_edit.merge]
 | 
					                and (
 | 
				
			||||||
                and not user.has_perm(
 | 
					                    method in [bulk_edit.split, bulk_edit.merge]
 | 
				
			||||||
                    "documents.add_document",
 | 
					                    or (
 | 
				
			||||||
 | 
					                        method == bulk_edit.edit_pdf
 | 
				
			||||||
 | 
					                        and not parameters["update_document"]
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
					                and not user.has_perm("documents.add_document")
 | 
				
			||||||
            ):
 | 
					            ):
 | 
				
			||||||
                has_perms = False
 | 
					                has_perms = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user