@samitouri / QOSami-HFS / commits / e445301f

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