]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: live preview of storage path (#7870)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Wed, 9 Oct 2024 23:35:36 +0000 (16:35 -0700)
committerGitHub <noreply@github.com>
Wed, 9 Oct 2024 23:35:36 +0000 (23:35 +0000)
15 files changed:
src-ui/messages.xlf
src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html
src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.scss
src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts
src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts
src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.scss
src-ui/src/app/components/common/input/textarea/textarea.component.html
src-ui/src/app/services/rest/storage-path.service.spec.ts
src-ui/src/app/services/rest/storage-path.service.ts
src-ui/src/styles.scss
src/documents/serialisers.py
src/documents/templating/filepath.py
src/documents/tests/test_api_objects.py
src/documents/views.py
src/paperless/urls.py

index 7838c63edb74576bce7b80c9d0b8b107637cfaa0..8a7d7098100e59b2d451d9d4a5f29524a6c3a465 100644 (file)
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">29</context>
+          <context context-type="linenumber">79</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
           <context context-type="linenumber">35</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">50</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
           <context context-type="linenumber">51</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">28</context>
+          <context context-type="linenumber">78</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">14</context>
+          <context context-type="linenumber">64</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">16</context>
+          <context context-type="linenumber">66</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">19</context>
+          <context context-type="linenumber">69</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
           <context context-type="linenumber">42</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="6625768491622252297" datatype="html">
-        <source>e.g.</source>
+      <trans-unit id="2816147949408898105" datatype="html">
+        <source>See &lt;a target=&apos;_blank&apos; href=&apos;https://docs.paperless-ngx.com/advanced_usage/#file-name-handling&apos;&gt;the documentation&lt;/a&gt;.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
-          <context context-type="linenumber">28</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1295614462098694869" datatype="html">
+        <source>Preview</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">18</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">282</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="1918584360573970155" datatype="html">
-        <source>or use slashes to add directories e.g.</source>
+      <trans-unit id="8057014866157903311" datatype="html">
+        <source>Path test failed</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
           <context context-type="linenumber">30</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="7871464228487558644" datatype="html">
-        <source>See &lt;a target=&quot;_blank&quot; href=&quot;https://docs.paperless-ngx.com/advanced_usage/#file-name-handling&quot;&gt;documentation&lt;/a&gt; for full list.</source>
+      <trans-unit id="9116034231465034307" datatype="html">
+        <source>No document selected</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
           <context context-type="linenumber">32</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="5676637575587497817" datatype="html">
+        <source>Search for documents</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">38</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
+          <context context-type="linenumber">53</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6423278459497515329" datatype="html">
+        <source>No documents found</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">39</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
+          <context context-type="linenumber">44</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6898961890896270754" datatype="html">
         <source>Create new storage path</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
-          <context context-type="linenumber">37</context>
+          <context context-type="linenumber">63</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3754859110054016570" datatype="html">
         <source>Edit storage path</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
-          <context context-type="linenumber">41</context>
+          <context context-type="linenumber">67</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9011959596901584887" datatype="html">
           <context context-type="linenumber">14</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="6423278459497515329" datatype="html">
-        <source>No documents found</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
-          <context context-type="linenumber">44</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="5676637575587497817" datatype="html">
-        <source>Search for documents</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
-          <context context-type="linenumber">53</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="8627133593113147800" datatype="html">
         <source>Selected items</source>
         <context-group purpose="location">
           <context context-type="linenumber">275</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="1295614462098694869" datatype="html">
-        <source>Preview</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">282</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="7206723502037428235" datatype="html">
         <source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
         <context-group purpose="location">
index f8232f9578d52286dfe1c0fb4b81523c60d652ff..45b2bc5e95a72067711714228b331a237741ce6c 100644 (file)
   <div class="modal-body">
 
     <pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
-    <pngx-input-textarea i18n-title title="Path" formControlName="path" [error]="error?.path" [hint]="pathHint" [monospace]="true"></pngx-input-textarea>
+    <pngx-input-textarea i18n-title title="Path" formControlName="path" [error]="error?.path" hint="See <a target='_blank' href='https://docs.paperless-ngx.com/advanced_usage/#file-name-handling'>the documentation</a>." i18n-hint [monospace]="true"></pngx-input-textarea>
+
+    <div ngbAccordion>
+      <div ngbAccordionItem>
+        <h2 ngbAccordionHeader>
+          <button ngbAccordionButton i18n>Preview</button>
+        </h2>
+        <div ngbAccordionCollapse>
+          <div ngbAccordionBody>
+            <ng-template>
+              <div class="card mb-2">
+                <div class="card-body p-2">
+                  @if (testLoading) {
+                    <ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
+                  } @else if (testResult) {
+                    <code>{{testResult}}</code>
+                  } @else if (testFailed) {
+                    <div class="text-danger" i18n>Path test failed</div>
+                  } @else {
+                    <div class="text-muted small" i18n>No document selected</div>
+                  }
+                </div>
+              </div>
+              <ng-select name="testDocument"
+                [items]="foundDocuments$ | async"
+                placeholder="Search for a document" i18n-placeholder
+                notFoundText="No documents found" i18n-notFoundText
+                bindValue="id"
+                bindLabel="title"
+                [compareWith]="compareDocuments"
+                [trackByFn]="trackByFn"
+                [minTermLength]="2"
+                [loading]="loading"
+                [typeahead]="documentsInput$"
+                (change)="testPath($event)">
+                <ng-template #loadingTemplate ng-loadingspinner-tmp>
+                  <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
+                  <div class="visually-hidden" i18n>Loading...</div>
+                </ng-template>
+                <ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
+                  <div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
+                </ng-template>
+              </ng-select>
+            </ng-template>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <hr/>
+
     <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
     @if (patternRequired) {
       <pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
index 051d21527b0059837553f76df57f6edb886832a4..174397981ca5f84bd607666a3ffa81fd1c6c87eb 100644 (file)
@@ -1,7 +1,11 @@
 import { provideHttpClientTesting } from '@angular/common/http/testing'
 import { ComponentFixture, TestBed } from '@angular/core/testing'
 import { FormsModule, ReactiveFormsModule } from '@angular/forms'
-import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import {
+  NgbAccordionButton,
+  NgbActiveModal,
+  NgbModule,
+} from '@ng-bootstrap/ng-bootstrap'
 import { NgSelectModule } from '@ng-select/ng-select'
 import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
 import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -14,10 +18,16 @@ import { TextAreaComponent } from '../../input/textarea/textarea.component'
 import { EditDialogMode } from '../edit-dialog.component'
 import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
 import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { StoragePathService } from 'src/app/services/rest/storage-path.service'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { of, throwError } from 'rxjs'
+import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
+import { By } from '@angular/platform-browser'
 
 describe('StoragePathEditDialogComponent', () => {
   let component: StoragePathEditDialogComponent
   let settingsService: SettingsService
+  let documentService: DocumentService
   let fixture: ComponentFixture<StoragePathEditDialogComponent>
 
   beforeEach(async () => {
@@ -40,6 +50,7 @@ describe('StoragePathEditDialogComponent', () => {
       ],
     }).compileComponents()
 
+    documentService = TestBed.inject(DocumentService)
     fixture = TestBed.createComponent(StoragePathEditDialogComponent)
     settingsService = TestBed.inject(SettingsService)
     settingsService.currentUser = { id: 99, username: 'user99' }
@@ -59,4 +70,87 @@ describe('StoragePathEditDialogComponent', () => {
     fixture.detectChanges()
     expect(editTitleSpy).toHaveBeenCalled()
   })
+
+  it('should support test path', () => {
+    const testSpy = jest.spyOn(
+      component['service'] as StoragePathService,
+      'testPath'
+    )
+    testSpy.mockReturnValueOnce(of('test/abc123'))
+    component.objectForm.patchValue({ path: 'test/{{title}}' })
+    fixture.detectChanges()
+    component.testPath({ id: 1 })
+    expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
+    expect(component.testResult).toBe('test/abc123')
+    expect(component.testFailed).toBeFalsy()
+
+    // test failed
+    testSpy.mockReturnValueOnce(of(''))
+    component.testPath({ id: 1 })
+    expect(component.testResult).toBeNull()
+    expect(component.testFailed).toBeTruthy()
+
+    component.testPath(null)
+    expect(component.testResult).toBeNull()
+  })
+
+  it('should compare two documents by id', () => {
+    const doc1 = { id: 1 }
+    const doc2 = { id: 2 }
+    expect(component.compareDocuments(doc1, doc1)).toBeTruthy()
+    expect(component.compareDocuments(doc1, doc2)).toBeFalsy()
+  })
+
+  it('should use id as trackBy', () => {
+    expect(component.trackByFn({ id: 1 })).toBe(1)
+  })
+
+  it('should search on select text input', () => {
+    fixture.debugElement
+      .query(By.directive(NgbAccordionButton))
+      .triggerEventHandler('click', null)
+    fixture.detectChanges()
+    const documents = [
+      { id: 1, title: 'foo' },
+      { id: 2, title: 'bar' },
+    ]
+    const listSpy = jest.spyOn(documentService, 'listFiltered')
+    listSpy.mockReturnValueOnce(
+      of({
+        count: 1,
+        results: documents[0],
+        all: [1],
+      } as any)
+    )
+    component.documentsInput$.next('bar')
+    expect(listSpy).toHaveBeenCalledWith(
+      1,
+      null,
+      'created',
+      true,
+      [{ rule_type: FILTER_TITLE, value: 'bar' }],
+      { truncate_content: true }
+    )
+    listSpy.mockReturnValueOnce(
+      of({
+        count: 2,
+        results: [...documents],
+        all: [1, 2],
+      } as any)
+    )
+    component.documentsInput$.next('ba')
+    listSpy.mockReturnValueOnce(throwError(() => new Error()))
+    component.documentsInput$.next('foo')
+  })
+
+  it('should run path test on path change', () => {
+    const testSpy = jest.spyOn(component, 'testPath')
+    component['testDocument'] = { id: 1 } as any
+    component.objectForm.patchValue(
+      { path: 'test/{{title}}' },
+      { emitEvent: true }
+    )
+    fixture.detectChanges()
+    expect(testSpy).toHaveBeenCalled()
+  })
 })
index 0f9cc97117339cdf46ab548b6dee7b4e209a9139..a530502dc73545561d459fa4326f9bb68bf7c611 100644 (file)
@@ -1,9 +1,25 @@
-import { Component } from '@angular/core'
+import { Component, OnDestroy } from '@angular/core'
 import { FormControl, FormGroup } from '@angular/forms'
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import {
+  Subject,
+  Observable,
+  concat,
+  of,
+  distinctUntilChanged,
+  takeUntil,
+  tap,
+  switchMap,
+  map,
+  catchError,
+  filter,
+} from 'rxjs'
 import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
+import { Document } from 'src/app/data/document'
+import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
 import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
 import { StoragePath } from 'src/app/data/storage-path'
+import { DocumentService } from 'src/app/services/rest/document.service'
 import { StoragePathService } from 'src/app/services/rest/storage-path.service'
 import { UserService } from 'src/app/services/rest/user.service'
 import { SettingsService } from 'src/app/services/settings.service'
@@ -13,24 +29,34 @@ import { SettingsService } from 'src/app/services/settings.service'
   templateUrl: './storage-path-edit-dialog.component.html',
   styleUrls: ['./storage-path-edit-dialog.component.scss'],
 })
-export class StoragePathEditDialogComponent extends EditDialogComponent<StoragePath> {
+export class StoragePathEditDialogComponent
+  extends EditDialogComponent<StoragePath>
+  implements OnDestroy
+{
+  public documentsInput$ = new Subject<string>()
+  public foundDocuments$: Observable<Document[]>
+  private testDocument: Document
+  public testResult: string
+  public testFailed: boolean = false
+  public loading = false
+  public testLoading = false
+
+  private unsubscribeNotifier: Subject<any> = new Subject()
+
   constructor(
     service: StoragePathService,
     activeModal: NgbActiveModal,
     userService: UserService,
-    settingsService: SettingsService
+    settingsService: SettingsService,
+    private documentsService: DocumentService
   ) {
     super(service, activeModal, userService, settingsService)
+    this.initPathObservables()
   }
 
-  get pathHint() {
-    return (
-      $localize`e.g.` +
-      ' <code class="text-nowrap">{{ created_year }}-{{ title }}</code> ' +
-      $localize`or use slashes to add directories e.g.` +
-      ' <code class="text-nowrap">{{ created_year }}/{{ title }}</code>. ' +
-      $localize`See <a target="_blank" href="https://docs.paperless-ngx.com/advanced_usage/#file-name-handling">documentation</a> for full list.`
-    )
+  ngOnDestroy(): void {
+    this.unsubscribeNotifier.next(this)
+    this.unsubscribeNotifier.complete()
   }
 
   getCreateTitle() {
@@ -51,4 +77,71 @@ export class StoragePathEditDialogComponent extends EditDialogComponent<StorageP
       permissions_form: new FormControl(null),
     })
   }
+
+  public testPath(document: Document) {
+    if (!document) {
+      this.testResult = null
+      return
+    }
+    this.testDocument = document
+    this.testLoading = true
+    ;(this.service as StoragePathService)
+      .testPath(this.objectForm.get('path').value, document.id)
+      .subscribe((result) => {
+        if (result?.length) {
+          this.testResult = result
+          this.testFailed = false
+        } else {
+          this.testResult = null
+          this.testFailed = true
+        }
+        this.testLoading = false
+      })
+  }
+
+  compareDocuments(document: Document, selectedDocument: Document) {
+    return document.id === selectedDocument.id
+  }
+
+  private initPathObservables() {
+    this.objectForm
+      .get('path')
+      .valueChanges.pipe(
+        takeUntil(this.unsubscribeNotifier),
+        filter((path) => path && !!this.testDocument)
+      )
+      .subscribe(() => {
+        this.testPath(this.testDocument)
+      })
+
+    this.foundDocuments$ = concat(
+      of([]), // default items
+      this.documentsInput$.pipe(
+        tap(() => console.log('searching')),
+        distinctUntilChanged(),
+        takeUntil(this.unsubscribeNotifier),
+        tap(() => (this.loading = true)),
+        switchMap((title) =>
+          this.documentsService
+            .listFiltered(
+              1,
+              null,
+              'created',
+              true,
+              [{ rule_type: FILTER_TITLE, value: title }],
+              { truncate_content: true }
+            )
+            .pipe(
+              map((result) => result.results),
+              catchError(() => of([])), // empty on error
+              tap(() => (this.loading = false))
+            )
+        )
+      )
+    )
+  }
+
+  trackByFn(item: Document) {
+    return item.id
+  }
 }
index ad12f4a97578a108c7e80ae40e8a707e54c216db..6cfcf86b4d464fc97a9ba61f61bfcdf6be77f189 100644 (file)
@@ -3,3 +3,7 @@
         color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;
     }
 }
