upload: ability to comment for each file
Massimo Melina committed
Sep 24, 2023 at 20:42 UTC
e445301fea22e6f4df93afe898516ca483527214
12 files changed
+197
-120
frontend/src/BrowseFiles.ts
+6
-1
@@ -99,7 +99,12 @@ function FilesList() {
99
: filteredList && !filteredList.length && t('filter_none', "No match for this filter")
100
101
return h(Fragment, {},
102
- h('ul', { ref, className: 'dir', ...acceptDropFiles(can_upload && enqueue) },
102
+ h('ul', {
103
+ ref,
104
+ className: 'dir',
105
+ ...acceptDropFiles(files => can_upload ? enqueue(files.map(file => ({ file })))
106
+ : alertDialog(t("Upload not available"), 'warning') )
107
+ },
108
msgInstead ? h('p', {}, msgInstead)
109
: theList.slice(offset, offset + pageSize * (1+extraPages)).map((entry, idx) =>
110
h(Entry, {
frontend/src/dialog.ts
+16
-7
@@ -1,16 +1,17 @@
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, ReactElement, ReactNode, useEffect, useRef, useState } from 'react'
3
+import { createElement as h, ReactElement, ReactNode, useEffect, useRef, useState, KeyboardEvent } from 'react'
4
import './dialog.css'
5
import { newDialog, closeDialog, DialogOptions } from '@hfs/shared/dialogs'
6
import _ from 'lodash'
7
import { useInterval } from 'usehooks-ts'
8
import { t } from './i18n'
9
-import { err2msg, pendingPromise } from './misc'
9
+import { err2msg, isCtrlKey, pendingPromise } from './misc'
10
export * from '@hfs/shared/dialogs'
11
12
interface PromptOptions extends Partial<DialogOptions> { def?:string, type?:string, trim?: boolean }
13
export async function promptDialog(msg: string, { def, type, trim=true, ...rest }:PromptOptions={}) : Promise<string | null> {
14
+ const textarea = type === 'textarea' && type
15
return new Promise(resolve => newDialog({
16
className: 'dialog-prompt',
17
icon: '?',
@@ -28,23 +29,32 @@ export async function promptDialog(msg: string, { def, type, trim=true, ...rest
29
setTimeout(()=> inp.focus(),100)
30
if (def)
31
inp.value = def
31
- },[])
32
+ if (textarea) {
33
+ function resize() {
34
+ inp.style.height = 'auto'
35
+ inp.style.height = (inp.scrollHeight + 5) + 'px'
36
+ }
37
+ setTimeout(resize)
38
+ inp.addEventListener('input', resize)
39
+ }
40
+ }, [textarea])
41
return h('form', {},
42
h('label', { htmlFor: 'input' }, msg),
34
- h('input', {
43
+ h(textarea || 'input', {
44
ref,
45
type,
46
name: 'input',
47
style: {
48
width: def ? (def.length / 2) + 'em' : 'auto',
40
- minWidth: '100%', maxWidth: '100%'
49
+ minWidth: '100%', maxWidth: '100%',
50
+ ...textarea && { width: '30em', maxHeight: '70vh' },
51
},
52
autoFocus: true,
53
onKeyDown(ev: KeyboardEvent) {
54
const { key } = ev
55
if (key === 'Escape')
56
return closeDialog(null)
47
- if (key === 'Enter')
57
+ if ((textarea ? isCtrlKey(ev) : key) === 'Enter')
58
return go()
59
}
60
}),
@@ -138,4 +148,3 @@ export function confirmDialog(msg: ReactElement | string, options: ConfirmOption
148
)
149
}
150
}
141
-
frontend/src/icons.ts
+1
@@ -36,6 +36,7 @@ const SYS_ICONS: Record<string, [string] | [string, string]> = {
36
edit: ['✏️'],
37
zoom: ['↔'],
38
delete: ['🗑️', 'trash'],
39
+ comment: ['💬']
40
}
41
42
document.fonts.ready.then(async ()=> {
frontend/src/index.scss
+25
-22
@@ -55,7 +55,7 @@ body, input {
55
code {
56
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
57
}
58
-input:not([type=checkbox],[type=range]), select {
58
+input:not([type=checkbox],[type=range]), select, textarea {
59
padding: 0.3em 0.4em;
60
border-radius: 0.5em;
61
background: var(--bg);
@@ -71,6 +71,7 @@ input[type=checkbox] {
71
transform: scale(1.7);
72
accent-color: var(--button-bg);
73
}
74
+textarea { font-size: 14pt }
75
label input[type=checkbox] {
76
margin-right: .8em;
77
}
@@ -281,26 +282,6 @@ ul.dir {
282
}
283
}
284
}
284
- .entry-comment {
285
- display: inline;
286
- &::before, &::after {
287
- font-size: 1.5em;
288
- font-family: serif;
289
- line-height: 1px;
290
- position: relative;
291
- }
292
- &::before {
293
- content: open-quote;
294
- margin-left: 0.5em;
295
- margin-right: 0.1em;
296
- top: 0.2em;
297
- }
298
- &::after {
299
- content: "„";
300
- top: -0.1em;
301
- margin-left: 0.1em;
302
- }
303
- }
285
.entry-panel {
286
float: right;
287
padding-top: 0.3em;
@@ -336,6 +317,27 @@ ul.dir {
317
}
318
}
319
}
320
+.entry-comment {
321
+ display: inline;
322
+ white-space: pre-wrap;
323
+ &::before, &::after {
324
+ font-size: 1.5em;
325
+ font-family: serif;
326
+ line-height: 1px;
327
+ position: relative;
328
+ }
329
+ &::before {
330
+ content: "“";
331
+ margin-left: 0.5em;
332
+ margin-right: 0.1em;
333
+ top: 0.2em;
334
+ }
335
+ &::after {
336
+ content: "„";
337
+ top: -0.1em;
338
+ margin-left: 0.1em;
339
+ }
340
+}
341
342
#menu-bar {
343
display: flex;
@@ -433,10 +435,11 @@ button label {
435
margin-left: 0.5em;
436
}
437
.upload-list {
436
- td:nth-child(1) { width: 0; }
438
+ td:nth-child(1) { width: 0; }
439
td:nth-child(2) { text-align: right; width: 0; white-space: nowrap; padding-left: 0.5em; }
440
td:nth-child(3) { padding: .2em .5em; word-break: break-word; }
441
}
442
+.nowrap { white-space: nowrap }
443
444
.dialog-login {
445
form {
frontend/src/upload.ts
+75
-60
@@ -1,6 +1,6 @@
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, useState } from 'react'
3
+import { createElement as h, DragEvent, Fragment, useMemo } from 'react'
4
import { Checkbox, Flex, FlexV, iconBtn } from './components'
5
import {
6
closeDialog,
@@ -22,13 +22,15 @@ import { Link } from 'react-router-dom'
22
import { t } from './i18n'
23
import { subscribeKey } from 'valtio/utils'
24
25
+interface ToUpload { file: File, comment?: string }
26
export const uploadState = proxy<{
27
done: number
28
doneByte: number
29
errors: number
29
- qs: { to: string, files: File[] }[]
30
+ adding: ToUpload[]
31
+ qs: { to: string, entries: ToUpload[] }[]
32
paused: boolean
31
- uploading?: File
33
+ uploading?: ToUpload
34
progress: number // percentage
35
partial: number // relative to uploading file. This is how much we have done of the current queue.
36
speed: number
@@ -41,6 +43,7 @@ export const uploadState = proxy<{
43
progress: 0,
44
paused: false,
45
qs: [],
46
+ adding: [],
47
errors: 0,
48
doneByte: 0,
49
done: 0,
@@ -59,7 +62,7 @@ setInterval(() => {
62
bytesSentTimestamp = now
63
64
// keep track of ETA
62
- const qBytes = _.sumBy(uploadState.qs, q => _.sumBy(q.files, f => f.size))
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)
@@ -99,16 +102,20 @@ export function showUpload() {
102
}
103
})
104
105
+ function clear() {
106
+ uploadState.adding.splice(0,Infinity)
107
+ }
108
+
109
function Content(){
103
- const [files, setFiles] = useState([] as File[])
110
+ const adding = useSnapshot(uploadState.adding)
111
const { qs, paused, eta, skipExisting } = useSnapshot(uploadState)
112
const { can_upload, accept } = useSnapState()
113
const etaStr = useMemo(() => !eta ? '' : formatTime(eta*1000, 0, 2), [eta])
107
- const inQ = _.sumBy(qs, q => q.files.length) - (uploadState.uploading ? 1 : 0)
114
+ const inQ = _.sumBy(qs, q => q.entries.length) - (uploadState.uploading ? 1 : 0)
115
const queueStr = inQ && t('in_queue', { n: inQ }, "{n} in queue")
109
- const size = formatBytes(files.reduce((a, f) => a + f.size, 0))
116
+ const size = formatBytes(adding.reduce((a, x) => a + x.file.size, 0))
117
111
- return h(FlexV, { gap: 0, props: acceptDropFiles(x => setFiles([ ...files, ...x ])) },
118
+ return h(FlexV, { gap: 0, props: acceptDropFiles(more => uploadState.adding.push(...more.map(f => ({ file: ref(f) })))) },
119
h(FlexV, { className: 'upload-toolbar' },
120
!can_upload ? t('no_upload_here', "No upload permission for the current folder")
121
: h(FlexV, { margin: '0 0 1em' },
@@ -124,23 +131,25 @@ export function showUpload() {
131
h('button', { className: 'create-folder', onClick: createFolder }, t`Create folder`),
132
h(Checkbox, { value: skipExisting, onChange: v => uploadState.skipExisting = v }, t`Skip existing files`),
133
),
127
- files.length > 0 && h(Flex, { justifyContent: 'center', flexWrap: 'wrap' },
134
+ adding.length > 0 && h(Flex, { justifyContent: 'center', flexWrap: 'wrap' },
135
h('button', {
136
className: 'upload-send',
137
onClick() {
131
- enqueue(files).then()
132
- setFiles([])
138
+ enqueue(uploadState.adding).then()
139
+ clear()
140
}
134
- }, t('send_files', { n: files.length, size }, "Send {n,plural,one{# file} other{# files}}, {size}")),
135
- h('button', { onClick() { setFiles([]) } }, t`Clear`),
141
+ }, t('send_files', { n: adding.length, size }, "Send {n,plural,one{# file} other{# files}}, {size}")),
142
+ h('button', { onClick: clear }, t`Clear`),
143
)
144
),
145
),
146
h(FilesList, {
140
- files,
141
- remove(f) {
142
- setFiles(files.filter(x => x !== f))
143
- }
147
+ entries: adding,
148
+ actions: {
149
+ delete: rec => _.remove(uploadState.adding, { file: rec.file }),
150
+ comment: rec => promptDialog(t('enter_comment', "Comment for this file"), { def: rec.comment, type: 'textarea' })
151
+ .then(s => _.find(uploadState.adding, { file: rec.file })!.comment = s || undefined),
152
+ },
153
}),
154
h(UploadStatus),
155
qs.length > 0 && h('div', {},
@@ -162,14 +171,16 @@ export function showUpload() {
171
h('div', { key: q.to },
172
h(Link, { to: q.to, onClick: close }, t`Destination`, ' ', decodeURI(q.to)),
173
h(FilesList, {
165
- files: Array.from(q.files),
166
- remove(f) {
167
- if (f === uploadState.uploading)
168
- return abortCurrentUpload()
169
- const q = uploadState.qs[idx]
170
- _.pull(q.files, f)
171
- if (!q.files.length)
172
- uploadState.qs.splice(idx,1)
174
+ entries: Array.from(q.entries),
175
+ actions: {
176
+ delete: f => {
177
+ if (f === uploadState.uploading)
178
+ return abortCurrentUpload()
179
+ const q = uploadState.qs[idx]
180
+ _.pull(q.entries, f)
181
+ if (!q.entries.length)
182
+ uploadState.qs.splice(idx,1)
183
+ }
184
}
185
}),
186
))
@@ -178,7 +189,7 @@ export function showUpload() {
189
190
function pickFiles(options: Parameters<typeof selectFiles>[1]) {
191
selectFiles(list => {
181
- setFiles([...files, ...Array.from(list || []).filter(simulateBrowserAccept)])
192
+ uploadState.adding.push( ...Array.from(list || []).filter(simulateBrowserAccept).map(f => ({ file: ref(f) })) )
193
}, options)
194
}
195
}
@@ -189,19 +200,22 @@ function path(f: File, pre='') {
200
return (prefix('', pre, '/') + (f.webkitRelativePath || f.name)).replaceAll('//','/')
201
}
202
192
-function FilesList({ files, remove }: { files: File[], remove: (f:File) => any }) {
203
+function FilesList({ entries, actions }: { entries: Readonly<ToUpload[]>, actions: { [icon:string]: (rec :ToUpload) => any } }) {
204
const { uploading, progress } = useSnapshot(uploadState)
194
- return !files.length ? null : h('table', { className: 'upload-list', width: '100%' },
205
+ return !entries.length ? null : h('table', { className: 'upload-list', width: '100%' },
206
h('tbody', {},
196
- files.map((f,i) => {
197
- const working = f === uploading
198
- return h('tr', { key: i },
199
- h('td', {}, iconBtn('delete', () => remove(f))),
200
- h('td', {}, formatBytes(f.size)),
201
- h('td', { className: working ? 'ani-working' : undefined },
202
- path(f),
203
- working && h('span', { className: 'upload-progress' }, formatPerc(progress))
207
+ entries.map((e, i) => {
208
+ const working = e === uploading
209
+ return h(Fragment, { key: i },
210
+ h('tr', {},
211
+ h('td', { className: 'nowrap '}, ..._.map(actions, (cb, icon) => iconBtn(icon, () => cb(e))) ),
212
+ h('td', {}, formatBytes(e.file.size)),
213
+ h('td', { className: working ? 'ani-working' : undefined },
214
+ path(e.file),
215
+ working && h('span', { className: 'upload-progress' }, formatPerc(progress))
216
+ ),
217
),
218
+ e.comment && h('tr', {}, h('td', { colSpan: 3 }, h('div', { className: 'entry-comment' }, e.comment)) )
219
)
220
})
221
)
@@ -223,27 +237,27 @@ function formatTime(time: number, decimals=0, length=Infinity) {
237
238
subscribe(uploadState, () => {
239
const [cur] = uploadState.qs
226
- if (!cur?.files.length) {
240
+ if (!cur?.entries.length) {
241
notificationChannel = '' // renew channel at each queue for improved security
228
- notificationSource.close()
242
+ notificationSource?.close()
243
return
244
}
231
- if (cur?.files.length && !uploadState.uploading && !uploadState.paused)
232
- startUpload(cur.files[0], cur.to).then()
245
+ if (cur?.entries.length && !uploadState.uploading && !uploadState.paused)
246
+ startUpload(cur.entries[0], cur.to).then()
247
})
248
235
-export async function enqueue(files: File[]) {
236
- if (_.remove(files, f => !simulateBrowserAccept(f)).length)
249
+export async function enqueue(entries: ToUpload[]) {
250
+ if (_.remove(entries, x => !simulateBrowserAccept(x.file)).length)
251
await alertDialog(t('upload_file_rejected', "Some files were not accepted"), 'warning')
252
239
- files = _.uniqBy(files, path)
240
- if (!files.length) return
253
+ entries = _.uniqBy(entries, x => path(x.file))
254
+ if (!entries.length) return
255
const to = location.pathname
256
const q = _.find(uploadState.qs, { to })
257
if (!q)
244
- return uploadState.qs.push({ to, files: files.map(ref) })
245
- const missing = _.differenceBy(files, q.files, path)
246
- q.files.push(...missing.map(ref))
258
+ return uploadState.qs.push({ to, entries: entries.map(ref) })
259
+ const missing = _.differenceBy(entries, q.entries, x => path(x.file))
260
+ q.entries.push(...missing.map(ref))
261
}
262
263
function simulateBrowserAccept(f: File) {
@@ -262,13 +276,13 @@ function normalizeAccept(accept?: string) {
276
let req: XMLHttpRequest | undefined
277
let overrideStatus = 0
278
let notificationChannel = ''
265
-let notificationSource: EventSource
279
+let notificationSource: EventSource | undefined
280
let closeLast: undefined | (() => void)
281
268
-async function startUpload(f: File, to: string, resume=0) {
282
+async function startUpload(toUpload: ToUpload, to: string, resume=0) {
283
let resuming = false
284
overrideStatus = 0
271
- uploadState.uploading = f
285
+ uploadState.uploading = toUpload
286
await subscribeNotifications()
287
req = new XMLHttpRequest()
288
req.onloadend = () => {
@@ -294,10 +308,11 @@ async function startUpload(f: File, to: string, resume=0) {
308
req.open('POST', to + '?' + new URLSearchParams({
309
notificationChannel,
310
resume: String(resume),
311
+ comment: toUpload.comment || '',
312
...uploadState.skipExisting && { skipExisting: '1' },
313
}), true)
314
const form = new FormData()
300
- form.append('file', f.slice(resume), path(f))
315
+ form.append('file', toUpload.file.slice(resume), path(toUpload.file))
316
req.send(form)
317
318
async function subscribeNotifications() {
@@ -307,15 +322,15 @@ async function startUpload(f: File, to: string, resume=0) {
322
const {uploading} = uploadState
323
if (!uploading) return
324
if (name === 'upload.resumable') {
310
- const size = data?.[path(uploading)]
311
- if (!size || size > f.size) return
325
+ const size = data?.[path(uploading.file)]
326
+ if (!size || size > toUpload.file.size) return
327
const {expires} = data
328
const timeout = typeof expires !== 'number' ? 0
329
: (Number(new Date(expires)) - Date.now()) / 1000
330
closeLast?.()
331
const cancelSub = subscribeKey(uploadState, 'partial', v =>
332
v >= size && closeLast?.() ) // dismiss dialog as soon as we pass the threshold
318
- const msg = t('confirm_resume', "Resume upload?") + ` (${formatPerc(size/f.size)} = ${formatBytes(size)})`
333
+ const msg = t('confirm_resume', "Resume upload?") + ` (${formatPerc(size/toUpload.file.size)} = ${formatBytes(size)})`
334
const dialog = confirmDialog(msg, { timeout })
335
closeLast = dialog.close
336
const confirmed = await dialog
@@ -324,10 +339,10 @@ async function startUpload(f: File, to: string, resume=0) {
339
if (uploading !== uploadState.uploading) return // too late
340
resuming = true
341
abortCurrentUpload()
327
- return startUpload(f, to, size)
342
+ return startUpload(toUpload, to, size)
343
}
344
if (name === 'upload.status') {
330
- overrideStatus = data?.[path(uploading)]
345
+ overrideStatus = data?.[path(uploading.file)]
346
if (overrideStatus >= 400)
347
abortCurrentUpload()
348
return
@@ -341,14 +356,14 @@ async function startUpload(f: File, to: string, resume=0) {
356
413: t`file too large`,
357
}
358
const specifier = (ERRORS as any)[status]
344
- const msg = t('failed_upload', f, "Couldn't upload {name}") + prefix(': ', specifier)
359
+ const msg = t('failed_upload', toUpload, "Couldn't upload {name}") + prefix(': ', specifier)
360
closeLast?.()
361
closeLast = alertDialog(msg, 'error').close
362
}
363
364
function done() {
365
uploadState.done++
351
- uploadState.doneByte += f!.size
366
+ uploadState.doneByte += toUpload!.file.size
367
reloadOnClose = true
368
}
369
@@ -357,8 +372,8 @@ async function startUpload(f: File, to: string, resume=0) {
372
uploadState.partial = 0
373
const { qs } = uploadState
374
if (!qs.length) return
360
- qs[0].files.shift()
361
- if (!qs[0].files.length)
375
+ qs[0].entries.shift()
376
+ if (!qs[0].entries.length)
377
qs.shift()
378
if (qs.length) return
379
setTimeout(reloadList, 500) // workaround: reloading too quickly can meet the new file still with its temp name
src/api.file_list.ts
+2
-23
@@ -15,15 +15,11 @@ import {
15
import { ApiError, ApiHandler, SendListReadable } from './apiMiddleware'
16
import { stat } from 'fs/promises'
17
import { mapPlugins } from './plugins'
18
-import { asyncGeneratorToArray, basename, dirTraversal, parseFile, pattern2filter } from './misc'
18
+import { asyncGeneratorToArray, dirTraversal, pattern2filter } from './misc'
19
import _ from 'lodash'
20
import { HTTP_FOOL, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND } from './const'
21
import Koa from 'koa'
22
-import { defineConfig } from './config'
23
-import { dirname, join } from 'path'
24
-
25
-const DESCRIPT_ION = 'descript.ion'
26
-const descriptIon = defineConfig('descript_ion', true)
22
+import { descriptIon, DESCRIPT_ION, getCommentFor } from './comments'
23
24
export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string }
25
@@ -142,21 +138,4 @@ export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c
138
|| n.children?.some(c => c.can_read || filesInsideCould(c)) // we count on the boolean-compliant nature of the permission type here
139
}
140
}
145
-}
146
-
147
-async function getCommentFor(path?: string) {
148
- return !path || !descriptIon.get() ? undefined
149
- : readDescription(dirname(path)).then(x => x.get(basename(path)), () => undefined)
150
-}
151
-
152
-function readDescription(path: string) {
153
- return parseFile(join(path, DESCRIPT_ION), txt => new Map(txt.split('\n').map(line => {
154
- const quoted = line[0] === '"' ? 1 : 0
155
- const i = quoted ? line.indexOf('"', 2) : line.indexOf(' ')
156
- const fn = line.slice(quoted, i - quoted)
157
- let comment = line.slice(i + 1)
158
- if (comment.endsWith('\x04\xc2'))
159
- comment = comment.slice(0, -2).replaceAll('\\n', '\n')
160
- return [fn, comment]
161
- })))
141
}
\ No newline at end of file
src/comments.ts
new
+47
@@ -0,0 +1,47 @@
1
+import { defineConfig } from './config'
2
+import { dirname, join } from 'path'
3
+import { basename } from './cross'
4
+import { parseFile } from './util-files'
5
+import { writeFile } from 'fs/promises'
6
+import { singleFromBatch } from './misc'
7
+import _ from 'lodash'
8
+
9
+export const DESCRIPT_ION = 'descript.ion'
10
+export const descriptIon = defineConfig('descript_ion', true)
11
+
12
+export async function getCommentFor(path?: string) {
13
+ return !path || !descriptIon.get() ? undefined
14
+ : readDescription(dirname(path)).then(x => x.get(basename(path)), () => undefined)
15
+}
16
+
17
+export const setCommentFor = singleFromBatch(async (jobs: [path: string, comment: string][]) => {
18
+ const byFolder = _.groupBy(jobs, job => dirname(job[0]))
19
+ return Promise.allSettled(_.map(byFolder, async (jobs, folder) => {
20
+ const comments = await readDescription(folder).catch(() => new Map())
21
+ for (const [path, comment] of jobs) {
22
+ const file = path.slice(folder.length + 1)
23
+ if (!comment)
24
+ comments.delete(file)
25
+ else
26
+ comments.set(file, comment)
27
+ }
28
+ // encode comments in descript.ion format
29
+ let txt = ''
30
+ comments.forEach((c, f) =>
31
+ txt += (f.includes(' ') ? `"${f}"` : f) + ' ' + (c.includes('\n') ? c.replaceAll('\n', '\\n') + MULTILINE_SUFFIX : c) + '\n')
32
+ await writeFile(join(folder, DESCRIPT_ION), txt)
33
+ }))
34
+})
35
+
36
+const MULTILINE_SUFFIX = '\x04\xc2'
37
+function readDescription(path: string) {
38
+ return parseFile(join(path, DESCRIPT_ION), txt => new Map(txt.split('\n').map(line => {
39
+ const quoted = line[0] === '"' ? 1 : 0
40
+ const i = quoted ? line.indexOf('"', 2) + 1 : line.indexOf(' ')
41
+ const fn = line.slice(quoted, i - quoted)
42
+ let comment = line.slice(i + 1)
43
+ if (comment.endsWith(MULTILINE_SUFFIX))
44
+ comment = comment.slice(0, -2).replaceAll('\\n', '\n')
45
+ return [fn, comment]
46
+ })))
47
+}
src/debounceAsync.ts
+14
-1
@@ -1,7 +1,7 @@
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
// like lodash.debounce, but also avoids async invocations to overlap
4
-export default function debounceAsync<Cancelable extends boolean = false, A extends unknown[] = unknown[], R = unknown>(
4
+export function debounceAsync<Cancelable extends boolean = false, A extends unknown[] = unknown[], R = unknown>(
5
callback: (...args: A) => Promise<R>,
6
wait: number=100,
7
options: { leading?: boolean, maxWait?:number, retain?: number, retainFailure?: number, cancelable?: Cancelable }={}
@@ -66,3 +66,16 @@ export default function debounceAsync<Cancelable extends boolean = false, A exte
66
}
67
}
68
69
+// given a function that works on a batch of requests, returns the function that works on a single request
70
+export function singleFromBatch<Args extends any[]>(batchWorker: (batch: Args[]) => unknown) {
71
+ let batch: Args[] = []
72
+ const debounced = debounceAsync(async () => {
73
+ const ret = batchWorker(batch)
74
+ batch = []
75
+ return ret
76
+ })
77
+ return (...args: Args) => {
78
+ batch.push(args)
79
+ return debounced()
80
+ }
81
+}
\ No newline at end of file
src/langs/hfs-lang-en.json
+3
-2
@@ -1,7 +1,7 @@
1
{
2
"author": "YOUR NAME HERE",
3
"version": 1.0,
4
- "hfs_version": "0.47.0",
4
+ "hfs_version": "0.49.0",
5
"translate": {
6
"Select": "Select",
7
"n_files": "{n,plural,one{# file} other{# files}}",
@@ -136,6 +136,7 @@
136
"showHelp_Z_body": "zoom modes",
137
"showHelp_F_body": "full screen",
138
"Destination": "Destination",
139
- "in_queue": "{n} in queue"
139
+ "in_queue": "{n} in queue",
140
+ "enter_comment": "Comment for this file"
141
}
142
}
src/langs/hfs-lang-it.json
+2
-1
@@ -1,7 +1,7 @@
1
{
2
"author": "Massimo Melina",
3
"version": 1.8,
4
- "hfs_version": "0.47.1",
4
+ "hfs_version": "0.49.0",
5
"translate": {
6
"Select": "Seleziona",
7
"n_files": "{n} file",
@@ -129,6 +129,7 @@
129
"showHelp_F_body": "pieno schermo",
130
"Destination": "Destinazione",
131
"in_queue": "{n} in coda",
132
+ "enter_comment": "Comment for this file",
133
134
"": "PLUGINS SECTION",
135
"": "PLUGIN thumbnails",
src/misc.ts
+1
-2
@@ -9,14 +9,13 @@ import assert from 'assert'
9
export * from './util-http'
10
export * from './util-files'
11
export * from './cross'
12
+export * from './debounceAsync'
13
import { Readable } from 'stream'
14
import { matcher } from 'micromatch'
15
import { SocketAddress, BlockList } from 'node:net'
15
-import debounceAsync from './debounceAsync'
16
import { ApiError } from './apiMiddleware'
17
import { HTTP_BAD_REQUEST } from './const'
18
import { ipLocalHost } from './cross'
19
-export { debounceAsync }
19
20
type ProcessExitHandler = (signal:string) => any
21
const cbs = new Set<ProcessExitHandler>()
src/upload.ts
+5
-1
@@ -15,6 +15,7 @@ import { getFreeDiskSync } from './util-os'
15
import { socket2connection, updateConnection } from './connections'
16
import { roundSpeed } from './throttler'
17
import { getCurrentUsername } from './perm'
18
+import { setCommentFor } from './comments'
19
20
export const deleteUnfinishedUploadsAfter = defineConfig<undefined|number>('delete_unfinished_uploads_after', 86_400)
21
export const minAvailableMb = defineConfig('min_available_mb', 100)
@@ -103,7 +104,10 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
104
while (fs.existsSync(dest))
105
}
106
return fs.rename(tempName, dest, err => {
106
- err && console.error("couldn't rename temp to", dest, String(err))
107
+ if (err)
108
+ console.error("couldn't rename temp to", dest, String(err))
109
+ else if (ctx.query.comment)
110
+ setCommentFor(dest, String(ctx.query.comment))
111
if (resumable)
112
delayedDelete(resumable, 0)
113
})