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 +}