plugin/list-uploader: avoid flooding with requests
Massimo Melina committed
Jul 9, 2023 at 15:31 UTC
f73636d77837eaa5a4c10691d0abb92eef209b62
9 files changed
+80
-28
dev-plugins.md
+3
@@ -187,6 +187,7 @@ The HFS objects contains many properties:
187
- `emit: (name: string, params?: object) => any[]` use this to emit a custom event. Prefix name with your plugin name to avoid conflicts.
188
- `Icon: ReactComponent` Properties:
189
- `name: string` refer to file `icons.ts` for names, but you can also enter an emoji instead.
190
+- `useBatch: (worker, job) => any`
191
192
The following properties are accessible only immediately at top-level; don't call it later in a callback.
193
- `getPluginConfig()` returns object of all config keys that are declared frontend-accessible by this plugin.
@@ -324,6 +325,8 @@ Where `h` is just `import { createElement as h } from 'react'`.
325
326
## API version history
327
328
+- 8.3 (v0.47.0)
329
+ - HFS.useBatch
330
- 8.23 (v0.46.0)
331
- entry.getNext, getPrevious, getNextFiltered, getPreviousFiltered, getDefaultIcon
332
- platform-dependent distribution
frontend/src/fileMenu.ts
+2
-2
@@ -64,9 +64,9 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe
64
: [ev.pageX, ev.pageY - scrollY] as [number, number],
65
Content() {
66
const {t} = useI18N()
67
- const [details] = useApi('get_file_details', { uri: fullUri })
67
+ const [details] = useApi('get_file_details', { uris: [fullUri] });
68
const showProps = [ ...props,
69
- with_(details?.upload, x => x && [ t`Uploader`, x.ip + prefix(' (', x.username, ')') ])
69
+ with_(details?.[0]?.upload, x => x && [ t`Uploader`, x.ip + prefix(' (', x.username, ')') ])
70
]
71
return h(Fragment, {},
72
h('dl', { className: 'file-dialog-properties' },
frontend/src/misc.ts
+2
-2
@@ -4,7 +4,7 @@ import React, { createElement as h } from 'react'
4
import { Spinner } from './components'
5
import { newDialog } from './dialog'
6
import { Icon } from './icons'
7
-import { Dict, getHFS } from '@hfs/shared'
7
+import { Dict, getHFS, useBatch } from '@hfs/shared'
8
import { state } from './state'
9
import { t } from './i18n'
10
import * as dialogLib from './dialog'
@@ -60,7 +60,7 @@ export function hfsEvent(name: string, params?:Dict) {
60
return output
61
}
62
63
-const tools = { h, React, state, t, _, dialogLib, apiCall, useApi, reloadList, logout, Icon, hIcon,
63
+const tools = { h, React, state, t, _, dialogLib, apiCall, useApi, reloadList, logout, Icon, hIcon, useBatch,
64
watchState(k: string, cb: (v: any) => void) {
65
const up = k.split('upload.')[1]
66
return subscribeKey(up ? uploadState : state as any, up || k, cb, true)
plugins/list-uploader/plugin.js
+1
-1
@@ -1,6 +1,6 @@
1
exports.version = 1.0
2
exports.description = "Show uploader info in list"
3
-exports.apiRequired = 8.23
3
+exports.apiRequired = 8.3 // useBatch
4
exports.frontend_js = "main.js"
5
6
exports.configDialog = {
plugins/list-uploader/public/main.js
+9
-9
@@ -1,27 +1,27 @@
1
"use strict";{
2
+ const { React } = HFS
3
const { display } = HFS.getPluginConfig()
4
5
HFS.onEvent('additionalEntryDetails', ({ entry }) =>
6
HFS.h(Uploader, entry))
7
7
- const cache = {}
8
-
8
function Uploader({ uri }) {
9
const fullUri = location.pathname + uri
11
- const cachedData = cache[fullUri]
12
- const [freshData, error] = HFS.useApi(!cachedData && 'get_file_details', { uri: fullUri })
13
- if (!cachedData)
14
- cache[fullUri] = freshData || Boolean(error)
15
- const data = freshData || cachedData
16
- const text = HFS.React.useMemo(() => {
10
+ const { data } = HFS.useBatch(getDetails, fullUri)
11
+ const text = React.useMemo(() => {
12
if (!data || data === true) return ''
13
const { upload: x } = data
14
return !x ? ''
15
: display === 'user' ? x.username
16
: display === 'ip' || !x.username ? x.ip
17
: x.ip + ' (' + x.username + ')'
23
- })
18
+ }, [data])
19
return text && HFS.h('span', { className: 'uploader', title: HFS.t`Uploader` },
20
HFS.hIcon('upload'), ' ', text, ' – ')
21
}
22
+
23
+ function getDetails(batched) {
24
+ return batched.length && HFS.apiCall('get_file_details', { uris: batched }).then(x => x.details)
25
+ }
26
+
27
}
shared/react.ts
+36
-1
@@ -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, Fragment, ReactElement, ReactNode, useCallback, useState } from 'react'
3
+import { createElement as h, Fragment, ReactElement, ReactNode, useCallback, useEffect, useState } from 'react'
4
import { useIsMounted } from 'usehooks-ts'
5
6
export function useStateMounted<T>(init: T) {
@@ -32,3 +32,38 @@ export function dontBotherWithKeys(elements: ReactNode[]): (ReactNode|string)[]
32
: h(Fragment, { key:i, children:e }) )
33
}
34
35
+export function useBatch<Job=unknown,Result=unknown>(
36
+ worker: ((jobs: Job[]) => Promise<Result[]>),
37
+ job: undefined | Job,
38
+ { delay=0 }={}
39
+) {
40
+ interface Env {
41
+ batch: Set<Job>,
42
+ cache: Map<Job, Result | null>,
43
+ timeout?: ReturnType<typeof setTimeout>
44
+ }
45
+ const worker2env = (useBatch as any).worker2env ||= worker && new Map<typeof worker, Env>()
46
+ const env = worker && (worker2env.get(worker) || (() => {
47
+ const ret = { batch: new Set<Job>(), cache: new Map<Job, Result>() } as Env
48
+ worker2env.set(worker, ret)
49
+ return ret
50
+ })())
51
+ const [, setRefresher] = useState(0)
52
+ useEffect(() => {
53
+ if (!env) return
54
+ env.timeout ||= setTimeout(async () => {
55
+ env.timeout = undefined
56
+ const jobs = [...env.batch.values()]
57
+ env.batch.clear()
58
+ const res = await worker(jobs)
59
+ let i = 0
60
+ for (const job of jobs)
61
+ env.cache.set(job, res[i++] ?? null)
62
+ setRefresher(x => x + 1)
63
+ }, delay)
64
+ }, [])
65
+ const cached = env?.cache.get(job)
66
+ if (env && cached === undefined)
67
+ env.batch.add(job)
68
+ return { data: cached, ...env } as Env & { data: Result | undefined | null } // so you can cache.clear
69
+}
\ No newline at end of file
src/apiMiddleware.ts
+9
-2
@@ -36,10 +36,17 @@ export function apiMiddleware(apis: ApiHandlers) : Koa.Middleware {
36
let res
37
try {
38
for (const [k,v] of Object.entries(params))
39
- if (k.startsWith('uri') && typeof v === 'string')
40
- params[k] = removeStarting(ctx.state.revProxyPath, v)
39
+ if (k.startsWith('uri'))
40
+ if (typeof v === 'string')
41
+ fixUri(params, k)
42
+ else if (typeof (v as any)?.[0] === 'string')
43
+ (v as string[]).forEach((x,i) => fixUri(v,i))
44
res = csrf && csrf !== params.csrf ? new ApiError(HTTP_UNAUTHORIZED, 'csrf')
45
: await apiFun(params || {}, ctx)
46
+
47
+ function fixUri(o: any, k: string | number) {
48
+ o[k] = removeStarting(ctx.state.revProxyPath, o[k])
49
+ }
50
}
51
catch(e) {
52
res = e
src/const.ts
+1
-1
@@ -16,7 +16,7 @@ const pkg = JSON.parse(fs.readFileSync(PKG_PATH,'utf8'))
16
export const VERSION = pkg.version
17
export const DAY = 86_400_000
18
19
-export const API_VERSION = 8.23
19
+export const API_VERSION = 8.3
20
export const COMPATIBLE_API_VERSION = 1 // while changes in the api are not breaking, this number stays the same, otherwise it is made equal to API_VERSION
21
22
export const HFS_REPO = 'rejetto/hfs'
src/frontEndApis.ts
+17
-10
@@ -37,13 +37,19 @@ export const frontEndApis: ApiHandlers = {
37
})
38
},
39
40
- async get_file_details({ uri }, ctx) {
41
- apiAssertTypes({ string: { uri } })
42
- const node = await urlToNode(uri, ctx)
43
- if (!node)
44
- return new ApiError(HTTP_NOT_FOUND)
40
+ async get_file_details({ uris }, ctx) {
41
+ if (typeof uris?.[0] !== 'string')
42
+ return new ApiError(HTTP_BAD_REQUEST, 'bad uris')
43
return {
46
- upload: node.source && await getUploadMeta(node.source).catch(() => undefined)
44
+ details: Promise.all(uris.map(async (uri: any) => {
45
+ if (typeof uri !== 'string')
46
+ return false // false means error
47
+ const node = await urlToNode(uri, ctx)
48
+ if (!node)
49
+ return false
50
+ const upload = node.source && await getUploadMeta(node.source).catch(() => undefined)
51
+ return upload && { upload }
52
+ }))
53
}
54
},
55
@@ -117,8 +123,9 @@ export function notifyClient(ctx: Koa.Context, name: string, data: any) {
123
const NOTIFICATION_PREFIX = 'notificationChannel:'
124
125
function apiAssertTypes(paramsByType: { [type:string]: { [name:string]: any } }) {
120
- for (const [type,params] of Object.entries(paramsByType))
121
- for (const [name,val] of Object.entries(params))
122
- if (typeof val !== type)
123
- throw new ApiError(HTTP_BAD_REQUEST, 'bad ' + name)
126
+ for (const [types,params] of Object.entries(paramsByType))
127
+ for (const type of types.split('_'))
128
+ for (const [name,val] of Object.entries(params))
129
+ if (typeof val !== type)
130
+ throw new ApiError(HTTP_BAD_REQUEST, 'bad ' + name)
131
}
\ No newline at end of file