@samitouri / QOSami-HFS / commits / f73636d7

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