selection checkboxes are now disabled on entries that cannot be archived or deleted

Massimo Melina committed Oct 2, 2024 at 23:15 UTC a33ca00631858fdfe252a0ac396235206b6e1162
8 files changed +113 -95
README.md
+1 -1
@@ -123,7 +123,7 @@ If your language is missing, please consider [translating yourself](https://gith
123
124 - Appending `#LOGIN` to address will bring up the login dialog
125 - Appending ?lang=CODE to address will force a specific language
126 -- Right/ctrl/command click on toggle-all checkbox will invert each checkbox state
126 +- Right-click on toggle-all checkbox will invert each checkbox state
127 - Appending `?login=USER:PASSWORD` will automatically log in the browser
128 - Appending `?overwrite` on uploads, will override the dont_overwrite_uploading configuration, provided you also have delete permission
129 - Appending `?search=PATTERN` will trigger search at start
dev-plugins.md
+83 -73
@@ -312,91 +312,101 @@ You can produce output for such events also by adding sections (with same name a
312 This is a list of available frontend-events, with respective object parameter and output.
313
314 - `additionalEntryDetails`
315 - - you receive each entry of the list, and optionally produce HTML code that will be added in the `entry-details` container.
316 - - parameter `{ entry: DirEntry }`
317 -
318 - The `DirEntry` type is an object with the following properties:
319 - - `name: string` name of the entry.
320 - - `ext: string` just the extension part of the name, dot excluded and lowercase.
321 - - `isFolder: boolean` true if it's a folder.
322 - - `n: string` name of the entry, including relative path when searched in sub-folders.
323 - - `uri: string` relative url of the entry.
324 - - `s?: number` size of the entry, in bytes. It may be missing, for example for folders.
325 - - `t?: Date` generic timestamp, combination of creation-time and modified-time.
326 - - `c?: Date` creation-time.
327 - - `m?: Date` modified-time.
328 - - `p?: string` permissions missing
329 - - `cantOpen: boolean` true if current user has no permission to open this entry
330 - - `getNext/getPrevious: ()=>DirEntry` return next/previous DirEntry in list
331 - - `getNextFiltered/getPreviousFiltered: ()=>DirEntry` as above, but considers the filtered-list instead
332 - - `getDefaultIcon: ()=>ReactElement` produces the default icon for this entry
333 - - output `Html`
315 + - you receive each entry of the list, and optionally produce HTML code that will be added in the `entry-details` container.
316 + - parameter `{ entry: DirEntry }`
317 +
318 + The `DirEntry` type is an object with the following properties:
319 + - `name: string` name of the entry.
320 + - `ext: string` just the extension part of the name, dot excluded and lowercase.
321 + - `isFolder: boolean` true if it's a folder.
322 + - `n: string` name of the entry, including relative path when searched in sub-folders.
323 + - `uri: string` relative url of the entry.
324 + - `s?: number` size of the entry, in bytes. It may be missing, for example for folders.
325 + - `t?: Date` generic timestamp, combination of creation-time and modified-time.
326 + - `c?: Date` creation-time.
327 + - `m?: Date` modified-time.
328 + - `p?: string` permissions missing
329 + - `cantOpen: boolean` true if current user has no permission to open this entry
330 + - `getNext/getPrevious: ()=>DirEntry` return next/previous DirEntry in list
331 + - `getNextFiltered/getPreviousFiltered: ()=>DirEntry` as above, but considers the filtered-list instead
332 + - `getDefaultIcon: ()=>ReactElement` produces the default icon for this entry
333 + - output `Html`
334 - `entry`
335 - - you receive each entry of the list, and optionally produce HTML code that will completely replace the entry row/slot.
336 - - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
337 - - output `Html | null` return null if you want to hide this entry
335 + - you receive each entry of the list, and optionally produce HTML code that will completely replace the entry row/slot.
336 + - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
337 + - output `Html | null` return null if you want to hide this entry
338 - `afterEntryName`
339 - - you receive each entry of the list, and optionally produce HTML code that will be added after the name of the entry.
340 - - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
341 - - output `Html`
339 + - you receive each entry of the list, and optionally produce HTML code that will be added after the name of the entry.
340 + - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
341 + - output `Html`
342 - `entryIcon`
343 - - you receive an entry of the list and optionally produce HTML that will be used in place of the standard icon.
344 - - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
345 - - output `Html`
343 + - you receive an entry of the list and optionally produce HTML that will be used in place of the standard icon.
344 + - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
345 + - output `Html`
346 - `beforeHeader` & `afterHeader`
347 - - use this to produce content that should go right before/after the `header` part
348 - - output `Html`
347 + - use this to produce content that should go right before/after the `header` part
348 + - output `Html`
349 - `beforeLogin`
350 - - no parameter
351 - - output `Html`
350 + - no parameter
351 + - output `Html`
352 - `fileMenu`
353 - - add or manipulate entries of the menu. If you return something, that will be added to the menu.
354 - You can also delete or replace the content of the `menu` array.
355 - - parameter `{ entry: DirEntry, menu: FileMenuEntry[], props: FileMenuProp[] }`
356 - - output `undefined | FileMenuEntry | FileMenuEntry[]`
357 - ```typescript
358 - interface FileMenuEntry {
359 - id?: string,
360 - label: ReactNode,
361 - subLabel: ReactNode,
362 - href?: string, // use this if you want your entry to be a link
363 - icon?: string, // supports: emoji, name from a limited set
364 - onClick?: () => (Promisable<boolean>) // return false to not close menu dialog
365 - //...rest is transfered to <a> element, for example 'target', or 'title'
366 - }
367 - type FileMenuProp = { id?: string, label: ReactNode, value: ReactNode } | ReactElement
368 - ```
369 - Example, if you want to remove the 'show' item of the menu:
370 - ```typescript
371 - HFS.onEvent('fileMenu', ({ entry, menu }) => {
372 - const index = menu.findIndex(x => x.id === 'show')
373 - if (index >= 0)
374 - menu.splice(index, 1)
375 - })
376 - ```
377 - or if you like lodash, you can simply `HFS._.remove(menu, { id: 'show' })`
353 + - add or manipulate entries of the menu. If you return something, that will be added to the menu.
354 + You can also delete or replace the content of the `menu` array.
355 + - parameter `{ entry: DirEntry, menu: FileMenuEntry[], props: FileMenuProp[] }`
356 + - output `undefined | FileMenuEntry | FileMenuEntry[]`
357 + ```typescript
358 + interface FileMenuEntry {
359 + id?: string,
360 + label: ReactNode,
361 + subLabel: ReactNode,
362 + href?: string, // use this if you want your entry to be a link
363 + icon?: string, // supports: emoji, name from a limited set
364 + onClick?: () => (Promisable<boolean>) // return false to not close menu dialog
365 + //...rest is transfered to <a> element, for example 'target', or 'title'
366 + }
367 + type FileMenuProp = { id?: string, label: ReactNode, value: ReactNode } | ReactElement
368 + ```
369 + Example, if you want to remove the 'show' item of the menu:
370 + ```typescript
371 + HFS.onEvent('fileMenu', ({ entry, menu }) => {
372 + const index = menu.findIndex(x => x.id === 'show')
373 + if (index >= 0)
374 + menu.splice(index, 1)
375 + })
376 + ```
377 + or if you like lodash, you can simply `HFS._.remove(menu, { id: 'show' })`
378 - `fileShow`
379 - - you receive an entry of the list, and optionally produce React Component for visualization.
380 - - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
381 - - output `ReactComponent`
379 + - you receive an entry of the list, and optionally produce React Component for visualization.
380 + - parameter `{ entry: DirEntry }` (refer above for DirEntry object)
381 + - output `ReactComponent`
382 - `showPlay`
383 - - emitted on each file played inside file-show. Use setCover if you want to customize the background picture.
384 - - parameter `{ entry: DirEntry, setCover(uri: string), meta: { title, album, artist, year } }`
383 + - emitted on each file played inside file-show. Use setCover if you want to customize the background picture.
384 + - parameter `{ entry: DirEntry, setCover(uri: string), meta: { title, album, artist, year } }`
385 - `menuZip`
386 - - parameter `{ def: ReactNode }`
387 - - output `Html`
386 + - parameter `{ def: ReactNode }`
387 + - output `Html`
388 - `userPanelAfterInfo`
389 - - no parameter
390 - - output `Html`
389 + - no parameter
390 + - output `Html`
391 - `uriChanged`
392 - - DEPRECATED: use `watchState('uri', callback)` instead.
393 - - parameter `{ uri: string, previous: string }`
392 + - DEPRECATED: use `watchState('uri', callback)` instead.
393 + - parameter `{ uri: string, previous: string }`
394 - `sortCompare`
395 - - you can decide the order of entries by comparing two entries.
396 - Return a negative value if entry `a` must appear before `b`, or positive if you want the opposite.
397 - Return zero or any falsy value if you want to leave the order to what the user decided in his options.
398 - - parameter `{ a: DirEntry, b: DirEntry }`
399 - - output `number | undefined`
395 + - you can decide the order of entries by comparing two entries.
396 + Return a negative value if entry `a` must appear before `b`, or positive if you want the opposite.
397 + Return zero or any falsy value if you want to leave the order to what the user decided in his options.
398 + - parameter `{ a: DirEntry, b: DirEntry }`
399 + - output `number | undefined`
400 +- `enableEntrySelection`
401 + - selection of multiple entries is used for some standard actions like deletion or zip.
402 + When none of such standard actions is permitted on an entry, its selection control (checkbox) is disabled.
403 + If you want to override this behavior, because you have a custom action that makes use of the selection, return `true`.
404 + - parameter `{ entry: DirEntry }`
405 + - output `boolean`
406 +- `entryToggleSelection`
407 + - an entry is being un/selected
408 + - parameter `{ entry: DirEntry }`
409 + - can be prevented
410 - All of the following have no parameters and you are supposed to output `Html` that will be displayed in the described place:
411 - `appendMenuBar` inside menu-bar, at the end
412 - `afterMenuBar` between menu-bar and breadcrumbs
frontend/src/BrowseFiles.ts
+3 -2
@@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom'
4 import { createElement as h, Fragment, memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState,
5 useId} from 'react'
6 import { useEventListener, useMediaQuery, useWindowSize } from 'usehooks-ts'
7 -import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix, isMac, isCtrlKey } from './misc'
7 +import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix, isMac, isCtrlKey, hfsEvent } from './misc'
8 import { Checkbox, CustomCode, iconBtn, Spinner } from './components'
9 import { Head } from './Head'
10 import { DirEntry, state, useSnapState } from './state'
@@ -257,10 +257,11 @@ const Entry = ({ entry, midnight, separator }: EntryProps) => {
257 entry,
258 render: x => x ? h('li', { className, label: separator }, x) : _.remove(state.list, { n }) && null
259 }, showFilter && h(Checkbox, {
260 - disabled: isLink,
260 + disabled: !entry.canSelect(),
261 'aria-labelledby': ariaId,
262 value: selected[uri] || false,
263 onChange(v) {
264 + if (hfsEvent('entryToggleSelection', { entry }).isDefaultPrevent()) return
265 if (v)
266 return state.selected[uri] = true
267 delete state.selected[uri]
frontend/src/FilterBar.ts
+8 -12
@@ -4,7 +4,7 @@ import { useDebounce } from 'usehooks-ts'
4 import { Checkbox } from './components'
5 import { useI18N } from './i18n'
6 import { usePath } from './useFetchList'
7 -import { with_ } from './misc'
7 +import { getHFS, with_ } from './misc'
8
9 export function FilterBar() {
10 const { list, filteredList, selected, patternFilter, showFilter } = useSnapState()
@@ -14,13 +14,8 @@ export function FilterBar() {
14 const {t} = useI18N()
15
16 state.patternFilter = useDebounce(showFilter ? filter : '', 300)
17 + useEffect(() => getHFS().onEvent('entryToggleSelection', () => setAll(false)), [])
18
18 - const sel = Object.keys(selected).length
19 - const fil = filteredList?.length
20 - useEffect(() => {
21 - if (all && sel < (fil || list.length))
22 - setAll(false)
23 - }, [sel])
19 const tabIndex = showFilter ? undefined : -1
20 return h('div', { id: 'filter-bar', style: { display: showFilter ? undefined : 'none' } },
21 h(Checkbox, {
@@ -49,19 +44,20 @@ export function FilterBar() {
44 }
45 }),
46 h('span', {}, [
52 - sel && t('select_count', { n:sel }, "{n} selected"),
53 - fil !== undefined && fil < list.length && t('filter_count', {n:fil}, "{n} filtered"),
47 + with_(Object.keys(selected).length, n => n && t('select_count', { n }, "{n} selected")),
48 + with_(filteredList?.length, n => n !== undefined && n < list.length && t('filter_count', {n}, "{n} filtered")),
49 ].filter(Boolean).join(', ') ),
50 )
51
57 - function select(will: boolean | undefined) {
52 + function select(will: boolean | undefined) { // undefined will cause toggle of each element
53 const sel = state.selected
59 - for (const { uri } of state.filteredList || state.list) {
54 + for (const e of state.filteredList || state.list) {
55 + const { uri } = e
56 const was = sel[uri] || false
57 if (was === will) continue
58 if (was)
59 delete sel[uri]
64 - else
60 + else if (e.canSelect())
61 sel[uri] = true
62 }
63 if (will !== undefined)
frontend/src/fileMenu.ts
+2 -3
@@ -29,10 +29,9 @@ interface FileMenuEntry {
29 export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy | FileMenuEntry | 'open' | 'delete' | 'show')[]) {
30 const { uri, isFolder, s } = entry
31 const canRead = !entry.p?.includes('r')
32 - const canArchive = entry.p?.includes('A') || state.props?.can_archive && !entry.p?.includes('a')
32 const canList = !entry.p?.match(/L/i)
33 const forbidden = entry.cantOpen === DirEntry.FORBIDDEN
35 - const cantDownload = forbidden || isFolder && !(canRead && canArchive && canList) // folders needs list+read+archive
34 + const cantDownload = forbidden || isFolder && !(canRead && entry.canArchive() && canList) // folders needs list+read+archive
35 const menu = [
36 !cantDownload && { id: 'download', label: t`Download`, href: uri + (isFolder ? '?get=zip' : '?dl'), icon: 'download' },
37 state.props?.can_comment && { id: 'comment', label: t`Comment`, icon: 'comment', onClick: () => editComment(entry) },
@@ -50,7 +49,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy
49 return !isFolder || open.onClick ? open : h(LinkClosingDialog, { to: uri, reloadDocument: entry.web }, hIcon(open.icon), open.label)
50 }
51 if (x === 'delete')
53 - return (state.props?.can_delete || entry.p?.includes('d')) && {
52 + return entry.canDelete() && {
53 id: 'delete',
54 label: t`Delete`,
55 icon: 'delete',
frontend/src/state.ts
+13 -1
@@ -3,7 +3,7 @@
3 import _ from 'lodash'
4 import { proxy, useSnapshot } from 'valtio'
5 import { subscribeKey } from 'valtio/utils'
6 -import { FRONTEND_OPTIONS, getHFS, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc'
6 +import { FRONTEND_OPTIONS, getHFS, hfsEvent, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc'
7 import { DirEntry as ServerDirEntry } from '../../src/api.get_file_list'
8
9 export const state = proxy<typeof FRONTEND_OPTIONS & {
@@ -151,6 +151,18 @@ export class DirEntry implements StringifyProps<ServerDirEntry> {
151 getDefaultIcon() {
152 return hIcon(this.icon === true ? `${this.n}?get=icon` : (this.icon ?? (this.isFolder || this.web ? 'folder' : this.url ? 'link' : ext2type(this.ext) || 'file')))
153 }
154 +
155 + canArchive() {
156 + return this.p?.includes('A') || state.props?.can_archive && !this.p?.includes('a')
157 + }
158 + canDelete() {
159 + return state.props?.can_delete || this.p?.includes('d')
160 + }
161 + canSelect() {
162 + if (this.url) return false
163 + return this.canArchive() || this.canDelete() // selection is used only by zip and delete, but consider custom logic from plugins
164 + || hfsEvent('enableEntrySelection', { entry: this }).some(Boolean)
165 + }
166 }
167 export type DirList = DirEntry[]
168
src/api.get_file_list.ts
+2 -2
@@ -40,7 +40,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
40 const walker = walkNode(node, { ctx: admin ? undefined : ctx, onlyFolders, depth: search ? Infinity : 0 })
41 const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
42 const can_upload = admin || hasPermission(node, 'can_upload', ctx)
43 - const fakeChild = await applyParentToChild({ source: 'x' }, node) // can we delete children
43 + const fakeChild = await applyParentToChild({ source: 'dummy-file' }, node) // used to check permission; simple but but can produce false results
44 const can_delete = admin || hasPermission(fakeChild, 'can_delete', ctx)
45 const can_archive = admin || hasPermission(fakeChild, 'can_archive', ctx)
46 const can_comment = can_upload && areCommentsEnabled()
@@ -123,7 +123,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
123 : !hasPermission(node, 'can_read', ctx) ? 'R'
124 : ''
125 const pd = !can_delete && hasPermission(node, 'can_delete', ctx) ? 'd' : ''
126 - const pa = isFolder && Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A'
126 + const pa = Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A'
127 return {
128 n: name + (isFolder ? '/' : ''),
129 c: st?.ctime,
src/vfs.ts
+1 -1
@@ -4,7 +4,7 @@ import fs from 'fs/promises'
4 import { basename, dirname, join, resolve } from 'path'
5 import {
6 dirStream, getOrSet, isDirectory, makeMatcher, setHidden, onlyTruthy, isValidFileName, throw_, VfsPerms, Who,
7 - isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting, HTTP_SERVER_ERROR, try_, _log
7 + isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting, HTTP_SERVER_ERROR, try_
8 } from './misc'
9 import Koa from 'koa'
10 import _ from 'lodash'