search: by comment #494

Massimo Melina committed Jan 17, 2025 at 20:25 UTC eed57334e8dc2c44f5235f7f95bcf2a34329da09
8 files changed +84 -62
frontend/src/Breadcrumbs.ts
+1 -1
@@ -59,7 +59,7 @@ function Breadcrumb({ path, label, current, ...rest }: { current?: boolean, path
59 label: t`Reload`,
60 icon: 'reload',
61 onClick() {
62 - state.remoteSearch = ''
62 + state.remoteSearch = undefined
63 state.stopSearch?.()
64 reloadList()
65 }
frontend/src/index.scss
+1
@@ -513,6 +513,7 @@ button .icon + .label {
513 margin-left: .1em;
514 }
515 }
516 +#search-dialog form label:not(:first-of-type) { display: block; margin-top: 1em }
517
518 form label+input { margin-top: .2em; }
519
frontend/src/menu.ts
+39 -34
@@ -18,12 +18,12 @@ import { useSnapshot } from 'valtio'
18 import { apiCall } from '@hfs/shared/api'
19 import { reloadList } from './useFetchList'
20 import { cut } from './clip'
21 -import { Btn, BtnProps, CustomCode } from './components'
21 +import { Btn, BtnProps, Checkbox, CustomCode } from './components'
22 import i18n from './i18n'
23 const { t, useI18N } = i18n
24
25 export function MenuPanel() {
26 - const { showFilter, remoteSearch, stopSearch, searchManuallyInterrupted, selected, props, searchOptions } = useSnapState()
26 + const { showFilter, remoteSearch, stopSearch, searchManuallyInterrupted, selected, props } = useSnapState()
27 const { can_upload, can_delete, can_archive } = props ? { ...defaultPerms, ...props } : {} as VfsPerms
28 const { uploading, qs } = useSnapshot(uploadState)
29 useEffect(() => {
@@ -103,7 +103,7 @@ export function MenuPanel() {
103 : t('zip_tooltip_whole', "Download whole list (unfiltered) as a single zip file. If you select some elements, only those will be downloaded."),
104 href: buildUrlQueryString(_.pickBy({
105 get: 'zip',
106 - search: remoteSearch,
106 + ...remoteSearch,
107 list
108 })),
109 ...!list && {
@@ -123,7 +123,10 @@ export function MenuPanel() {
123 h(CustomCode, { name: 'appendMenuBar' }),
124 ),
125 remoteSearch && h('div', { id: 'searched' },
126 - (stopSearch ? t`Searching` : t`Searched`) + ': ' + remoteSearch + prefix(' (', searchManuallyInterrupted && t`interrupted`, ')')),
126 + (stopSearch ? t`Searching` : t`Searched`) + ': ',
127 + _.map({ search: t`Name`, searchComment: t`Comment` }, (v,k) => prefix(v + ': ', remoteSearch[k])).filter(Boolean).join(' and '),
128 + prefix(' (', searchManuallyInterrupted && t`interrupted`, ')')
129 + ),
130 )
131
132 function getSearchProps() {
@@ -141,42 +144,14 @@ export function MenuPanel() {
144 icon: 'search_off',
145 label: t`Clear search`,
146 onClick() {
144 - state.remoteSearch = ''
147 + state.remoteSearch = undefined
148 }
149 } : {
150 id: 'search-button',
151 icon: 'search',
152 label: t`Search`,
153 onClickAnimation: false,
151 - onClick: () => formDialog({
152 - title: t`Search`,
153 - Content: () => h('div', {},
154 - h('label', { htmlFor: 'text' }, t('search_msg', "Search this folder and sub-folders")),
155 - h('input', {
156 - name: 'text',
157 - style: { width: 0, minWidth: '100%', maxWidth: '100%', boxSizing: 'border-box' },
158 - autoFocus: true,
159 - }),
160 - h('div', { style: { margin: '1em 0' } },
161 - h('input', {
162 - type: 'checkbox',
163 - name: 'wild',
164 - defaultChecked: searchOptions.wild,
165 - style: { marginRight: '1em' },
166 - }),
167 - "Wildcards",
168 - h('a', { href: `${WIKI_URL}Wildcards`, target: 'doc' }, hIcon('info')),
169 - ),
170 - h('div', { style: { textAlign: 'right', marginTop: '.8em' } },
171 - h('button', {}, t`Continue`)),
172 - )
173 - }).then(res => {
174 - if (!res) return
175 - const { text='', wild, ...rest } = res
176 - state.searchOptions = { ...rest, wild: Boolean(wild) }
177 - state.remoteSearch = text
178 - state.stopSearch?.()
179 - })
154 + onClick: searchDialog,
155 }
156 }
157 }
@@ -233,4 +208,34 @@ export async function deleteFiles(uris: string[]) {
208 ...errors.map(e => h(ErrorMsg, { err: t(err2msg(e.err)) + ': ' + e.uri }))
209 )
210 ))
211 +}
212 +
213 +function searchDialog() {
214 + formDialog({
215 + title: t`Search`,
216 + dialogProps: { id: 'search-dialog' },
217 + Content() {
218 + const style = { width: 0, minWidth: '100%', maxWidth: '100%', boxSizing: 'border-box' }
219 + return h(Fragment, {},
220 + h('label', { htmlFor: 'name' }, t('search_msg', "Search this folder and sub-folders")),
221 + h('input', { name: 'name', style, autoFocus: true, }),
222 + h('label', { htmlFor: 'comment' }, t`Comment`),
223 + h('input', { name: 'comment', style, }),
224 + h('div', { style: { margin: '1em 0' } },
225 + h(Checkbox, { name: 'wild', defaultChecked: true }, "Wildcards"), // uncontrolled
226 + h('a', { href: `${WIKI_URL}Wildcards`, target: 'doc' }, hIcon('info')),
227 + ),
228 + h('div', { style: { textAlign: 'right', marginTop: '.8em' } },
229 + h('button', {}, t`Continue`)),
230 + )
231 + }
232 + }).then(res => {
233 + if (!res) return
234 + state.remoteSearch = !res.name && !res.comment ? undefined : {
235 + search: res.name || undefined,
236 + searchComment: res.comment || undefined,
237 + wild: res.wild ? undefined : 'no'
238 + }
239 + state.stopSearch?.()
240 + })
241 }
\ No newline at end of file
frontend/src/state.ts
+13 -5
@@ -3,7 +3,17 @@
3 import _ from 'lodash'
4 import { proxy, useSnapshot } from 'valtio'
5 import { subscribeKey } from 'valtio/utils'
6 -import { FRONTEND_OPTIONS, getHFS, hfsEvent, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc'
6 +import {
7 + Dict,
8 + FRONTEND_OPTIONS,
9 + getHFS,
10 + hfsEvent,
11 + hIcon,
12 + objSameKeys,
13 + pathEncode,
14 + StringifyProps,
15 + typedKeys
16 +} from './misc'
17 import { DirEntry as ServerDirEntry } from '../../src/api.get_file_list'
18
19 export const state = proxy<typeof FRONTEND_OPTIONS & {
@@ -21,7 +31,7 @@ export const state = proxy<typeof FRONTEND_OPTIONS & {
31 patternFilter: string,
32 showFilter: boolean,
33 selected: { [uri:string]: true }, // by using an object instead of an array, Entry components are not rendered when others get selected
24 - remoteSearch: string,
34 + remoteSearch: Dict<any> | undefined,
35 adminUrl?: string,
36 loginRequired?: boolean, // force user to login before proceeding
37 messageOnly?: string, // no gui, just show this message
@@ -37,11 +47,9 @@ export const state = proxy<typeof FRONTEND_OPTIONS & {
47 canChangePassword: boolean
48 uri: string
49 uploadOnExisting: 'skip' | 'overwrite' | 'rename'
40 - searchOptions: any
50 expandedUsername: string[]
51 }>({
52 expandedUsername: [],
44 - searchOptions: { wild: true },
53 uploadOnExisting: getHFS().dontOverwriteUploading ? 'rename' : 'skip',
54 uri: '',
55 canChangePassword: false,
@@ -56,7 +64,7 @@ export const state = proxy<typeof FRONTEND_OPTIONS & {
64 patternFilter: '',
65 showFilter: false,
66 selected: {},
59 - remoteSearch: '',
67 + remoteSearch: undefined,
68 })
69
70 export function useSnapState() {
frontend/src/useFetchList.ts
+6 -7
@@ -24,7 +24,7 @@ export function usePath() {
24 // allow links with ?search
25 let firstListRequest: any
26 setTimeout(() => {// wait, urlParams is defined at top level
27 - state.remoteSearch = urlParams.search || ''
27 + state.remoteSearch = urlParams.search ? { search: urlParams.search } : undefined
28 firstListRequest = objFromKeys(['onlyFiles', 'onlyFolders'], x => x in urlParams || undefined)
29 })
30
@@ -33,7 +33,7 @@ let autoPlayOnce: string | undefined = urlParams.autoplay // this will be consum
33 export default function useFetchList() {
34 const snap = useSnapState()
35 const uri = usePath() // this api can still work removing the initial slash, but then we'll have a mixed situation that will require plugins an extra effort
36 - const search = snap.remoteSearch || undefined
36 + const {remoteSearch} = snap
37 const lastUri = useRef('')
38 const lastParams = useRef<any>()
39 const lastReloader = useRef(snap.listReloader)
@@ -49,13 +49,12 @@ export default function useFetchList() {
49 state.stopSearch?.()
50 }
51 state.searchManuallyInterrupted = false
52 - if (previous && previous !== uri && search) {
53 - state.remoteSearch = ''
52 + if (previous && previous !== uri && remoteSearch) {
53 + state.remoteSearch = undefined
54 return
55 }
56
57 - const params = { uri, search, ...firstListRequest, ...snap.searchOptions }
58 - params.wild = params.wild ? undefined : 'no'
57 + const params = { uri, ...remoteSearch, ...firstListRequest }
58 if (snap.listReloader === lastReloader.current && _.isEqual(params, lastParams.current)) return
59 lastParams.current = params
60 lastReloader.current = snap.listReloader
@@ -162,7 +161,7 @@ export default function useFetchList() {
161 state.stopSearch?.()
162 lastParams.current = null
163 }
165 - }, [uri, search, snap.username, snap.listReloader, loginRequired])
164 + }, [uri, remoteSearch, snap.username, snap.listReloader, loginRequired])
165 }
166
167 export function reloadList() {
src/api.get_file_list.ts
+16 -10
@@ -7,8 +7,8 @@ import {
7 import { ApiError, ApiHandler } from './apiMiddleware'
8 import { stat } from 'fs/promises'
9 import { mapPlugins } from './plugins'
10 -import { asyncGeneratorToArray, dirTraversal, pattern2filter, WHO_NO_ONE } from './misc'
11 -import { HTTP_FOOL, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND } from './const'
10 +import { asyncGeneratorToArray, pattern2filter, WHO_NO_ONE } from './misc'
11 +import { HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND } from './const'
12 import Koa from 'koa'
13 import { getCommentFor, areCommentsEnabled } from './comments'
14 import { basename } from 'path'
@@ -19,14 +19,21 @@ import { SendListReadable } from './SendList'
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 }
21
22 -export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search, wild, c, onlyFolders, onlyFiles, admin }, ctx) => {
22 +export function paramsToFilter({ search, wild, searchComment }: any) {
23 + search = String(search || '').toLocaleLowerCase()
24 + searchComment = String(searchComment || '').toLocaleLowerCase()
25 + return {
26 + filterName: search > '' && (wild === 'no' ? (s: string) => s.includes(search) : pattern2filter(search)),
27 + filterComment: searchComment > '' && (wild === 'no' ? (s: string) => s.includes(searchComment) : pattern2filter(searchComment))
28 + }
29 +}
30 +
31 +export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onlyFolders, onlyFiles, admin, ...rest }, ctx) => {
32 const node = await urlToNode(uri, ctx)
33 const list = ctx.get('accept') === 'text/event-stream' ? new SendListReadable() : undefined
34 if (!node)
35 return fail(HTTP_NOT_FOUND)
36 admin &&= ctxAdminAccess(ctx) // validate 'admin' flag
28 - if (dirTraversal(search))
29 - return fail(HTTP_FOOL)
37 if (await hasDefaultFile(node, ctx) || !await nodeIsDirectory(node)) // in case of files without permission, we are provided with the frontend, and the location is the file itself
38 // so, we first check if you have a permission problem, to tell frontend to show login, otherwise we fall back to method_not_allowed, as it's proper for files.
39 return fail(!admin && statusCodeForMissingPerm(node, 'can_read', ctx) ? undefined : HTTP_METHOD_NOT_ALLOWED)
@@ -34,10 +41,8 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
41 return fail()
42 offset = Number(offset)
43 limit = Number(limit)
37 - search = String(search || '').toLocaleLowerCase()
38 - const filter = wild === 'no' ? (s: string) => s.includes(search)
39 - : pattern2filter(search)
40 - const walker = walkNode(node, { ctx: admin ? undefined : ctx, onlyFolders, onlyFiles, depth: search ? Infinity : 0 })
44 + const { filterName, filterComment } = paramsToFilter(rest)
45 + const walker = walkNode(node, { ctx: admin ? undefined : ctx, onlyFolders, onlyFiles, depth: filterName || filterComment ? Infinity : 0 })
46 const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
47 const can_upload = admin || hasPermission(node, 'can_upload', ctx)
48 const fakeChild = await applyParentToChild({ source: 'dummy-file' }, node) // used to check permission; simple but but can produce false results
@@ -70,7 +75,8 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
75 for await (const sub of walker) {
76 let name = getNodeName(sub)
77 name = basename(name) || name // on windows, basename('C:') === ''
73 - if (!filter(name))
78 + if (filterName && !filterName(name)
79 + || filterComment && !filterComment(await getCommentFor(sub.source) || ''))
80 continue
81 const entry = await nodeToDirEntry(ctx, sub)
82 if (!entry)
src/misc.ts
+2 -2
@@ -24,8 +24,8 @@ import _ from 'lodash'
24 export function pattern2filter(pattern: string){
25 const matcher = makeMatcher(pattern.includes('*') ? pattern // if you specify *, we'll respect its position
26 : pattern.split('|').map(x => `*${x}*`).join('|'))
27 - return (s?:string) =>
28 - !s || !pattern || matcher(basename(s))
27 + return (s: string) =>
28 + !pattern || matcher(basename(s||''))
29 }
30
31 export function isLocalHost(c: Connection | Koa.Context | string) {
src/zip.ts
+6 -3
@@ -2,7 +2,7 @@
2
3 import { getNodeName, hasPermission, nodeIsDirectory, nodeIsLink, urlToNode, VfsNode, walkNode, statusCodeForMissingPerm } from './vfs'
4 import Koa from 'koa'
5 -import { filterMapGenerator, isWindowsDrive, pattern2filter, safeDecodeURIComponent, wantArray } from './misc'
5 +import { filterMapGenerator, isWindowsDrive, safeDecodeURIComponent, wantArray } from './misc'
6 import { QuickZipStream } from './QuickZipStream'
7 import { createReadStream } from 'fs'
8 import fs from 'fs/promises'
@@ -10,6 +10,8 @@ import { defineConfig } from './config'
10 import { basename, dirname } from 'path'
11 import { applyRange, forceDownload, monitorAsDownload } from './serveFile'
12 import { HTTP_OK } from './const'
13 +import { paramsToFilter } from './api.get_file_list'
14 +import { getCommentFor } from './comments'
15
16 // expects 'node' to have had permissions checked by caller
17 export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
@@ -20,7 +22,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
22 // ctx.query.list is undefined | string | string[]
23 const name = list?.length === 1 ? safeDecodeURIComponent(basename(list[0]!)) : getNodeName(node)
24 forceDownload(ctx, (isWindowsDrive(name) ? name[0] : (name || 'archive')) + '.zip')
23 - const filter = pattern2filter(String(ctx.query.search||''))
25 + const { filterName, filterComment } = paramsToFilter(ctx.query)
26 const walker = !list ? walkNode(node, { ctx, requiredPerm: 'can_archive' })
27 : (async function*(): AsyncIterableIterator<VfsNode> {
28 for await (const uri of list) {
@@ -45,7 +47,8 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
47 if (!hasPermission(el, 'can_archive', ctx)) return // the fact you see it doesn't mean you can get it
48 const { source } = el
49 const name = getNodeName(el)
48 - if (!filter(name))
50 + if (filterName && !filterName(name)
51 + || filterComment && !filterComment(await getCommentFor(source) || ''))
52 return
53 try {
54 if (el.isFolder)