+
+.accordion-button {
+    font-size: 1rem;
+}
index b92bef476d301ada923ee93a5da432873dc40172..d92a8aa4f89dc9302b2ea725c6bffa0776b375b9 100644 (file)
@@ -20,7 +20,7 @@
           (change)="onChange(value)"
           [disabled]="disabled"
           [placeholder]="placeholder"
-          rows="6">
+          rows="4">
         </textarea>
         @if (hint) {
           <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
index f365f6aa1b472593a8dc3d7d0d395065b313488c..8b67a125b9195f205919021843ded2c3eeccf02e 100644 (file)
@@ -1,7 +1,35 @@
 import { StoragePathService } from './storage-path.service'
 import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
+import { Subscription } from 'rxjs'
+import { HttpTestingController } from '@angular/common/http/testing'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+
+let httpTestingController: HttpTestingController
+let service: StoragePathService
+let subscription: Subscription
+const endpoint = 'storage_paths'
 
 commonAbstractNameFilterPaperlessServiceTests(
   'storage_paths',
   StoragePathService
 )
+
+describe(`Additional service tests for StoragePathservice`, () => {
+  beforeEach(() => {
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(StoragePathService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+
+  it('should support testing path', () => {
+    subscription = service.testPath('path', 11).subscribe()
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}${endpoint}/test/`)
+      .flush('ok')
+  })
+})
index 52997c7a010d77f5570f366a894878a18a574e08..1ac7c82d76727ea3204be51645d945a785acdc18 100644 (file)
@@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { StoragePath } from 'src/app/data/storage-path'
 import { AbstractNameFilterService } from './abstract-name-filter-service'
+import { Observable } from 'rxjs'
 
 @Injectable({
   providedIn: 'root',
@@ -10,4 +11,11 @@ export class StoragePathService extends AbstractNameFilterService<StoragePath> {
   constructor(http: HttpClient) {
     super(http, 'storage_paths')
   }
+
+  public testPath(path: string, documentID: number): Observable<any> {
+    return this.http.post<string>(`${this.getResourceUrl()}test/`, {
+      path,
+      document: documentID,
+    })
+  }
 }
index ef856fbc797cfaa47810e2ac686a9bb53694d558..aadc1d4a9018b0c69748a3da255a4d6b6284e5b7 100644 (file)
@@ -21,7 +21,8 @@ $form-file-button-hover-bg: var(--pngx-bg-alt);
 
 // Paperless-ngx styles
 body {
-  font-size: 0.875rem;
+  --pngx-body-font-size: 0.875rem;
+  font-size: var(--pngx-body-font-size);
   height: 100vh;
 }
 
@@ -653,6 +654,10 @@ code {
   filter: invert(0.5) saturate(0);
 }
 
+.accordion-button {
+  font-size: var(--pngx-body-font-size);
+}
+
 .me-1px {
   margin-right: 1px !important;
 }
index 7c6e5a3ff9fe16183730f71268ea99c186037385..6f7dc8be094adde6d4d660b3d7cd17df97696a55 100644 (file)
@@ -2010,3 +2010,18 @@ class TrashSerializer(SerializerWithPerms):
                 "Some documents in the list have not yet been deleted.",
             )
         return documents
+
+
+class StoragePathTestSerializer(SerializerWithPerms):
+    path = serializers.CharField(
+        required=True,
+        label="Path",
+        write_only=True,
+    )
+
+    document = serializers.PrimaryKeyRelatedField(
+        queryset=Document.objects.all(),
+        required=True,
+        label="Document",
+        write_only=True,
+    )
index ec902bf54c9df9b30bd85fde2cb556525a749464..54ceb30a8168c0d320bfc3ab4746fb74260b8905 100644 (file)
@@ -237,7 +237,6 @@ def get_custom_fields_context(
         )
         # String types need to be sanitized
         if field_instance.field.data_type in {
-            CustomField.FieldDataType.DOCUMENTLINK,
             CustomField.FieldDataType.MONETARY,
             CustomField.FieldDataType.STRING,
             CustomField.FieldDataType.URL,
index c74248b9ae15275946a996b2ba71fb7fac8f21d4..d4d3c729ec9d1b2a959e7e9c2d43c9ffceb32ac4 100644 (file)
@@ -306,6 +306,35 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
         # only called once
         bulk_update_mock.assert_called_once_with([document.pk])
 
+    def test_test_storage_path(self):
+        """
+        GIVEN:
+            - API request to test a storage path
+        WHEN:
+            - API is called
+        THEN:
+            - Correct HTTP response
+            - Correct response data
+        """
+        document = Document.objects.create(
+            mime_type="application/pdf",
+            storage_path=self.sp1,
+            title="Something",
+            checksum="123",
+        )
+        response = self.client.post(
+            f"{self.ENDPOINT}test/",
+            json.dumps(
+                {
+                    "document": document.id,
+                    "path": "path/{{ title }}",
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, "path/Something")
+
 
 class TestBulkEditObjects(APITestCase):
     # See test_api_permissions.py for bulk tests on permissions
index 94674a83f134822653ee915f5279e1f62dd5f7d8..a3e19aba184cc6d113d98bd08516c19012fdd887 100644 (file)
@@ -140,6 +140,7 @@ from documents.serialisers import SavedViewSerializer
 from documents.serialisers import SearchResultSerializer
 from documents.serialisers import ShareLinkSerializer
 from documents.serialisers import StoragePathSerializer
+from documents.serialisers import StoragePathTestSerializer
 from documents.serialisers import TagSerializer
 from documents.serialisers import TagSerializerVersion1
 from documents.serialisers import TasksViewSerializer
@@ -151,6 +152,7 @@ from documents.serialisers import WorkflowTriggerSerializer
 from documents.signals import document_updated
 from documents.tasks import consume_file
 from documents.tasks import empty_trash
+from documents.templating.filepath import validate_filepath_template_and_render
 from paperless import version
 from paperless.celery import app as celery_app
 from paperless.config import GeneralConfig
@@ -1549,6 +1551,25 @@ class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
         return response
 
 
+class StoragePathTestView(GenericAPIView):
+    """
+    Test storage path against a document
+    """
+
+    permission_classes = [IsAuthenticated]
+    serializer_class = StoragePathTestSerializer
+
+    def post(self, request, *args, **kwargs):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+
+        document = serializer.validated_data.get("document")
+        path = serializer.validated_data.get("path")
+
+        result = validate_filepath_template_and_render(path, document)
+        return Response(result)
+
+
 class UiSettingsView(GenericAPIView):
     queryset = UiSettings.objects.all()
     permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
index 4de9f36628a99bdc09ddebf2065e5c19b35961ab..1b9ab5053bb9f9013ef01c3019c024522fdc2cf1 100644 (file)
@@ -32,6 +32,7 @@ from documents.views import SelectionDataView
 from documents.views import SharedLinkView
 from documents.views import ShareLinkViewSet
 from documents.views import StatisticsView
+from documents.views import StoragePathTestView
 from documents.views import StoragePathViewSet
 from documents.views import SystemStatusView
 from documents.views import TagViewSet
@@ -165,6 +166,11 @@ urlpatterns = [
                     TrashView.as_view(),
                     name="trash",
                 ),
+                re_path(
+                    "^storage_paths/test/",
+                    StoragePathTestView.as_view(),
+                    name="storage_paths_test",
+                ),
                 *api_router.urls,
             ],
         ),