Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.
dr-types::FormatFilter the tick-box selection, seeing through VFS
placeholder suffixes so a dehydrated CR2 still
matches as a CR2
dr-sync::scan recursive walk, Depth:1 per directory, pruning
unchanged subtrees where the backend propagates
directory ETags
Verified against nextcloud.tourolle.paris (34.0.2) on a real library:
browse root 32 entries, 98ms
scan PhotosRaw 17,185 RAW files in 334 directories, 34.1s
(7,836 CR2 + 9,349 DNG)
range read 262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
and enough to read "Canon EOS 6D | ISO 100"
That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.
Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.
Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
305 lines
10 KiB
Rust
305 lines
10 KiB
Rust
//! Exercise the connector against a real Nextcloud server.
|
|
//!
|
|
//! ```text
|
|
//! cargo run -p dr-sync-nextcloud --example connect -- <server> [path] [--raw|--formats cr2,nef]
|
|
//! ```
|
|
//!
|
|
//! With no path it lists the account root so a folder can be chosen. Given a
|
|
//! path it scans recursively for images matching the format filter, which is
|
|
//! the library-setup flow (FR-CAT-1).
|
|
//!
|
|
//! **Strictly read-only.** PROPFIND, ETag probes, range GETs and preview
|
|
//! requests only — no PUT, MOVE or DELETE — so it cannot alter a live library.
|
|
//!
|
|
//! Authentication uses Login Flow v2 (FR-NC-1): the app never sees a password.
|
|
//! A URL is printed for the browser, and the resulting app password is
|
|
//! device-scoped and revocable from the server's security settings.
|
|
//!
|
|
//! Credentials are cached under `$XDG_CACHE_HOME/darkroom/` so repeat runs do
|
|
//! not re-authenticate. That location is for *testing convenience* only;
|
|
//! FR-NC-2 requires the real app to use platform secure storage.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::time::Instant;
|
|
|
|
use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy};
|
|
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend};
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
|
|
let mut args = std::env::args().skip(1);
|
|
let Some(server) = args.next() else {
|
|
eprintln!("usage: connect <server-url> [remote/path]");
|
|
std::process::exit(2);
|
|
};
|
|
let rest: Vec<String> = args.collect();
|
|
let start_path = rest
|
|
.iter()
|
|
.find(|a| !a.starts_with("--"))
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
// Format selection — the tick-boxes, as a CLI flag.
|
|
let filter = if let Some(i) = rest.iter().position(|a| a == "--formats") {
|
|
let list = rest.get(i + 1).cloned().unwrap_or_default();
|
|
dr_types::FormatFilter::from_formats(
|
|
list.split(',')
|
|
.filter_map(|s| dr_types::Format::from_extension(s.trim())),
|
|
)
|
|
} else if rest.iter().any(|a| a == "--raw") {
|
|
dr_types::FormatFilter::raw_only()
|
|
} else {
|
|
dr_types::FormatFilter::all()
|
|
};
|
|
|
|
let creds = match load_cached(&server) {
|
|
Some(c) => {
|
|
println!("using cached credentials for {}", c.login_name);
|
|
c
|
|
}
|
|
None => match authenticate(&server).await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("authentication failed: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
},
|
|
};
|
|
|
|
// The DAV base needs the *user id*, which may differ from the login name
|
|
// (a login can be an email address). OCS reports the real one.
|
|
let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| {
|
|
eprintln!("could not resolve user id ({e}); falling back to login name");
|
|
creds.login_name.clone()
|
|
});
|
|
println!("user id: {user_id}");
|
|
|
|
let backend = NextcloudBackend::new(&creds, &user_id).expect("build backend");
|
|
|
|
println!("\nbackend: {}", backend.name());
|
|
let caps = backend.capabilities();
|
|
let strategy = SyncStrategy::for_capabilities(caps);
|
|
println!("strategy: {} ({:?})", strategy.describe(), strategy);
|
|
println!("cheap no-op sync: {}", strategy.has_cheap_noop());
|
|
|
|
let root = RemotePath::new(&start_path);
|
|
|
|
// No path given: list this level so the user can pick a folder.
|
|
if start_path.is_empty() {
|
|
println!("\n[browse] PROPFIND Depth:1 on the account root");
|
|
let t = Instant::now();
|
|
match backend.list(&root, None).await {
|
|
Ok(entries) => {
|
|
println!(
|
|
" {} entries in {:.0}ms\n",
|
|
entries.len(),
|
|
t.elapsed().as_secs_f64() * 1000.0
|
|
);
|
|
let mut dirs: Vec<_> = entries
|
|
.iter()
|
|
.filter(|e| e.kind == dr_sync::EntryKind::Directory)
|
|
.collect();
|
|
dirs.sort_by_key(|e| e.path.name().to_ascii_lowercase());
|
|
for d in &dirs {
|
|
println!(" {}/", d.path.name());
|
|
}
|
|
println!(
|
|
"\n Re-run with a folder to scan it, e.g.:\n … {} \"{}\" --raw",
|
|
server,
|
|
dirs.first().map(|d| d.path.name()).unwrap_or("Photos")
|
|
);
|
|
}
|
|
Err(e) => {
|
|
eprintln!(" FAILED: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// A path was given: scan it recursively for the selected formats.
|
|
println!("\n[scan] {} under /{start_path}", describe_filter(&filter));
|
|
let t = Instant::now();
|
|
let result = match dr_sync::scan(&backend, &root, &filter, &HashMap::new(), |p| {
|
|
if p.directories_listed % 25 == 0 && p.directories_listed > 0 {
|
|
print!(
|
|
"\r {} dirs, {} images…",
|
|
p.directories_listed, p.images_found
|
|
);
|
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("\n FAILED: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
let scan_ms = t.elapsed().as_secs_f64() * 1000.0;
|
|
|
|
println!(
|
|
"\r {} images in {} directories, {:.1}s",
|
|
result.images.len(),
|
|
result.progress.directories_listed,
|
|
scan_ms / 1000.0
|
|
);
|
|
|
|
let mut by_ext: std::collections::BTreeMap<String, usize> = Default::default();
|
|
for i in &result.images {
|
|
let ext = i
|
|
.path
|
|
.name()
|
|
.rsplit_once('.')
|
|
.map(|(_, e)| e.to_ascii_uppercase())
|
|
.unwrap_or_default();
|
|
*by_ext.entry(ext).or_default() += 1;
|
|
}
|
|
for (ext, n) in &by_ext {
|
|
println!(" {ext:<6} {n}");
|
|
}
|
|
|
|
// Prove the fast path on a real RAW: metadata from a header range alone.
|
|
let raw = result.images.iter().find(|i| {
|
|
i.path
|
|
.name()
|
|
.rsplit_once('.')
|
|
.and_then(|(_, e)| dr_types::Format::from_extension(&e.to_ascii_lowercase()))
|
|
.is_some_and(|f| f.is_raw())
|
|
&& i.size > 300_000
|
|
});
|
|
|
|
if let Some(f) = raw {
|
|
println!(
|
|
"\n[range] first 256KB of {} ({})",
|
|
f.path.name(),
|
|
human(f.size)
|
|
);
|
|
let id = RemoteId::Path(f.path.clone());
|
|
let t = Instant::now();
|
|
match backend.get(&id, Some(0..262_144)).await {
|
|
Ok(bytes) => {
|
|
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
|
let pct = (bytes.len() as f64 / f.size as f64) * 100.0;
|
|
println!(
|
|
" {} in {ms:.0}ms — {pct:.2}% of the file",
|
|
human(bytes.len() as u64)
|
|
);
|
|
match dr_decode::metadata(&bytes) {
|
|
Ok(m) => println!(
|
|
" metadata from that range alone: {} {} | {}",
|
|
m.make.unwrap_or_default().trim(),
|
|
m.model.unwrap_or_default().trim(),
|
|
m.iso.map(|i| format!("ISO {i}")).unwrap_or_default()
|
|
),
|
|
Err(e) => println!(" metadata: {e}"),
|
|
}
|
|
}
|
|
Err(e) => println!(" FAILED: {e}"),
|
|
}
|
|
} else {
|
|
println!("\n[range] skipped — no RAW over 300KB found");
|
|
}
|
|
|
|
println!("\nscan complete");
|
|
}
|
|
|
|
fn describe_filter(f: &dr_types::FormatFilter) -> String {
|
|
let names: Vec<&str> = f.iter().map(|x| x.label()).collect();
|
|
if names.len() >= 9 {
|
|
"all supported formats".into()
|
|
} else {
|
|
names.join(", ")
|
|
}
|
|
}
|
|
|
|
async fn authenticate(server: &str) -> Result<AppCredentials, String> {
|
|
let client =
|
|
dr_sync_nextcloud::http_client("DarkRoom (connect example)").map_err(|e| e.to_string())?;
|
|
|
|
let flow = auth::begin(&client, server, "DarkRoom (connect example)")
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
println!("\n Open this in a browser and approve:\n");
|
|
println!(" {}\n", flow.login_url);
|
|
println!(" waiting (20 minute limit)…");
|
|
|
|
let creds = auth::poll(&client, &flow)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
println!(" authenticated as {}", creds.login_name);
|
|
save_cached(server, &creds);
|
|
Ok(creds)
|
|
}
|
|
|
|
/// Resolve the real user id, which the DAV path needs.
|
|
async fn fetch_user_id(creds: &AppCredentials) -> Result<String, String> {
|
|
let client = dr_sync_nextcloud::http_client("DarkRoom").map_err(|e| e.to_string())?;
|
|
let url = format!(
|
|
"{}/ocs/v2.php/cloud/user?format=json",
|
|
creds.server.trim_end_matches('/')
|
|
);
|
|
let body = client
|
|
.get(&url)
|
|
.basic_auth(&creds.login_name, Some(&creds.app_password))
|
|
.header("OCS-APIRequest", "true")
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.text()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
|
v["ocs"]["data"]["id"]
|
|
.as_str()
|
|
.map(str::to_string)
|
|
.ok_or_else(|| "no id in OCS response".to_string())
|
|
}
|
|
|
|
fn cache_path(server: &str) -> PathBuf {
|
|
let dir = std::env::var_os("XDG_CACHE_HOME")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".cache"))
|
|
.join("darkroom");
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let key: String = server
|
|
.chars()
|
|
.map(|c| if c.is_alphanumeric() { c } else { '_' })
|
|
.collect();
|
|
dir.join(format!("{key}.json"))
|
|
}
|
|
|
|
fn load_cached(server: &str) -> Option<AppCredentials> {
|
|
let text = std::fs::read_to_string(cache_path(server)).ok()?;
|
|
serde_json::from_str(&text).ok()
|
|
}
|
|
|
|
fn save_cached(server: &str, creds: &AppCredentials) {
|
|
let path = cache_path(server);
|
|
if let Ok(json) = serde_json::to_string(creds) {
|
|
let _ = std::fs::write(&path, json);
|
|
// Testing convenience only — FR-NC-2 requires platform secure storage.
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
|
}
|
|
println!(" cached credentials at {}", path.display());
|
|
}
|
|
}
|
|
|
|
fn human(bytes: u64) -> String {
|
|
match bytes {
|
|
b if b >= 1_000_000_000 => format!("{:.1}GB", b as f64 / 1e9),
|
|
b if b >= 1_000_000 => format!("{:.1}MB", b as f64 / 1e6),
|
|
b if b >= 1_000 => format!("{:.0}KB", b as f64 / 1e3),
|
|
b => format!("{b}B"),
|
|
}
|
|
}
|