Skip to content

Commit c1957a4

Browse files
authored
fix: Merge pull request #255 from UniversalDataTool/feat/s3-import
IAM-based S3 Data Import and Upload
2 parents 873339f + 08c8a61 commit c1957a4

File tree

15 files changed

+647
-57
lines changed

15 files changed

+647
-57
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,15 @@
8383
"any-shell-escape": "^0.1.1",
8484
"aws-amplify": "^3.0.6",
8585
"aws-sign": "^1.0.0",
86+
"aws4fetch": "^1.0.12",
8687
"axios": "^0.19.2",
8788
"bent": "^7.1.0",
8889
"brace": "^0.11.1",
8990
"detect-browser": "^5.0.0",
9091
"download-file": "^0.1.5",
9192
"duration": "^0.2.2",
9293
"fast-json-patch": "^3.0.0-1",
94+
"fast-xml-parser": "^3.17.4",
9395
"ffmpeg-static": "^4.2.4",
9496
"form-data": "^3.0.0",
9597
"i18next": "^19.4.4",
@@ -102,7 +104,7 @@
102104
"posthog-js": "^1.0.4",
103105
"react-ace": "^7.0.4",
104106
"react-data-table-component": "^6.2.2",
105-
"react-dropzone": "^10.1.8",
107+
"react-dropzone": "^11.0.3",
106108
"react-github-btn": "^1.2.0",
107109
"react-hotkeys": "^2.0.0",
108110
"react-i18next": "^11.4.0",

