archive only selected files
Massimo Melina committed
Jan 20, 2022 at 15:53 UTC
2d65cc499075591d82e8a62b508342fa7ab0f293
9 files changed
+60
-22
config.yaml
-2
@@ -28,8 +28,6 @@ vfs:
28
- name: page
29
default: index.html
30
source: tests/page
31
- - name: proxy
32
- source: https://raw.githubusercontent.com/nodejs/node/master/README.md
31
- name: for-admins
32
perm:
33
admin: r
frontend/src/BrowseFiles.ts
+16
-8
@@ -1,7 +1,7 @@
1
import { Link, useLocation } from 'react-router-dom'
2
import { createContext, createElement as h, Fragment, useContext, useEffect, useMemo, useState, memo } from 'react'
3
import { formatBytes, hError, hIcon, hfsEvent } from './misc'
4
-import { Html, Spinner } from './components'
4
+import { Checkbox, Html, Spinner } from './components'
5
import { Head } from './Head'
6
import { state, useSnapState } from './state'
7
import _ from 'lodash'
@@ -63,17 +63,25 @@ function isMobile() {
63
}
64
65
const Entry = memo(function(entry: DirEntry & { hidden:boolean, midnight: Date }) {
66
- let { n, hidden, isFolder } = entry
66
+ let { n: relativePath, hidden, isFolder } = entry
67
const base = usePath()
68
- const href = fixUrl(n)
69
- const containerDir = isFolder ? '' : n.substring(0, n.lastIndexOf('/')+1)
70
- if (containerDir)
71
- n = n.substring(containerDir.length)
68
+ const { showFilter, selected } = useSnapState()
69
+ const href = fixUrl(relativePath)
70
+ const containerDir = isFolder ? '' : relativePath.substring(0, relativePath.lastIndexOf('/')+1)
71
+ const name = relativePath.substring(containerDir.length)
72
return h('li', { className:isFolder ? 'folder' : 'file', style:hidden ? { display:'none' } : null },
73
- isFolder ? h(Link, { to: base+href }, hIcon('folder'), n)
73
+ showFilter && h(Checkbox, {
74
+ value: selected[relativePath],
75
+ onChange(v){
76
+ if (v)
77
+ return state.selected[relativePath] = true
78
+ delete state.selected[relativePath]
79
+ },
80
+ }),
81
+ isFolder ? h(Link, { to: base+href }, hIcon('folder'), relativePath)
82
: h(Fragment, {},
83
containerDir && h(Link, { to: base+fixUrl(containerDir), className:'container-folder' }, hIcon('file'), containerDir ),
76
- h('a', { href }, !containerDir && hIcon('file'), n)
84
+ h('a', { href }, !containerDir && hIcon('file'), name)
85
),
86
h(EntryProps, entry),
87
h('div', { style:{ clear:'both' } })
frontend/src/Head.ts
+3
-1
@@ -28,7 +28,8 @@ function FolderStats() {
28
}
29
return { files, folders, size }
30
}, [list])
31
- const { filteredEntries, stoppedSearch } = useSnapState()
31
+ const { filteredEntries, selected, stoppedSearch } = useSnapState()
32
+ const sel = Object.keys(selected).length
33
return h('div', { id:'folder-stats' },
34
stoppedSearch ? hIcon('interrupted', { title:'Search was interrupted' })
35
: list?.length>0 && loading && h(Spinner),
@@ -36,6 +37,7 @@ function FolderStats() {
37
prefix('', stats.files,' file(s)'),
38
prefix('', stats.folders, ' folder(s)'),
39
stats.size ? formatBytes(stats.size) : '',
40
+ sel && sel+' selected',
41
filteredEntries >= 0 && filteredEntries+' displayed',
42
].filter(Boolean).join(', ')
43
)
frontend/src/menu.ts
+16
-3
@@ -7,12 +7,18 @@ import { login } from './login'
7
import { showOptions } from './options'
8
import showUserPanel from './UserPanel'
9
import { useNavigate } from 'react-router-dom'
10
+import _ from 'lodash'
11
12
export function MenuPanel() {
12
- const { remoteSearch, stopSearch, stoppedSearch, listFilter } = useSnapState()
13
+ const { remoteSearch, stopSearch, stoppedSearch, listFilter, selected } = useSnapState()
14
const [showFilter, setShowFilter] = useState(listFilter > '')
15
const [filter, setFilter] = useState(listFilter)
16
;[state.listFilter] = useDebounce(showFilter ? filter : '', 300)
17
+ state.showFilter = showFilter
18
+ useEffect(() => {
19
+ if (!showFilter)
20
+ state.selected = {}
21
+ }, [showFilter])
22
23
const [started1secAgo, setStarted1secAgo] = useState(false)
24
useEffect(() => {
@@ -20,6 +26,9 @@ export function MenuPanel() {
26
setStarted1secAgo(false)
27
setTimeout(() => setStarted1secAgo(true), 1000)
28
}, [stopSearch])
29
+
30
+ //TODO do something for list > 63KB (1kb reserved for the rest for the url)
31
+ const list = Object.keys(selected).map(s => s.endsWith('/') ? s.slice(0,-1) : s).join('*')
32
return h('div', { id: 'menu-panel' },
33
h('div', { id: 'menu-bar' },
34
h(LoginButton),
@@ -40,8 +49,12 @@ export function MenuPanel() {
49
h(MenuLink, {
50
icon: 'archive',
51
label: 'Archive',
43
- href: '?get=zip' + prefix('&search=', remoteSearch),
44
- confirm: remoteSearch ? 'Download results of this search as ZIP archive?' : 'Download whole folder as ZIP archive?',
52
+ href: '?'+String(new URLSearchParams(_.pickBy({
53
+ get: 'zip',
54
+ search: remoteSearch,
55
+ list
56
+ }))),
57
+ confirm: list ? undefined : remoteSearch ? 'Download results of this search as ZIP archive?' : 'Download whole folder as ZIP archive?',
58
})
59
),
60
remoteSearch && h('div', { id: 'searched' }, (stopSearch ? 'Searching' : 'Searched') + ': ' + remoteSearch + prefix(' (', stoppedSearch && 'interrupted', ')')),
frontend/src/state.ts
+4
@@ -8,6 +8,8 @@ export const state = proxy<{
8
iconsClass: string,
9
username: string,
10
listFilter: string,
11
+ showFilter: boolean,
12
+ selected: Record<string,true>, // optimization: by using an object instead of an array, components are not rendered when the array changes, but only when their specific property change
13
remoteSearch: string,
14
filteredEntries: number,
15
sortBy: string,
@@ -18,6 +20,8 @@ export const state = proxy<{
20
iconsClass: '',
21
username: '',
22
listFilter: '',
23
+ showFilter: false,
24
+ selected: {},
25
remoteSearch: '',
26
filteredEntries: -1,
27
sortBy: 'name',
frontend/src/useFetchList.ts
+1
-1
@@ -67,10 +67,10 @@ export default function useFetchList() {
67
}
68
})
69
state.stopSearch = ()=>{
70
+ state.stopSearch = undefined
71
buffer.length = 0
72
setLoading(false)
73
clearInterval(timer)
73
- state.stopSearch = undefined
74
src.close()
75
}
76
}, [desiredPath, search, snap.username, forcer])
src/vfs.ts
+2
-2
@@ -40,9 +40,9 @@ export class Vfs {
40
this.root = { ...EMPTY }
41
}
42
43
- async urlToNode(url: string, ctx: Koa.Context) : Promise<VfsNode | undefined> {
43
+ async urlToNode(url: string, ctx: Koa.Context, root?: VfsNode) : Promise<VfsNode | undefined> {
44
const users = await getCurrentUsernameExpanded(ctx)
45
- let run = this.root
45
+ let run = root || this.root
46
const rest = url.split('/').filter(Boolean).map(decodeURIComponent)
47
if (forbidden(run, users)) return
48
while (rest.length) {
src/zip.ts
+18
-4
@@ -1,10 +1,11 @@
1
-import { VfsNode, walkNode } from './vfs'
1
+import { vfs, VfsNode, walkNode } from './vfs'
2
import Koa from 'koa'
3
-import { filterMapGenerator, pattern2filter } from './misc'
3
+import { filterMapGenerator, isDirectory, pattern2filter, prefix } from './misc'
4
import { QuickZipStream } from './QuickZipStream'
5
import { createReadStream } from 'fs'
6
import fs from 'fs/promises'
7
import { getConfig } from './config'
8
+import { dirname } from 'path'
9
10
export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
11
ctx.status = 200
@@ -12,7 +13,20 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
13
const { name } = node
14
ctx.attachment((name || 'archive') + '.zip')
15
const filter = pattern2filter(String(ctx.query.search||''))
15
- const walker = filterMapGenerator(walkNode(node, ctx, Infinity), async (el:VfsNode) => {
16
+ const { list } = ctx.query
17
+ const walker = !list ? walkNode(node, ctx, Infinity)
18
+ : (async function*(): AsyncIterableIterator<VfsNode> {
19
+ for (const el of String(list).split('*')) { // we are using * as separator because it cannot be used in a file name and doesn't need url encoding
20
+ const subNode = await vfs.urlToNode(el, ctx, node)
21
+ if (!subNode)
22
+ continue
23
+ if (subNode.children || subNode.source && await isDirectory(subNode.source)) // a directory needs to walked
24
+ yield* walkNode(subNode, ctx, Infinity, el+'/')
25
+ else
26
+ yield { ...subNode, name: prefix('', dirname(el), '/') + subNode.name } // reflect relative path in archive, otherwise way may have name-clashes
27
+ }
28
+ })()
29
+ const mappedWalker = filterMapGenerator(walker, async (el:VfsNode) => {
30
const { source } = el
31
if (!source || ctx.req.aborted || !filter(el.name))
32
return
@@ -29,7 +43,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
43
}
44
catch {}
45
})
32
- const zip = new QuickZipStream(walker)
46
+ const zip = new QuickZipStream(mappedWalker)
47
const time = 1000 * (getConfig('zip-calculate-size-for-seconds') ?? 1)
48
ctx.response.length = await zip.calculateSize(time)
49
ctx.body = zip
todo.md
-1
@@ -14,7 +14,6 @@
14
- config: bans
15
- config: min disk space
16
- thumbnails support
17
-- archive only selected files
17
- "bottom" button (only on long screens, only after you scroll a bit, only for a few seconds)
18
- webdav?
19
- vfs: ability to remove/hide/rename files deep in a source