diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index 568b2bc0e..857bf2873 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -116,9 +116,11 @@ import { ConfirmButtonComponent } from './components/common/confirm-button/confi import { MonetaryComponent } from './components/common/input/monetary/monetary.component' import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component' import { NgxFilesizeModule } from 'ngx-filesize' +import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { airplane, archive, + arrowClockwise, arrowCounterclockwise, arrowDown, arrowLeft, @@ -127,6 +129,7 @@ import { arrowRightShort, arrowUpRight, asterisk, + bodyText, boxArrowUp, boxArrowUpRight, boxes, @@ -209,6 +212,7 @@ import { const icons = { airplane, archive, + arrowClockwise, arrowCounterclockwise, arrowDown, arrowLeft, @@ -217,6 +221,7 @@ const icons = { arrowRightShort, arrowUpRight, asterisk, + bodyText, boxArrowUp, boxArrowUpRight, boxes, @@ -458,6 +463,7 @@ function initializeApp(settings: SettingsService) { ConfirmButtonComponent, MonetaryComponent, SystemStatusDialogComponent, + RotateConfirmDialogComponent, ], imports: [ BrowserModule, diff --git a/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html new file mode 100644 index 000000000..f84464ae9 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html @@ -0,0 +1,53 @@ + + + diff --git a/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.scss b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.scss new file mode 100644 index 000000000..93e950ac1 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.scss @@ -0,0 +1,3 @@ +img { + transition: all 0.25s ease; +} diff --git a/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.spec.ts new file mode 100644 index 000000000..d70e73747 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.spec.ts @@ -0,0 +1,60 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' + +describe('RotateConfirmDialogComponent', () => { + let component: RotateConfirmDialogComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RotateConfirmDialogComponent, SafeHtmlPipe], + providers: [NgbActiveModal, SafeHtmlPipe], + imports: [ + HttpClientTestingModule, + NgxBootstrapIconsModule.pick(allIcons), + ], + }).compileComponents() + + fixture = TestBed.createComponent(RotateConfirmDialogComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should support rotating the image', () => { + component.documentID = 1 + fixture.detectChanges() + component.rotate() + fixture.detectChanges() + expect(component.degrees).toBe(90) + expect(fixture.nativeElement.querySelector('img').style.transform).toBe( + 'rotate(90deg)' + ) + component.rotate() + fixture.detectChanges() + expect(fixture.nativeElement.querySelector('img').style.transform).toBe( + 'rotate(180deg)' + ) + }) + + it('should normalize degrees', () => { + expect(component.degrees).toBe(0) + component.rotate() + expect(component.degrees).toBe(90) + component.rotate() + expect(component.degrees).toBe(180) + component.rotate() + expect(component.degrees).toBe(270) + component.rotate() + expect(component.degrees).toBe(0) + component.rotate() + expect(component.degrees).toBe(90) + component.rotate(false) + expect(component.degrees).toBe(0) + component.rotate(false) + expect(component.degrees).toBe(270) + }) +}) diff --git a/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.ts new file mode 100644 index 000000000..9b79ad0d6 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.ts @@ -0,0 +1,33 @@ +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' + +@Component({ + selector: 'pngx-rotate-confirm-dialog', + templateUrl: './rotate-confirm-dialog.component.html', + styleUrl: './rotate-confirm-dialog.component.scss', +}) +export class RotateConfirmDialogComponent extends ConfirmDialogComponent { + public documentID: number + + // animation is better if we dont normalize yet + public rotation: number = 0 + + public get degrees(): number { + let degrees = this.rotation % 360 + if (degrees < 0) degrees += 360 + return degrees + } + + constructor( + activeModal: NgbActiveModal, + public documentService: DocumentService + ) { + super(activeModal) + } + + rotate(clockwise: boolean = true) { + this.rotation += clockwise ? 90 : -90 + } +} diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html index 686c07bb3..9a6b7b10b 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -80,18 +80,23 @@ + -
- +
+ + -
- -
+
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index 4da9f36df..623a21d5b 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -52,6 +52,8 @@ import { StoragePath } from 'src/app/data/storage-path' 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 { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' +import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' +import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' const selectionData: SelectionData = { selected_tags: [ @@ -97,6 +99,7 @@ describe('BulkEditorComponent', () => { PermissionsGroupComponent, PermissionsUserComponent, SwitchComponent, + RotateConfirmDialogComponent, ], providers: [ PermissionsService, @@ -818,6 +821,42 @@ describe('BulkEditorComponent', () => { ) // listAllFilteredIds }) + it('should support rotate', () => { + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[0])) + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + jest + .spyOn(documentListViewService, 'documents', 'get') + .mockReturnValue([{ id: 3 }, { id: 4 }]) + jest + .spyOn(documentListViewService, 'selected', 'get') + .mockReturnValue(new Set([3, 4])) + jest + .spyOn(permissionsService, 'currentUserHasObjectPermissions') + .mockReturnValue(true) + component.showConfirmationDialogs = true + fixture.detectChanges() + component.rotateSelected() + expect(modal).not.toBeUndefined() + modal.componentInstance.rotate() + modal.componentInstance.confirm() + let req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/bulk_edit/` + ) + req.flush(true) + expect(req.request.body).toEqual({ + documents: [3, 4], + method: 'rotate', + parameters: { degrees: 90 }, + }) + httpTestingController.match( + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` + ) // list reload + httpTestingController.match( + `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` + ) // listAllFilteredIds + }) + it('should support bulk download with archive, originals or both and file formatting', () => { jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 0bfb287cb..cee054d9d 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -39,6 +39,7 @@ import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' +import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' @Component({ selector: 'pngx-bulk-editor', @@ -641,4 +642,25 @@ export class BulkEditorComponent } ) } + + rotateSelected() { + let modal = this.modalService.open(RotateConfirmDialogComponent, { + backdrop: 'static', + }) + const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent + rotateDialog.title = $localize`Rotate confirm` + rotateDialog.messageBold = $localize`This operation will permanently rotate ${this.list.selected.size} selected document(s).` + rotateDialog.message = $localize`This will alter the original copy.` + rotateDialog.btnClass = 'btn-danger' + rotateDialog.btnCaption = $localize`Proceed` + rotateDialog.documentID = Array.from(this.list.selected)[0] + rotateDialog.confirmClicked + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => { + rotateDialog.buttonsEnabled = false + this.executeBulkOperation(modal, 'rotate', { + degrees: rotateDialog.degrees, + }) + }) + } } diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index ba001fd14..5bd33c69a 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -1,4 +1,7 @@ +import hashlib import itertools +import logging +import os from django.db.models import Q @@ -9,6 +12,9 @@ from documents.models import StoragePath from documents.permissions import set_permissions_for_object from documents.tasks import bulk_update_documents from documents.tasks import update_document_archive_file +from paperless import settings + +logger = logging.getLogger("paperless.bulk_edit") def set_correspondent(doc_ids, correspondent): @@ -146,3 +152,30 @@ def set_permissions(doc_ids, set_permissions, owner=None, merge=False): bulk_update_documents.delay(document_ids=affected_docs) return "OK" + + +def rotate(doc_ids: list[int], degrees: int): + qs = Document.objects.filter(id__in=doc_ids) + affected_docs = [] + import pikepdf + + for doc in qs: + try: + with pikepdf.open(doc.source_path, allow_overwriting_input=True) as pdf: + for page in pdf.pages: + page.rotate(degrees, relative=True) + pdf.save() + doc.checksum = hashlib.md5(doc.source_file.read()).hexdigest() + doc.save() + update_document_archive_file.delay( + document_id=doc.id, + ) + logger.info(f"Rotated document {doc.id} ({path}) by {degrees} degrees") + affected_docs.append(doc.id) + except Exception as e: + logger.exception(f"Error rotating document {doc.id}", e) + + if len(affected_docs) > 0: + bulk_update_documents.delay(document_ids=affected_docs) + + return "OK" diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 1c2c6a095..972d1c8e5 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -869,6 +869,7 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin): "delete", "redo_ocr", "set_permissions", + "rotate", ], label="Method", write_only=True, @@ -906,6 +907,8 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin): return bulk_edit.redo_ocr elif method == "set_permissions": return bulk_edit.set_permissions + elif method == "rotate": + return bulk_edit.rotate else: raise serializers.ValidationError("Unsupported method.") @@ -984,6 +987,16 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin): if "merge" not in parameters: parameters["merge"] = False + def _validate_parameters_rotate(self, parameters): + try: + if ( + "degrees" not in parameters + or not float(parameters["degrees"]).is_integer() + ): + raise serializers.ValidationError("invalid rotation degrees") + except ValueError: + raise serializers.ValidationError("invalid rotation degrees") + def validate(self, attrs): method = attrs["method"] parameters = attrs["parameters"] @@ -1000,6 +1013,8 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin): self._validate_storage_path(parameters) elif method == bulk_edit.set_permissions: self._validate_parameters_set_permissions(parameters) + elif method == bulk_edit.rotate: + self._validate_parameters_rotate(parameters) return attrs diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 10093eb44..bbd2485af 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -781,3 +781,58 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) m.assert_called_once() + + @mock.patch("documents.serialisers.bulk_edit.rotate") + def test_rotate(self, m): + m.return_value = "OK" + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "method": "rotate", + "parameters": {"degrees": 90}, + }, + ), + 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.doc3.id]) + self.assertEqual(kwargs["degrees"], 90) + + @mock.patch("documents.serialisers.bulk_edit.rotate") + def test_rotate_invalid_params(self, m): + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "method": "rotate", + "parameters": {"degrees": "foo"}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "method": "rotate", + "parameters": {"degrees": 90.5}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + m.assert_not_called() diff --git a/src/documents/views.py b/src/documents/views.py index 5fa0f7eb1..3e1996215 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -891,7 +891,8 @@ class BulkEditView(GenericAPIView, PassUserMixin): document_objs = Document.objects.filter(pk__in=documents) has_perms = ( all((doc.owner == user or doc.owner is None) for doc in document_objs) - if method == bulk_edit.set_permissions + if method + in [bulk_edit.set_permissions, bulk_edit.delete, bulk_edit.rotate] else all( has_perms_owner_aware(user, "change_document", doc) for doc in document_objs