From c86ebc0260730cb66a9fda9a8fecb97e38d36f34 Mon Sep 17 00:00:00 2001 From: Jan Kleine Date: Thu, 26 Feb 2026 19:06:47 +0100 Subject: [PATCH] Enhancment: Formatted filename for single document downloads (#12095) --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- .../document-detail.component.html | 19 +++++++---- .../document-detail.component.spec.ts | 24 +++++++++++-- .../document-detail.component.ts | 4 ++- .../services/rest/document.service.spec.ts | 8 ++--- .../src/app/services/rest/document.service.ts | 6 +++- src/documents/tests/test_api_documents.py | 34 +++++++++++++++++++ src/documents/views.py | 30 ++++++++++++++-- 7 files changed, 107 insertions(+), 18 deletions(-) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 7c15d513a..b1b8a35ba 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -44,14 +44,21 @@ Download - @if (metadata?.has_archive_version) { -
- -
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 5c62bc2dd..799a08455 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1813,7 +1813,13 @@ describe('DocumentDetailComponent', () => { component.selectedVersionId = 10 component.download() - expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(1, doc.id, false, null) + expect(getDownloadUrlSpy).toHaveBeenNthCalledWith( + 1, + doc.id, + false, + null, + false + ) httpTestingController .expectOne('download-latest') .error(new ProgressEvent('failed')) @@ -1826,7 +1832,13 @@ describe('DocumentDetailComponent', () => { component.selectedVersionId = doc.id component.download() - expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(3, doc.id, false, doc.id) + expect(getDownloadUrlSpy).toHaveBeenNthCalledWith( + 3, + doc.id, + false, + doc.id, + false + ) httpTestingController .expectOne('download-non-latest') .error(new ProgressEvent('failed')) @@ -1849,7 +1861,13 @@ describe('DocumentDetailComponent', () => { .mockReturnValueOnce('print-no-version') component.download() - expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(1, doc.id, false, null) + expect(getDownloadUrlSpy).toHaveBeenNthCalledWith( + 1, + doc.id, + false, + null, + false + ) httpTestingController .expectOne('download-no-version') .error(new ProgressEvent('failed')) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index cfb06b9a0..3a3cdae00 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -276,6 +276,7 @@ export class DocumentDetailComponent customFields: CustomField[] public downloading: boolean = false + public useFormattedFilename: boolean = false public readonly CustomFieldDataType = CustomFieldDataType @@ -1289,7 +1290,8 @@ export class DocumentDetailComponent const downloadUrl = this.documentsService.getDownloadUrl( this.documentId, original, - selectedVersionId + selectedVersionId, + this.useFormattedFilename ) this.http .get(downloadUrl, { observe: 'response', responseType: 'blob' }) diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts index 48c40e4d5..78cef69cb 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -272,10 +272,10 @@ describe(`DocumentService`, () => { expect(url).toEqual( `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/download/?version=123` ) - url = service.getDownloadUrl(documents[0].id, true, 123) - expect(url).toEqual( - `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/download/?original=true&version=123` - ) + url = service.getDownloadUrl(documents[0].id, true, 123, true) + expect(url).toContain('original=true') + expect(url).toContain('version=123') + expect(url).toContain('follow_formatting=true') }) it('should pass optional get params for version and fields', () => { diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index ace34cc00..4b4ed7072 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -202,7 +202,8 @@ export class DocumentService extends AbstractPaperlessService { getDownloadUrl( id: number, original: boolean = false, - versionID: number = null + versionID: number = null, + followFormatting: boolean = false ): string { let url = new URL(this.getResourceUrl(id, 'download')) if (original) { @@ -211,6 +212,9 @@ export class DocumentService extends AbstractPaperlessService { if (versionID) { url.searchParams.append('version', versionID.toString()) } + if (followFormatting) { + url.searchParams.append('follow_formatting', 'true') + } return url.toString() } diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index ea7240765..e8e3c407d 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -445,6 +445,40 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.content, content) + @override_settings(FILENAME_FORMAT="") + def test_download_follow_formatting(self) -> None: + content = b"This is a test" + content_archive = b"This is the same test but archived" + + doc = Document.objects.create( + title="none", + filename="my_document.pdf", + archive_filename="archived.pdf", + mime_type="application/pdf", + ) + + with Path(doc.source_path).open("wb") as f: + f.write(content) + + with Path(doc.archive_path).open("wb") as f: + f.write(content_archive) + + # Without follow_formatting, should use public filename + response = self.client.get(f"/api/documents/{doc.pk}/download/") + self.assertIn("none.pdf", response["Content-Disposition"]) + + # With follow_formatting, should use actual filename on disk + response = self.client.get( + f"/api/documents/{doc.pk}/download/?follow_formatting=true", + ) + self.assertIn("archived.pdf", response["Content-Disposition"]) + + # With follow_formatting and original, should use source filename + response = self.client.get( + f"/api/documents/{doc.pk}/download/?original=true&follow_formatting=true", + ) + self.assertIn("my_document.pdf", response["Content-Disposition"]) + def test_document_actions_not_existing_file(self) -> None: doc = Document.objects.create( title="none", diff --git a/src/documents/views.py b/src/documents/views.py index e333d64af..546da6e83 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -10,6 +10,7 @@ from collections import deque from datetime import datetime from pathlib import Path from time import mktime +from typing import TYPE_CHECKING from typing import Any from typing import Literal from unicodedata import normalize @@ -616,6 +617,12 @@ class EmailDocumentDetailSchema(EmailSerializer): type=OpenApiTypes.BOOL, location=OpenApiParameter.QUERY, ), + OpenApiParameter( + name="follow_formatting", + description="Whether or not to use the filename on disk", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + ), ], responses={200: OpenApiTypes.BINARY}, ), @@ -1061,6 +1068,7 @@ class DocumentViewSet( use_archive=not self.original_requested(request) and file_doc.has_archive_version, disposition=disposition, + follow_formatting=request.query_params.get("follow_formatting", False), ) def get_metadata(self, file, mime_type): @@ -3445,14 +3453,30 @@ class SharedLinkView(View): return response -def serve_file(*, doc: Document, use_archive: bool, disposition: str) -> HttpResponse: +def serve_file( + *, + doc: Document, + use_archive: bool, + disposition: str, + follow_formatting: bool = False, +) -> HttpResponse: if use_archive: + if TYPE_CHECKING: + assert doc.archive_filename + file_handle = doc.archive_file - filename = doc.get_public_filename(archive=True) + filename = ( + doc.archive_filename + if follow_formatting + else doc.get_public_filename(archive=True) + ) mime_type = "application/pdf" else: + if TYPE_CHECKING: + assert doc.filename + file_handle = doc.source_file - filename = doc.get_public_filename() + filename = doc.filename if follow_formatting else doc.get_public_filename() mime_type = doc.mime_type # Support browser previewing csv files by using text mime type if mime_type in {"application/csv", "text/csv"} and disposition == "inline":