]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Chore: refactor document details component (#10662)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 26 Aug 2025 20:29:38 +0000 (13:29 -0700)
committerGitHub <noreply@github.com>
Tue, 26 Aug 2025 20:29:38 +0000 (13:29 -0700)
src-ui/src/app/components/document-detail/document-detail.component.ts

index 302dc9114a416e4be40e92839ab7538de1920f7d..29bcf8fa8937184b984887d536eaf0f012e0c2f1 100644 (file)
@@ -21,8 +21,9 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
 import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
 import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 import { DeviceDetectorService } from 'ngx-device-detector'
-import { BehaviorSubject, Observable, Subject } from 'rxjs'
+import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
 import {
+  catchError,
   debounceTime,
   distinctUntilChanged,
   filter,
@@ -327,19 +328,163 @@ export class DocumentDetailComponent
     }
   }
 
+  private mapDocToForm(doc: Document): any {
+    return {
+      ...doc,
+      permissions_form: { owner: doc.owner, set_permissions: doc.permissions },
+    }
+  }
+
+  private mapFormToDoc(value: any): any {
+    const docValues = { ...value }
+    docValues['owner'] = value['permissions_form']?.owner
+    docValues['set_permissions'] = value['permissions_form']?.set_permissions
+    delete docValues['permissions_form']
+    return docValues
+  }
+
+  private prepareForm(doc: Document): void {
+    this.documentForm.reset(this.mapDocToForm(doc), { emitEvent: false })
+    if (!this.userCanEditDoc(doc)) {
+      this.documentForm.disable({ emitEvent: false })
+    } else {
+      this.documentForm.enable({ emitEvent: false })
+    }
+    if (doc.__changedFields) {
+      doc.__changedFields.forEach((field) => {
+        if (field === 'owner' || field === 'set_permissions') {
+          this.documentForm.get('permissions_form')?.markAsDirty()
+        } else {
+          this.documentForm.get(field)?.markAsDirty()
+        }
+      })
+    }
+  }
+
+  private setupDirtyTracking(
+    currentDocument: Document,
+    originalDocument: Document
+  ): void {
+    this.store = new BehaviorSubject({
+      title: originalDocument.title,
+      content: originalDocument.content,
+      created: originalDocument.created,
+      correspondent: originalDocument.correspondent,
+      document_type: originalDocument.document_type,
+      storage_path: originalDocument.storage_path,
+      archive_serial_number: originalDocument.archive_serial_number,
+      tags: [...originalDocument.tags],
+      permissions_form: {
+        owner: originalDocument.owner,
+        set_permissions: originalDocument.permissions,
+      },
+      custom_fields: [...originalDocument.custom_fields],
+    })
+    this.isDirty$ = dirtyCheck(this.documentForm, this.store.asObservable())
+    this.isDirty$
+      .pipe(
+        takeUntil(this.unsubscribeNotifier),
+        takeUntil(this.docChangeNotifier)
+      )
+      .subscribe((dirty) =>
+        this.openDocumentService.setDirty(
+          currentDocument,
+          dirty,
+          this.getChangedFields()
+        )
+      )
+  }
+
+  private loadDocument(documentId: number): void {
+    this.previewUrl = this.documentsService.getPreviewUrl(documentId)
+    this.http
+      .get(this.previewUrl, { responseType: 'text' })
+      .pipe(
+        first(),
+        takeUntil(this.unsubscribeNotifier),
+        takeUntil(this.docChangeNotifier)
+      )
+      .subscribe({
+        next: (res) => (this.previewText = res.toString()),
+        error: (err) =>
+          (this.previewText = $localize`An error occurred loading content: ${
+            err.message ?? err.toString()
+          }`),
+      })
+    this.thumbUrl = this.documentsService.getThumbUrl(documentId)
+    this.documentsService
+      .get(documentId)
+      .pipe(
+        catchError(() => {
+          // 404 is handled in the subscribe below
+          return of(null)
+        }),
+        first(),
+        takeUntil(this.unsubscribeNotifier),
+        takeUntil(this.docChangeNotifier)
+      )
+      .subscribe({
+        next: (doc) => {
+          if (!doc) {
+            this.router.navigate(['404'], { replaceUrl: true })
+            return
+          }
+          this.documentId = doc.id
+          this.suggestions = null
+          const openDocument = this.openDocumentService.getOpenDocument(
+            this.documentId
+          )
+          const useDoc = openDocument || doc
+          if (openDocument) {
+            if (
+              new Date(doc.modified) > new Date(openDocument.modified) &&
+              !this.modalService.hasOpenModals()
+            ) {
+              const modal = this.modalService.open(ConfirmDialogComponent)
+              modal.componentInstance.title = $localize`Document changes detected`
+              modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
+              modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
+              modal.componentInstance.cancelBtnClass = 'visually-hidden'
+              modal.componentInstance.btnCaption = $localize`Ok`
+              modal.componentInstance.confirmClicked.subscribe(() =>
+                modal.close()
+              )
+            }
+          } else {
+            this.openDocumentService
+              .openDocument(doc)
+              .pipe(
+                first(),
+                takeUntil(this.unsubscribeNotifier),
+                takeUntil(this.docChangeNotifier)
+              )
+              .subscribe()
+          }
+          this.updateComponent(useDoc)
+          this.titleSubject
+            .pipe(
+              debounceTime(1000),
+              distinctUntilChanged(),
+              takeUntil(this.docChangeNotifier),
+              takeUntil(this.unsubscribeNotifier)
+            )
+            .subscribe((titleValue) => {
+              if (titleValue !== this.titleInput.value) return
+              this.title = titleValue
+              this.documentForm.patchValue({ title: titleValue })
+            })
+          this.setupDirtyTracking(useDoc, doc)
+        },
+      })
+  }
+
   ngOnInit(): void {
     this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
     this.documentForm.valueChanges
       .pipe(takeUntil(this.unsubscribeNotifier))
-      .subscribe(() => {
+      .subscribe((values) => {
         this.error = null
-        const docValues = Object.assign({}, this.documentForm.value)
-        docValues['owner'] =
-          this.documentForm.get('permissions_form').value['owner']
-        docValues['set_permissions'] =
-          this.documentForm.get('permissions_form').value['set_permissions']
-        delete docValues['permissions_form']
-        Object.assign(this.document, docValues)
+        Object.assign(this.document, this.mapFormToDoc(values))
       })
 
     if (
@@ -391,171 +536,36 @@ export class DocumentDetailComponent
 
     this.route.paramMap
       .pipe(
-        filter((paramMap) => {
-          // only init when changing docs & section is set
-          return (
+        filter(
+          (paramMap) =>
             +paramMap.get('id') !== this.documentId &&
             paramMap.get('section')?.length > 0
-          )
-        }),
-        takeUntil(this.unsubscribeNotifier),
-        switchMap((paramMap) => {
-          const documentId = +paramMap.get('id')
-          this.docChangeNotifier.next(documentId)
-          // Dont wait to get the preview
-          this.previewUrl = this.documentsService.getPreviewUrl(documentId)
-          this.http.get(this.previewUrl, { responseType: 'text' }).subscribe({
-            next: (res) => {
-              this.previewText = res.toString()
-            },
-            error: (err) => {
-              this.previewText = $localize`An error occurred loading content: ${
-                err.message ?? err.toString()
-              }`
-            },
-          })
-          this.thumbUrl = this.documentsService.getThumbUrl(documentId)
-          return this.documentsService.get(documentId)
-        })
+        ),
+        takeUntil(this.unsubscribeNotifier)
       )
-      .pipe(
-        switchMap((doc) => {
-          this.documentId = doc.id
-          this.suggestions = null
-          const openDocument = this.openDocumentService.getOpenDocument(
-            this.documentId
-          )
-
-          if (openDocument) {
-            if (
-              new Date(doc.modified) > new Date(openDocument.modified) &&
-              !this.modalService.hasOpenModals()
-            ) {
-              let modal = this.modalService.open(ConfirmDialogComponent)
-              modal.componentInstance.title = $localize`Document changes detected`
-              modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
-              modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
-              modal.componentInstance.cancelBtnClass = 'visually-hidden'
-              modal.componentInstance.btnCaption = $localize`Ok`
-              modal.componentInstance.confirmClicked.subscribe(() =>
-                modal.close()
-              )
-            }
-
-            // Prevent mutating stale form values into the next document: only sync if it still matches the active document.
-            if (
-              this.documentForm.dirty &&
-              (this.document?.id === openDocument.id || !this.document)
-            ) {
-              Object.assign(openDocument, this.documentForm.value)
-              openDocument['owner'] =
-                this.documentForm.get('permissions_form').value['owner']
-              openDocument['permissions'] =
-                this.documentForm.get('permissions_form').value[
-                  'set_permissions'
-                ]
-              delete openDocument['permissions_form']
-            }
-            if (openDocument.__changedFields) {
-              openDocument.__changedFields.forEach((field) => {
-                if (field === 'owner' || field === 'set_permissions') {
-                  this.documentForm.get('permissions_form').markAsDirty()
-                } else {
-                  this.documentForm.get(field)?.markAsDirty()
-                }
-              })
-            }
-            this.updateComponent(openDocument)
-          } else {
-            this.openDocumentService.openDocument(doc)
-            this.updateComponent(doc)
-          }
-
-          this.titleSubject
-            .pipe(
-              debounceTime(1000),
-              distinctUntilChanged(),
-              takeUntil(this.docChangeNotifier),
-              takeUntil(this.unsubscribeNotifier)
-            )
-            .subscribe({
-              next: (titleValue) => {
-                // In the rare case when the field changed just after debounced event was fired.
-                // We dont want to overwrite what's actually in the text field, so just return
-                if (titleValue !== this.titleInput.value) return
-
-                this.title = titleValue
-                this.documentForm.patchValue({ title: titleValue })
-              },
-              complete: () => {
-                // doc changed so we manually check dirty in case title was changed
-                if (
-                  this.store.getValue().title !==
-                  this.documentForm.get('title').value
-                ) {
-                  this.openDocumentService.setDirty(
-                    doc,
-                    true,
-                    this.getChangedFields()
-                  )
-                }
-              },
-            })
-
-          // Initialize dirtyCheck
-          this.store = new BehaviorSubject({
-            title: doc.title,
-            content: doc.content,
-            created: doc.created,
-            correspondent: doc.correspondent,
-            document_type: doc.document_type,
-            storage_path: doc.storage_path,
-            archive_serial_number: doc.archive_serial_number,
-            tags: [...doc.tags],
-            permissions_form: {
-              owner: doc.owner,
-              set_permissions: doc.permissions,
-            },
-            custom_fields: [...doc.custom_fields],
-          })
-
-          this.isDirty$ = dirtyCheck(
-            this.documentForm,
-            this.store.asObservable()
-          )
+      .subscribe((paramMap) => {
+        const documentId = +paramMap.get('id')
+        this.docChangeNotifier.next(documentId)
+        this.loadDocument(documentId)
+      })
 
-          return this.isDirty$.pipe(
-            takeUntil(this.unsubscribeNotifier),
-            map((dirty) => ({ doc, dirty }))
+    this.route.paramMap
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((paramMap) => {
+        const section = paramMap.get('section')
+        if (section) {
+          const navIDKey: string = Object.keys(DocumentDetailNavIDs).find(
+            (navID) => navID.toLowerCase() == section
           )
-        })
-      )
-      .subscribe({
-        next: ({ doc, dirty }) => {
-          this.openDocumentService.setDirty(doc, dirty, this.getChangedFields())
-        },
-        error: (error) => {
-          this.router.navigate(['404'], {
+          if (navIDKey) {
+            this.activeNavID = DocumentDetailNavIDs[navIDKey]
+          }
+        } else if (paramMap.get('id')) {
+          this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
             replaceUrl: true,
           })
-        },
-      })
-
-    this.route.paramMap.subscribe((paramMap) => {
-      const section = paramMap.get('section')
-      if (section) {
-        const navIDKey: string = Object.keys(DocumentDetailNavIDs).find(
-          (navID) => navID.toLowerCase() == section
-        )
-        if (navIDKey) {
-          this.activeNavID = DocumentDetailNavIDs[navIDKey]
         }
-      } else if (paramMap.get('id')) {
-        this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
-          replaceUrl: true,
-        })
-      }
-    })
+      })
 
     this.hotKeyService
       .addShortcut({
@@ -682,19 +692,7 @@ export class DocumentDetailComponent
         })
     }
     this.title = this.documentTitlePipe.transform(doc.title)
-    const docFormValues = Object.assign({}, doc)
-    docFormValues['permissions_form'] = {
-      owner: doc.owner,
-      set_permissions: doc.permissions,
-    }
-
-    this.documentForm.patchValue(docFormValues, { emitEvent: false })
-    if (!this.userCanEdit) this.documentForm.disable()
-    setTimeout(() => {
-      // check again after a tick in case form was dirty
-      if (!this.userCanEdit) this.documentForm.disable()
-      else this.documentForm.enable()
-    }, 10)
+    this.prepareForm(doc)
   }
 
   get customFieldFormFields(): FormArray {
@@ -797,7 +795,11 @@ export class DocumentDetailComponent
   discard() {
     this.documentsService
       .get(this.documentId)
-      .pipe(first())
+      .pipe(
+        first(),
+        takeUntil(this.unsubscribeNotifier),
+        takeUntil(this.docChangeNotifier)
+      )
       .subscribe({
         next: (doc) => {
           Object.assign(this.document, doc)
@@ -900,9 +902,10 @@ export class DocumentDetailComponent
       .patch(this.getChangedFields())
       .pipe(
         switchMap((updateResult) => {
-          return this.documentListViewService
-            .getNext(this.documentId)
-            .pipe(map((nextDocId) => ({ nextDocId, updateResult })))
+          return this.documentListViewService.getNext(this.documentId).pipe(
+            map((nextDocId) => ({ nextDocId, updateResult })),
+            takeUntil(this.unsubscribeNotifier)
+          )
         })
       )
       .pipe(
@@ -912,7 +915,10 @@ export class DocumentDetailComponent
             return this.openDocumentService
               .closeDocument(this.document)
               .pipe(
-                map((closeResult) => ({ updateResult, nextDocId, closeResult }))
+                map(
+                  (closeResult) => ({ updateResult, nextDocId, closeResult }),
+                  takeUntil(this.unsubscribeNotifier)
+                )
               )
           }
         })
@@ -1238,16 +1244,19 @@ export class DocumentDetailComponent
     ) {
       doc.owner = this.store.value.permissions_form.owner
     }
+    return !this.document || this.userCanEditDoc(doc)
+  }
+
+  private userCanEditDoc(doc: Document): boolean {
     return (
-      !this.document ||
-      (this.permissionsService.currentUserCan(
+      this.permissionsService.currentUserCan(
         PermissionAction.Change,
         PermissionType.Document
       ) &&
-        this.permissionsService.currentUserHasObjectPermissions(
-          PermissionAction.Change,
-          doc
-        ))
+      this.permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.Change,
+        doc
+      )
     )
   }
 
@@ -1429,43 +1438,50 @@ export class DocumentDetailComponent
   }
 
   private tryRenderTiff() {
-    this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
-      next: (res) => {
-        /* istanbul ignore next */
-        try {
-          // See UTIF.js > _imgLoaded
-          const tiffIfds: any[] = UTIF.decode(res)
-          var vsns = tiffIfds,
-            ma = 0,
-            page = vsns[0]
-          if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
-          for (var i = 0; i < vsns.length; i++) {
-            var img = vsns[i]
-            if (img['t258'] == null || img['t258'].length < 3) continue
-            var ar = img['t256'] * img['t257']
-            if (ar > ma) {
-              ma = ar
-              page = img
+    this.http
+      .get(this.previewUrl, { responseType: 'arraybuffer' })
+      .pipe(
+        first(),
+        takeUntil(this.unsubscribeNotifier),
+        takeUntil(this.docChangeNotifier)
+      )
+      .subscribe({
+        next: (res) => {
+          /* istanbul ignore next */
+          try {
+            // See UTIF.js > _imgLoaded
+            const tiffIfds: any[] = UTIF.decode(res)
+            var vsns = tiffIfds,
+              ma = 0,
+              page = vsns[0]
+            if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
+            for (var i = 0; i < vsns.length; i++) {
+              var img = vsns[i]
+              if (img['t258'] == null || img['t258'].length < 3) continue
+              var ar = img['t256'] * img['t257']
+              if (ar > ma) {
+                ma = ar
+                page = img
+              }
             }
+            UTIF.decodeImage(res, page, tiffIfds)
+            const rgba = UTIF.toRGBA8(page)
+            const { width: w, height: h } = page
+            var cnv = document.createElement('canvas')
+            cnv.width = w
+            cnv.height = h
+            var ctx = cnv.getContext('2d'),
+              imgd = ctx.createImageData(w, h)
+            for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
+            ctx.putImageData(imgd, 0, 0)
+            this.tiffURL = cnv.toDataURL()
+          } catch (err) {
+            this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
           }
-          UTIF.decodeImage(res, page, tiffIfds)
-          const rgba = UTIF.toRGBA8(page)
-          const { width: w, height: h } = page
-          var cnv = document.createElement('canvas')
-          cnv.width = w
-          cnv.height = h
-          var ctx = cnv.getContext('2d'),
-            imgd = ctx.createImageData(w, h)
-          for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
-          ctx.putImageData(imgd, 0, 0)
-          this.tiffURL = cnv.toDataURL()
-        } catch (err) {
+        },
+        error: (err) => {
           this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
-        }
-      },
-      error: (err) => {
-        this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
-      },
-    })
+        },
+      })
   }
 }