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 @@
+
+
+
+
+
+
+
+ @if (documentID) {
+
![]()
+ }
+
+
+
+
+
+
+
+ @if (messageBold) {
+
{{messageBold}}
+ }
+ @if (message) {
+
+ }
+
+
+
+
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