Skip to content

Commit 081a725

Browse files
committed
fix(files): Allow downloading multiple nodes not from same base
When downloading files in e.g. the *favorites* or *recent* view, then the nodes are not always share the same parent folder and we can not use the current directory as it is probably just a virtual one. So we calculate the longest common base and use that as the directory for the download endpoint. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent f61f0a2 commit 081a725

3 files changed

Lines changed: 141 additions & 12 deletions

File tree

apps/files/src/actions/downloadAction.spec.ts

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
*/
55
import { action } from './downloadAction'
66
import { expect } from '@jest/globals'
7-
import { File, Folder, Permission, View, FileAction, DefaultType } from '@nextcloud/files'
7+
import {
8+
File,
9+
Folder,
10+
Permission,
11+
View,
12+
FileAction,
13+
DefaultType,
14+
} from '@nextcloud/files'
815

916
const view = {
1017
id: 'files',
@@ -104,7 +111,9 @@ describe('Download action execute tests', () => {
104111
// Silent action
105112
expect(exec).toBe(null)
106113
expect(link.download).toEqual('')
107-
expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt')
114+
expect(link.href).toEqual(
115+
'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
116+
)
108117
expect(link.click).toHaveBeenCalledTimes(1)
109118
})
110119

@@ -122,7 +131,9 @@ describe('Download action execute tests', () => {
122131
// Silent action
123132
expect(exec).toStrictEqual([null])
124133
expect(link.download).toEqual('')
125-
expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt')
134+
expect(link.href).toEqual(
135+
'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
136+
)
126137
expect(link.click).toHaveBeenCalledTimes(1)
127138
})
128139

@@ -139,7 +150,11 @@ describe('Download action execute tests', () => {
139150
// Silent action
140151
expect(exec).toBe(null)
141152
expect(link.download).toEqual('')
142-
expect(link.href.startsWith('/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22FooBar%22%5D&downloadStartSecret=')).toBe(true)
153+
expect(
154+
link.href.startsWith(
155+
'/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22FooBar%22%5D&downloadStartSecret=',
156+
),
157+
).toBe(true)
143158
expect(link.click).toHaveBeenCalledTimes(1)
144159
})
145160

@@ -164,7 +179,76 @@ describe('Download action execute tests', () => {
164179
// Silent action
165180
expect(exec).toStrictEqual([null, null])
166181
expect(link.download).toEqual('')
167-
expect(link.href.startsWith('/index.php/apps/files/ajax/download.php?dir=%2FDir&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D&downloadStartSecret=')).toBe(true)
168182
expect(link.click).toHaveBeenCalledTimes(1)
183+
184+
expect(link.href).toMatch(
185+
'/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D&downloadStartSecret=',
186+
)
187+
})
188+
189+
test('Download multiple nodes from different sources', async () => {
190+
const files = [
191+
new File({
192+
id: 1,
193+
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 1/foo.txt',
194+
owner: 'admin',
195+
mime: 'text/plain',
196+
permissions: Permission.READ,
197+
}),
198+
new File({
199+
id: 2,
200+
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 2/bar.txt',
201+
owner: 'admin',
202+
mime: 'text/plain',
203+
permissions: Permission.READ,
204+
}),
205+
new File({
206+
id: 3,
207+
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 2/baz.txt',
208+
owner: 'admin',
209+
mime: 'text/plain',
210+
permissions: Permission.READ,
211+
}),
212+
]
213+
214+
const exec = await action.execBatch!(files, view, '/Dir')
215+
216+
// Silent action
217+
expect(exec).toStrictEqual([null, null, null])
218+
expect(link.download).toEqual('')
219+
expect(link.click).toHaveBeenCalledTimes(1)
220+
221+
expect(link.href).toMatch(
222+
'/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22foo.txt%22%2C%22bar.txt%22%2C%22baz.txt%22%5D&downloadStartSecret=',
223+
)
224+
})
225+
226+
test('Download node and parent folder', async () => {
227+
const files = [
228+
new File({
229+
id: 1,
230+
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 1/foo.txt',
231+
owner: 'admin',
232+
mime: 'text/plain',
233+
permissions: Permission.READ,
234+
}),
235+
new Folder({
236+
id: 2,
237+
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 1',
238+
owner: 'admin',
239+
permissions: Permission.READ,
240+
}),
241+
]
242+
243+
const exec = await action.execBatch!(files, view, '/Dir')
244+
245+
// Silent action
246+
expect(exec).toStrictEqual([null, null])
247+
expect(link.download).toEqual('')
248+
expect(link.click).toHaveBeenCalledTimes(1)
249+
250+
expect(link.href).toMatch(
251+
'/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22foo.txt%22%2C%22Folder%201%22%5D&downloadStartSecret=',
252+
)
169253
})
170254
})

