Skip to content
Merged
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
52 changes: 46 additions & 6 deletions src/utils/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,47 @@ const uploadFile = ({
})
}

// extract the file name from a Content-Disposition header
const getFilenameFromContentDisposition = (header) => {
if (!header) {
return undefined
}

const extendedMatch = header.match(/filename\*\s*=\s*([^;]+)/i)
if (extendedMatch) {
const value = extendedMatch[1].trim().replace(/^"(.*)"$/, '$1')
const encoded = value.replace(/^[^']*'[^']*'/, '')
try {
return decodeURIComponent(encoded)
} catch (e) {
return encoded
}
}

const match = header.match(/filename\s*=\s*("([^"]*)"|[^;]+)/i)
if (match) {
return (match[2] ?? match[1]).trim() || undefined
}

return undefined
}

const compressionSuffixes = { zip: '.zip', gzip: '.gz', gz: '.gz' }

// build a file name from the url path, add compression suffix when compression is passed as query param
const getFilenameFromUrl = (downloadUrl) => {
const { pathname, searchParams } = new URL(downloadUrl)
const filename = pathname.split('/').pop()
const suffix = compressionSuffixes[searchParams.get('compression')]

if (suffix && !filename.endsWith(suffix)) {
return `${filename}${suffix}`
}
return filename
}

// call stub function if available
const locationAssign = (url, blob) => {
const locationAssign = (url, blob, filename) => {
if (window.locationAssign) {
window.locationAssign(url)
} else {
Expand All @@ -192,14 +231,11 @@ const locationAssign = (url, blob) => {
? new URL(url, document.baseURI).href
: url

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

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

const link = document.createElement('a')
link.href = objectUrl || downloadUrl
link.download = filename
link.download = filename || getFilenameFromUrl(downloadUrl)
link.target = '_blank'
link.click()

Expand Down Expand Up @@ -244,7 +280,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 @@ -286,6 +325,7 @@ export {
getPrevJobDetails,
getInitialBoolValue,
formatNumber,
getFilenameFromContentDisposition,
locationAssign,
jsDateToISO8601,
jsDateToString,
Expand Down
87 changes: 86 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 @@ -79,6 +84,55 @@ describe('locationAssign', () => {
const link = locationAssign(url)
expect(link.download).toEqual('events.json.zip')
})
it('should add the zip suffix when compression is a query param', () => {
const url =
'https://debug.dhis2.org/dev/api/dataValueSets.json?dataSet=lyLU2wR22tC&orgUnit=ImspTQPwCqd&compression=zip'
const link = locationAssign(url)
expect(link.download).toEqual('dataValueSets.json.zip')
})
it('should add the gz suffix when compression is a gzip query param', () => {
const url =
'https://debug.dhis2.org/dev/api/dataValueSets.csv?dataSet=lyLU2wR22tC&orgUnit=ImspTQPwCqd&compression=gzip'
const link = locationAssign(url)
expect(link.download).toEqual('dataValueSets.csv.gz')
})
it('should prefer the provided file name over the url', () => {
const url =
'https://debug.dhis2.org/dev/api/dataValueSets.json?compression=zip'
const link = locationAssign(url, undefined, 'dataValues.json.zip')
expect(link.download).toEqual('dataValues.json.zip')
})
})

describe('getFilenameFromContentDisposition', () => {
it('returns undefined when there is no header', () => {
expect(getFilenameFromContentDisposition(null)).toBeUndefined()
expect(getFilenameFromContentDisposition('')).toBeUndefined()
})
it('returns undefined when the header has no file name', () => {
expect(getFilenameFromContentDisposition('inline')).toBeUndefined()
})
it('reads a quoted file name', () => {
expect(
getFilenameFromContentDisposition(
'attachment; filename="dataValues.json.zip"'
)
).toEqual('dataValues.json.zip')
})
it('reads an unquoted file name', () => {
expect(
getFilenameFromContentDisposition(
'attachment; filename=dataValues.csv.gz'
)
).toEqual('dataValues.csv.gz')
})
it('prefers the extended file name', () => {
expect(
getFilenameFromContentDisposition(
'attachment; filename="fallback.json"; filename*=UTF-8\'\'data%20values.json.zip'
)
).toEqual('data values.json.zip')
})
})

describe('fetchAndDownload', () => {
Expand All @@ -87,6 +141,37 @@ describe('fetchAndDownload', () => {
afterEach(() => {
global.fetch.mockRestore?.()
delete global.fetch
jest.restoreAllMocks()
})

it('saves the file with the name from Content-Disposition', async () => {
URL.createObjectURL = jest.fn(() => 'blob:mock')
URL.revokeObjectURL = jest.fn()
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const createElementSpy = jest.spyOn(document, 'createElement')

global.fetch = jest.fn().mockResolvedValue({
ok: true,
headers: new Headers({
'Content-Disposition':
'attachment; filename="dataValues.json.zip"',
}),
blob: () => Promise.resolve(new Blob(['zip'])),
})

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

const link = createElementSpy.mock.results.find(
(r) => r.value instanceof HTMLAnchorElement
).value
expect(clickSpy).toHaveBeenCalled()
expect(link.download).toEqual('dataValues.json.zip')
expect(link.href).toEqual('blob:mock')
})

it('downloads the file and returns no error when the response is ok', async () => {
Expand Down
Loading