better code: split files
Massimo Melina committed
Oct 28, 2024 at 16:17 UTC
47b09e25d0ed080cad74d37cc87867351a24d5f2
5 files changed
+301
-278
frontend/src/BrowseFiles.ts
+3
-2
@@ -11,7 +11,8 @@ import { DirEntry, state, useSnapState } from './state'
11
import { alertDialog } from './dialog'
12
import useFetchList from './useFetchList'
13
import { useAuthorized } from './login'
14
-import { acceptDropFiles, enqueue } from './upload'
14
+import { acceptDropFiles } from './upload'
15
+import { enqueueUpload } from './uploadQueue'
16
import _ from 'lodash'
17
import { t, useI18N } from './i18n'
18
import { makeOnClickOpen, openFileMenu } from './fileMenu'
@@ -27,7 +28,7 @@ export function BrowseFiles() {
28
const propsDropFiles = useMemo(() => ({
29
id: 'files-dropper',
30
...acceptDropFiles((files, to) =>
30
- props?.can_upload ? enqueue(files.map(file => ({ file })), location.pathname + to)
31
+ props?.can_upload ? enqueueUpload(files.map(file => ({ file })), location.pathname + to)
32
: alertDialog(t("Upload not available"), 'warning')
33
),
34
}), [props])
frontend/src/menu.ts
+2
-1
@@ -12,7 +12,8 @@ import { showOptions } from './options'
12
import showUserPanel from './UserPanel'
13
import _ from 'lodash'
14
import { closeDialog } from '@hfs/shared/dialogs'
15
-import { showUpload, uploadState } from './upload'
15
+import { showUpload } from './upload'
16
+import { uploadState } from './uploadQueue'
17
import { useSnapshot } from 'valtio'
18
import { apiCall } from '@hfs/shared/api'
19
import { reloadList } from './useFetchList'
frontend/src/misc.ts
+1
-1
@@ -15,7 +15,7 @@ import _ from 'lodash'
15
import { reloadList } from './useFetchList'
16
import { logout } from './login'
17
import { subscribeKey } from 'valtio/utils'
18
-import { uploadState } from './upload'
18
+import { uploadState } from './uploadQueue'
19
import { fileShow } from './show'
20
import { debounceAsync } from '../../src/debounceAsync'
21
export * from '@hfs/shared'
frontend/src/upload.ts
+18
-274
@@ -1,103 +1,42 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, DragEvent, Fragment, useMemo, CSSProperties, useState, useEffect } from 'react'
3
+import { createElement as h, DragEvent, Fragment, useMemo, useState, useEffect, CSSProperties } from 'react'
4
import { Btn, Flex, FlexV, iconBtn, Select } from './components'
5
import {
6
- basename, closeDialog, formatBytes, formatPerc, hIcon, useIsMobile, newDialog, prefix, selectFiles, working,
7
- HTTP_CONFLICT, HTTP_PAYLOAD_TOO_LARGE, formatSpeed, dirname, getHFS, onlyTruthy, with_, cpuSpeedIndex,
8
- buildUrlQueryString, randomId, HTTP_MESSAGES, pathEncode, pendingPromise,
6
+ basename, formatBytes, formatPerc, hIcon, useIsMobile, newDialog, selectFiles, working,
7
+ HTTP_CONFLICT, formatSpeed, getHFS, onlyTruthy, cpuSpeedIndex, closeDialog, prefix,
8
} from './misc'
9
import _ from 'lodash'
11
-import { INTERNAL_Snapshot, proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio'
12
-import { alertDialog, confirmDialog, promptDialog, toast } from './dialog'
10
+import { INTERNAL_Snapshot, ref, useSnapshot } from 'valtio'
11
+import { alertDialog, promptDialog } from './dialog'
12
import { reloadList } from './useFetchList'
14
-import { apiCall, getNotifications } from '@hfs/shared/api'
13
+import { apiCall } from '@hfs/shared/api'
14
import { state, useSnapState } from './state'
15
import { Link } from 'react-router-dom'
16
import { t } from './i18n'
18
-import { subscribeKey } from 'valtio/utils'
17
import { LinkClosingDialog } from './fileMenu'
18
+import {
19
+ abortCurrentUpload, enqueueUpload, getFilePath, normalizeAccept, resetCounters, resetReloadOnClose,
20
+ simulateBrowserAccept, ToUpload, uploadState
21
+} from './uploadQueue'
22
23
const renameEnabled = getHFS().dontOverwriteUploading
24
23
-interface ToUpload { file: File, comment?: string, name?: string, to?: string, error?: string }
24
-export const uploadState = proxy<{
25
- done: ToUpload[]
26
- doneByte: number
27
- errors: ToUpload[]
28
- skipped: ToUpload[]
29
- adding: ToUpload[]
30
- qs: { to: string, entries: ToUpload[] }[]
31
- paused: boolean
32
- uploading?: ToUpload
33
- progress: number // percentage
34
- partial: number // relative to uploading file. This is how much we have done of the current queue.
35
- speed: number
36
- eta: number
37
-}>({
38
- eta: 0,
39
- speed: 0,
40
- partial: 0,
41
- progress: 0,
42
- paused: false,
43
- qs: [],
44
- adding: [],
45
- skipped: [],
46
- errors: [],
47
- doneByte: 0,
48
- done: [],
49
-})
50
-
51
-// keep track of speed
52
-let bytesSentTimestamp = Date.now()
53
-let bytesSent = 0
54
-setInterval(() => {
55
- const now = Date.now()
56
- const passed = (now - bytesSentTimestamp) / 1000
57
- if (passed < 3 && uploadState.speed) return
58
- uploadState.speed = bytesSent / passed
59
- bytesSent = 0 // reset counter
60
- bytesSentTimestamp = now
61
-
62
- // keep track of ETA
63
- const qBytes = _.sumBy(uploadState.qs, q => _.sumBy(q.entries, x => x.file.size))
64
- const left = (qBytes - uploadState.partial)
65
- uploadState.eta = uploadState.speed && Math.round(left / uploadState.speed)
66
-}, 5_000)
67
-
68
-window.onbeforeunload = ev => {
69
- if (!uploadState.qs.length) return
70
- ev.preventDefault()
71
- return ev.returnValue = t("Uploading") // modern browsers ignore this message
72
-}
73
-
74
-let reloadOnClose = false
75
-let uploadDialogIsOpen = false
25
let everPaused = false
26
78
-function resetCounters() {
79
- Object.assign(uploadState, {
80
- errors: [],
81
- done: [],
82
- doneByte: 0,
83
- skipped: [],
84
- })
85
-}
86
-
27
export function showUpload() {
28
if (!uploadState.qs.length)
29
resetCounters()
90
- uploadDialogIsOpen = true
30
+ uploadState.uploadDialogIsOpen = true
31
const { close } = newDialog({
32
dialogProps: { id: 'upload-dialog', style: { minHeight: '6em', minWidth: 'min(20em, 100vw - 1em)' } },
33
title: t`Upload`,
34
icon: () => hIcon('upload'),
35
Content,
36
onClose() {
97
- uploadDialogIsOpen = false
98
- if (!reloadOnClose) return
99
- reloadOnClose = false
100
- reloadList()
37
+ uploadState.uploadDialogIsOpen = false
38
+ if (resetReloadOnClose())
39
+ reloadList()
40
}
41
})
42
@@ -145,7 +84,7 @@ export function showUpload() {
84
h('button', {
85
className: 'upload-send',
86
onClick() {
148
- void enqueue(uploadState.adding)
87
+ void enqueueUpload(uploadState.adding)
88
clear()
89
}
90
}, t('send_files', { n: adding.length, size }, "Send {n,plural,one{# file} other{# files}}, {size}")),
@@ -219,10 +158,6 @@ export function showUpload() {
158
159
}
160
222
-function path(f: File) {
223
- return (f.webkitRelativePath || f.name).replaceAll('//','/')
224
-}
225
-
161
function FilesList({ entries, actions }: { entries: ToUpload[], actions: { [icon:string]: null | ((rec :ToUpload) => any) } }) {
162
const { uploading, progress } = useSnapshot(uploadState)
163
const snapEntries = useSnapshot(entries)
@@ -240,7 +175,7 @@ function FilesList({ entries, actions }: { entries: ToUpload[], actions: { [icon
175
cb && iconBtn(icon, () => cb(entries[i]), { className: `action-${icon}` })) ),
176
h('td', {}, formatBytes(e.file.size)),
177
h('td', {},
243
- h('span', { className: working ? 'ani-working' : undefined }, e.name || path(entries[i].file)),
178
+ h('span', { className: working ? 'ani-working' : undefined }, e.name || getFilePath(entries[i].file)),
179
working && h('span', { className: 'upload-progress' }, formatPerc(progress)),
180
working && h('progress', { className: 'upload-progress-bar', value: progress, max: 1 }),
181
),
@@ -264,195 +199,8 @@ function formatTime(time: number, decimals=0, length=Infinity) {
199
return ret.slice(-length).reverse().join('')
200
}
201
267
-/// Manage upload queue
268
-
269
-subscribe(uploadState, () => {
270
- const [cur] = uploadState.qs
271
- if (!cur?.entries.length) {
272
- notificationChannel = '' // renew channel at each queue for improved security
273
- notificationSource?.close()
274
- return
275
- }
276
- if (cur?.entries.length && !uploadState.uploading && !uploadState.paused)
277
- void startUpload(cur.entries[0], cur.to)
278
-})
279
-
280
-export async function enqueue(entries: ToUpload[], to=location.pathname) {
281
- if (_.remove(entries, x => !simulateBrowserAccept(x.file)).length)
282
- await alertDialog(t('upload_file_rejected', "Some files were not accepted"), 'warning')
283
-
284
- entries = _.uniqBy(entries, x => path(x.file))
285
- if (!entries.length) return
286
- const q = _.find(uploadState.qs, { to })
287
- if (!q)
288
- return uploadState.qs.push({ to, entries: entries.map(ref) })
289
- const missing = _.differenceBy(entries, q.entries, x => path(x.file))
290
- q.entries.push(...missing.map(ref))
291
-}
292
-
293
-function simulateBrowserAccept(f: File) {
294
- const { props } = state
295
- if (!props?.accept) return true
296
- return normalizeAccept(props?.accept)!.split(/ *[|,] */).some(pattern =>
297
- pattern.startsWith('.') ? f.name.endsWith(pattern)
298
- : f.type.match(pattern.replace('.','\\.').replace('*', '.*')) // '.' for .ext and '*' for 'image/*'
299
- )
300
-}
301
-
302
-function normalizeAccept(accept?: string) {
303
- return accept?.replace(/\|/g, ',').replace(/ +/g, '')
304
-}
305
-
306
-let req: XMLHttpRequest | undefined
307
-let overrideStatus = 0
308
-let notificationChannel = ''
309
-let notificationSource: EventSource | undefined
310
-let closeLast: undefined | (() => void)
202
312
-async function startUpload(toUpload: ToUpload, to: string, resume=0) {
313
- let resuming = false
314
- overrideStatus = 0
315
- uploadState.uploading = toUpload
316
- await subscribeNotifications()
317
- const splitSize = getHFS().splitUploads
318
- const fullSize = toUpload.file.size
319
- let offset = resume
320
- do { // at least one iteration, even for empty files
321
- req = new XMLHttpRequest()
322
- const finished = pendingPromise()
323
- req.onloadend = () => {
324
- finished.resolve()
325
- if (req?.readyState !== 4) return
326
- const status = overrideStatus || req.status
327
- if (!partial) // if the upload ends here, the offer for resuming must stop
328
- closeLast?.()
329
- if (resuming) { // resuming requested
330
- resuming = false // this behavior is only for once, for cancellation of the upload that is in the background while resume is confirmed
331
- stopLooping()
332
- return
333
- }
334
- if (!status || status === HTTP_CONFLICT) // 0 = user-aborted, HTTP_CONFLICT = skipped because existing
335
- uploadState.skipped.push(toUpload)
336
- else if (status >= 400)
337
- error(status)
338
- else {
339
- if (splitSize) {
340
- offset += splitSize
341
- if (offset < fullSize) return // continue looping
342
- }
343
- done()
344
- }
345
- next()
346
- }
347
- req.onerror = () => {
348
- error(0)
349
- finished.resolve()
350
- stopLooping()
351
- }
352
- let lastProgress = 0
353
- req.upload.onprogress = (e:any) => {
354
- uploadState.partial = e.loaded + offset
355
- uploadState.progress = uploadState.partial / fullSize
356
- bytesSent += e.loaded - lastProgress
357
- lastProgress = e.loaded
358
- }
359
- let uploadPath = path(toUpload.file)
360
- if (toUpload.name)
361
- uploadPath = prefix('', dirname(uploadPath), '/') + toUpload.name
362
- const partial = splitSize && offset + splitSize < fullSize
363
- req.open('PUT', to + pathEncode(uploadPath) + buildUrlQueryString({
364
- notificationChannel,
365
- ...partial && { partial: 'y' },
366
- ...offset && { resume: String(offset) },
367
- ...toUpload.comment && { comment: toUpload.comment },
368
- ...with_(state.uploadOnExisting, x => x !== 'rename' && { existing: x }), // rename is the default
369
- }), true)
370
- req.send(toUpload.file.slice(offset, splitSize ? offset + splitSize : undefined))
371
- await finished
372
- } while (offset < fullSize)
373
-
374
- function stopLooping() { offset = fullSize }
375
-
376
- async function subscribeNotifications() {
377
- if (notificationChannel) return
378
- notificationChannel = 'upload-' + randomId()
379
- notificationSource = await getNotifications(notificationChannel, async (name, data) => {
380
- const {uploading} = uploadState
381
- if (!uploading) return
382
- if (name === 'upload.resumable') {
383
- const size = data?.[path(uploading.file)] //TODO use toUpload?
384
- if (!size || size > toUpload.file.size) return
385
- const {expires} = data
386
- const timeout = typeof expires !== 'number' ? 0
387
- : (Number(new Date(expires)) - Date.now()) / 1000
388
- closeLast?.()
389
- const cancelSub = subscribeKey(uploadState, 'partial', v =>
390
- v >= size && closeLast?.() ) // dismiss dialog as soon as we pass the threshold
391
- const msg = t('confirm_resume', "Resume upload?") + ` (${formatPerc(size/toUpload.file.size)} = ${formatBytes(size)})`
392
- const dialog = confirmDialog(msg, { timeout })
393
- closeLast = dialog.close
394
- const confirmed = await dialog
395
- cancelSub()
396
- if (!confirmed) return
397
- if (uploading !== uploadState.uploading) return // too late
398
- resuming = true
399
- abortCurrentUpload()
400
- return startUpload(toUpload, to, size)
401
- }
402
- if (name === 'upload.status') {
403
- overrideStatus = data?.[path(uploading.file)]
404
- if (overrideStatus >= 400)
405
- abortCurrentUpload()
406
- return
407
- }
408
- })
409
- }
410
-
411
- function error(status: number) {
412
- const ERRORS = {
413
- [HTTP_PAYLOAD_TOO_LARGE]: t`file too large`,
414
- [HTTP_CONFLICT]: t('upload_conflict', "already exists"),
415
- }
416
- const specifier = (ERRORS as any)[status] || HTTP_MESSAGES[status]
417
- toUpload.error = specifier
418
- if (uploadState.errors.push(toUpload)) return
419
- const msg = t('failed_upload', toUpload, "Couldn't upload {name}") + prefix(': ', specifier)
420
- closeLast?.()
421
- closeLast = alertDialog(msg, 'error').close
422
- }
423
-
424
- function done() {
425
- uploadState.done.push(toUpload)
426
- uploadState.doneByte += toUpload!.file.size
427
- reloadOnClose = true
428
- }
429
-
430
- function next() {
431
- stopLooping()
432
- uploadState.uploading = undefined
433
- uploadState.partial = 0
434
- const { qs } = uploadState
435
- if (!qs.length) return
436
- qs[0].entries.shift()
437
- if (!qs[0].entries.length)
438
- qs.shift()
439
- if (qs.length) return
440
- setTimeout(reloadList, 500) // workaround: reloading too quickly can meet the new file still with its temp name
441
- reloadOnClose = false
442
- if (uploadDialogIsOpen) return
443
- // freeze and reset
444
- const snap = snapshot(uploadState)
445
- resetCounters()
446
- const msg = h('div', {}, t(['upload_concluded', "Upload terminated"], "Upload concluded:"),
447
- h(UploadStatus, { snapshot: snap, display: 'flex', flexDirection: 'column' }) )
448
- if (snap.errors.length || snap.skipped.length)
449
- alertDialog(msg, 'warning')
450
- else
451
- toast(msg, 'success')
452
- }
453
-}
454
-
455
-function UploadStatus({ snapshot, ...props }: { snapshot?: INTERNAL_Snapshot<typeof uploadState> } & CSSProperties) {
203
+export function UploadStatus({ snapshot, ...props }: { snapshot?: INTERNAL_Snapshot<typeof uploadState> } & CSSProperties) {
204
const current = useSnapshot(uploadState)
205
const { done, doneByte, errors, skipped } = snapshot || current
206
const msgDone = done.length > 0 && t('upload_finished', { n: done.length, size: formatBytes(doneByte) }, "{n} finished ({size})")
@@ -464,7 +212,7 @@ function UploadStatus({ snapshot, ...props }: { snapshot?: INTERNAL_Snapshot<typ
212
s, ' – ', h(Btn, { label: t`Show details`, asText: true, onClick: showDetails }) )
213
214
function showDetails() {
467
- if (!uploadDialogIsOpen)
215
+ if (!uploadState.uploadDialogIsOpen)
216
closeDialog() // don't nest dialogs unnecessarily (apply only to the dialog outside upload-dialog)
217
alertDialog(h('div', {},
218
([
@@ -478,10 +226,6 @@ function UploadStatus({ snapshot, ...props }: { snapshot?: INTERNAL_Snapshot<typ
226
}
227
}
228
481
-function abortCurrentUpload() {
482
- req?.abort()
483
-}
484
-
229
export function acceptDropFiles(cb: false | undefined | ((files:File[], to: string) => void)) {
230
return {
231
onDragOver(ev: DragEvent) {
frontend/src/uploadQueue.ts
new
+277
@@ -0,0 +1,277 @@
1
+import {
2
+ buildUrlQueryString, dirname, formatBytes, formatPerc, getHFS,
3
+ HTTP_CONFLICT, HTTP_MESSAGES, HTTP_PAYLOAD_TOO_LARGE,
4
+ pathEncode, pendingPromise, prefix, randomId, with_
5
+} from '@hfs/shared'
6
+import { state } from './state'
7
+import { getNotifications } from '@hfs/shared/api'
8
+import { subscribeKey } from 'valtio/utils'
9
+import { t } from './i18n'
10
+import { alertDialog, confirmDialog, toast } from './dialog'
11
+import { reloadList } from './useFetchList'
12
+import { proxy, ref, snapshot, subscribe } from 'valtio'
13
+import { createElement as h } from 'react'
14
+import _ from 'lodash'
15
+import { UploadStatus } from './upload'
16
+
17
+export interface ToUpload { file: File, comment?: string, name?: string, to?: string, error?: string }
18
+export const uploadState = proxy<{
19
+ done: ToUpload[]
20
+ doneByte: number
21
+ errors: ToUpload[]
22
+ skipped: ToUpload[]
23
+ adding: ToUpload[]
24
+ qs: { to: string, entries: ToUpload[] }[]
25
+ paused: boolean
26
+ uploading?: ToUpload
27
+ progress: number // percentage
28
+ partial: number // relative to uploading file. This is how much we have done of the current queue.
29
+ speed: number
30
+ eta: number
31
+ uploadDialogIsOpen: boolean
32
+}>({
33
+ uploadDialogIsOpen: false,
34
+ eta: 0,
35
+ speed: 0,
36
+ partial: 0,
37
+ progress: 0,
38
+ paused: false,
39
+ qs: [],
40
+ adding: [],
41
+ skipped: [],
42
+ errors: [],
43
+ doneByte: 0,
44
+ done: [],
45
+})
46
+
47
+window.onbeforeunload = ev => {
48
+ if (!uploadState.qs.length) return
49
+ ev.preventDefault()
50
+ return ev.returnValue = t("Uploading") // modern browsers ignore this message
51
+}
52
+
53
+// keep track of speed
54
+let bytesSentTimestamp = Date.now()
55
+let bytesSent = 0
56
+setInterval(() => {
57
+ const now = Date.now()
58
+ const passed = (now - bytesSentTimestamp) / 1000
59
+ if (passed < 3 && uploadState.speed) return
60
+ uploadState.speed = bytesSent / passed
61
+ bytesSent = 0 // reset counter
62
+ bytesSentTimestamp = now
63
+
64
+ // keep track of ETA
65
+ const qBytes = _.sumBy(uploadState.qs, q => _.sumBy(q.entries, x => x.file.size))
66
+ const left = (qBytes - uploadState.partial)
67
+ uploadState.eta = uploadState.speed && Math.round(left / uploadState.speed)
68
+}, 5_000)
69
+
70
+let req: XMLHttpRequest | undefined
71
+let overrideStatus = 0
72
+let notificationChannel = ''
73
+let notificationSource: EventSource | undefined
74
+let closeLast: undefined | (() => void)
75
+
76
+let reloadOnClose = false
77
+export function resetReloadOnClose() {
78
+ if (!reloadOnClose) return
79
+ reloadOnClose = false
80
+ return true
81
+}
82
+
83
+export async function startUpload(toUpload: ToUpload, to: string, resume=0) {
84
+ let resuming = false
85
+ overrideStatus = 0
86
+ uploadState.uploading = toUpload
87
+ await subscribeNotifications()
88
+ const splitSize = getHFS().splitUploads
89
+ const fullSize = toUpload.file.size
90
+ let offset = resume
91
+ do { // at least one iteration, even for empty files
92
+ req = new XMLHttpRequest()
93
+ const finished = pendingPromise()
94
+ req.onloadend = () => {
95
+ finished.resolve()
96
+ if (req?.readyState !== 4) return
97
+ const status = overrideStatus || req.status
98
+ if (!partial) // if the upload ends here, the offer for resuming must stop
99
+ closeLast?.()
100
+ if (resuming) { // resuming requested
101
+ resuming = false // this behavior is only for once, for cancellation of the upload that is in the background while resume is confirmed
102
+ stopLooping()
103
+ return
104
+ }
105
+ if (!status || status === HTTP_CONFLICT) // 0 = user-aborted, HTTP_CONFLICT = skipped because existing
106
+ uploadState.skipped.push(toUpload)
107
+ else if (status >= 400)
108
+ error(status)
109
+ else {
110
+ if (splitSize) {
111
+ offset += splitSize
112
+ if (offset < fullSize) return // continue looping
113
+ }
114
+ done()
115
+ }
116
+ next()
117
+ }
118
+ req.onerror = () => {
119
+ error(0)
120
+ finished.resolve()
121
+ stopLooping()
122
+ }
123
+ let lastProgress = 0
124
+ req.upload.onprogress = (e:any) => {
125
+ uploadState.partial = e.loaded + offset
126
+ uploadState.progress = uploadState.partial / fullSize
127
+ bytesSent += e.loaded - lastProgress
128
+ lastProgress = e.loaded
129
+ }
130
+ let uploadPath = getFilePath(toUpload.file)
131
+ if (toUpload.name)
132
+ uploadPath = prefix('', dirname(uploadPath), '/') + toUpload.name
133
+ const partial = splitSize && offset + splitSize < fullSize
134
+ req.open('PUT', to + pathEncode(uploadPath) + buildUrlQueryString({
135
+ notificationChannel,
136
+ ...partial && { partial: 'y' },
137
+ ...offset && { resume: String(offset) },
138
+ ...toUpload.comment && { comment: toUpload.comment },
139
+ ...with_(state.uploadOnExisting, x => x !== 'rename' && { existing: x }), // rename is the default
140
+ }), true)
141
+ req.send(toUpload.file.slice(offset, splitSize ? offset + splitSize : undefined))
142
+ await finished
143
+ } while (offset < fullSize)
144
+
145
+ function stopLooping() { offset = fullSize }
146
+
147
+ async function subscribeNotifications() {
148
+ if (notificationChannel) return
149
+ notificationChannel = 'upload-' + randomId()
150
+ notificationSource = await getNotifications(notificationChannel, async (name, data) => {
151
+ const {uploading} = uploadState
152
+ if (!uploading) return
153
+ if (name === 'upload.resumable') {
154
+ const size = data?.[getFilePath(uploading.file)] //TODO use toUpload?
155
+ if (!size || size > toUpload.file.size) return
156
+ const {expires} = data
157
+ const timeout = typeof expires !== 'number' ? 0
158
+ : (Number(new Date(expires)) - Date.now()) / 1000
159
+ closeLast?.()
160
+ const cancelSub = subscribeKey(uploadState, 'partial', v =>
161
+ v >= size && closeLast?.() ) // dismiss dialog as soon as we pass the threshold
162
+ const msg = t('confirm_resume', "Resume upload?") + ` (${formatPerc(size/toUpload.file.size)} = ${formatBytes(size)})`
163
+ const dialog = confirmDialog(msg, { timeout })
164
+ closeLast = dialog.close
165
+ const confirmed = await dialog
166
+ cancelSub()
167
+ if (!confirmed) return
168
+ if (uploading !== uploadState.uploading) return // too late
169
+ resuming = true
170
+ abortCurrentUpload()
171
+ return startUpload(toUpload, to, size)
172
+ }
173
+ if (name === 'upload.status') {
174
+ overrideStatus = data?.[getFilePath(uploading.file)]
175
+ if (overrideStatus >= 400)
176
+ abortCurrentUpload()
177
+ return
178
+ }
179
+ })
180
+ }
181
+
182
+ function error(status: number) {
183
+ const ERRORS = {
184
+ [HTTP_PAYLOAD_TOO_LARGE]: t`file too large`,
185
+ [HTTP_CONFLICT]: t('upload_conflict', "already exists"),
186
+ }
187
+ const specifier = (ERRORS as any)[status] || HTTP_MESSAGES[status]
188
+ toUpload.error = specifier
189
+ if (uploadState.errors.push(toUpload)) return
190
+ const msg = t('failed_upload', toUpload, "Couldn't upload {name}") + prefix(': ', specifier)
191
+ closeLast?.()
192
+ closeLast = alertDialog(msg, 'error').close
193
+ }
194
+
195
+ function done() {
196
+ uploadState.done.push(toUpload)
197
+ uploadState.doneByte += toUpload!.file.size
198
+ reloadOnClose = true
199
+ }
200
+
201
+ function next() {
202
+ stopLooping()
203
+ uploadState.uploading = undefined
204
+ uploadState.partial = 0
205
+ const { qs } = uploadState
206
+ if (!qs.length) return
207
+ qs[0].entries.shift()
208
+ if (!qs[0].entries.length)
209
+ qs.shift()
210
+ if (qs.length) return
211
+ setTimeout(reloadList, 500) // workaround: reloading too quickly can meet the new file still with its temp name
212
+ reloadOnClose = false
213
+ if (uploadState.uploadDialogIsOpen) return
214
+ // freeze and reset
215
+ const snap = snapshot(uploadState)
216
+ resetCounters()
217
+ const msg = h('div', {}, t(['upload_concluded', "Upload terminated"], "Upload concluded:"),
218
+ h(UploadStatus, { snapshot: snap, display: 'flex', flexDirection: 'column' }) )
219
+ if (snap.errors.length || snap.skipped.length)
220
+ alertDialog(msg, 'warning')
221
+ else
222
+ toast(msg, 'success')
223
+ }
224
+}
225
+
226
+export function abortCurrentUpload() {
227
+ req?.abort()
228
+}
229
+subscribe(uploadState, () => {
230
+ const [cur] = uploadState.qs
231
+ if (!cur?.entries.length) {
232
+ notificationChannel = '' // renew channel at each queue for improved security
233
+ notificationSource?.close()
234
+ return
235
+ }
236
+ if (cur?.entries.length && !uploadState.uploading && !uploadState.paused)
237
+ void startUpload(cur.entries[0], cur.to)
238
+})
239
+
240
+export async function enqueueUpload(entries: ToUpload[], to=location.pathname) {
241
+ if (_.remove(entries, x => !simulateBrowserAccept(x.file)).length)
242
+ await alertDialog(t('upload_file_rejected', "Some files were not accepted"), 'warning')
243
+
244
+ entries = _.uniqBy(entries, x => getFilePath(x.file))
245
+ if (!entries.length) return
246
+ const q = _.find(uploadState.qs, { to })
247
+ if (!q)
248
+ return uploadState.qs.push({ to, entries: entries.map(ref) })
249
+ const missing = _.differenceBy(entries, q.entries, x => getFilePath(x.file))
250
+ q.entries.push(...missing.map(ref))
251
+}
252
+
253
+export function simulateBrowserAccept(f: File) {
254
+ const { props } = state
255
+ if (!props?.accept) return true
256
+ return normalizeAccept(props?.accept)!.split(/ *[|,] */).some(pattern =>
257
+ pattern.startsWith('.') ? f.name.endsWith(pattern)
258
+ : f.type.match(pattern.replace('.','\\.').replace('*', '.*')) // '.' for .ext and '*' for 'image/*'
259
+ )
260
+}
261
+
262
+export function normalizeAccept(accept?: string) {
263
+ return accept?.replace(/\|/g, ',').replace(/ +/g, '')
264
+}
265
+
266
+export function getFilePath(f: File) {
267
+ return (f.webkitRelativePath || f.name).replaceAll('//','/')
268
+}
269
+
270
+export function resetCounters() {
271
+ Object.assign(uploadState, {
272
+ errors: [],
273
+ done: [],
274
+ doneByte: 0,
275
+ skipped: [],
276
+ })
277
+}