mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			29 Commits
		
	
	
		
			feature-ai
			...
			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) |         -   `"merge": true or false` (defaults to false) | ||||||
|     -   The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including |     -   The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including | ||||||
|         removing them) or be merged with existing permissions. |         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` | -   `merge` | ||||||
|     -   No additional `parameters` required. |     -   No additional `parameters` required. | ||||||
|     -   The ordering of the merged document is determined by the list of IDs. |     -   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 | ## 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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -121,6 +121,26 @@ if (!URL.revokeObjectURL) { | |||||||
| } | } | ||||||
| Object.defineProperty(window, 'ResizeObserver', { value: mock() }) | 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 = < | 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>PDF Editor</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> | ||||||
|   | |||||||
| @@ -1158,81 +1158,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', () => { | ||||||
|   | |||||||
| @@ -82,9 +82,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' | ||||||
| @@ -102,6 +99,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' | ||||||
| @@ -1349,13 +1350,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`PDF Editor` | ||||||
|     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 | ||||||
| @@ -1363,15 +1364,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() | ||||||
|             }, |             }, | ||||||
| @@ -1380,86 +1384,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,103 @@ 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)] | ||||||
|  |  | ||||||
|  |             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( | 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,47 @@ class BulkEditSerializer( | |||||||
|         else: |         else: | ||||||
|             parameters["archive_fallback"] = False |             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): |     def validate(self, attrs): | ||||||
|         method = attrs["method"] |         method = attrs["method"] | ||||||
|         parameters = attrs["parameters"] |         parameters = attrs["parameters"] | ||||||
| @@ -1554,6 +1599,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, attrs["documents"][0]) | ||||||
|  |  | ||||||
|         return attrs |         return attrs | ||||||
|  |  | ||||||
|   | |||||||
| @@ -41,6 +41,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): | |||||||
|             title="B", |             title="B", | ||||||
|             correspondent=self.c1, |             correspondent=self.c1, | ||||||
|             document_type=self.dt1, |             document_type=self.dt1, | ||||||
|  |             page_count=5, | ||||||
|         ) |         ) | ||||||
|         self.doc3 = Document.objects.create( |         self.doc3 = Document.objects.create( | ||||||
|             checksum="C", |             checksum="C", | ||||||
| @@ -1369,6 +1370,218 @@ 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, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     @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) |     @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"): | ||||||
|  |             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", |         "delete_pages": "checksum", | ||||||
|         "split": None, |         "split": None, | ||||||
|         "merge": None, |         "merge": None, | ||||||
|  |         "edit_pdf": "checksum", | ||||||
|         "reprocess": "checksum", |         "reprocess": "checksum", | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -1339,6 +1340,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 | ||||||
|  |  | ||||||
| @@ -1358,27 +1360,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 | ||||||
|  |  | ||||||
| @@ -1416,7 +1427,6 @@ class BulkEditView(PassUserMixin): | |||||||
|                     ) |                     ) | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|             # TODO: parameter validation |  | ||||||
|             result = method(documents, **parameters) |             result = method(documents, **parameters) | ||||||
|  |  | ||||||
|             if settings.AUDIT_LOG_ENABLED and modified_field: |             if settings.AUDIT_LOG_ENABLED and modified_field: | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user