feat(doi): add DOI-based path access for commands
Support accessing repositories using DOI strings with optional subpaths
across ls, download, metadata, and file-metadata commands.
- Implement GET v3/doi/{id}/ API model and client calls
- Parse and resolve DOI paths into respective folder and files
- Extract common folder and file resolution logic to shared helpers
- Update README with example DOI-based shell commands
This commit is contained in:
@@ -103,7 +103,7 @@ mdrs labs neurodata:
|
||||
|
||||
### ls
|
||||
|
||||
List the contents of a remote folder.
|
||||
List the contents of a remote folder. You can also specify a DOI path in the form `remote:10.xxxx/yyy.ID[/optional/subpath]`.
|
||||
|
||||
```shell
|
||||
mdrs ls neurodata:/NIU/Repository/
|
||||
@@ -111,6 +111,10 @@ mdrs ls -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/
|
||||
mdrs ls -r neurodata:/NIU/Repository/Dataset1/
|
||||
mdrs ls -J -r neurodata:/NIU/Repository/Dataset1/
|
||||
mdrs ls -q neurodata:/NIU/Repository/
|
||||
|
||||
# DOI access examples:
|
||||
mdrs ls neurodata:10.60178/cbs.20260429-001
|
||||
mdrs ls "neurodata:10.60178/cbs.20260429-001/Figure 1"
|
||||
```
|
||||
|
||||
### mkdir
|
||||
@@ -133,7 +137,7 @@ mdrs upload -r --skip-if-exists ./dataset neurodata:/NIU/Repository/TEST/
|
||||
|
||||
### download
|
||||
|
||||
Download a file or folder from a remote path.
|
||||
Download a file or folder from a remote path. You can also specify a DOI path.
|
||||
|
||||
```shell
|
||||
mdrs download neurodata:/NIU/Repository/TEST/sample.dat ./
|
||||
@@ -141,6 +145,9 @@ mdrs download -r neurodata:/NIU/Repository/TEST/dataset/ ./
|
||||
mdrs download -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.dat ./
|
||||
mdrs download -r --exclude /NIU/Repository/TEST/dataset/skip neurodata:/NIU/Repository/TEST/dataset/ ./
|
||||
mdrs download -r --skip-if-exists neurodata:/NIU/Repository/TEST/dataset/ ./
|
||||
|
||||
# DOI access examples:
|
||||
mdrs download -r neurodata:10.60178/cbs.20260429-001 ./
|
||||
```
|
||||
|
||||
### mv
|
||||
@@ -184,20 +191,26 @@ Available access levels: `private`, `public`, `pw_open`, `cbs_open`, `5kikan_ope
|
||||
|
||||
### metadata
|
||||
|
||||
Show metadata for a remote folder.
|
||||
Show metadata for a remote folder. You can also specify a DOI path.
|
||||
|
||||
```shell
|
||||
mdrs metadata neurodata:/NIU/Repository/TEST/
|
||||
mdrs metadata neurodata:/NIU/Repository/Private/
|
||||
mdrs metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/
|
||||
|
||||
# DOI access examples:
|
||||
mdrs metadata neurodata:10.60178/cbs.20260429-001
|
||||
```
|
||||
|
||||
### file-metadata
|
||||
|
||||
Show metadata for a remote file.
|
||||
Show metadata for a remote file. You can also specify a DOI path.
|
||||
|
||||
```shell
|
||||
mdrs file-metadata neurodata:/NIU/Repository/TEST/dataset/sample.dat
|
||||
mdrs file-metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.txt
|
||||
|
||||
# DOI access examples:
|
||||
mdrs file-metadata "neurodata:10.60178/cbs.20260429-001/Figure 1/Figure1v3.pdf"
|
||||
```
|
||||
|
||||
### version
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::connection::MDRSConnection;
|
||||
use crate::models::doi::DoiResponse;
|
||||
use anyhow::bail;
|
||||
|
||||
impl MDRSConnection {
|
||||
/// Retrieve the folder associated with a DOI suffix ID (GET v3/doi/{id}/).
|
||||
///
|
||||
/// The MDRS DOI format is `10.xxxx/prefix.{id}` where the suffix after the
|
||||
/// last `.` is the internal system ID passed to this endpoint.
|
||||
pub async fn retrieve_doi(&self, id: &str) -> Result<DoiResponse, anyhow::Error> {
|
||||
let resp = self.get(&format!("v3/doi/{}/", id)).await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("DOI lookup failed: {}", resp.status());
|
||||
}
|
||||
Ok(resp.json::<DoiResponse>().await?)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// API module (add users, files, folders, laboratories, etc. here)
|
||||
|
||||
pub mod doi;
|
||||
pub mod files;
|
||||
pub mod folders;
|
||||
pub mod laboratories;
|
||||
|
||||
+193
-6
@@ -1,7 +1,7 @@
|
||||
use crate::cache::create_readonly_conn;
|
||||
use crate::commands::shared::{
|
||||
find_file_by_name, find_folder_limited, find_laboratory, find_subfolder_by_name,
|
||||
parse_remote_path,
|
||||
find_file_by_name, find_folder_by_doi, find_folder_limited, find_laboratory,
|
||||
find_subfolder_by_name, is_doi, parse_doi_remote_path, parse_remote_path,
|
||||
};
|
||||
use crate::connection::{ApiRequestLimiter, MDRSConnection};
|
||||
use anyhow::{anyhow, bail};
|
||||
@@ -17,11 +17,7 @@ pub async fn download(
|
||||
password: Option<&str>,
|
||||
excludes: Vec<String>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let (remote, labname, r_path) = parse_remote_path(remote_path)?;
|
||||
let (conn, cache) = create_readonly_conn(&remote).await?;
|
||||
let conn = Arc::new(conn);
|
||||
let limiter = ApiRequestLimiter::new(crate::settings::SETTINGS.concurrent);
|
||||
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?;
|
||||
|
||||
// Validate that local_path is an existing directory (matching Python's behaviour).
|
||||
let local_real = std::fs::canonicalize(local_path)
|
||||
@@ -30,6 +26,197 @@ pub async fn download(
|
||||
bail!("Local directory `{}` not found.", local_path);
|
||||
}
|
||||
|
||||
// Detect DOI path: "remote:10.xxxx/prefix.ID[/optional/sub/path]"
|
||||
if is_doi(remote_path.splitn(2, ':').nth(1).unwrap_or("")) {
|
||||
let (remote, doi, subpath) = parse_doi_remote_path(remote_path)?;
|
||||
let (raw_conn, _cache) = create_readonly_conn(&remote).await?;
|
||||
let (doi_folder, lab) = find_folder_by_doi(&raw_conn, &doi, password).await?;
|
||||
let abs_path = format!("{}{}", doi_folder.path.trim_end_matches('/'), subpath);
|
||||
let abs_path_clean = abs_path.trim_end_matches('/');
|
||||
|
||||
// Check if the target is a folder.
|
||||
// If it is, we download the directory. If it is not, we download the file or subfolder.
|
||||
let (folder, is_folder) = match find_folder_limited(
|
||||
&raw_conn,
|
||||
&limiter,
|
||||
lab.id,
|
||||
abs_path_clean,
|
||||
password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(f) => (f, true),
|
||||
Err(_) => {
|
||||
let (parent_path, _) = match abs_path_clean.rfind('/') {
|
||||
Some(0) => ("/".to_string(), abs_path_clean[1..].to_string()),
|
||||
Some(pos) => (
|
||||
abs_path_clean[..pos].to_string(),
|
||||
abs_path_clean[pos + 1..].to_string(),
|
||||
),
|
||||
None => ("/".to_string(), abs_path_clean.to_string()),
|
||||
};
|
||||
let parent =
|
||||
find_folder_limited(&raw_conn, &limiter, lab.id, &parent_path, password)
|
||||
.await?;
|
||||
(parent, false)
|
||||
}
|
||||
};
|
||||
|
||||
let conn = Arc::new(raw_conn);
|
||||
let excludes = Arc::new(excludes);
|
||||
|
||||
if is_folder {
|
||||
let path = folder.path.clone();
|
||||
let lab_name = Arc::new(lab.name);
|
||||
let top_local = local_real.join(&folder.name);
|
||||
let password_owned = password.map(str::to_string);
|
||||
|
||||
let mut folder_tasks: JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>> =
|
||||
JoinSet::new();
|
||||
let mut download_tasks: JoinSet<Result<(), anyhow::Error>> = JoinSet::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if !recursive {
|
||||
// Single-folder, non-recursive: download the files inside the DOI folder.
|
||||
let files = conn.list_all_files_limited(&folder.id, &limiter).await?;
|
||||
for file in &files {
|
||||
if is_excluded(&excludes, lab_name.as_str(), &path, Some(&file.name)) {
|
||||
continue;
|
||||
}
|
||||
let dest = local_real.join(&file.name);
|
||||
if skip_if_exists {
|
||||
if dest.exists() {
|
||||
if let Ok(meta) = std::fs::metadata(&dest) {
|
||||
if meta.len() == file.size {
|
||||
println!("{}", dest.display());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = make_absolute_url(&conn, &file.download_url);
|
||||
conn.download_file_limited(&url, &dest.to_string_lossy(), &limiter)
|
||||
.await?;
|
||||
println!("{}", dest.display());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
spawn_download_folder_task(
|
||||
&mut folder_tasks,
|
||||
conn.clone(),
|
||||
limiter.clone(),
|
||||
lab_name.clone(),
|
||||
excludes.clone(),
|
||||
folder.id.clone(),
|
||||
top_local,
|
||||
password_owned.clone(),
|
||||
skip_if_exists,
|
||||
);
|
||||
|
||||
drive_download_tasks(
|
||||
&mut folder_tasks,
|
||||
&mut download_tasks,
|
||||
&mut errors,
|
||||
conn.clone(),
|
||||
limiter,
|
||||
lab_name,
|
||||
excludes,
|
||||
password_owned,
|
||||
skip_if_exists,
|
||||
)
|
||||
.await;
|
||||
|
||||
if !errors.is_empty() {
|
||||
bail!(errors.join("\n"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Case: target is a file or subfolder inside the resolved folder.
|
||||
let basename = match abs_path_clean.rfind('/') {
|
||||
Some(pos) => abs_path_clean[pos + 1..].to_string(),
|
||||
None => abs_path_clean.to_string(),
|
||||
};
|
||||
let files = conn.list_all_files_limited(&folder.id, &limiter).await?;
|
||||
|
||||
// Case 1: basename matches a file in the folder.
|
||||
if let Some(file) = find_file_by_name(&files, &basename) {
|
||||
if is_excluded(&excludes, &lab.name, &folder.path, Some(&file.name)) {
|
||||
return Ok(());
|
||||
}
|
||||
let dest = local_real.join(&file.name);
|
||||
if skip_if_exists {
|
||||
if dest.exists() {
|
||||
if let Ok(meta) = std::fs::metadata(&dest) {
|
||||
if meta.len() == file.size {
|
||||
println!("{}", dest.display());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = make_absolute_url(&conn, &file.download_url);
|
||||
conn.download_file_limited(&url, &dest.to_string_lossy(), &limiter)
|
||||
.await?;
|
||||
println!("{}", dest.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Case 2: basename matches a sub-folder.
|
||||
let subfolder = find_subfolder_by_name(&folder.sub_folders, &basename);
|
||||
if let Some(sub) = subfolder {
|
||||
if !recursive {
|
||||
bail!("Cannot download `{}`: Is a folder.", abs_path_clean);
|
||||
}
|
||||
let top_local = local_real.join(&sub.name);
|
||||
let mut folder_tasks: JoinSet<Result<DownloadFolderTaskResult, anyhow::Error>> =
|
||||
JoinSet::new();
|
||||
let mut download_tasks: JoinSet<Result<(), anyhow::Error>> = JoinSet::new();
|
||||
let mut errors = Vec::new();
|
||||
let lab_name = Arc::new(lab.name.clone());
|
||||
let password_owned = password.map(str::to_string);
|
||||
|
||||
spawn_download_folder_task(
|
||||
&mut folder_tasks,
|
||||
conn.clone(),
|
||||
limiter.clone(),
|
||||
lab_name.clone(),
|
||||
excludes.clone(),
|
||||
sub.id.clone(),
|
||||
top_local,
|
||||
password_owned.clone(),
|
||||
skip_if_exists,
|
||||
);
|
||||
|
||||
drive_download_tasks(
|
||||
&mut folder_tasks,
|
||||
&mut download_tasks,
|
||||
&mut errors,
|
||||
conn.clone(),
|
||||
limiter,
|
||||
lab_name,
|
||||
excludes,
|
||||
password_owned,
|
||||
skip_if_exists,
|
||||
)
|
||||
.await;
|
||||
|
||||
if !errors.is_empty() {
|
||||
bail!(errors.join("\n"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
bail!("File or folder `{}` not found.", abs_path_clean);
|
||||
}
|
||||
|
||||
// Normal path: "remote:/labname/path/..."
|
||||
let (remote, labname, r_path) = parse_remote_path(remote_path)?;
|
||||
let (raw_conn, cache) = create_readonly_conn(&remote).await?;
|
||||
let conn = Arc::new(raw_conn);
|
||||
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?;
|
||||
|
||||
// Split r_path into the parent directory path and the target basename.
|
||||
// Trailing slashes are already stripped by parse_remote_path, so this is safe.
|
||||
let r_path_clean = r_path.trim_end_matches('/');
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
use crate::cache::create_readonly_conn;
|
||||
use crate::commands::shared::{find_file_by_name, find_folder, find_laboratory, parse_remote_path};
|
||||
use crate::commands::shared::{find_file_by_name, resolve_remote_file};
|
||||
use anyhow::anyhow;
|
||||
|
||||
pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> {
|
||||
let (remote, labname, r_path) = parse_remote_path(remote_path)?;
|
||||
let remote = remote_path
|
||||
.splitn(2, ':')
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("Invalid remote path"))?;
|
||||
let (conn, cache) = create_readonly_conn(remote).await?;
|
||||
let (parent_folder, basename) =
|
||||
resolve_remote_file(&conn, cache.as_ref(), remote_path, password).await?;
|
||||
|
||||
let (conn, cache) = create_readonly_conn(&remote).await?;
|
||||
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?;
|
||||
let lab_id = lab.id;
|
||||
|
||||
// Split the file path into parent directory and filename
|
||||
let path = r_path.trim_end_matches('/');
|
||||
let (dirname, basename) = if let Some(pos) = path.rfind('/') {
|
||||
let d = if pos == 0 { "/" } else { &path[..pos] };
|
||||
(d.to_string(), path[pos + 1..].to_string())
|
||||
} else {
|
||||
("/".to_string(), path.to_string())
|
||||
};
|
||||
|
||||
let parent_folder = find_folder(&conn, lab_id, &dirname, password).await?;
|
||||
let files = conn.list_all_files(&parent_folder.id).await?;
|
||||
|
||||
let file = find_file_by_name(&files, &basename)
|
||||
|
||||
+10
-7
@@ -1,5 +1,5 @@
|
||||
use crate::cache::create_readonly_conn;
|
||||
use crate::commands::shared::{find_folder, find_laboratory, fmt_datetime, parse_remote_path};
|
||||
use crate::commands::shared::{fmt_datetime, resolve_remote_folder};
|
||||
use crate::connection::MDRSConnection;
|
||||
use crate::models::file::File;
|
||||
use crate::models::folder::{FolderDetail, FolderSimple};
|
||||
@@ -14,11 +14,14 @@ pub async fn ls(
|
||||
is_recursive: bool,
|
||||
is_quiet: bool,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let (remote, labname, path) = parse_remote_path(remote_path)?;
|
||||
let (conn, cache) = create_readonly_conn(&remote).await?;
|
||||
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?;
|
||||
|
||||
let folder = find_folder(&conn, lab.id, &path, password).await?;
|
||||
let remote = remote_path
|
||||
.splitn(2, ':')
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid remote path"))?;
|
||||
let (conn, cache) = create_readonly_conn(remote).await?;
|
||||
let (folder, lab) =
|
||||
resolve_remote_folder(&conn, cache.as_ref(), None, remote_path, password).await?;
|
||||
let labname = lab.name;
|
||||
|
||||
if is_json {
|
||||
let output = if is_recursive {
|
||||
@@ -28,7 +31,7 @@ pub async fn ls(
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
} else if is_recursive {
|
||||
let prefix = format!("{}:/{}", remote, labname);
|
||||
let prefix = format!("{}:/{}", conn.remote.as_deref().unwrap_or(""), labname);
|
||||
ls_plain_recursive(&conn, folder, &labname, &prefix, password).await?;
|
||||
} else {
|
||||
let files = conn.list_all_files(&folder.id).await?;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use crate::cache::create_readonly_conn;
|
||||
use crate::commands::shared::{find_folder, find_laboratory, parse_remote_path};
|
||||
use crate::commands::shared::resolve_remote_folder;
|
||||
|
||||
pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> {
|
||||
let (remote, labname, folder_path) = parse_remote_path(remote_path)?;
|
||||
let (conn, cache) = create_readonly_conn(&remote).await?;
|
||||
let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?;
|
||||
let folder = find_folder(&conn, lab.id, &folder_path, password).await?;
|
||||
let remote = remote_path
|
||||
.splitn(2, ':')
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid remote path"))?;
|
||||
let (conn, cache) = create_readonly_conn(remote).await?;
|
||||
let (folder, _) =
|
||||
resolve_remote_folder(&conn, cache.as_ref(), None, remote_path, password).await?;
|
||||
|
||||
let resp = conn
|
||||
.get(&format!("v3/folders/{}/metadata/", folder.id))
|
||||
|
||||
@@ -7,6 +7,152 @@ use crate::models::laboratory::Laboratory;
|
||||
use anyhow::{anyhow, bail};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return true if the path component (after the remote: prefix) looks like a
|
||||
/// DOI string, i.e. starts with "10." and contains a "/".
|
||||
pub fn is_doi(path: &str) -> bool {
|
||||
path.starts_with("10.") && path.contains('/')
|
||||
}
|
||||
|
||||
/// Extract the DOI suffix ID from a full DOI string.
|
||||
///
|
||||
/// MDRS uses the segment after the last `.` in the DOI as its internal
|
||||
/// identifier, e.g. `10.xxxx/prefix.20230511-001` → `20230511-001`.
|
||||
/// If there is no `.` after the `/`, the entire suffix after `/` is used.
|
||||
pub fn doi_suffix_id(doi: &str) -> &str {
|
||||
// Find the slash that separates DOI prefix from suffix.
|
||||
if let Some(slash_pos) = doi.find('/') {
|
||||
let suffix = &doi[slash_pos + 1..];
|
||||
// Use the part after the last `.` within the suffix.
|
||||
if let Some(dot_pos) = suffix.rfind('.') {
|
||||
&suffix[dot_pos + 1..]
|
||||
} else {
|
||||
suffix
|
||||
}
|
||||
} else {
|
||||
doi
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a DOI-with-optional-path string into `(doi, subpath)`.
|
||||
///
|
||||
/// A DOI has the form `10.REGISTRANT/SUFFIX` where SUFFIX contains no `/`.
|
||||
/// Anything after the first `/` following SUFFIX is treated as a subfolder path.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `"10.1234/prefix.ID"` → `("10.1234/prefix.ID", "")`
|
||||
/// - `"10.1234/prefix.ID/"` → `("10.1234/prefix.ID", "")`
|
||||
/// - `"10.1234/prefix.ID/sub"` → `("10.1234/prefix.ID", "/sub")`
|
||||
/// - `"10.1234/prefix.ID/sub/deep"` → `("10.1234/prefix.ID", "/sub/deep")`
|
||||
pub fn split_doi_and_subpath(doi_with_path: &str) -> (&str, &str) {
|
||||
// Find the first `/` that separates registrant from suffix.
|
||||
if let Some(first_slash) = doi_with_path.find('/') {
|
||||
let after_suffix_start = first_slash + 1;
|
||||
let after_first = &doi_with_path[after_suffix_start..];
|
||||
// Find the next `/` inside the suffix portion — this starts the subpath.
|
||||
if let Some(second_slash) = after_first.find('/') {
|
||||
let doi_end = after_suffix_start + second_slash;
|
||||
let doi = &doi_with_path[..doi_end];
|
||||
let subpath = &doi_with_path[doi_end..]; // begins with "/"
|
||||
// Treat a bare trailing slash as no subpath (root of DOI folder).
|
||||
if subpath == "/" {
|
||||
(doi, "")
|
||||
} else {
|
||||
(doi, subpath)
|
||||
}
|
||||
} else {
|
||||
// No second slash — the whole string is the DOI, no subpath.
|
||||
(doi_with_path, "")
|
||||
}
|
||||
} else {
|
||||
(doi_with_path, "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `"remote:10.xxxx/prefix.ID[/optional/sub/path]"` into
|
||||
/// `(remote, doi, subpath)`.
|
||||
///
|
||||
/// `subpath` is empty when the remote path points directly at the DOI root
|
||||
/// folder. Otherwise it is an absolute path string like `"/subfolder/deep"`.
|
||||
pub fn parse_doi_remote_path(remote_path: &str) -> Result<(String, String, String), anyhow::Error> {
|
||||
let parts: Vec<&str> = remote_path.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
bail!("remote_path must be in the form 'remote:10.xxxx/prefix.ID'");
|
||||
}
|
||||
let remote = parts[0].to_string();
|
||||
let doi_with_path = parts[1];
|
||||
if !is_doi(doi_with_path) {
|
||||
bail!(
|
||||
"Path `{}` does not look like a DOI (must start with '10.' and contain '/').",
|
||||
doi_with_path
|
||||
);
|
||||
}
|
||||
let (doi, subpath) = split_doi_and_subpath(doi_with_path);
|
||||
Ok((remote, doi.to_string(), subpath.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a DOI string to a (FolderDetail, Laboratory) pair.
|
||||
///
|
||||
/// Calls GET v3/doi/{id}/ to look up the folder ID, then fetches the full
|
||||
/// folder detail (which carries `laboratory_id`) and resolves the laboratory.
|
||||
pub async fn find_folder_by_doi(
|
||||
conn: &MDRSConnection,
|
||||
doi: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<(FolderDetail, Laboratory), anyhow::Error> {
|
||||
// Strip any trailing slash from the DOI before extracting the suffix ID.
|
||||
let doi_clean = doi.trim_end_matches('/');
|
||||
let id = doi_suffix_id(doi_clean);
|
||||
let doi_resp = conn.retrieve_doi(id).await?;
|
||||
|
||||
// Verify that the returned DOI matches the one supplied by the caller
|
||||
// (case-insensitive, trimming trailing slashes).
|
||||
let returned = doi_resp.doi.trim_end_matches('/');
|
||||
if !returned.eq_ignore_ascii_case(doi_clean) {
|
||||
bail!(
|
||||
"DOI mismatch: requested `{}` but server returned `{}`.",
|
||||
doi_clean,
|
||||
returned
|
||||
);
|
||||
}
|
||||
|
||||
let folder_id = &doi_resp.folder.id;
|
||||
|
||||
// Fetch the full folder detail; laboratory_id is available here.
|
||||
let folder = conn
|
||||
.retrieve_folder(folder_id)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to retrieve folder for DOI `{}`: {}", doi_clean, e))?;
|
||||
|
||||
// Handle password-locked folder.
|
||||
if folder.lock {
|
||||
match password {
|
||||
None => {
|
||||
bail!(
|
||||
"Folder for DOI `{}` is locked. Use -p/--password to provide a password.",
|
||||
doi_clean
|
||||
);
|
||||
}
|
||||
Some(pw) => conn.folder_auth(folder_id, pw).await?,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve laboratory using the laboratory_id from the folder detail.
|
||||
let lab_id = folder.laboratory_id;
|
||||
let lab = conn
|
||||
.list_laboratories()
|
||||
.await?
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|l| l.id == lab_id)
|
||||
.ok_or_else(|| anyhow!("Laboratory with id {} not found.", lab_id))?;
|
||||
|
||||
Ok((folder, lab))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,6 +352,89 @@ pub fn fmt_datetime(iso: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve any remote path (normal or DOI-based) into a FolderDetail and Laboratory.
|
||||
/// Takes an optional API limiter for download command compatibility.
|
||||
pub async fn resolve_remote_folder(
|
||||
conn: &MDRSConnection,
|
||||
cache: Option<&Cache>,
|
||||
limiter: Option<&ApiRequestLimiter>,
|
||||
remote_path: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<(FolderDetail, Laboratory), anyhow::Error> {
|
||||
let path_component = remote_path.splitn(2, ':').nth(1).unwrap_or("");
|
||||
if is_doi(path_component) {
|
||||
let (_, doi, subpath) = parse_doi_remote_path(remote_path)?;
|
||||
let (doi_folder, lab) = find_folder_by_doi(conn, &doi, password).await?;
|
||||
if subpath.is_empty() {
|
||||
Ok((doi_folder, lab))
|
||||
} else {
|
||||
let abs_path = format!("{}{}", doi_folder.path.trim_end_matches('/'), subpath);
|
||||
let folder = if let Some(l) = limiter {
|
||||
find_folder_limited(conn, l, lab.id, &abs_path, password).await?
|
||||
} else {
|
||||
find_folder(conn, lab.id, &abs_path, password).await?
|
||||
};
|
||||
Ok((folder, lab))
|
||||
}
|
||||
} else {
|
||||
let (_, labname, folder_path) = parse_remote_path(remote_path)?;
|
||||
let lab = find_laboratory(conn, cache, &labname).await?;
|
||||
let folder = if let Some(l) = limiter {
|
||||
find_folder_limited(conn, l, lab.id, &folder_path, password).await?
|
||||
} else {
|
||||
find_folder(conn, lab.id, &folder_path, password).await?
|
||||
};
|
||||
Ok((folder, lab))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a remote path pointing to a file into the parent FolderDetail and the file's basename.
|
||||
pub async fn resolve_remote_file(
|
||||
conn: &MDRSConnection,
|
||||
cache: Option<&Cache>,
|
||||
remote_path: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<(FolderDetail, String), anyhow::Error> {
|
||||
let path_component = remote_path.splitn(2, ':').nth(1).unwrap_or("");
|
||||
if is_doi(path_component) {
|
||||
let (_, doi, subpath) = parse_doi_remote_path(remote_path)?;
|
||||
let (doi_folder, lab) = find_folder_by_doi(conn, &doi, password).await?;
|
||||
let subpath_clean = subpath.trim_end_matches('/');
|
||||
if subpath_clean.is_empty() {
|
||||
bail!("DOI path must point to a file, not a folder.");
|
||||
}
|
||||
let (sub_dir, basename) = if let Some(pos) = subpath_clean.rfind('/') {
|
||||
let d = if pos == 0 { "/" } else { &subpath_clean[..pos] };
|
||||
(d.to_string(), subpath_clean[pos + 1..].to_string())
|
||||
} else {
|
||||
("/".to_string(), subpath_clean.to_string())
|
||||
};
|
||||
let abs_path = format!(
|
||||
"{}{}",
|
||||
doi_folder.path.trim_end_matches('/'),
|
||||
if sub_dir.starts_with('/') {
|
||||
sub_dir
|
||||
} else {
|
||||
format!("/{}", sub_dir)
|
||||
}
|
||||
);
|
||||
let parent = find_folder(conn, lab.id, &abs_path, password).await?;
|
||||
Ok((parent, basename))
|
||||
} else {
|
||||
let (_, labname, r_path) = parse_remote_path(remote_path)?;
|
||||
let lab = find_laboratory(conn, cache, &labname).await?;
|
||||
let path = r_path.trim_end_matches('/');
|
||||
let (dirname, basename) = if let Some(pos) = path.rfind('/') {
|
||||
let d = if pos == 0 { "/" } else { &path[..pos] };
|
||||
(d.to_string(), path[pos + 1..].to_string())
|
||||
} else {
|
||||
("/".to_string(), path.to_string())
|
||||
};
|
||||
let parent = find_folder(conn, lab.id, &dirname, password).await?;
|
||||
Ok((parent, basename))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -243,4 +472,104 @@ mod tests {
|
||||
assert_eq!(lab.name, "public-lab");
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// DOI helper unit tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn is_doi_returns_true_for_valid_doi() {
|
||||
assert!(is_doi("10.12345/prefix.20230511-001"));
|
||||
assert!(is_doi("10.1234/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_doi_returns_false_for_normal_paths() {
|
||||
assert!(!is_doi("/labname/path/to/folder"));
|
||||
assert!(!is_doi("labname/path"));
|
||||
assert!(!is_doi("10.1234")); // no slash
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doi_suffix_id_extracts_last_dot_segment() {
|
||||
assert_eq!(
|
||||
doi_suffix_id("10.12345/prefix.20230511-001"),
|
||||
"20230511-001"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doi_suffix_id_no_dot_in_suffix_returns_whole_suffix() {
|
||||
assert_eq!(doi_suffix_id("10.1234/nodot"), "nodot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doi_suffix_id_no_slash_returns_whole_input() {
|
||||
assert_eq!(doi_suffix_id("10.1234"), "10.1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_doi_remote_path_valid_no_subpath() {
|
||||
let (remote, doi, subpath) =
|
||||
parse_doi_remote_path("neurodata:10.12345/prefix.20230511-001").unwrap();
|
||||
assert_eq!(remote, "neurodata");
|
||||
assert_eq!(doi, "10.12345/prefix.20230511-001");
|
||||
assert_eq!(subpath, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_doi_remote_path_valid_trailing_slash() {
|
||||
let (remote, doi, subpath) =
|
||||
parse_doi_remote_path("neurodata:10.12345/prefix.20230511-001/").unwrap();
|
||||
assert_eq!(remote, "neurodata");
|
||||
assert_eq!(doi, "10.12345/prefix.20230511-001");
|
||||
assert_eq!(subpath, ""); // trailing slash treated as no subpath
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_doi_remote_path_valid_with_subpath() {
|
||||
let (remote, doi, subpath) =
|
||||
parse_doi_remote_path("neurodata:10.12345/prefix.20230511-001/sub/folder").unwrap();
|
||||
assert_eq!(remote, "neurodata");
|
||||
assert_eq!(doi, "10.12345/prefix.20230511-001");
|
||||
assert_eq!(subpath, "/sub/folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_doi_remote_path_rejects_normal_path() {
|
||||
let err = parse_doi_remote_path("neurodata:/lab/path").unwrap_err();
|
||||
assert!(err.to_string().contains("does not look like a DOI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_doi_and_subpath_no_subpath() {
|
||||
assert_eq!(
|
||||
split_doi_and_subpath("10.1234/prefix.ID"),
|
||||
("10.1234/prefix.ID", "")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_doi_and_subpath_trailing_slash_only() {
|
||||
assert_eq!(
|
||||
split_doi_and_subpath("10.1234/prefix.ID/"),
|
||||
("10.1234/prefix.ID", "")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_doi_and_subpath_single_level() {
|
||||
assert_eq!(
|
||||
split_doi_and_subpath("10.1234/prefix.ID/sub"),
|
||||
("10.1234/prefix.ID", "/sub")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_doi_and_subpath_multi_level() {
|
||||
assert_eq!(
|
||||
split_doi_and_subpath("10.1234/prefix.ID/sub/deep/path"),
|
||||
("10.1234/prefix.ID", "/sub/deep/path")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Nested folder information returned inside a DOI response.
|
||||
/// The DOI endpoint only returns the folder `id`; `laboratory_id` must be
|
||||
/// obtained by subsequently calling the folder retrieve endpoint.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DoiFolderRef {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Response from GET v3/doi/{id}/
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DoiResponse {
|
||||
/// The internal DOI suffix ID (e.g. "20260429-001") returned as a string.
|
||||
pub id: String,
|
||||
pub doi: String,
|
||||
pub folder: DoiFolderRef,
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Model definitions (User, Laboratory, File, Folder, etc.)
|
||||
|
||||
pub mod doi;
|
||||
pub mod file;
|
||||
pub mod folder;
|
||||
pub mod laboratory;
|
||||
|
||||
Reference in New Issue
Block a user