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)