mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			29 Commits
		
	
	
		
			feature-co
			...
			47d697fb6b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 47d697fb6b | ||
|   | 232f13f69b | ||
|   | 92b9c69806 | ||
|   | d9e9478d68 | ||
|   | ea18fb611f | ||
|   | 6a13c3d2db | ||
|   | 57e42c7df2 | ||
|   | 54868846c2 | ||
|   | 676f793bcf | ||
|   | 87d5058ede | ||
|   | 7a1b9007ff | ||
|   | 018c6337ff | ||
|   | 65ca2be2c9 | ||
|   | 935b03201c | ||
|   | ecbd68fb01 | ||
|   | 7ccbaa4b52 | ||
|   | e48eabc3e1 | ||
|   | ec3b6c582a | ||
|   | 3717f2360f | ||
|   | 4b2e493a7a | ||
|   | 0909b874b3 | ||
|   | 0255113ccd | ||
|   | 4c0381f69d | ||
|   | eb3d03b5f9 | ||
|   | 45f03781b8 | ||
|   | 7fd6c6d189 | ||
|   | e8980e2aa7 | ||
|   | 21268738aa | ||
|   | 9bb4404129 | 
							
								
								
									
										12
									
								
								docs/api.md
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								docs/api.md
									
									
									
									
									
								
							| @@ -282,6 +282,18 @@ The following methods are supported: | ||||
