Enhancment: Formatted filename for single document downloads (#12095)

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Jan Kleine
2026-02-26 19:06:47 +01:00
committed by GitHub
parent 5e1202a416
commit c86ebc0260
7 changed files with 107 additions and 18 deletions

View File

@@ -44,14 +44,21 @@
<span class="d-none d-lg-inline ps-1" i18n>Download</span>
</button>
@if (metadata?.has_archive_version) {
<div class="btn-group" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" [disabled]="downloading" ngbDropdownToggle></button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<div class="btn-group" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" [disabled]="downloading" ngbDropdownToggle></button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
@if (metadata?.has_archive_version) {
<button ngbDropdownItem (click)="download(true)" [disabled]="downloading" i18n>Download original</button>
</div>
<div class="dropdown-divider"></div>
}
<form class="px-3 py-1">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" [(ngModel)]="useFormattedFilename" [ngModelOptions]="{standalone: true}" />
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
</div>
</form>
</div>
}
</div>
</div>
<div class="ms-auto" ngbDropdown>

View File

@@ -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'))

View File

@@ -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' })

View File

@@ -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', () => {

View File

@@ -202,7 +202,8 @@ export class DocumentService extends AbstractPaperlessService<Document> {
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<Document> {
if (versionID) {
url.searchParams.append('version', versionID.toString())
}
if (followFormatting) {
url.searchParams.append('follow_formatting', 'true')
}
return url.toString()
}

View File

@@ -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",

View File

@@ -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":