//! Exercise the connector against a real Nextcloud server. //! //! ```text //! cargo run -p dr-sync-nextcloud --example connect -- [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::time::Instant; use dr_plat::PlatformSecretStore; use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy}; use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, Session, SessionStore}; #[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 [remote/path]"); std::process::exit(2); }; let rest: Vec = 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() }; // Sessions persist across runs: credentials in the platform keyring // (FR-NC-2), everything else as ordinary config. let sessions = SessionStore::open(Box::new(PlatformSecretStore::new())); if !sessions.can_remember() { println!("note: no secrets daemon — sign-in will not persist this session"); } let existing = sessions .current() .filter(|s| s.server == server.trim_end_matches('/')); let (session, creds) = match existing { Some(s) => match sessions.credentials(&s) { Ok(c) => { println!("signed in: {}", s.describe()); (s, c) } Err(e) => { // Revoked server-side, or the keyring was cleared. println!("stored credential unusable ({e}); signing in again"); sign_in(&server, &sessions).await } }, None => sign_in(&server, &sessions).await, }; let user_id = session.user_id.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 = 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"); } // Remember what was scanned, so the next launch resumes here. let mut updated = session.clone(); updated.root = start_path.clone(); updated.set_format_filter(&filter); if let Err(e) = sessions.update(&updated) { eprintln!("could not update session: {e}"); } else { println!("\nremembered: {}", updated.describe()); } println!("scan 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(", ") } } /// Run Login Flow v2 and persist the result. async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredentials) { let client = match dr_sync_nextcloud::http_client("DarkRoom") { Ok(c) => c, Err(e) => { eprintln!("could not build http client: {e}"); std::process::exit(1); } }; let flow = match auth::begin(&client, server, "DarkRoom (connect example)").await { Ok(f) => f, Err(e) => { eprintln!("could not start login: {e}"); std::process::exit(1); } }; println!("\n Open this in a browser and approve:\n"); println!(" {}\n", flow.login_url); println!(" waiting (20 minute limit)…"); let creds = match auth::poll(&client, &flow).await { Ok(c) => c, Err(e) => { eprintln!("login failed: {e}"); std::process::exit(1); } }; println!(" authenticated as {}", creds.login_name); let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| { eprintln!(" could not resolve user id ({e}); using login name"); creds.login_name.clone() }); let session = Session::new(&creds, user_id); match sessions.save(&session, &creds) { Ok(()) => println!(" session saved to {}", sessions.config_path().display()), Err(e) => eprintln!(" could not persist session: {e}"), } (session, creds) } /// Resolve the real user id, which the DAV path needs. async fn fetch_user_id(creds: &AppCredentials) -> Result { 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 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"), } }