diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index 26c890a79..f990122dd 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -118,6 +118,7 @@ import { SystemStatusDialogComponent } from './components/common/system-status-d import { NgxFilesizeModule } from 'ngx-filesize' import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' +import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component' import { airplane, archive, @@ -193,6 +194,7 @@ import { plus, plusCircle, questionCircle, + scissors, search, slashCircle, sliders2Vertical, @@ -286,6 +288,7 @@ const icons = { plus, plusCircle, questionCircle, + scissors, search, slashCircle, sliders2Vertical, @@ -468,6 +471,7 @@ function initializeApp(settings: SettingsService) { SystemStatusDialogComponent, RotateConfirmDialogComponent, MergeConfirmDialogComponent, + SplitConfirmDialogComponent, ], imports: [ BrowserModule, diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html new file mode 100644 index 000000000..6510482d9 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html @@ -0,0 +1,55 @@ + + + diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.scss b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.scss new file mode 100644 index 000000000..c2fc99d55 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.scss @@ -0,0 +1,9 @@ +.pdf-viewer-container { + background-color: gray; + height: 300px; + + pngx-pdf-viewer { + width: 100%; + height: 100%; + } + } diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.spec.ts new file mode 100644 index 000000000..b88835895 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.spec.ts @@ -0,0 +1,81 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { SplitConfirmDialogComponent } from './split-confirm-dialog.component' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { ReactiveFormsModule, FormsModule } from '@angular/forms' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { DocumentService } from 'src/app/services/rest/document.service' +import { PdfViewerComponent } from '../../pdf-viewer/pdf-viewer.component' + +describe('SplitConfirmDialogComponent', () => { + let component: SplitConfirmDialogComponent + let fixture: ComponentFixture + let documentService: DocumentService + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [SplitConfirmDialogComponent, PdfViewerComponent], + providers: [NgbActiveModal], + imports: [ + HttpClientTestingModule, + NgxBootstrapIconsModule.pick(allIcons), + ReactiveFormsModule, + FormsModule, + ], + }).compileComponents() + + fixture = TestBed.createComponent(SplitConfirmDialogComponent) + documentService = TestBed.inject(DocumentService) + component = fixture.componentInstance + fixture.detectChanges() + }) + + 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) + }) +}) diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts new file mode 100644 index 000000000..42b574b93 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts @@ -0,0 +1,66 @@ +import { Component } from '@angular/core' +import { ConfirmDialogComponent } from '../confirm-dialog.component' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { DocumentService } from 'src/app/services/rest/document.service' +import { PDFDocumentProxy } from '../../pdf-viewer/typings' + +@Component({ + selector: 'pngx-split-confirm-dialog', + templateUrl: './split-confirm-dialog.component.html', + styleUrl: './split-confirm-dialog.component.scss', +}) +export class SplitConfirmDialogComponent extends ConfirmDialogComponent { + 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 = new Set() + + public documentID: number + public page: number = 1 + public totalPages: number + + public get pdfSrc(): string { + return this.documentService.getPreviewUrl(this.documentID) + } + + constructor( + activeModal: NgbActiveModal, + private documentService: DocumentService + ) { + super(activeModal) + this.confirmButtonEnabled = this.pages.size > 0 + } + + 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()) + 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 + } +} diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 5b27a51ac..4e8443b02 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -44,11 +44,15 @@
+ +
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index e0da11a3e..7663657c0 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1070,6 +1070,33 @@ describe('DocumentDetailComponent', () => { ).not.toBeUndefined() }) + it('should support split', () => { + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[0])) + initNormally() + component.splitDocument() + expect(modal).not.toBeUndefined() + modal.componentInstance.documentID = doc.id + modal.componentInstance.totalPages = 5 + modal.componentInstance.page = 2 + modal.componentInstance.addSplit() + modal.componentInstance.confirm() + let req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/bulk_edit/` + ) + expect(req.request.body).toEqual({ + documents: [doc.id], + method: 'split', + parameters: { pages: '1-2,3-5' }, + }) + req.error(new ProgressEvent('failed')) + modal.componentInstance.confirm() + req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/bulk_edit/` + ) + req.flush(true) + }) + function initNormally() { jest .spyOn(activatedRoute, 'paramMap', 'get') diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 4eae47615..0c61044aa 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -67,6 +67,7 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomFieldInstance } from 'src/app/data/custom-field-instance' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { PDFDocumentProxy } from '../common/pdf-viewer/typings' +import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component' enum DocumentDetailNavIDs { Details = 1, @@ -1040,4 +1041,41 @@ export class DocumentDetailComponent this.updateFormForCustomFields(true) this.documentForm.updateValueAndValidity() } + + splitDocument() { + let modal = this.modalService.open(SplitConfirmDialogComponent, { + backdrop: 'static', + }) + modal.componentInstance.title = $localize`Split confirm` + modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.` + 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], 'split', { + pages: modal.componentInstance.pagesString, + }) + .pipe(first(), takeUntil(this.unsubscribeNotifier)) + .subscribe({ + next: () => { + this.toastService.showInfo( + $localize`Split operation will begin in the background.` + ) + modal.close() + }, + error: (error) => { + if (modal) { + modal.componentInstance.buttonsEnabled = true + } + this.toastService.showError( + $localize`Error executing split operation`, + error + ) + }, + }) + }) + } } diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 8f4cb05d1..1c1d3cf79 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -212,31 +212,13 @@ def merge(doc_ids: list[int], metadata_document_id: Optional[int] = None): ) merged_pdf.save(filepath) - overrides = DocumentMetadataOverrides() - if metadata_document_id: metadata_document = qs.get(id=metadata_document_id) if metadata_document is not None: + overrides = DocumentMetadataOverrides.from_document(metadata_document) overrides.title = metadata_document.title + " (merged)" - overrides.correspondent_id = ( - metadata_document.correspondent.pk - if metadata_document.correspondent - else None - ) - overrides.document_type_id = ( - metadata_document.document_type.pk - if metadata_document.document_type - else None - ) - overrides.storage_path_id = ( - metadata_document.storage_path.pk - if metadata_document.storage_path - else None - ) - overrides.tag_ids = list( - metadata_document.tags.values_list("id", flat=True), - ) - # Include owner and permissions? + else: + overrides = DocumentMetadataOverrides() logger.info("Adding merged document to the task queue.") consume_file.delay( @@ -248,3 +230,42 @@ def merge(doc_ids: list[int], metadata_document_id: Optional[int] = None): ) return "OK" + + +def split(doc_ids: list[int], pages: list[list[int]]): + logger.info( + f"Attempting to split document {doc_ids[0]} into {len(pages)} documents", + ) + doc = Document.objects.get(id=doc_ids[0]) + import pikepdf + + path = os.path.join(settings.ORIGINALS_DIR, str(doc.filename)) + try: + with pikepdf.open(path, allow_overwriting_input=True) as pdf: + for idx, split_doc in enumerate(pages): + dst = pikepdf.new() + for page in split_doc: + dst.pages.append(pdf.pages[page - 1]) + filepath = os.path.join( + settings.CONSUMPTION_DIR, + f"{doc.filename}_{split_doc[0]}-{split_doc[-1]}.pdf", + ) + + dst.save(filepath) + + overrides = DocumentMetadataOverrides().from_document(doc) + overrides.title = f"{doc.title} (split {idx + 1})" + logger.info( + f"Adding split document with pages {split_doc} to the task queue.", + ) + consume_file.delay( + ConsumableDocument( + source=DocumentSource.ConsumeFolder, + original_file=filepath, + ), + overrides, + ) + except Exception as e: + logger.exception(f"Error splitting document {doc.id}", e) + + return "OK" diff --git a/src/documents/data_models.py b/src/documents/data_models.py index 6bf3f4f96..34a2e8203 100644 --- a/src/documents/data_models.py +++ b/src/documents/data_models.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Optional import magic +from guardian.shortcuts import get_groups_with_perms +from guardian.shortcuts import get_users_with_perms @dataclasses.dataclass @@ -88,6 +90,40 @@ class DocumentMetadataOverrides: return self + @staticmethod + def from_document(doc) -> "DocumentMetadataOverrides": + """ + Fills in the overrides from a document object + """ + overrides = DocumentMetadataOverrides() + overrides.title = doc.title + overrides.correspondent_id = doc.correspondent.id if doc.correspondent else None + overrides.document_type_id = doc.document_type.id if doc.document_type else None + overrides.storage_path_id = doc.storage_path.id if doc.storage_path else None + overrides.owner_id = doc.owner.id if doc.owner else None + overrides.tag_ids = list(doc.tags.values_list("id", flat=True)) + overrides.view_users = get_users_with_perms( + doc, + only_with_perms_in=["view_document"], + ).values_list("id", flat=True) + overrides.view_groups = get_groups_with_perms( + doc, + only_with_perms_in=["view_document"], + ).values_list("id", flat=True) + overrides.change_users = get_users_with_perms( + doc, + only_with_perms_in=["change_document"], + ).values_list("id", flat=True) + overrides.change_groups = get_groups_with_perms( + doc, + only_with_perms_in=["change_document"], + ).values_list("id", flat=True) + overrides.custom_field_ids = list( + doc.custom_fields.values_list("id", flat=True), + ) + + return overrides + class DocumentSource(IntEnum): """ diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 5e9b3edbb..bdac7660e 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -871,6 +871,7 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin): "set_permissions", "rotate", "merge", + "split", ], label="Method", write_only=True, @@ -912,6 +913,8 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin): return bulk_edit.rotate elif method == "merge": return bulk_edit.merge + elif method == "split": + return bulk_edit.split else: raise serializers.ValidationError("Unsupported method.") @@ -1000,6 +1003,29 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin): except ValueError: raise serializers.ValidationError("invalid rotation degrees") + def _validate_parameters_split(self, parameters): + if "pages" not in parameters: + raise serializers.ValidationError("pages not specified") + try: + pages = [] + docs = parameters["pages"].split(",") + for doc in docs: + if "-" in doc: + pages.append( + [ + x + for x in range( + int(doc.split("-")[0]), + int(doc.split("-")[1]) + 1, + ) + ], + ) + else: + pages.append([int(doc)]) + parameters["pages"] = pages + except ValueError: + raise serializers.ValidationError("invalid pages specified") + def validate(self, attrs): method = attrs["method"] parameters = attrs["parameters"] @@ -1018,6 +1044,12 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin): self._validate_parameters_set_permissions(parameters) elif method == bulk_edit.rotate: self._validate_parameters_rotate(parameters) + elif method == bulk_edit.split: + if len(attrs["documents"]) > 1: + raise serializers.ValidationError( + "Split method only supports one document", + ) + self._validate_parameters_split(parameters) return attrs diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index e1af6830e..d659c82e8 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -859,3 +859,75 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): args, kwargs = m.call_args self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) self.assertEqual(kwargs["metadata_document_id"], self.doc3.id) + + @mock.patch("documents.serialisers.bulk_edit.split") + def test_split(self, m): + m.return_value = "OK" + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": "split", + "parameters": {"pages": "1,2-4,5-6,7"}, + }, + ), + 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["pages"], [[1], [2, 3, 4], [5, 6], [7]]) + + def test_split_invalid_params(self): + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": "split", + "parameters": {}, # pages not specified + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"pages not specified", response.content) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": "split", + "parameters": {"pages": "1:7"}, # wrong format + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"invalid pages specified", response.content) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [ + self.doc1.id, + self.doc2.id, + ], # only one document supported + "method": "split", + "parameters": {"pages": "1-2,3-7"}, # wrong format + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"Split method only supports one document", response.content)