feat: support sort by name, mtime, size (#128)

This commit is contained in:
sigoden 2022-08-23 14:24:42 +08:00 committed by GitHub
parent 9f8171a22f
commit 31c832a742
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 203 additions and 51 deletions

9
Cargo.lock generated
View file

@ -17,6 +17,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "alphanumeric-sort"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77e9c9abb82613923ec78d7a461595d52491ba7240f3c64c0bbe0e6d98e0fce0"
[[package]]
name = "assert_cmd"
version = "2.0.4"
@ -341,6 +347,7 @@ checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
name = "dufs"
version = "0.29.0"
dependencies = [
"alphanumeric-sort",
"assert_cmd",
"assert_fs",
"async-stream",
@ -350,10 +357,12 @@ dependencies = [
"clap",
"clap_complete",
"diqwest",
"form_urlencoded",
"futures",
"headers",
"hyper",
"if-addrs",
"indexmap",
"lazy_static",
"log",
"md5",

View file

@ -38,6 +38,8 @@ log = "0.4"
socket2 = "0.4"
async-stream = "0.3"
walkdir = "2.3"
form_urlencoded = "1.0"
alphanumeric-sort = "1.4"
[features]
default = ["tls"]
@ -53,6 +55,7 @@ regex = "1"
url = "2"
diqwest = { version = "1", features = ["blocking"] }
predicates = "2"
indexmap = "1.9"
[profile.release]
lto = true

View file

@ -131,7 +131,16 @@ body {
padding-left: 0.6em;
}
.paths-table tr:hover {
.paths-table thead a {
color: unset;
text-decoration: none;
}
.paths-table thead a > span {
padding-left: 2px;
}
.paths-table tbody tr:hover {
background-color: #fafafa;
}
@ -232,7 +241,7 @@ body {
color: #3191ff;
}
.paths-table tr:hover {
.paths-table tbody tr:hover {
background-color: #1a1a1a;
}
}

View file

@ -48,12 +48,6 @@
</table>
<table class="paths-table hidden">
<thead>
<tr>
<th class="cell-name" colspan="2">Name</th>
<th class="cell-mtime">Last modified</th>
<th class="cell-size">Size</th>
<th class="cell-actions">Actions</th>
</tr>
</thead>
<tbody>
</tbody>

View file

@ -6,17 +6,41 @@
* @property {number} size
*/
// https://stackoverflow.com/a/901144/3642588
const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop),
});
/**
* @typedef {object} DATA
* @property {string} href
* @property {string} uri_prefix
* @property {PathItem[]} paths
* @property {boolean} allow_upload
* @property {boolean} allow_delete
* @property {boolean} allow_search
* @property {boolean} dir_exists
*/
const dirEmptyNote = params.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
/**
* @type {DATA} DATA
*/
var DATA;
/**
* @type {PARAMS}
* @typedef {object} PARAMS
* @property {string} q
* @property {string} sort
* @property {string} order
*/
const PARAMS = Object.fromEntries(new URLSearchParams(window.location.search).entries());
const dirEmptyNote = PARAMS.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
/**
* @type Element
*/
let $pathsTable;
/**
* @type Element
*/
let $pathsTableHead;
/**
* @type Element
*/
@ -175,6 +199,67 @@ function addBreadcrumb(href, uri_prefix) {
}
}
/**
* Render path table thead
*/
function renderPathsTableHead() {
const headerItems = [
{
name: "name",
props: `colspan="2"`,
text: "Name",
},
{
name: "mtime",
props: ``,
text: "Last Modified",
},
{
name: "size",
props: ``,
text: "Size",
}
];
$pathsTableHead.insertAdjacentHTML("beforeend", `
<tr>
${headerItems.map(item => {
let svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5zm-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5z"/></svg>`;
let order = "asc";
if (PARAMS.sort === item.name) {
if (PARAMS.order === "asc") {
order = "desc";
svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>`
} else {
svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>`
}
}
const qs = new URLSearchParams({...PARAMS, order, sort: item.name }).toString();
const icon = `<span>${svg}</span>`
return `<th class="cell-${item.name}" ${item.props}><a href="?${qs}">${item.text}${icon}</a></th>`
}).join("\n")}
<th class="cell-actions">Actions</th>
</tr>
`);
}
/**
* Render path table tbody
*/
function renderPathsTableBody() {
if (DATA.paths && DATA.paths.length > 0) {
const len = DATA.paths.length;
if (len > 0) {
$pathsTable.classList.remove("hidden");
}
for (let i = 0; i < len; i++) {
addPath(DATA.paths[i], i);
}
} else {
$emptyFolder.textContent = dirEmptyNote;
$emptyFolder.classList.remove("hidden");
}
}
/**
* Add pathitem
* @param {PathItem} file
@ -430,6 +515,7 @@ function encodedStr(rawStr) {
function ready() {
document.title = `Index of ${DATA.href} - Dufs`;
$pathsTable = document.querySelector(".paths-table")
$pathsTableHead = document.querySelector(".paths-table thead");
$pathsTableBody = document.querySelector(".paths-table tbody");
$uploadersTable = document.querySelector(".uploaders-table");
$emptyFolder = document.querySelector(".empty-folder");
@ -437,26 +523,15 @@ function ready() {
if (DATA.allow_search) {
document.querySelector(".searchbar").classList.remove("hidden");
if (params.q) {
document.getElementById('search').value = params.q;
if (PARAMS.q) {
document.getElementById('search').value = PARAMS.q;
}
}
addBreadcrumb(DATA.href, DATA.uri_prefix);
if (Array.isArray(DATA.paths)) {
const len = DATA.paths.length;
if (len > 0) {
$pathsTable.classList.remove("hidden");
}
for (let i = 0; i < len; i++) {
addPath(DATA.paths[i], i);
}
if (len == 0) {
$emptyFolder.textContent = dirEmptyNote;
$emptyFolder.classList.remove("hidden");
}
}
renderPathsTableHead();
renderPathsTableBody();
if (DATA.allow_upload) {
dropzone();
if (DATA.allow_delete) {

View file

@ -19,6 +19,7 @@ use hyper::header::{
};
use hyper::{Body, Method, StatusCode, Uri};
use serde::Serialize;
use std::collections::HashMap;
use std::fs::Metadata;
use std::io::SeekFrom;
use std::net::SocketAddr;
@ -160,6 +161,9 @@ impl Server {
let path = path.as_path();
let query = req.uri().query().unwrap_or_default();
let query_params: HashMap<String, String> = form_urlencoded::parse(query.as_bytes())
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let (is_miss, is_dir, is_file, size) = match fs::metadata(path).await.ok() {
Some(meta) => (false, meta.is_dir(), meta.is_file(), meta.len()),
@ -182,27 +186,32 @@ impl Server {
Method::GET | Method::HEAD => {
if is_dir {
if render_try_index {
if query == "zip" {
if query_params.contains_key("zip") {
self.handle_zip_dir(path, head_only, &mut res).await?;
} else if allow_search && query.starts_with("q=") {
let q = decode_uri(&query[2..]).unwrap_or_default();
self.handle_search_dir(path, &q, head_only, &mut res)
} else if allow_search && query_params.contains_key("q") {
self.handle_search_dir(path, &query_params, head_only, &mut res)
.await?;
} else {
self.handle_render_index(path, headers, head_only, &mut res)
.await?;
self.handle_render_index(
path,
&query_params,
headers,
head_only,
&mut res,
)
.await?;
}
} else if render_index || render_spa {
self.handle_render_index(path, headers, head_only, &mut res)
self.handle_render_index(path, &query_params, headers, head_only, &mut res)
.await?;
} else if query == "zip" {
} else if query_params.contains_key("zip") {
self.handle_zip_dir(path, head_only, &mut res).await?;
} else if allow_search && query.starts_with("q=") {
let q = decode_uri(&query[2..]).unwrap_or_default();
self.handle_search_dir(path, &q, head_only, &mut res)
} else if allow_search && query_params.contains_key("q") {
self.handle_search_dir(path, &query_params, head_only, &mut res)
.await?;
} else {
self.handle_ls_dir(path, true, head_only, &mut res).await?;
self.handle_ls_dir(path, true, &query_params, head_only, &mut res)
.await?;
}
} else if is_file {
self.handle_send_file(path, headers, head_only, &mut res)
@ -211,7 +220,8 @@ impl Server {
self.handle_render_spa(path, headers, head_only, &mut res)
.await?;
} else if allow_upload && req_path.ends_with('/') {
self.handle_ls_dir(path, false, head_only, &mut res).await?;
self.handle_ls_dir(path, false, &query_params, head_only, &mut res)
.await?;
} else {
status_not_found(&mut res);
}
@ -344,6 +354,7 @@ impl Server {
&self,
path: &Path,
exist: bool,
query_params: &HashMap<String, String>,
head_only: bool,
res: &mut Response,
) -> BoxResult<()> {
@ -357,13 +368,13 @@ impl Server {
}
}
};
self.send_index(path, paths, exist, head_only, res)
self.send_index(path, paths, exist, query_params, head_only, res)
}
async fn handle_search_dir(
&self,
path: &Path,
search: &str,
query_params: &HashMap<String, String>,
head_only: bool,
res: &mut Response,
) -> BoxResult<()> {
@ -372,7 +383,7 @@ impl Server {
let hidden = Arc::new(self.args.hidden.to_vec());
let hidden = hidden.clone();
let running = self.running.clone();
let search = search.to_lowercase();
let search = query_params.get("q").unwrap().to_lowercase();
let search_paths = tokio::task::spawn_blocking(move || {
let mut it = WalkDir::new(&path_buf).into_iter();
let mut paths: Vec<PathBuf> = vec![];
@ -405,7 +416,7 @@ impl Server {
paths.push(item);
}
}
self.send_index(path, paths, true, head_only, res)
self.send_index(path, paths, true, query_params, head_only, res)
}
async fn handle_zip_dir(
@ -445,6 +456,7 @@ impl Server {
async fn handle_render_index(
&self,
path: &Path,
query_params: &HashMap<String, String>,
headers: &HeaderMap<HeaderValue>,
head_only: bool,
res: &mut Response,
@ -459,7 +471,8 @@ impl Server {
self.handle_send_file(&index_path, headers, head_only, res)
.await?;
} else if self.args.render_try_index {
self.handle_ls_dir(path, true, head_only, res).await?;
self.handle_ls_dir(path, true, query_params, head_only, res)
.await?;
} else {
status_not_found(res)
}
@ -754,10 +767,30 @@ impl Server {
path: &Path,
mut paths: Vec<PathItem>,
exist: bool,
query_params: &HashMap<String, String>,
head_only: bool,
res: &mut Response,
) -> BoxResult<()> {
paths.sort_unstable();
if let Some(sort) = query_params.get("sort") {
if sort == "name" {
paths.sort_by(|v1, v2| {
alphanumeric_sort::compare_str(v1.name.to_lowercase(), v2.name.to_lowercase())
})
} else if sort == "mtime" {
paths.sort_by(|v1, v2| v1.mtime.cmp(&v2.mtime))
} else if sort == "size" {
paths.sort_by(|v1, v2| v1.size.unwrap_or(0).cmp(&v2.size.unwrap_or(0)))
}
if query_params
.get("order")
.map(|v| v == "desc")
.unwrap_or_default()
{
paths.reverse()
}
} else {
paths.sort_unstable();
}
let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?));
let data = IndexData {
href,

29
tests/sort.rs Normal file
View file

@ -0,0 +1,29 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn ls_dir_sort_by_name(server: TestServer) -> Result<(), Error> {
let url = server.url();
let resp = reqwest::blocking::get(format!("{}?sort=name&order=asc", url))?;
let paths1 = self::utils::retrieve_index_paths(&resp.text()?);
let resp = reqwest::blocking::get(format!("{}?sort=name&order=desc", url))?;
let mut paths2 = self::utils::retrieve_index_paths(&resp.text()?);
paths2.reverse();
assert_eq!(paths1, paths2);
Ok(())
}
#[rstest]
fn search_dir_sort_by_name(server: TestServer) -> Result<(), Error> {
let url = server.url();
let resp = reqwest::blocking::get(format!("{}?q={}&sort=name&order=asc", url, "test.html"))?;
let paths1 = self::utils::retrieve_index_paths(&resp.text()?);
let resp = reqwest::blocking::get(format!("{}?q={}&sort=name&order=desc", url, "test.html"))?;
let mut paths2 = self::utils::retrieve_index_paths(&resp.text()?);
paths2.reverse();
assert_eq!(paths1, paths2);
Ok(())
}

View file

@ -1,5 +1,5 @@
use indexmap::IndexSet;
use serde_json::Value;
use std::collections::HashSet;
#[macro_export]
macro_rules! assert_resp_paths {
@ -25,7 +25,7 @@ macro_rules! fetch {
}
#[allow(dead_code)]
pub fn retrieve_index_paths(index: &str) -> HashSet<String> {
pub fn retrieve_index_paths(index: &str) -> IndexSet<String> {
retrieve_index_paths_impl(index).unwrap_or_default()
}
@ -35,7 +35,7 @@ pub fn encode_uri(v: &str) -> String {
parts.join("/")
}
fn retrieve_index_paths_impl(index: &str) -> Option<HashSet<String>> {
fn retrieve_index_paths_impl(index: &str) -> Option<IndexSet<String>> {
let lines: Vec<&str> = index.lines().collect();
let line = lines.iter().find(|v| v.contains("DATA ="))?;
let value: Value = line[7..].parse().ok()?;