Skip to content

Commit 99d2354

Browse files
authored
feat: Add download functionality to My Files (#964)
1 parent 4f3d01c commit 99d2354

28 files changed

Lines changed: 497 additions & 54 deletions

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"codemirror-json-schema": "0.7.9",
5757
"compare-versions": "^6.1.1",
5858
"evt": "^2.5.9",
59+
"fflate": "^0.8.2",
5960
"file-saver": "^2.0.5",
6061
"flexsearch": "0.7.43",
6162
"i18nifty": "^3.2.6",

web/src/core/adapters/s3Client/s3Client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,7 @@ export function createS3Client(
592592
size: metadata.ContentLength,
593593
lastModified: metadata.LastModified,
594594
policy: "private",
595-
canChangePolicy: false
595+
canChangePolicy: true
596596
} satisfies S3Object.File;
597597
},
598598
deleteFile: async ({ path }) => {

web/src/core/tools/crawl.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,46 @@ export function crawlFactory(params: {
88
}) {
99
const { list } = params;
1010

11-
async function crawlRec(params: { directoryPath: string; filePaths: string[] }) {
12-
const { directoryPath, filePaths } = params;
11+
async function crawlRec(params: {
12+
directoryPath: string;
13+
filePaths: string[];
14+
directoryPaths: string[];
15+
}) {
16+
const { directoryPath, filePaths, directoryPaths } = params;
1317

1418
const { directoryBasenames, fileBasenames } = await list({
1519
directoryPath
1620
});
1721

18-
const toPath = (fileOrDirectoryBasename: string) =>
19-
pathJoin(directoryPath, fileOrDirectoryBasename);
22+
const toPath = (fileOrDirectoryBasename: string) => {
23+
return pathJoin(directoryPath, fileOrDirectoryBasename);
24+
};
2025

2126
filePaths.push(...fileBasenames.map(toPath));
2227

2328
await Promise.all(
24-
directoryBasenames
25-
.map(toPath)
26-
.map(directoryPath => crawlRec({ directoryPath, filePaths }))
29+
directoryBasenames.map(toPath).map(directoryPath => {
30+
directoryPaths.push(directoryPath);
31+
return crawlRec({ directoryPath, filePaths, directoryPaths });
32+
})
2733
);
2834
}
2935

3036
async function crawl(params: {
3137
directoryPath: string;
32-
}): Promise<{ filePaths: string[] }> {
38+
}): Promise<{ filePaths: string[]; directoryPaths: string[] }> {
3339
const { directoryPath } = params;
3440

3541
const filePaths: string[] = [];
42+
const directoryPaths: string[] = [directoryPath];
3643

37-
await crawlRec({ directoryPath, filePaths });
44+
await crawlRec({ directoryPath, filePaths, directoryPaths });
3845

3946
return {
40-
filePaths: filePaths.map(filePath => pathRelative(directoryPath, filePath))
47+
filePaths: filePaths.map(filePath => pathRelative(directoryPath, filePath)),
48+
directoryPaths: directoryPaths.map(dirPath =>
49+
pathRelative(directoryPath, dirPath)
50+
)
4151
};
4252
}
4353

web/src/core/usecases/fileExplorer/selectors.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ type UploadProgress = {
2020
uploadPercent: number;
2121
};
2222
};
23+
const isDownloadPreparing = createSelector(
24+
createSelector(state, state => state.ongoingOperations),
25+
(ongoingOperations): boolean =>
26+
ongoingOperations.some(operation => operation.operation === "downloading")
27+
);
2328

2429
const uploadProgress = createSelector(state, (state): UploadProgress => {
2530
const { s3FilesBeingUploaded } = state;
@@ -306,6 +311,7 @@ const main = createSelector(
306311
pathMinDepth,
307312
createSelector(state, state => state.viewMode),
308313
shareView,
314+
isDownloadPreparing,
309315
(
310316
directoryPath,
311317
uploadProgress,
@@ -314,7 +320,8 @@ const main = createSelector(
314320
isNavigationOngoing,
315321
pathMinDepth,
316322
viewMode,
317-
shareView
323+
shareView,
324+
isDownloadPreparing
318325
) => {
319326
if (directoryPath === undefined) {
320327
return {
@@ -323,7 +330,8 @@ const main = createSelector(
323330
uploadProgress,
324331
commandLogsEntries,
325332
pathMinDepth,
326-
viewMode
333+
viewMode,
334+
isDownloadPreparing
327335
};
328336
}
329337

@@ -338,7 +346,8 @@ const main = createSelector(
338346
pathMinDepth,
339347
currentWorkingDirectoryView,
340348
viewMode,
341-
shareView
349+
shareView,
350+
isDownloadPreparing
342351
};
343352
}
344353
);

web/src/core/usecases/fileExplorer/state.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export type State = {
1515
ongoingOperations: {
1616
directoryPath: string;
1717
operationId: string;
18-
operation: "create" | "delete" | "modifyPolicy";
18+
operation: "create" | "delete" | "modifyPolicy" | "downloading";
1919
objects: S3Object[];
2020
}[];
2121
s3FilesBeingUploaded: {
@@ -171,7 +171,7 @@ export const { reducer, actions } = createUsecaseActions({
171171
payload: {
172172
operationId: string;
173173
objects: S3Object[];
174-
operation: "create" | "delete" | "modifyPolicy";
174+
operation: "create" | "delete" | "modifyPolicy" | "downloading";
175175
};
176176
}
177177
) => {
@@ -200,6 +200,7 @@ export const { reducer, actions } = createUsecaseActions({
200200
state.objects.push(...objects);
201201
break;
202202
case "modifyPolicy":
203+
case "downloading":
203204
break;
204205
}
205206
},

web/src/core/usecases/fileExplorer/thunks.ts

Lines changed: 205 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { assert } from "tsafe/assert";
22
import { Evt } from "evt";
3+
import { Zip, ZipPassThrough } from "fflate/browser";
34
import type { Thunks } from "core/bootstrap";
45
import { name, actions } from "./state";
56
import { protectedSelectors } from "./selectors";
@@ -31,7 +32,7 @@ export declare namespace ExplorersCreateParams {
3132
const privateThunks = {
3233
createOperation:
3334
(params: {
34-
operation: "create" | "delete" | "modifyPolicy";
35+
operation: "create" | "delete" | "modifyPolicy" | "downloading";
3536
objects: S3Object[];
3637
directoryPath: string;
3738
}) =>
@@ -178,6 +179,184 @@ const privateThunks = {
178179
isBucketPolicyAvailable
179180
})
180181
);
182+
},
183+
downloadObjectsAsZip:
184+
(params: { s3Objects: S3Object[] }) =>
185+
async (...args) => {
186+
const [dispatch, getState] = args;
187+
188+
const { directoryPath } = getState()[name];
189+
assert(directoryPath !== undefined);
190+
191+
const { s3Objects } = params;
192+
193+
const operationId = await dispatch(
194+
privateThunks.createOperation({
195+
operation: "downloading",
196+
objects: s3Objects,
197+
directoryPath
198+
})
199+
);
200+
201+
const s3Client = await dispatch(
202+
s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer()
203+
).then(r => {
204+
assert(r !== undefined);
205+
return r.s3Client;
206+
});
207+
208+
const { readable, writable } = new TransformStream();
209+
const writer = writable.getWriter();
210+
211+
{
212+
const zip = new Zip((err, chunk, final) => {
213+
if (err) {
214+
writer.abort(err);
215+
throw err;
216+
}
217+
218+
writer.write(chunk);
219+
220+
if (final) {
221+
writer.close();
222+
}
223+
});
224+
225+
const { crawl } = crawlFactory({
226+
list: async ({ directoryPath }) => {
227+
const { objects } = await s3Client.listObjects({
228+
path: directoryPath
229+
});
230+
231+
return objects.reduce<{
232+
fileBasenames: string[];
233+
directoryBasenames: string[];
234+
}>(
235+
(acc, { kind, basename }) => {
236+
switch (kind) {
237+
case "directory":
238+
acc.directoryBasenames.push(basename);
239+
break;
240+
case "file":
241+
if (basename !== ".keep") {
242+
acc.fileBasenames.push(basename);
243+
}
244+
break;
245+
}
246+
return acc;
247+
},
248+
{
249+
fileBasenames: [],
250+
directoryBasenames: []
251+
}
252+
);
253+
}
254+
});
255+
256+
const createZipEntryFromUrl = async ({
257+
zipPath,
258+
url,
259+
modifiedDate
260+
}: {
261+
zipPath: string;
262+
url: string;
263+
modifiedDate?: string | number | Date;
264+
}) => {
265+
const res = await fetch(url);
266+
if (!res.ok || !res.body) return;
267+
268+
const entry = new ZipPassThrough(zipPath);
269+
if (modifiedDate) entry.mtime = modifiedDate;
270+
271+
zip.add(entry);
272+
273+
const reader = res.body.getReader();
274+
while (true) {
275+
const { done, value } = await reader.read();
276+
if (done) break;
277+
entry.push(value);
278+
}
279+
280+
entry.push(new Uint8Array(0), true);
281+
};
282+
283+
const downloadTasks: Promise<void>[] = [];
284+
285+
for (const object of s3Objects) {
286+
const basePath = pathJoin(directoryPath, object.basename);
287+
288+
switch (object.kind) {
289+
case "directory": {
290+
const { filePaths, directoryPaths } = await crawl({
291+
directoryPath: basePath
292+
});
293+
294+
directoryPaths.forEach(path => {
295+
const zipEntry = new ZipPassThrough(
296+
`${pathJoin(object.basename, path)}/`
297+
);
298+
zip.add(zipEntry);
299+
zipEntry.push(new Uint8Array(0), true);
300+
});
301+
302+
for (const relativeFilePath of filePaths) {
303+
const absolutePath = pathJoin(basePath, relativeFilePath);
304+
const zipEntryPath = pathJoin(
305+
object.basename,
306+
relativeFilePath
307+
);
308+
309+
const url = await s3Client.getFileDownloadUrl({
310+
path: absolutePath,
311+
validityDurationSecond: 300
312+
});
313+
314+
downloadTasks.push(
315+
createZipEntryFromUrl({
316+
zipPath: zipEntryPath,
317+
url,
318+
modifiedDate: undefined
319+
})
320+
);
321+
}
322+
break;
323+
}
324+
325+
case "file": {
326+
const url = await s3Client.getFileDownloadUrl({
327+
path: basePath,
328+
validityDurationSecond: 300
329+
});
330+
331+
downloadTasks.push(
332+
createZipEntryFromUrl({
333+
zipPath: object.basename,
334+
url,
335+
modifiedDate: object.lastModified
336+
})
337+
);
338+
break;
339+
}
340+
}
341+
}
342+
343+
await Promise.all(downloadTasks);
344+
zip.end();
345+
}
346+
347+
dispatch(
348+
actions.operationCompleted({
349+
objects: s3Objects,
350+
operationId
351+
})
352+
);
353+
return {
354+
stream: readable,
355+
zipFileName:
356+
s3Objects.length === 1
357+
? `${s3Objects[0].basename}.zip`
358+
: `onyxia-download-${new Date().toISOString()}.zip`
359+
};
181360
}
182361
} satisfies Thunks;
183362

@@ -858,5 +1037,30 @@ export const thunks = {
8581037
);
8591038

8601039
dispatch(actions.requestSignedUrlCompleted({ url }));
1040+
},
1041+
getDownloadUrl:
1042+
(params: { s3Objects: S3Object[] }) =>
1043+
async (...args): Promise<{ url: string; filename: string }> => {
1044+
const { s3Objects } = params;
1045+
const [dispatch] = args;
1046+
1047+
if (s3Objects.length === 1 && s3Objects[0].kind === "file") {
1048+
const basename = s3Objects[0].basename;
1049+
const url = await dispatch(
1050+
thunks.getFileDownloadUrl({
1051+
basename
1052+
})
1053+
);
1054+
return { url, filename: basename };
1055+
}
1056+
1057+
const { stream, zipFileName } = await dispatch(
1058+
privateThunks.downloadObjectsAsZip({ s3Objects })
1059+
);
1060+
1061+
const blob = await new Response(stream).blob();
1062+
const blobUrl = URL.createObjectURL(blob);
1063+
1064+
return { url: blobUrl, filename: zipFileName };
8611065
}
8621066
} satisfies Thunks;

web/src/ui/i18n/resources/de.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ export const translations: Translations<"de"> = {
370370
ExplorerButtonBar: {
371371
file: "Datei",
372372
delete: "löschen",
373+
"download directory": "Herunterladen",
373374
"upload file": "Datei hochladen",
374375
"copy path": "Den S3-Objektnamen kopieren",
375376
"create directory": "Neues Verzeichnis",
@@ -379,6 +380,9 @@ export const translations: Translations<"de"> = {
379380
"alt list view": "Liste anzeigen",
380381
"alt block view": "Blockansicht anzeigen"
381382
},
383+
ExplorerDownloadSnackbar: {
384+
"download preparation": "Vorbereitung des Downloads ..."
385+
},
382386
SecretsExplorerButtonBar: {
383387
secret: "Geheimnis",
384388
rename: "umbenennen",

0 commit comments

Comments
 (0)