apps/files/src/actions/downloadAction.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,57 @@ const triggerDownload = function(url: string) {
1717
hiddenElement.click()
1818
}
1919

20-
const downloadNodes = function(dir: string, nodes: Node[]) {
20+
/**
21+
* Find the longest common path prefix of both input paths
22+
* @param first The first path
23+
* @param second The second path
24+
*/
25+
function longestCommonPath(first: string, second: string): string {
26+
const firstSegments = first.split('/').filter(Boolean)
27+
const secondSegments = second.split('/').filter(Boolean)
28+
let base = '/'
29+
for (const [index, segment] of firstSegments.entries()) {
30+
if (index >= second.length) {
31+
break
32+
}
33+
if (segment !== secondSegments[index]) {
34+
break
35+
}
36+
const sep = base === '/' ? '' : '/'
37+
base = `${base}${sep}${segment}`
38+
}
39+
return base
40+
}
41+
42+
/**
43+
* Handle downloading multiple nodes
44+
* @param nodes The nodes to download
45+
*/
46+
function downloadNodes(nodes: Node[]): void {
47+
// Remove nodes that are already included in parent folders
48+
// Example: Download A/foo.txt and A will only return A as A/foo.txt is already included
49+
const filteredNodes = nodes.filter((node) => {
50+
const parent = nodes.find((other) => (
51+
other.type === FileType.Folder
52+
&& node.path.startsWith(`${other.path}/`)
53+
))
54+
return parent === undefined
55+
})
56+
57+
let base = filteredNodes[0].dirname
58+
for (const node of filteredNodes.slice(1)) {
59+
base = longestCommonPath(base, node.dirname)
60+
}
61+
base = base || '/'
62+
63+
// Remove the common prefix
64+
const filenames = filteredNodes.map((node) => node.path.slice(base === '/' ? 1 : (base.length + 1)))
65+
2166
const secret = Math.random().toString(36).substring(2)
22-
const url = generateUrl('/apps/files/ajax/download.php?dir={dir}&files={files}&downloadStartSecret={secret}', {
23-
dir,
67+
const url = generateUrl('/apps/files/ajax/download.php?dir={base}&files={files}&downloadStartSecret={secret}', {
68+
base,
2469
secret,
25-
files: JSON.stringify(nodes.map(node => node.basename)),
70+
files: JSON.stringify(filenames),
2671
})
2772
triggerDownload(url)
2873
}
@@ -69,7 +114,7 @@ export const action = new FileAction({
69114

70115
async exec(node: Node, view: View, dir: string) {
71116
if (node.type === FileType.Folder) {
72-
downloadNodes(dir, [node])
117+
downloadNodes([node])
73118
return null
74119
}
75120

@@ -83,7 +128,7 @@ export const action = new FileAction({
83128
return [null]
84129
}
85130

86-
downloadNodes(dir, nodes)
131+
downloadNodes(nodes)
87132
return new Array(nodes.length).fill(null)
88133
},
89134

apps/files/src/actions/editLocallyAction.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ describe('Edit locally action enabled tests', () => {
120120
describe('Edit locally action execute tests', () => {
121121
test('Edit locally opens proper URL', async () => {
122122
jest.spyOn(axios, 'post').mockImplementation(async () => ({
123-
data: { ocs: { data: { token: 'foobar' } } }
123+
data: { ocs: { data: { token: 'foobar' } } },
124124
}))
125125
const mockedShowError = jest.mocked(showError)
126126
const spyDialogBuilder = jest.spyOn(dialogBuilder, 'build')

0 commit comments

Comments
 (0)