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