plugins: new backend event "dirEntry"

Massimo Melina committed Mar 5, 2026 at 19:35 UTC 1a3e6ea8dee2e7ccbe156a6f8024ea9750a001e2
4 files changed +47 -16
dev-plugins.md
+26 -14
@@ -189,20 +189,9 @@ exports.depend = [{ repo: "x", version: 1 }] // non-JSON object key
189
190 - `unload: function` called when unloading a plugin. This is a good place for example to clearInterval().
191 - `onDirEntry: ({ entry: DirEntryBackend, listUri: string, ctx, node: VfsNode }) => Promisable<void | false>`
192 - by providing this callback you can manipulate the record that is sent to the frontend (`entry`),
193 - or you can return false to exclude this entry from the results.
194 - `DirEntryBackend` fields:
195 - - `n: string` name of the entry. (May include relative path when searching in sub-folders.)
196 - - `s?: number` size of the entry, in bytes. It may be missing, for example for folders.
197 - - `m?: Date` modified-time.
198 - - `c?: Date` creation-time.
199 - - `p?: string` permissions.
200 - - `comment?: string` comment for the entry.
201 - - `web?: boolean` true for web links.
202 - - `url?: string` target url for links.
203 - - `target?: string` target for links.
204 - - `icon?: string | true` icon override or true for "specific for this file".
205 - - `order?: number` custom sort order.
192 + legacy callback for compatibility. For new plugins prefer the backend event `dirEntry`, so all plugin hooks follow the same event-based DX.
193 + You can manipulate the record that will be sent to the frontend (`entry`), or return false to exclude this entry from the results.
194 +
195 - `config: Functionable<{ [key]: FieldDescriptor }, values:object>` declare a set of admin-configurable values owned by the plugin
196 that will be displayed inside Admin-panel for change. Each property is identified by its key,
197 and the descriptor is another object with options about the field.
@@ -809,6 +798,27 @@ This section is still partially documented, and you may need to have a look at t
798 - parameters: { node, ctx }
799 - async supported
800 - stoppable
801 +- `dirEntry` called for each entry before it is sent to the frontend list
802 + - parameters: { entry, listUri, ctx, node }
803 + - `entry: DirEntryBackend`
804 + - `listUri: string`
805 + - `ctx: Context`
806 + - `node: VfsNode`
807 + - async supported
808 + - preventable
809 + - note: legacy `onDirEntry` hooks run first; use this event for new code
810 + - types `DirEntryBackend` fields:
811 + - `n: string` name of the entry. (May include the relative path when searching in subfolders.)
812 + - `s?: number` size of the entry, in bytes. It may be missing, for example, for folders.
813 + - `m?: Date` modified-time.
814 + - `c?: Date` creation-time.
815 + - `p?: string` permissions.
816 + - `comment?: string` comment for the entry.
817 + - `web?: boolean` true for web links.
818 + - `url?: string` target url for links.
819 + - `target?: string` target for links.
820 + - `icon?: string | true` icon override or true for "specific for this file".
821 + - `order?: number` custom sort order.
822 - `listDiskFolder` called when a list is read from the disk; useful to implement a cache
823 - parameters: { path, ctx? }
824 - async supported
@@ -1166,3 +1176,5 @@ If you want to override a text regardless of the language, use the special langu
1176 - api.normalizeFilename
1177 - 12.97 (v0.57.28)
1178 - HFS.customizeText
1179 +- 13 (v3.1.0)
1180 + - backend event: dirEntry
\ No newline at end of file
src/api.get_file_list.ts
+3
@@ -15,6 +15,7 @@ import { updateConnectionForCtx } from './connections'
15 import { ctxAdminAccess } from './adminApis'
16 import { dontOverwriteUploading } from './upload'
17 import { SendListReadable } from './SendList'
18 +import events from './events'
19
20 export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean, url?: string, target?: string, icon?: string | true, order?: number }
21
@@ -89,6 +90,8 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onl
90 const res = await Promise.all(onDirEntryHandlers.map(cb => cb(cbParams)))
91 if (res.some(x => x === false))
92 continue
93 + if ((await events.emitAsync('dirEntry', cbParams))?.isDefaultPrevented())
94 + continue
95 }
96 catch(e) {
97 console.log("a plugin with onDirEntry is causing problems:", e)
src/const.ts
+1 -1
@@ -9,7 +9,7 @@ import { formatTimestamp } from './cross'
9 import { argv } from './argv'
10 export * from './cross-const'
11
12 -export const API_VERSION = 12.97
12 +export const API_VERSION = 13
13 export const COMPATIBLE_API_VERSION = 1 // the day we break with the past, we'll update this
14
15 export const DEV = process.env.DEV ? 'DEV' : ''
tests/test.ts
+17 -1
@@ -6,7 +6,7 @@ import { basename, dirname, resolve } from 'path'
6 import { exec } from 'child_process'
7 import _ from 'lodash'
8 import yaml from 'yaml'
9 -import { findDefined, pathEncode, randomId, try_, tryJson, UPLOAD_TEMP_HASH, wait } from '../src/cross'
9 +import { findDefined, pathEncode, randomId, try_, tryJson, UPLOAD_TEMP_HASH, wait, waitFor } from '../src/cross'
10 import { httpStream, parseHttpUrl, stream2string, XRequestOptions } from '../src/util-http'
11 import { ThrottledStream, ThrottleGroup } from '../src/ThrottledStream'
12 import { mkdir, rm, rename, writeFile, access } from 'fs/promises'
@@ -738,6 +738,22 @@ describe('admin', () => {
738 throw "plugin didn't start"
739 }, { auth })()
740 })
741 + test('plugins.dirEntry event', async () => {
742 + const script = `exports.init = api => api.events.on('dirEntry', ({ entry }) => entry.n === 'f2/' && api.events.stop)`
743 + await switchIt(true).finally(() => switchIt(false).catch(() => {}))
744 +
745 + async function switchIt(on: boolean) {
746 + let lastNames: any
747 + await reqApi('set_config', { values: { server_code: on ? script : '' } }, 200, { auth })()
748 + const good = await waitFor(async () => {
749 + const res = await reqList('/f1/', { status: 200 })()
750 + lastNames = res?.list?.map((x: any) => x.n)
751 + return on === !isInList(res, 'f2/')
752 + }, { interval: 100, timeout: 3000 })
753 + if (!good)
754 + throw Error("condition not met on list: " + JSON.stringify(lastNames))
755 + }
756 + })
757 test('plugins.download-counter percent name', async () => {
758 const id = 'download-counter'
759 await reqApi('start_plugin', { id }, 200, { auth })()