Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions src/utils/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,31 @@ const uploadFile = ({
})
}

// returns the file name from a Content-Disposition header, preferring the
// RFC 5987 encoded form (filename*=UTF-8''...), or undefined if there is none
const getFilenameFromContentDisposition = (header) => {
if (!header) {
return undefined
}
const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)
if (encoded) {
try {
return decodeURIComponent(encoded[1].trim())
} catch (e) {
// malformed encoding, fall back to the plain file name
}
}
const plain = /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i.exec(
header
)
if (plain) {
return (plain[1] || plain[2]).trim()
}
return undefined
}

// call stub function if available
const locationAssign = (url, blob) => {
const locationAssign = (url, blob, filename) => {
if (window.locationAssign) {
window.locationAssign(url)
} else {
Expand All @@ -193,13 +216,15 @@ const locationAssign = (url, blob) => {
: url

const urlFilePart = new URL(downloadUrl).pathname.split('/').pop()
const [filename] = urlFilePart.match(/(^[^.]+)(\..+$)/)
const [urlFilename] = /(^[^.]+)(\..+$)/.exec(urlFilePart)

const objectUrl = blob ? URL.createObjectURL(blob) : undefined

const link = document.createElement('a')
link.href = objectUrl || downloadUrl
link.download = filename
// a blob URL carries no Content-Disposition, so the server's file
// name has to be passed on explicitly
link.download = filename || urlFilename
link.target = '_blank'
link.click()

Expand Down Expand Up @@ -244,7 +269,10 @@ const fetchAndDownload = async (url, source) => {
}

const blob = await response.blob()
locationAssign(url, blob)
const filename = getFilenameFromContentDisposition(
response.headers?.get('Content-Disposition')
)
locationAssign(url, blob, filename)
return undefined
} catch (e) {
console.error(`${source}-export: request failed`, e)
Expand Down Expand Up @@ -283,6 +311,7 @@ const formatNumber = (value, locale) => {
export {
fetchAttributes,
fetchAndDownload,
getFilenameFromContentDisposition,
getPrevJobDetails,
getInitialBoolValue,
formatNumber,
Expand Down
85 changes: 84 additions & 1 deletion src/utils/helper.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { FORM_ERROR } from './final-form.js'
import { fetchAndDownload, formatNumber, locationAssign } from './helper.js'
import {
fetchAndDownload,
formatNumber,
getFilenameFromContentDisposition,
locationAssign,
} from './helper.js'

describe('formatNumber', () => {
it('adds digit group separators to numbers', () => {
Expand Down Expand Up @@ -61,6 +66,12 @@ describe('locationAssign', () => {
const link = locationAssign(url)
expect(link.download).toEqual('events.json.gz')
})
it('should use the given file name instead of the one from the url', () => {
const url =
'https://debug.dhis2.org/dev/api/dataValueSets.json?dataSet=pBOMPrpg1QX&compression=zip'
const link = locationAssign(url, undefined, 'dataValues.json.zip')
expect(link.download).toEqual('dataValues.json.zip')
})
it('should work with relative URLs when bundled in DHIS2', () => {
Object.defineProperty(global.document, 'baseURI', {
value: 'http://localhost:8080/dhis-web-import-export/index.html#/export/tei',
Expand All @@ -81,6 +92,41 @@ describe('locationAssign', () => {
})
})

describe('getFilenameFromContentDisposition', () => {
it('reads a quoted file name', () => {
expect(
getFilenameFromContentDisposition(
'attachment; filename="dataValues_2026-07-02_2026-10-02.json.zip"'
)
).toEqual('dataValues_2026-07-02_2026-10-02.json.zip')
})
it('reads an unquoted file name', () => {
expect(
getFilenameFromContentDisposition(
'attachment; filename=metadata.json.gz'
)
).toEqual('metadata.json.gz')
})
it('prefers the encoded file name', () => {
expect(
getFilenameFromContentDisposition(
'attachment; filename="export.json"; filename*=UTF-8\'\'donn%C3%A9es.json.zip'
)
).toEqual('données.json.zip')
})
it('falls back to the plain file name when the encoded one is malformed', () => {
expect(
getFilenameFromContentDisposition(
'attachment; filename*=UTF-8\'\'bad%E0%A4%A; filename="export.json.zip"'
)
).toEqual('export.json.zip')
})
it('returns undefined without a file name', () => {
expect(getFilenameFromContentDisposition(null)).toBeUndefined()
expect(getFilenameFromContentDisposition('inline')).toBeUndefined()
})
})

describe('fetchAndDownload', () => {
const url = 'https://debug.dhis2.org/dev/api/metadata.json'

Expand All @@ -103,6 +149,43 @@ describe('fetchAndDownload', () => {
expect(result).toBeUndefined()
})

it('saves the download under the file name sent by the server', async () => {
const downloads = []
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(function () {
downloads.push(this.download)
})
const createObjectURL = URL.createObjectURL
const revokeObjectURL = URL.revokeObjectURL
URL.createObjectURL = jest.fn(() => 'blob:export')
URL.revokeObjectURL = jest.fn()
global.fetch = jest.fn().mockResolvedValue({
ok: true,
headers: {
get: (name) =>
name.toLowerCase() === 'content-disposition'
? 'attachment; filename="dataValues.json.zip"'
: null,
},
blob: () => Promise.resolve(new Blob(['PK'])),
})

try {
const result = await fetchAndDownload(
'https://debug.dhis2.org/dev/api/dataValueSets.json?compression=zip',
'data'
)

expect(result).toBeUndefined()
expect(downloads).toEqual(['dataValues.json.zip'])
} finally {
clickSpy.mockRestore()
URL.createObjectURL = createObjectURL
URL.revokeObjectURL = revokeObjectURL
}
})

it('returns a form error with the server message when the response is not ok', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
Expand Down
Loading