feat: support sort by name, mtime, size (#128)
This commit is contained in:
parent
9f8171a22f
commit
31c832a742
8 changed files with 203 additions and 51 deletions
9
Cargo.lock
generated
9
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
117
assets/index.js
117
assets/index.js
|
@ -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) {
|
||||
|
|
|
@ -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
29
tests/sort.rs
Normal 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(())
|
||||
}
|
|
@ -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()?;
|
||||
|
|
Loading…
Reference in a new issue