]> OzVa Git service - ozva-cloud/commitdiff
feat: support sort by name, mtime, size (#128)
authorsigoden <sigoden@gmail.com>
Tue, 23 Aug 2022 06:24:42 +0000 (14:24 +0800)
committerGitHub <noreply@github.com>
Tue, 23 Aug 2022 06:24:42 +0000 (14:24 +0800)
Cargo.lock
Cargo.toml
assets/index.css
assets/index.html
assets/index.js
src/server.rs
tests/sort.rs [new file with mode: 0644]
tests/utils.rs

index 799350f1c3fbc825e5c49f4ad8a5f4d0686928f7..3952d4338843f230338a1db97c0a0addfd313f16 100644 (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",
index a0ab9039a824bba92e841295b6e3116ebde4a7a7..2776d6d5f3cdad862331313495fe9afb4596c0db 100644 (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
index ff17861d8c9207d86d7f2dc1314632ebd5145892..c42ae00fba94473fa6d59f1a9361166d51f064ce 100644 (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;
   }
 } 
index e5cc799ff0bb368d363a1fb857ec694934b21534..927d7fcc47ca852bed5bd8b2b20f86b747f638da 100644 (file)
     </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>
index df1e2b73fbb29244b7c79084d601c0e326dcddd2..d5d5c1e531a781235d5b2e222e9c24809f1399c3 100644 (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) {
index cac662019e4c713519f99645d48bba85a93d2e6b..47afc58c577b0c7f777de59c9a674e347dcda212 100644 (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,
diff --git a/tests/sort.rs b/tests/sort.rs
new file mode 100644 (file)
index 0000000..4563a51
--- /dev/null
@@ -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(())
+}
index 5b01cbe42224d75b017fc72a172bcb05e2a5edfd..449fbbfb02c7bfb914e72ec7e514ce3dabe5d956 100644 (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()?;