mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-28 01:19:36 -06:00
Enhancment: Formatted filename for single document downloads (#12095)
--------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user