]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Support update vs create
authorshamoon <4887959+shamoon@users.noreply.github.com>
Wed, 2 Jul 2025 19:33:36 +0000 (12:33 -0700)
committershamoon <4887959+shamoon@users.noreply.github.com>
Sat, 2 Aug 2025 12:22:55 +0000 (08:22 -0400)
src-ui/src/app/components/common/pdf-editor/pdf-editor.component.html
src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts
src-ui/src/app/components/document-detail/document-detail.component.spec.ts
src-ui/src/app/components/document-detail/document-detail.component.ts
src/documents/bulk_edit.py
src/documents/serialisers.py
src/documents/views.py

index 648bc1ce4110a3603953805aff56254e97e39da4..2b1623c1ff804429c3bb5bb2d84ae69c5b11593f 100644 (file)
             <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="Split document here" i18n-title>
+            <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()">
+            <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>
 </div>
-<div class="modal-footer">
-  <div class="form-check form-switch me-auto">
-    <input class="form-check-input" type="checkbox" id="deleteSwitch" [(ngModel)]="deleteOriginal">
-    <label class="form-check-label" for="deleteSwitch" i18n>Delete original after edit</label>
+<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]="EditMode.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]="EditMode.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 === EditMode.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>
-  <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
-  <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
 </div>
index 32cc707608b5650b0c23289719d925d9ae402cc2..c3ab30f20758381dfbb885c0656418a030d123a7 100644 (file)
@@ -19,6 +19,11 @@ interface PageOperation {
   loaded?: boolean
 }
 
+enum EditMode {
+  Update = 'update',
+  Create = 'create',
+}
+
 @Component({
   selector: 'pngx-pdf-editor',
   templateUrl: './pdf-editor.component.html',
@@ -31,13 +36,18 @@ interface PageOperation {
   ],
 })
 export class PDFEditorComponent extends ConfirmDialogComponent {
+  public EditMode = EditMode
+
   private documentService = inject(DocumentService)
-  activeModal = inject(NgbActiveModal)
+  activeModal: NgbActiveModal = inject(NgbActiveModal)
 
   documentID: number
   pages: PageOperation[] = []
   totalPages = 0
-  deleteOriginal = false
+  editMode: EditMode = EditMode.Create
+  deleteOriginal: boolean = false
+  updateDocument: boolean = false
+  includeMetadata: boolean = true
 
   get pdfSrc(): string {
     return this.documentService.getPreviewUrl(this.documentID)
@@ -76,6 +86,10 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
 
   toggleSplit(i: number) {
     this.pages[i].splitAfter = !this.pages[i].splitAfter
+    if (this.pages[i].splitAfter) {
+      // force create mode
+      this.editMode = EditMode.Create
+    }
   }
 
   selectAll() {
@@ -94,6 +108,10 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
     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)
   }
index e818239d83d9e9452d6eafbd1c8cdda528e4f387..38a8cd8347b9b7025c468cb0f4976837f376c8cb 100644 (file)
@@ -1175,7 +1175,8 @@ describe('DocumentDetailComponent', () => {
       method: 'edit_pdf',
       parameters: {
         operations: [{ page: 1, rotate: 0, doc: 0 }],
-        delete_original: false,
+        update_document: false,
+        include_metadata: true,
       },
     })
     req.flush(true)
index c5fbf8e05eee693f10997fd3aefed5e23514525f..d7d2559750a6acd4a4f402ee167c954d25be5f66 100644 (file)
@@ -1363,7 +1363,8 @@ export class DocumentDetailComponent
         this.documentsService
           .bulkEdit([this.document.id], 'edit_pdf', {
             operations: modal.componentInstance.getOperations(),
-            delete_original: modal.componentInstance.deleteOriginal,
+            update_document: modal.componentInstance.updateDocument,
+            include_metadata: modal.componentInstance.includeMetadata,
           })
           .pipe(first(), takeUntil(this.unsubscribeNotifier))
           .subscribe({
index 9b1740a65aa57abe0cf5aa495af519e729682baf..894d0d4aa40b62e8d911654179b2962310fbc75c 100644 (file)
@@ -502,6 +502,8 @@ def edit_pdf(
     operations: list[dict],
     *,
     delete_original: bool = False,
+    update_document: bool = False,
+    include_metadata: bool = True,
     user: User | None = None,
 ) -> Literal["OK"]:
     """
@@ -533,9 +535,25 @@ def edit_pdf(
                 if op.get("rotate"):
                     dst.pages[-1].rotate(op["rotate"], relative=True)
 
+        if update_document:
+            if len(pdf_docs) != 1:
+                logger.error(
+                    "Update requested but multiple output documents specified",
+                )
+                return "ERROR"
+            pdf = pdf_docs[0]
+            pdf.remove_unreferenced_resources()
+            pdf.save(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 = (
+            overrides = (
                 DocumentMetadataOverrides().from_document(doc)
+                if include_metadata
+                else DocumentMetadataOverrides()
             )
             if user is not None:
                 overrides.owner_id = user.id
@@ -564,6 +582,7 @@ def edit_pdf(
 
     except Exception as e:
         logger.exception(f"Error editing document {doc.id}: {e}")
+        return "ERROR"
 
     return "OK"
 
index ae7ab162cdeb899968c5ec5075fe47f17b658ef0..3077d0dd176a0ea0905c3fab819f6caf193d563c 100644 (file)
@@ -1537,11 +1537,23 @@ class BulkEditSerializer(
                 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 "delete_original" in parameters:
-            if not isinstance(parameters["delete_original"], bool):
-                raise serializers.ValidationError("delete_original must be a boolean")
+        if "update_document" in parameters:
+            if not isinstance(parameters["update_document"], bool):
+                raise serializers.ValidationError("update_document must be a boolean")
         else:
-            parameters["delete_original"] = False
+            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",
+                )
 
     def validate(self, attrs):
         method = attrs["method"]
index 94329043c8c5dc21f4269098872b464a7d90e12e..003f6e09b8772d606494da63f8f1dc638993d0db 100644 (file)
@@ -1375,17 +1375,21 @@ class BulkEditView(PassUserMixin):
                     method in [bulk_edit.merge, bulk_edit.split]
                     and parameters["delete_originals"]
                 )
-                or (method == bulk_edit.edit_pdf and parameters["delete_original"])
+                or (method == bulk_edit.edit_pdf and parameters["update_document"])
             ):
                 has_perms = user_is_owner_of_all_documents
 
             # check global add permissions for methods that create documents
             if (
                 has_perms
-                and method in [bulk_edit.split, bulk_edit.merge, bulk_edit.edit_pdf]
-                and not user.has_perm(
-                    "documents.add_document",
+                and (
+                    method in [bulk_edit.split, bulk_edit.merge]
+                    or (
+                        method == bulk_edit.edit_pdf
+                        and not parameters["update_document"]
+                    )
                 )
+                and not user.has_perm("documents.add_document")
             ):
                 has_perms = False
 
@@ -1398,7 +1402,6 @@ class BulkEditView(PassUserMixin):
                         method in [bulk_edit.merge, bulk_edit.split]
                         and parameters["delete_originals"]
                     )
-                    or (method == bulk_edit.edit_pdf and parameters["delete_original"])
                 )
                 and not user.has_perm("documents.delete_document")
             ):