|         -   `"merge": true or false` (defaults to false) | ||||
|     -   The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including | ||||
|         removing them) or be merged with existing permissions. | ||||
| -   `edit_pdf` | ||||
|     -   Requires `parameters`: | ||||
|         -   `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit. | ||||
|         -   `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary | ||||
|             with the following keys: | ||||
|             -   `"page": PAGE_NUMBER` The page number to edit (1-based). | ||||
|             -   `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270). | ||||
|             -   `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations. | ||||
|     -   Optional `parameters`: | ||||
|         -   `"delete_original": true` to delete the original documents after editing. | ||||
|         -   `"update_document": true` to update the existing document with the edited PDF. | ||||
|         -   `"include_metadata": true` to copy metadata from the original document to the edited document. | ||||
| -   `merge` | ||||
|     -   No additional `parameters` required. | ||||
|     -   The ordering of the merged document is determined by the list of IDs. | ||||
|   | ||||
| @@ -576,12 +576,14 @@ The following custom field types are supported: | ||||
|  | ||||
| ## 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'. | ||||
| -   Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page. | ||||
| -   Splitting documents: available from an individual document's details page. | ||||
| -   Deleting pages: available 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: via the pdf editor on 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 | ||||
|  | ||||
|   | ||||
| @@ -121,6 +121,26 @@ if (!URL.revokeObjectURL) { | ||||
| } | ||||
| Object.defineProperty(window, 'ResizeObserver', { value: mock() }) | ||||
|  | ||||
| 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 = < | ||||
|   typeof HTMLCanvasElement.prototype.getContext | ||||
| >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> | ||||
|       </button> | ||||
|  | ||||
|       <button ngbDropdownItem (click)="splitDocument()" [disabled]="!userCanAdd || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1"> | ||||
|         <i-bs width="1em" height="1em" name="scissors"></i-bs> <span i18n>Split</span> | ||||
|       </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 ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF"> | ||||
|         <i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   | ||||
| @@ -1158,81 +1158,40 @@ describe('DocumentDetailComponent', () => { | ||||
|     ).not.toBeUndefined() | ||||
|   }) | ||||
|  | ||||
|   it('should support split', () => { | ||||
|   it('should support pdf editor, handle error', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[0])) | ||||
|     initNormally() | ||||
|     component.splitDocument() | ||||
|     component.editPdf() | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     modal.componentInstance.documentID = doc.id | ||||
|     modal.componentInstance.totalPages = 5 | ||||
|     modal.componentInstance.page = 2 | ||||
|     modal.componentInstance.addSplit() | ||||
|     modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] | ||||
|     modal.componentInstance.confirm() | ||||
|     let req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     expect(req.request.body).toEqual({ | ||||
|       documents: [doc.id], | ||||
|       method: 'split', | ||||
|       parameters: { pages: '1-2,3-5', delete_originals: false }, | ||||
|       method: 'edit_pdf', | ||||
|       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) | ||||
|   }) | ||||
|  | ||||
|   it('should support rotate', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[0])) | ||||
|     initNormally() | ||||
|     component.rotateDocument() | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     component.editPdf() | ||||
|     modal.componentInstance.documentID = doc.id | ||||
|     modal.componentInstance.rotate() | ||||
|     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.pages = [{ page: 1, rotate: 0, splitAfter: true }] | ||||
|     modal.componentInstance.confirm() | ||||
|     const errorSpy = jest.spyOn(toastService, 'showError') | ||||
|     req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     req.flush(true) | ||||
|   }) | ||||
|  | ||||
|   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) | ||||
|     req.error(new ErrorEvent('failed')) | ||||
|     expect(errorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support keyboard shortcuts', () => { | ||||
|   | ||||
| @@ -82,9 +82,6 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http' | ||||
| import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' | ||||
| import * as UTIF from 'utif' | ||||
| 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 { 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' | ||||
| @@ -102,6 +99,10 @@ import { TagsComponent } from '../common/input/tags/tags.component' | ||||
| import { TextComponent } from '../common/input/text/text.component' | ||||
| import { UrlComponent } from '../common/input/url/url.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 { DocumentHistoryComponent } from '../document-history/document-history.component' | ||||
| import { DocumentNotesComponent } from '../document-notes/document-notes.component' | ||||
| @@ -1349,13 +1350,13 @@ export class DocumentDetailComponent | ||||
|     this.documentForm.updateValueAndValidity() | ||||
|   } | ||||
|  | ||||
|   splitDocument() { | ||||
|     let modal = this.modalService.open(SplitConfirmDialogComponent, { | ||||
|   editPdf() { | ||||
|     let modal = this.modalService.open(PDFEditorComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'lg', | ||||
|       size: 'xl', | ||||
|       scrollable: true, | ||||
|     }) | ||||
|     modal.componentInstance.title = $localize`Split confirm` | ||||
|     modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.` | ||||
|     modal.componentInstance.title = $localize`PDF Editor` | ||||
|     modal.componentInstance.btnCaption = $localize`Proceed` | ||||
|     modal.componentInstance.documentID = this.document.id | ||||
|     modal.componentInstance.confirmClicked | ||||
| @@ -1363,15 +1364,18 @@ export class DocumentDetailComponent | ||||
|       .subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         this.documentsService | ||||
|           .bulkEdit([this.document.id], 'split', { | ||||
|             pages: modal.componentInstance.pagesString, | ||||
|             delete_originals: modal.componentInstance.deleteOriginal, | ||||
|           .bulkEdit([this.document.id], 'edit_pdf', { | ||||
|             operations: modal.componentInstance.getOperations(), | ||||
|             delete_original: modal.componentInstance.deleteOriginal, | ||||
|             update_document: | ||||
|               modal.componentInstance.editMode == PdfEditorEditMode.Update, | ||||
|             include_metadata: modal.componentInstance.includeMetadata, | ||||
|           }) | ||||
|           .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|           .subscribe({ | ||||
|             next: () => { | ||||
|               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() | ||||
|             }, | ||||
| @@ -1380,86 +1384,7 @@ export class DocumentDetailComponent | ||||
|                 modal.componentInstance.buttonsEnabled = true | ||||
|               } | ||||
|               this.toastService.showError( | ||||
|                 $localize`Error executing split 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`, | ||||
|                 $localize`Error executing PDF edit operation`, | ||||
|                 error | ||||
|               ) | ||||
|             }, | ||||
|   | ||||
| @@ -497,6 +497,103 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["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)] | ||||
|  | ||||
|             if update_document and len(pdf_docs) > 1: | ||||
|                 logger.error( | ||||
|                     "Update requested but multiple output documents specified", | ||||
|                 ) | ||||
|                 raise ValueError("Multiple output documents specified") | ||||
|  | ||||
|             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: | ||||
|             temp_path = doc.source_path.with_suffix(".tmp.pdf") | ||||
|             pdf = pdf_docs[0] | ||||
|             pdf.remove_unreferenced_resources() | ||||
|             # save the edited PDF to a temporary file in case of errors | ||||
|             pdf.save(temp_path) | ||||
|             # replace the original document with the edited one | ||||
|             temp_path.replace(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}") | ||||
|         raise ValueError( | ||||
|             f"An error occurred while editing the document: {e}", | ||||
|         ) from e | ||||
|  | ||||
|     return "OK" | ||||
|  | ||||
|  | ||||
| def reflect_doclinks( | ||||
|     document: Document, | ||||
|     field: CustomField, | ||||
|   | ||||
| @@ -1293,6 +1293,7 @@ class BulkEditSerializer( | ||||
|             "merge", | ||||
|             "split", | ||||
|             "delete_pages", | ||||
|             "edit_pdf", | ||||
|         ], | ||||
|         label="Method", | ||||
|         write_only=True, | ||||
| @@ -1366,7 +1367,10 @@ class BulkEditSerializer( | ||||
|             return bulk_edit.split | ||||
|         elif method == "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.") | ||||
|  | ||||
|     def _validate_parameters_tags(self, parameters): | ||||
| @@ -1520,6 +1524,47 @@ class BulkEditSerializer( | ||||
|         else: | ||||
|             parameters["archive_fallback"] = False | ||||
|  | ||||
|     def _validate_parameters_edit_pdf(self, parameters, document_id): | ||||
|         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", | ||||
|                 ) | ||||
|  | ||||
|         doc = Document.objects.get(id=document_id) | ||||
|         # doc existence is already validated | ||||
|         if doc.page_count: | ||||
|             for op in parameters["operations"]: | ||||
|                 if op["page"] < 1 or op["page"] > doc.page_count: | ||||
|                     raise serializers.ValidationError( | ||||
|                         f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.", | ||||
|                     ) | ||||
|  | ||||
|     def validate(self, attrs): | ||||
|         method = attrs["method"] | ||||
|         parameters = attrs["parameters"] | ||||
| @@ -1554,6 +1599,12 @@ class BulkEditSerializer( | ||||
|             self._validate_parameters_delete_pages(parameters) | ||||
|         elif method == bulk_edit.merge: | ||||
|             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, attrs["documents"][0]) | ||||
|  | ||||
|         return attrs | ||||
|  | ||||
|   | ||||
| @@ -41,6 +41,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): | ||||
|             title="B", | ||||
|             correspondent=self.c1, | ||||
|             document_type=self.dt1, | ||||
|             page_count=5, | ||||
|         ) | ||||
|         self.doc3 = Document.objects.create( | ||||
|             checksum="C", | ||||
| @@ -1369,6 +1370,218 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         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, | ||||
|         ) | ||||
|  | ||||
|     @mock.patch("documents.serialisers.bulk_edit.edit_pdf") | ||||
|     def test_edit_pdf_page_out_of_bounds(self, m): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - API data for editing PDF is called | ||||
|             - The page number is out of bounds | ||||
|         WHEN: | ||||
|             - API is called | ||||
|         THEN: | ||||
|             - The API fails with a correct error code | ||||
|         """ | ||||
|         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": 99}]}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"out of bounds", response.content) | ||||
|  | ||||
|     @override_settings(AUDIT_LOG_ENABLED=True) | ||||
|     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" | ||||
|             self.assertIn(expected_str, error_str) | ||||
|             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"): | ||||
|             with self.assertRaises(Exception): | ||||
|                 bulk_edit.edit_pdf(doc_ids, operations) | ||||
|         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"): | ||||
|             with self.assertRaises(ValueError): | ||||
|                 bulk_edit.edit_pdf(doc_ids, operations, update_document=True) | ||||
|         mock_group.assert_not_called() | ||||
|         mock_consume_file.assert_not_called() | ||||
|   | ||||
| @@ -1321,6 +1321,7 @@ class BulkEditView(PassUserMixin): | ||||
|         "delete_pages": "checksum", | ||||
|         "split": None, | ||||
|         "merge": None, | ||||
|         "edit_pdf": "checksum", | ||||
|         "reprocess": "checksum", | ||||
|     } | ||||
|  | ||||
| @@ -1339,6 +1340,7 @@ class BulkEditView(PassUserMixin): | ||||
|         if method in [ | ||||
|             bulk_edit.split, | ||||
|             bulk_edit.merge, | ||||
|             bulk_edit.edit_pdf, | ||||
|         ]: | ||||
|             parameters["user"] = user | ||||
|  | ||||
| @@ -1358,27 +1360,36 @@ class BulkEditView(PassUserMixin): | ||||
|  | ||||
|             # check ownership for methods that change original document | ||||
|             if ( | ||||
|                 has_perms | ||||
|                 and method | ||||
|                 in [ | ||||
|                     bulk_edit.set_permissions, | ||||
|                     bulk_edit.delete, | ||||
|                     bulk_edit.rotate, | ||||
|                     bulk_edit.delete_pages, | ||||
|                 ] | ||||
|             ) or ( | ||||
|                 method in [bulk_edit.merge, bulk_edit.split] | ||||
|                 and parameters["delete_originals"] | ||||
|                 ( | ||||
|                     has_perms | ||||
|                     and method | ||||
|                     in [ | ||||
|                         bulk_edit.set_permissions, | ||||
|                         bulk_edit.delete, | ||||
|                         bulk_edit.rotate, | ||||
|                         bulk_edit.delete_pages, | ||||
|                         bulk_edit.edit_pdf, | ||||
|                     ] | ||||
|                 ) | ||||
|                 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 | ||||
|  | ||||
|             # check global add permissions for methods that create documents | ||||
|             if ( | ||||
|                 has_perms | ||||
|                 and method in [bulk_edit.split, bulk_edit.merge] | ||||
|                 and not user.has_perm( | ||||
|                     "documents.add_document", | ||||
|                 and ( | ||||
|                     method in [bulk_edit.split, bulk_edit.merge] | ||||
|                     or ( | ||||
|                         method == bulk_edit.edit_pdf | ||||
|                         and not parameters["update_document"] | ||||
|                     ) | ||||
|                 ) | ||||
|                 and not user.has_perm("documents.add_document") | ||||
|             ): | ||||
|                 has_perms = False | ||||
|  | ||||
| @@ -1416,7 +1427,6 @@ class BulkEditView(PassUserMixin): | ||||
|                     ) | ||||
|                 } | ||||
|  | ||||
|             # TODO: parameter validation | ||||
|             result = method(documents, **parameters) | ||||
|  | ||||
|             if settings.AUDIT_LOG_ENABLED and modified_field: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user