src/components/AddAuthFromTemplateDialog/index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ const forms = {
102102
type: "text",
103103
isRequired: true,
104104
},
105+
{
106+
name: "auth.s3iam.region",
107+
title: "Region",
108+
placeholder: "us-east-1",
109+
type: "text",
110+
isRequired: true,
111+
},
105112
],
106113
},
107114
proxy: {

src/components/AppConfig/index.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const configKeyNames = [
1414
"auth.cognito.storage.aws_s3.region",
1515
"auth.s3iam.access_key_id",
1616
"auth.s3iam.secret_access_key",
17+
"auth.s3iam.region",
1718
"auth.proxy.corsproxy",
1819
"labelhelp.disabled",
1920
"labelhelp.apikey",
@@ -27,9 +28,20 @@ const defaultAppConfig = {
2728
"https://corsproxy.seve.workers.dev/corsproxy/?apiurl={URL}",
2829
}
2930

31+
const jsonParseOrEmpty = (s) => {
32+
try {
33+
return JSON.parse(s)
34+
} catch (e) {
35+
return {}
36+
}
37+
}
38+
3039
// NOTE: appConfig should not allow any nested values
3140
export const AppConfigContext = createContext({
32-
appConfig: {},
41+
appConfig: {
42+
...defaultAppConfig,
43+
...jsonParseOrEmpty(window.localStorage.app_config),
44+
},
3345
setAppConfig: (newConfig) => undefined,
3446
fromConfig: (key) => undefined,
3547
setInConfig: (key, value) => undefined,

src/components/ImportFromS3Dialog/index.js

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,90 @@
1-
import React from "react"
1+
import React, { useState, useEffect } from "react"
2+
import Box from "@material-ui/core/Box"
3+
import CircularProgress from "@material-ui/core/CircularProgress"
24
import SimpleDialog from "../SimpleDialog"
5+
import S3PathSelector from "../S3PathSelector"
6+
import getSampleFromUrl from "../PasteUrlsDialog/get-sample-from-url.js"
7+
// import { useAppConfig } from "../AppConfig"
8+
import useIAMS3API from "../../utils/auth-handlers/use-iam-s3-api"
39

4-
export const ImportFromS3Dialog = ({ open, onClose }) => {
10+
export const ImportFromS3Dialog = ({ open, onClose, onAddSamples }) => {
11+
const { listBuckets, listBucketItemsAt } = useIAMS3API()
12+
const [s3Path, setS3Path] = useState("")
13+
const [{ options, optionsLoading }, setOptions] = useState({
14+
optionsLoading: true,
15+
})
16+
useEffect(() => {
17+
if (!open) return
18+
if (!listBuckets) return
19+
async function loadS3Path() {
20+
if (s3Path === "") {
21+
setOptions({
22+
optionsLoading: false,
23+
options: (
24+
await listBuckets()
25+
).ListAllMyBucketsResult.Buckets.Bucket.map((b) => ({
26+
name: b.Name,
27+
type: "bucket",
28+
})),
29+
})
30+
} else {
31+
setOptions({
32+
optionsLoading: false,
33+
options: (
34+
(await listBucketItemsAt(s3Path)).ListBucketResult.CommonPrefixes ||
35+
[]
36+
).map((a) => ({
37+
name: a.Prefix.split("/").slice(-2).join("/"),
38+
type: "directory",
39+
})),
40+
})
41+
}
42+
}
43+
loadS3Path()
44+
// eslint-disable-next-line
45+
}, [s3Path, listBuckets, listBucketItemsAt, open])
46+
if (!open) return null
547
return (
6-
<SimpleDialog onClose={onClose} open={open} title="Import from S3">
7-
This is a work in progress! Available soon!{" "}
8-
<a href="https://github.com/UniversalDataTool/universal-data-tool/issues/205">
9-
Track the progress here.
10-
</a>
48+
<SimpleDialog
49+
onClose={onClose}
50+
open={open}
51+
title="Import from S3"
52+
actions={[
53+
{
54+
onClick: async () => {
55+
const bucket = s3Path.match(/s3:\/\/([^/]+)/)[1]
56+
57+
// TODO support more than 1000 file import by using
58+
// continuation-token on S3
59+
const items = (
60+
await listBucketItemsAt(s3Path, { files: true })
61+
).ListBucketResult.Contents.filter(
62+
(item) => !item.Key.endsWith("/")
63+
)
64+
.map((item) => `https://s3.amazonaws.com/${bucket}/${item.Key}`)
65+
.map((url) => getSampleFromUrl(url, { returnNulls: true }))
66+
.filter(Boolean)
67+
onAddSamples(items)
68+
},
69+
disabled: !s3Path,
70+
text: "Import All Files in Directory",
71+
},
72+
]}
73+
>
74+
{optionsLoading ? (
75+
<Box height={300} width={400} paddingTop={4} textAlign="center">
76+
<CircularProgress size={100} />
77+
</Box>
78+
) : (
79+
<S3PathSelector
80+
currentPath={s3Path}
81+
options={options || []}
82+
onChangePath={(newS3Path) => {
83+
setS3Path(newS3Path)
84+
setOptions({ optionsLoading: true })
85+
}}
86+
/>
87+
)}
1188
</SimpleDialog>
1289
)
1390
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// @flow
2+
3+
import React from "react"
4+
5+
import { storiesOf } from "@storybook/react"
6+
import { action } from "@storybook/addon-actions"
7+
8+
import ImportFromS3Dialog from "./"
9+
10+
// NOTE: THIS STORY REQUIRES the localStorage app_config to have s3 auth options!! It won't
11+
// work by default!
12+
13+
storiesOf("ImportFromS3Dialog", module).add(
14+
"Basic (doesn't work by default)",
15+
() => <ImportFromS3Dialog open onAddSamples={action("onAddSamples")} />
16+
)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React, { useCallback, useState } from "react"
2+
import { styled } from "@material-ui/core/styles"
3+
import * as colors from "@material-ui/core/colors"
4+
import Box from "@material-ui/core/Box"
5+
import { useDropzone } from "react-dropzone"
6+
7+
const Container = styled(Box)({
8+
width: 400,
9+
height: 200,
10+
padding: 20,
11+
border: `2px dotted ${colors.grey[400]}`,
12+
textAlign: "center",
13+
fontSize: 24,
14+
})
15+
16+
const FileCounter = styled(Box)({
17+
fontSize: 24,
18+
color: colors.grey[700],
19+
fontWeight: "bold",
20+
})
21+
22+
/*
23+
This component allows you to upload multiple files, then reads the files in
24+
serial. It's perfect for an uploading process where you don't want to upload one
25+
file at a time. It gives a progress bar indicating upload progres.
26+
*/
27+
28+
export const MultiFileDrop = ({ loadFile, onComplete }) => {
29+
const [totalUploaded, setTotalUploaded] = useState(0)
30+
const [errors, setErrors] = useState([])
31+
const [{ totalFiles, filesSelected }, setUploadStartState] = useState({
32+
filesSelected: false,
33+
})
34+
const onDrop = useCallback(async (acceptedFiles) => {
35+
setUploadStartState({
36+
totalFiles: acceptedFiles.length,
37+
filesSelected: true,
38+
})
39+
const errors = []
40+
for (const [index, file] of acceptedFiles.entries()) {
41+
await loadFile(file).catch((err) => {
42+
errors.push(err.toString())
43+
setErrors(errors)
44+
})
45+
setTotalUploaded(index + 1)
46+
}
47+
onComplete()
48+
// eslint-disable-next-line
49+
}, [])
50+
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop })
51+
52+
if (!filesSelected) {
53+
return (
54+
<Container {...getRootProps()}>
55+
<input {...getInputProps()} />
56+
{isDragActive ? (
57+
<p>Drop the files here ...</p>
58+
) : (
59+
<p>Drag 'n' drop some files here, or click to select files</p>
60+
)}
61+
</Container>
62+
)
63+
}
64+
65+
return (
66+
<Container>
67+
<FileCounter>
68+
{totalUploaded} / {totalFiles} Files Processed
69+
</FileCounter>
70+
{/* TODO progress bar */}
71+
<Box
72+
maxHeight={100}
73+
overflowY="auto"
74+
textAlign="left"
75+
color="red"
76+
fontSize={12}
77+
>
78+
{errors.map((err, i) => (
79+
<div key={i}>{err}</div>
80+
))}
81+
</Box>
82+
</Container>
83+
)
84+
}
85+
86+
export default MultiFileDrop
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export default (s, opts = {}) => {
2+
console.log(s)
3+
let extension = s.replace(/\?.*/g, "").split(".").slice(-1)[0]
4+
if (s.includes("gstatic.com/images")) {
5+
extension = "jpg"
6+
}
7+
switch (extension.toLowerCase()) {
8+
case "png":
9+
case "jpg":
10+
case "gif":
11+
case "jpeg":
12+
case "bmp": {
13+
return { imageUrl: s }
14+
}
15+
case "pdf": {
16+
return { pdfUrl: s }
17+
}
18+
case "mp3":
19+
case "wav": {
20+
return { audioUrl: s }
21+
}
22+
default: {
23+
if (opts.returnNulls) return null
24+
throw new Error(`extension not recognized: "${extension}" in "${s}"`)
25+
// TODO if the user doesn't care, return null (this
26+
// behavior could be enabled with textfield option)
27+
}
28+
}
29+
}

src/components/PasteUrlsDialog/index.js

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { styled } from "@material-ui/core/styles"
44
import SimpleDialog from "../SimpleDialog"
55
import Select from "react-select"
66
import * as colors from "@material-ui/core/colors"
7+
import getSampleFromUrl from "./get-sample-from-url"
78

89
const SelectContainer = styled("div")({
910
marginTop: 8,
@@ -48,41 +49,11 @@ export default ({ open, onClose, onAddSamples }) => {
4849
.split("\n")
4950
.map((l) => l.trim())
5051
.filter(Boolean)
51-
.map((s) => {
52-
if (urlType === "images") {
53-
return { imageUrl: s }
54-
}
55-
let extension = s
56-
.replace(/\?.*/g, "")
57-
.split(".")
58-
.slice(-1)[0]
59-
if (s.includes("gstatic.com/images")) {
60-
extension = "jpg"
61-
}
62-
switch (extension.toLowerCase()) {
63-
case "png":
64-
case "jpg":
65-
case "gif":
66-
case "jpeg":
67-
case "bmp": {
68-
return { imageUrl: s }
69-
}
70-
case "pdf": {
71-
return { pdfUrl: s }
72-
}
73-
case "mp3":
74-
case "wav": {
75-
return { audioUrl: s }
76-
}
77-
default: {
78-
throw new Error(
79-
`extension not recognized: "${extension}" in "${s}"`
80-
)
81-
// TODO if the user doesn't care, return null (this
82-
// behavior could be enabled with textfield option)
83-
}
84-
}
85-
})
52+
.map((s) =>
53+
urlType === "images"
54+
? { imageUrl: s }
55+
: getSampleFromUrl(s, { returnNulls: true })
56+
)
8657
.filter(Boolean)
8758
)
8859
} catch (e) {

0 commit comments

Comments
 (0)