@octane-xplat/files 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -20
- package/package.json +11 -4
- package/src/FileInput.linux.tsrx +1 -0
- package/src/FileInput.macos.tsrx +167 -0
- package/src/FileInput.tsrx +168 -0
- package/src/FileInput.web.test.tsx +99 -0
- package/src/FileInput.web.tsrx +240 -0
- package/src/file-bytes.test.ts +53 -0
- package/src/file-bytes.ts +68 -0
- package/src/file-input-utils.test.ts +22 -0
- package/src/file-input-utils.ts +113 -0
- package/src/files.bridge.test.ts +342 -0
- package/src/files.macos.ts +69 -7
- package/src/files.ts +350 -19
- package/src/files.web.test.ts +117 -0
- package/src/files.web.ts +156 -15
- package/src/index.macos.ts +9 -0
- package/src/index.ts +9 -0
- package/src/props.ts +52 -0
- package/src/types.ts +65 -1
- package/types/index.d.ts +22 -0
- package/types/index.macos.d.ts +21 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useImperativeHandle, useId, useRef, useState } from 'octane'
|
|
2
|
+
import { Field as FormField, Icon, Spinner as ActivityIndicator, Tooltip, useFieldControl } from '@octane-xplat/ui'
|
|
3
|
+
import type { FileInputFile, FileInputProps } from './props'
|
|
4
|
+
import { cx, formatFileSize, normalizePicked, validateFiles } from './file-input-utils'
|
|
5
|
+
|
|
6
|
+
const STATUS_GLYPHS = { error: '⚠', warning: '⚠', success: '✓' } as const
|
|
7
|
+
|
|
8
|
+
function FileInputControl(inputProps: FileInputProps) @{
|
|
9
|
+
const { props } = useFieldControl(inputProps)
|
|
10
|
+
const generatedId = useId()
|
|
11
|
+
const inputId = props.id ?? generatedId
|
|
12
|
+
const fileInput = useRef<HTMLInputElement | null>(null)
|
|
13
|
+
|
|
14
|
+
const [dragging, setDragging] = useState(false)
|
|
15
|
+
const [busy, setBusy] = useState(false)
|
|
16
|
+
const [validationErrors, setValidationErrors] = useState<string[]>([])
|
|
17
|
+
|
|
18
|
+
const files: FileInputFile[] = props.value == null ? [] : Array.isArray(props.value) ? props.value : [props.value]
|
|
19
|
+
const isDisabled = !!props.isDisabled || !!props.isReadOnly
|
|
20
|
+
const isBusy = !!props.isLoading || busy
|
|
21
|
+
const canClear = files.length > 0 && !isDisabled && !isBusy
|
|
22
|
+
const showsDisabledReason = isDisabled && !!props.disabledMessage
|
|
23
|
+
const statusType = validationErrors.length ? 'error' : props.status?.type
|
|
24
|
+
const statusMessage = validationErrors.length ? validationErrors.join(' ') : props.status?.message
|
|
25
|
+
|
|
26
|
+
// `onChange` mirrors `value`'s shape: array when `isMultiple`, single ref
|
|
27
|
+
// or null otherwise.
|
|
28
|
+
const emitValue = (next: FileInputFile[]): FileInputFile | FileInputFile[] | null =>
|
|
29
|
+
props.isMultiple ? next : (next[0] ?? null)
|
|
30
|
+
|
|
31
|
+
const fireChange = (next: FileInputFile[]) => {
|
|
32
|
+
const emitted = emitValue(next)
|
|
33
|
+
props.onChange?.(emitted)
|
|
34
|
+
if (props.changeAction) {
|
|
35
|
+
setBusy(true)
|
|
36
|
+
Promise.resolve(props.changeAction(emitted)).finally(() => setBusy(false))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const acceptRefs = (picked: FileInputFile[]) => {
|
|
41
|
+
if (isDisabled || isBusy || !picked.length) return
|
|
42
|
+
const merged = props.isMultiple ? [...files, ...picked] : picked
|
|
43
|
+
const { valid, errors } = validateFiles(merged, {
|
|
44
|
+
accept: props.accept,
|
|
45
|
+
maxSize: props.maxSize,
|
|
46
|
+
maxFiles: props.maxFiles,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
setValidationErrors(errors)
|
|
50
|
+
const next = !props.isMultiple && errors.length ? files : valid
|
|
51
|
+
if (next.length !== files.length || next.some((file, index) => file.uri !== files[index]?.uri)) {
|
|
52
|
+
fireChange(next)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const removeAt = (index: number) => {
|
|
57
|
+
if (isDisabled || isBusy) return
|
|
58
|
+
setValidationErrors([])
|
|
59
|
+
fireChange(files.filter((_, i) => i !== index))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const pick = () => {
|
|
63
|
+
if (isDisabled || isBusy) return
|
|
64
|
+
// An optional custom picker can provide cloud or app-specific sources.
|
|
65
|
+
if (props.pick) {
|
|
66
|
+
Promise.resolve(props.pick({ accept: props.accept, multiple: props.isMultiple }))
|
|
67
|
+
.then((r) => acceptRefs(normalizePicked(r)))
|
|
68
|
+
.catch((error) => setValidationErrors([String(error?.message ?? error)]))
|
|
69
|
+
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fileInput.current?.click()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const handleFileSelect = (e: any) => {
|
|
77
|
+
const list = e.target.files as any
|
|
78
|
+
const picked: FileInputFile[] = []
|
|
79
|
+
if (list) {
|
|
80
|
+
for (let i = 0; i < list.length; i++) {
|
|
81
|
+
const f = list[i]
|
|
82
|
+
picked.push({ name: f.name, uri: URL.createObjectURL(f), size: f.size, mimeType: f.type, file: f })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
e.target.value = ''
|
|
87
|
+
acceptRefs(picked)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handleDrop = (e: any) => {
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
e.stopPropagation()
|
|
93
|
+
setDragging(false)
|
|
94
|
+
if (isDisabled || isBusy) return
|
|
95
|
+
const list = e.dataTransfer?.files
|
|
96
|
+
if (!list?.length) return
|
|
97
|
+
const picked: FileInputFile[] = []
|
|
98
|
+
for (let i = 0; i < list.length; i++) {
|
|
99
|
+
const f = list[i]
|
|
100
|
+
picked.push({ name: f.name, uri: URL.createObjectURL(f), size: f.size, mimeType: f.type, file: f })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
acceptRefs(picked)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const dropzone = props.mode === 'dropzone'
|
|
107
|
+
const itemContent = (
|
|
108
|
+
<>
|
|
109
|
+
{files.length ? (
|
|
110
|
+
<ul className="vx-fileinput-list">
|
|
111
|
+
{files.map((f: FileInputFile, i: number) => (
|
|
112
|
+
<li key={`${f.uri}-${i}`} className="vx-fileinput-item">
|
|
113
|
+
<Icon name="xplat-upload" size={14} />
|
|
114
|
+
<span className="vx-fileinput-itemname">{f.name}</span>
|
|
115
|
+
{f.size != null ? <span className="vx-fileinput-itemsize">{formatFileSize(f.size)}</span> : null}
|
|
116
|
+
{!isDisabled && !isBusy
|
|
117
|
+
? <button type="button" className="vx-fileinput-itemremove" aria-label={`Remove ${f.name}`} onClick={() => removeAt(i)}>{'×'}</button>
|
|
118
|
+
: null}
|
|
119
|
+
</li>
|
|
120
|
+
))}
|
|
121
|
+
</ul>
|
|
122
|
+
) : null}
|
|
123
|
+
{dropzone ? (
|
|
124
|
+
<div className="vx-fileinput-dropzone-hint">
|
|
125
|
+
<Icon name="xplat-upload" size={24} />
|
|
126
|
+
<span>{isDisabled ? 'Upload disabled' : 'Drag & drop files here or click to browse'}</span>
|
|
127
|
+
{props.accept ? <span className="vx-fileinput-accept">{props.accept}</span> : null}
|
|
128
|
+
</div>
|
|
129
|
+
) : (
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
id={inputId}
|
|
133
|
+
className="vx-fileinput-trigger"
|
|
134
|
+
disabled={isDisabled && !showsDisabledReason || undefined}
|
|
135
|
+
aria-disabled={showsDisabledReason || undefined}
|
|
136
|
+
aria-describedby={props.ariaDescribedBy}
|
|
137
|
+
aria-required={props.isRequired && !props.isOptional || undefined}
|
|
138
|
+
aria-invalid={statusType === 'error' || undefined}
|
|
139
|
+
aria-busy={isBusy || undefined}
|
|
140
|
+
onClick={pick}
|
|
141
|
+
>
|
|
142
|
+
<Icon name="xplat-upload" size={14} /> {files.length ? 'Choose more files' : 'Choose files'}
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
{dropzone ? (
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
id={inputId}
|
|
149
|
+
className="vx-fileinput-trigger vx-fileinput-trigger--hidden"
|
|
150
|
+
disabled={isDisabled && !showsDisabledReason || undefined}
|
|
151
|
+
aria-disabled={showsDisabledReason || undefined}
|
|
152
|
+
aria-describedby={props.ariaDescribedBy}
|
|
153
|
+
aria-required={props.isRequired && !props.isOptional || undefined}
|
|
154
|
+
aria-invalid={statusType === 'error' || undefined}
|
|
155
|
+
aria-busy={isBusy || undefined}
|
|
156
|
+
onClick={pick}
|
|
157
|
+
>
|
|
158
|
+
Browse files
|
|
159
|
+
</button>
|
|
160
|
+
) : null}
|
|
161
|
+
{canClear ? <button type="button" className="vx-fileinput-clear" aria-label={`Clear ${props.fieldLabel ?? props.label ?? 'files'}`} onClick={() => { setValidationErrors([]); fireChange([]) }}>{'×'}</button> : null}
|
|
162
|
+
{isBusy ? <ActivityIndicator busy className="vx-fileinput-spinner" /> : null}
|
|
163
|
+
{statusType ? (
|
|
164
|
+
props.statusVariant === 'tooltip' && statusMessage
|
|
165
|
+
? <Tooltip trigger={<span className={cx('vx-field-icon', `vx-field-icon--${statusType}`)}>{STATUS_GLYPHS[statusType]}</span>} content={statusMessage} />
|
|
166
|
+
: <span className={cx('vx-field-icon', `vx-field-icon--${statusType}`)}>{STATUS_GLYPHS[statusType]}</span>
|
|
167
|
+
) : null}
|
|
168
|
+
{statusMessage && props.statusVariant !== 'tooltip'
|
|
169
|
+
? <span className={cx('vx-field-status', `vx-field-status--${statusType}`)} role={statusType === 'error' ? 'alert' : 'status'}>{statusMessage}</span>
|
|
170
|
+
: null}
|
|
171
|
+
<input
|
|
172
|
+
ref={(el: any) => { fileInput.current = el; }}
|
|
173
|
+
type="file"
|
|
174
|
+
className="vx-fileinput-nativeinput"
|
|
175
|
+
accept={props.accept}
|
|
176
|
+
multiple={props.isMultiple || undefined}
|
|
177
|
+
disabled={isDisabled || undefined}
|
|
178
|
+
aria-hidden="true"
|
|
179
|
+
tabIndex={-1}
|
|
180
|
+
onChange={handleFileSelect}
|
|
181
|
+
/>
|
|
182
|
+
</>
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
useImperativeHandle(props.ref, () => ({ open: pick, native: fileInput.current }), undefined);
|
|
186
|
+
|
|
187
|
+
<div
|
|
188
|
+
className={cx(
|
|
189
|
+
'vx-fileinput',
|
|
190
|
+
dropzone && 'vx-fileinput--dropzone',
|
|
191
|
+
isDisabled && 'vx-fileinput--disabled',
|
|
192
|
+
isBusy && 'vx-fileinput--loading',
|
|
193
|
+
dragging && 'vx-fileinput--dragging',
|
|
194
|
+
statusType && `vx-fileinput--status-${statusType}`,
|
|
195
|
+
props.size && `vx-fileinput--size-${props.size}`,
|
|
196
|
+
props.className,
|
|
197
|
+
)}
|
|
198
|
+
style={props.style}
|
|
199
|
+
onDragOver={(e: any) => { e.preventDefault(); if (!isDisabled && !isBusy) setDragging(true) }}
|
|
200
|
+
onDragEnter={(e: any) => { e.preventDefault() }}
|
|
201
|
+
onDragLeave={() => setDragging(false)}
|
|
202
|
+
onDrop={handleDrop}
|
|
203
|
+
>
|
|
204
|
+
{itemContent}
|
|
205
|
+
</div>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* File entry — `compact` (button + list) or `dropzone` mode. Values are
|
|
210
|
+
* `{name, uri, size?, mimeType?}` portable refs. On web the hidden browser
|
|
211
|
+
* input returns the `File` alongside its object URL. Drag/drop targets the
|
|
212
|
+
* control wrapper in both modes.
|
|
213
|
+
*/
|
|
214
|
+
export function FileInput(props: FileInputProps) @{
|
|
215
|
+
const { inField } = useFieldControl(props)
|
|
216
|
+
const cid = useId()
|
|
217
|
+
const control = <FileInputControl {...props} />
|
|
218
|
+
if (inField) return control
|
|
219
|
+
return (
|
|
220
|
+
<FormField
|
|
221
|
+
inputID={props.id ?? cid}
|
|
222
|
+
label={props.label}
|
|
223
|
+
isLabelHidden={props.isLabelHidden}
|
|
224
|
+
description={props.description}
|
|
225
|
+
isDisabled={props.isDisabled}
|
|
226
|
+
disabledMessage={props.disabledMessage}
|
|
227
|
+
isReadOnly={props.isReadOnly}
|
|
228
|
+
isLoading={props.isLoading}
|
|
229
|
+
isRequired={props.isRequired}
|
|
230
|
+
isOptional={props.isOptional}
|
|
231
|
+
size={props.size}
|
|
232
|
+
status={props.status}
|
|
233
|
+
statusVariant={props.statusVariant}
|
|
234
|
+
labelTooltip={props.labelTooltip}
|
|
235
|
+
width={props.width}
|
|
236
|
+
>
|
|
237
|
+
{control}
|
|
238
|
+
</FormField>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { assertSafeFileName, concatBytes, exactBuffer, throwIfAborted } from './file-bytes'
|
|
3
|
+
|
|
4
|
+
describe('assertSafeFileName', () => {
|
|
5
|
+
it('accepts plain file names', () => {
|
|
6
|
+
for (const name of ['a.txt', 'report 1.pdf', 'x.y.z.bin', 'résumé.md']) {
|
|
7
|
+
expect(() => assertSafeFileName(name)).not.toThrow()
|
|
8
|
+
}
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('rejects empty, dot, and traversal names', () => {
|
|
12
|
+
for (const name of ['', '.', '..', '../x', 'a/b', 'a\\b', '..\\x', 'a\0b']) {
|
|
13
|
+
expect(() => assertSafeFileName(name)).toThrow(/Unsafe file name/)
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('throwIfAborted', () => {
|
|
19
|
+
it('passes for live or absent signals', () => {
|
|
20
|
+
expect(() => throwIfAborted(undefined)).not.toThrow()
|
|
21
|
+
const controller = new AbortController()
|
|
22
|
+
expect(() => throwIfAborted(controller.signal)).not.toThrow()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('throws AbortError for aborted signals', () => {
|
|
26
|
+
const controller = new AbortController()
|
|
27
|
+
controller.abort()
|
|
28
|
+
expect(() => throwIfAborted(controller.signal)).toThrowError(
|
|
29
|
+
expect.objectContaining({ name: 'AbortError' }),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('concatBytes / exactBuffer', () => {
|
|
35
|
+
it('concatenates chunks in order', () => {
|
|
36
|
+
const joined = concatBytes(
|
|
37
|
+
[new Uint8Array([1, 2]), new Uint8Array([3]), new Uint8Array([4, 5])],
|
|
38
|
+
5,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect([...joined]).toEqual([1, 2, 3, 4, 5])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns the buffer only when the view covers it', () => {
|
|
45
|
+
const buffer = new Uint8Array([9, 8, 7, 6]).buffer
|
|
46
|
+
expect(exactBuffer(new Uint8Array(buffer))).toBe(buffer)
|
|
47
|
+
|
|
48
|
+
const windowed = new Uint8Array(buffer, 1, 2)
|
|
49
|
+
const exact = exactBuffer(windowed)
|
|
50
|
+
expect(exact.byteLength).toBe(2)
|
|
51
|
+
expect([...new Uint8Array(exact)]).toEqual([8, 7])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the binary file primitives (readBytes/writeBytes/export).
|
|
3
|
+
* DOM-free so the native leaf can use it too.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** File names used for sandbox writes and export temp files must be bare
|
|
7
|
+
* names, not paths — a `/`, `\`, or `..` segment escapes the sandbox. */
|
|
8
|
+
export function assertSafeFileName(name: string): void {
|
|
9
|
+
if (
|
|
10
|
+
typeof name !== 'string' ||
|
|
11
|
+
name.length === 0 ||
|
|
12
|
+
name === '.' ||
|
|
13
|
+
name === '..' ||
|
|
14
|
+
name.includes('/') ||
|
|
15
|
+
name.includes('\\') ||
|
|
16
|
+
name.includes('\0')
|
|
17
|
+
) {
|
|
18
|
+
throw new Error(`Unsafe file name: ${JSON.stringify(name)}`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function fileTooLarge(name: string, maxBytes: number): Error {
|
|
23
|
+
return new Error(`${name}: file exceeds the ${maxBytes}-byte limit`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Named rejection for a second `files.export` while one is in flight. */
|
|
27
|
+
export function exportBusy(): Error {
|
|
28
|
+
const error = new Error('files.export: an export is already in progress')
|
|
29
|
+
error.name = 'ExportBusyError'
|
|
30
|
+
return error
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
34
|
+
if (!signal?.aborted) {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const thrower = (signal as { throwIfAborted?: () => void }).throwIfAborted
|
|
39
|
+
if (typeof thrower === 'function') {
|
|
40
|
+
thrower.call(signal)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const error = new Error('The operation was aborted')
|
|
44
|
+
error.name = 'AbortError'
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Concatenate Uint8Array chunks into one exact-length array. */
|
|
49
|
+
export function concatBytes(chunks: Uint8Array[], total: number): Uint8Array {
|
|
50
|
+
const out = new Uint8Array(total)
|
|
51
|
+
let offset = 0
|
|
52
|
+
for (const chunk of chunks) {
|
|
53
|
+
out.set(chunk, offset)
|
|
54
|
+
offset += chunk.byteLength
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Return an ArrayBuffer covering exactly this view's byte range — native
|
|
61
|
+
* marshallers treat the argument as the whole buffer. */
|
|
62
|
+
export function exactBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
63
|
+
if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
|
|
64
|
+
return bytes.buffer as ArrayBuffer
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return bytes.slice().buffer as ArrayBuffer
|
|
68
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { fileAccepts, validateFiles } from './file-input-utils'
|
|
3
|
+
|
|
4
|
+
describe('file validation', () => {
|
|
5
|
+
it('matches accept tokens', () => {
|
|
6
|
+
expect(fileAccepts({ name: 'a.png', uri: 'x' }, '.png')).toBe(true)
|
|
7
|
+
expect(fileAccepts({ name: 'a.jpg', uri: 'x' }, '.png')).toBe(false)
|
|
8
|
+
expect(fileAccepts({ name: 'a.png', uri: 'x', mimeType: 'image/png' }, 'image/*')).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('enforces maxSize and maxFiles', () => {
|
|
12
|
+
const files = [
|
|
13
|
+
{ name: 'a', uri: 'a', size: 10 },
|
|
14
|
+
{ name: 'b', uri: 'b', size: 99999 },
|
|
15
|
+
{ name: 'c', uri: 'c' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const result = validateFiles(files, { maxSize: 100, maxFiles: 1 })
|
|
19
|
+
expect(result.valid.map((file) => file.name)).toEqual(['a'])
|
|
20
|
+
expect(result.errors).toHaveLength(2)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting and validation helpers for this package's FileInput.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FileInputFile } from './props'
|
|
6
|
+
|
|
7
|
+
export function cx(...values: Array<string | false | null | undefined>): string {
|
|
8
|
+
return values.filter(Boolean).join(' ')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizePicked(result: FileInputFile[] | FileInputFile | null): FileInputFile[] {
|
|
12
|
+
if (result == null) {
|
|
13
|
+
return []
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return Array.isArray(result) ? result : [result]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Validation — `accept` tokens (`.ext`, `type/subtype`, `type/*`, `*/*`),
|
|
21
|
+
// `maxSize` bytes, `maxFiles` count. Refs without `size`/`mimeType` skip the
|
|
22
|
+
// checks that need them.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface FileValidationOptions {
|
|
26
|
+
accept?: string
|
|
27
|
+
maxSize?: number
|
|
28
|
+
maxFiles?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FileValidationResult {
|
|
32
|
+
valid: FileInputFile[]
|
|
33
|
+
errors: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function fileAccepts(file: FileInputFile, accept: string | undefined): boolean {
|
|
37
|
+
if (!accept) {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tokens = accept
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((t) => t.trim().toLowerCase())
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
|
|
46
|
+
if (!tokens.length) {
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const name = file.name.toLowerCase()
|
|
51
|
+
const mime = (file.mimeType ?? '').toLowerCase()
|
|
52
|
+
return tokens.some((token) => {
|
|
53
|
+
if (token === '*/*') {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (token.startsWith('.')) {
|
|
58
|
+
return name.endsWith(token)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (token.endsWith('/*')) {
|
|
62
|
+
return mime.startsWith(token.slice(0, -1))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return mime === token
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatFileSize(bytes: number): string {
|
|
70
|
+
if (bytes < 1024) {
|
|
71
|
+
return `${bytes} B`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (bytes < 1024 * 1024) {
|
|
75
|
+
return `${(bytes / 1024).toFixed(1)} KB`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
79
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function validateFiles(
|
|
86
|
+
files: FileInputFile[],
|
|
87
|
+
opts: FileValidationOptions,
|
|
88
|
+
): FileValidationResult {
|
|
89
|
+
const errors: string[] = []
|
|
90
|
+
const valid: FileInputFile[] = []
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
if (!fileAccepts(file, opts.accept)) {
|
|
93
|
+
errors.push(`${file.name}: file type not accepted`)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (opts.maxSize != null && file.size != null && file.size > opts.maxSize) {
|
|
98
|
+
errors.push(`${file.name}: file exceeds ${formatFileSize(opts.maxSize)}`)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
valid.push(file)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (opts.maxFiles != null && valid.length > opts.maxFiles) {
|
|
106
|
+
const extra = valid.splice(opts.maxFiles)
|
|
107
|
+
for (const file of extra) {
|
|
108
|
+
errors.push(`${file.name}: exceeds the ${opts.maxFiles}-file limit`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { valid, errors }
|
|
113
|
+
}
|