//! Keeping a collection's originals on this device: the offline prompt, the //! pin fetch, and the connectivity banner every worker in this directory //! paints through [`refresh_offline`]. //! //! Split out because it is one coherent user question — "what of this is on //! my device, and what would keeping or releasing it cost" — with its own //! catalog queries, distinct from loading a window over the grid or scanning //! the tree. See `docs/dev/code-health.md` CH-1. use std::rc::Rc; use dr_catalog::Catalog; use slint::ComponentHandle; use crate::library; use crate::{AppWindow, Library}; use super::controller::{stop, LibraryController}; /// TRACES: FR-NC-6a | FR-NC-6c /// What a collection would cost to take with you, and what it is holding now. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct OfflineSummary { /// Photographs in the collection and its children, deduplicated. total: usize, /// Of those, how many have their original on this device. held: usize, /// Disk those originals occupy — what releasing would give back. held_bytes: u64, /// What the rest would cost to fetch, from the sizes the scan recorded. /// Zero where nothing has been stat-ed yet, which reads as "unknown" /// rather than "free" in the label built from it. missing_bytes: u64, } impl OfflineSummary { fn missing(self) -> usize { self.total.saturating_sub(self.held) } } /// Read the offline summary for a set of images. /// /// One query with the ids inlined as placeholders — the same shape /// [`collection_images`] uses, and for the same reason: a collection is tens to /// thousands of rows, and a round trip per photograph to answer one dialogue is /// not a trade worth making. fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary { if images.is_empty() { return OfflineSummary::default(); } let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .join(","); // `tier_actual`, never `tier_desired`: the question is what can be opened // on the aeroplane, and a pin whose download has not run yet answers no. let sql = format!( "SELECT count(*), coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0), coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0), coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0 ELSE coalesce(i.file_size, 0) END), 0) FROM images i LEFT JOIN image_cache c ON c.image_id = i.id WHERE i.id IN ({placeholders})" ); let mut params: Vec = vec![rusqlite::types::Value::Integer( dr_types::Tier::Original.stored(), )]; params.extend( images .iter() .map(|i| rusqlite::types::Value::Integer(i.0 as i64)), ); catalog .connection() .query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| { Ok(OfflineSummary { total: r.get::<_, i64>(0)? as usize, held: r.get::<_, i64>(1)? as usize, held_bytes: r.get::<_, i64>(2)? as u64, missing_bytes: r.get::<_, i64>(3)? as u64, }) }) .unwrap_or_else(|e| { log::debug!("reading offline summary: {e}"); OfflineSummary::default() }) } /// TRACES: FR-NC-6a | FR-NC-6c /// Ask what should happen to a collection's local copies. /// /// Both answers are expensive — one commits the device to a download of /// gigabytes, the other deletes gigabytes it already holds — so this is a /// question rather than a toggle, and the counts and sizes go in the buttons /// where they are read *before* the tap rather than in a second dialogue after /// it (FR-NC-6c: an operation requiring absent data says so, with the size, /// before starting). pub(super) fn open_offline_prompt( window: &AppWindow, ctl: &Rc, id: dr_types::CollectionId, ) { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; // Descendants, matching what the grid shows when scoped to this row: // keeping a parent whose children hold the photographs must keep the // photographs, or the answer would appear to do nothing. let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { Ok(ids) => ids, Err(e) => { window .global::() .set_library_error(format!("resolving collection: {e}").into()); return; } }; let images = collection_images(catalog, &ids); let summary = offline_summary(catalog, &images); let name = dr_catalog::collections::tree(catalog.connection()) .ok() .and_then(|rows| { rows.into_iter() .find(|r| r.collection.id == id) .map(|r| r.collection.name) }) .unwrap_or_else(|| "This collection".to_string()); ctl.offline_target.set(Some(id)); window.set_offline_prompt_title(name.as_str().into()); window.set_offline_prompt_detail( if summary.total == 0 { "Nothing in here yet. Put some photographs in it first.".to_string() } else if summary.held == summary.total { format!( "All {} on this device · {}", summary.total, crate::activity::describe_bytes(summary.held_bytes) ) } else { format!( "{} photographs · {} already on this device", summary.total, summary.held ) } .as_str() .into(), ); window.set_offline_prompt_keep_label( // The size is named where the scan has recorded one. Where it has not, // the label says what it will do and not what it will cost, which is // honest — a "0 B" download would be a lie about a gigabyte. if summary.missing_bytes > 0 { format!( "Download {} · {}", summary.missing(), crate::activity::describe_bytes(summary.missing_bytes) ) } else if summary.missing() > 0 { format!("Download {}", summary.missing()) } else { "Everything is already here".to_string() } .as_str() .into(), ); window.set_offline_prompt_release_label( format!( "Remove {} local copies · frees {}", summary.held, crate::activity::describe_bytes(summary.held_bytes) ) .as_str() .into(), ); window.set_offline_prompt_can_keep(summary.missing() > 0); window.set_offline_prompt_can_release(summary.held > 0); window.set_offline_prompt_busy(window.global::().get_library_pin_total() > 0); } /// Close the offline question without answering it. pub(super) fn close_offline_prompt(window: &AppWindow, ctl: &Rc) { ctl.offline_target.set(None); // The title is what the prompt's visibility is bound to: one fact, so a // dialogue cannot be up with nothing written on it. window.set_offline_prompt_title(slint::SharedString::new()); } /// TRACES: FR-NC-6a /// Keep a collection on this device: record the pin, then start the transfer. /// /// Two separate things, and keeping them separate is what makes the answer feel /// immediate — recording the intent is a local catalog write that completes at /// once, and downloading the bytes may take a very long time. The pin also /// survives the app being closed halfway through, which is what makes the /// transfer resumable rather than something to start again. pub(super) fn keep_collection_offline( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, ) { let Some(id) = ctl.offline_target.get() else { return; }; let Some(cache) = ctl.cache() else { window .global::() .set_library_error("No cache directory for this library.".into()); return; }; let images = { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { Ok(ids) => ids, Err(e) => { window .global::() .set_library_error(format!("resolving collection: {e}").into()); return; } }; let images = collection_images(catalog, &ids); if images.is_empty() { window .global::() .set_library_error("Nothing in that collection to keep offline.".into()); return; } if let Err(e) = cache.pin(catalog.connection(), &images) { window .global::() .set_library_error(format!("pinning: {e}").into()); return; } images }; log::info!("pinned {} image(s) for offline use", images.len()); window .global::() .set_library_error(slint::SharedString::new()); if ctl.scope.borrow().as_ref() == Some(&id) { window.global::().set_library_scope_pinned(true); } close_offline_prompt(window, ctl); start_pin_fetch(window, ctl); refresh_collection_tree(window, ctl, coll_ctl); } /// TRACES: FR-NC-6a | FR-NC-6b /// Give the disk back: release the pin *and* delete the originals it held. /// /// Deliberately destructive, where unpinning alone is not. "Remove the local /// copies" is asked by someone whose device is full, and answering it by /// withdrawing a promise and leaving the gigabytes for a future eviction to /// notice is not an answer. Nothing is lost that cannot be fetched again: the /// originals are on the server, and the ratings, the edit graph and the /// thumbnails are all untouched — they are authoritative and small. pub(super) fn release_collection_offline( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, ) { let Some(id) = ctl.offline_target.get() else { return; }; let Some(cache) = ctl.cache() else { window .global::() .set_library_error("No cache directory for this library.".into()); return; }; let released = { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { Ok(ids) => ids, Err(e) => { window .global::() .set_library_error(format!("resolving collection: {e}").into()); return; } }; let images = collection_images(catalog, &ids); let outcome = match cache.release(catalog.connection(), &images) { Ok(r) => r, Err(e) => { window .global::() .set_library_error(format!("removing local copies: {e}").into()); return; } }; // TRACES: FR-NC-6c // On a placeholder library the bookkeeping above owns no files, so it // freed nothing — the originals are materialised in the library folder // and only the sync client may take them back. Asking it to is what // makes unpinning actually return the disk, and it must be a // dehydration rather than a delete: removing a file inside a synced // tree propagates to the server (ARCH §9.0a). // // Fire and forget: it is per-file work over a socket, the user has // already been told the pin is withdrawn, and a client that refuses // leaves the content where it is at no cost but disk. if let Some(conn) = ctl.session() { let path = library::catalog_path(&conn.account); std::mem::drop(library::spawn_dehydrate(conn, path, images)); } outcome }; let (count, freed) = released; log::info!( "released {count} image(s), freeing {}", crate::activity::describe_bytes(freed) ); window .global::() .set_library_error(slint::SharedString::new()); window.global::().set_library_status( format!( "Removed local copies · {} freed", crate::activity::describe_bytes(freed) ) .as_str() .into(), ); if ctl.scope.borrow().as_ref() == Some(&id) { window.global::().set_library_scope_pinned(false); } // A download that was still running for this collection has just had its // reason withdrawn; the worker checks `pending_pins` per file, so it stops // finding work rather than being killed. window.global::().set_library_pin_total(0); window.global::().set_library_pin_done(0); close_offline_prompt(window, ctl); refresh_local_count(window, ctl); refresh_collection_tree(window, ctl, coll_ctl); } /// Redraw the sidebar, so the trays reflect what was just kept or released. fn refresh_collection_tree( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, ) { let borrow = ctl.catalog.borrow(); if let Some(catalog) = borrow.as_ref() { crate::collections_ui::refresh_tree(window, coll_ctl, catalog); } } /// Every image in the given collections, deduplicated. /// /// An image in both a parent and a child is one photograph and must be pinned /// once, exactly as the grid draws it once. pub(super) fn collection_images( catalog: &Catalog, ids: &[dr_types::CollectionId], ) -> Vec { if ids.is_empty() { return Vec::new(); } let placeholders = std::iter::repeat_n("?", ids.len()) .collect::>() .join(","); let sql = format!( "SELECT DISTINCT image_id FROM collection_members WHERE collection_id IN ({placeholders})" ); let params: Vec = ids .iter() .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) .collect(); let Ok(mut stmt) = catalog.connection().prepare(&sql) else { return Vec::new(); }; let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)) }); match rows { Ok(rows) => rows.flatten().collect(), Err(e) => { log::debug!("listing collection images: {e}"); Vec::new() } } } /// TRACES: FR-NC-6a /// Download whatever the pins still want, reporting progress. fn start_pin_fetch(window: &AppWindow, ctl: &Rc) { let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; let Some(cache_dir) = ctl.cache_dir() else { return; }; // Offline, there is nothing to download from. The pin is already recorded, // so it resumes on reconnect rather than being lost. if ctl.is_offline() { log::info!("offline: the pin is recorded and will download on reconnect"); return; } let rx = library::spawn_pin_fetch( conn.clone(), library::catalog_path(&conn.account), cache_dir, // Pinned originals are exempt from the budget, but a pin fetch also // stores passively when it finds an image already cached, so the worker // still needs the user's ceiling rather than the catalog's floor. ctl.cache_budget.get(), ); let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); // The longest-running transfer the app does, and the one most likely to be // watched from another view — which is the whole reason the register // exists (FR-NC-6, FR-NC-6c). let job = ctl.activity.begin( crate::activity::Kind::Download, "Keeping photographs on this device", ); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(300), move || { let Some(w) = weak.upgrade() else { return }; loop { let msg = match rx.try_recv() { Ok(m) => m, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { w.global::().set_library_pin_total(0); job.fail("stopped without finishing"); stop(&ctl_cb.pin_timer); return; } }; match msg { library::PinMessage::Planned { total } => { w.global::().set_library_pin_total(total as i32); w.global::().set_library_pin_done(0); job.total(total); } library::PinMessage::Stored { done } => { w.global::().set_library_pin_done(done as i32); job.progress(done, w.global::().get_library_pin_total() as usize); // The "On this device" count grows as they land, so // the chip agrees with the progress line beside it. refresh_local_count(&w, &ctl_cb); } library::PinMessage::Done { stored, bytes } => { log::info!( "pin complete: {stored} original(s), {:.1} MB", bytes as f64 / 1_048_576.0 ); job.finish(format!( "{stored} photograph(s) · {}", crate::activity::describe_bytes(bytes) )); w.global::().set_library_pin_total(0); w.global::().set_library_pin_done(0); refresh_local_count(&w, &ctl_cb); stop(&ctl_cb.pin_timer); return; } library::PinMessage::Failed { message, offline } => { log::warn!("pin fetch stopped: {message}"); job.fail(message.clone()); w.global::().set_library_pin_total(0); if offline { ctl_cb .reachability .borrow_mut() .mark_unreachable(message, std::time::Instant::now()); refresh_offline(&w, &ctl_cb); } else { w.global::() .set_library_error(format!("keeping offline: {message}").into()); } refresh_local_count(&w, &ctl_cb); stop(&ctl_cb.pin_timer); return; } } } }, ); *ctl.pin_timer.borrow_mut() = Some(timer); } /// Refresh the "On this device" count from the catalog. fn refresh_local_count(window: &AppWindow, ctl: &Rc) { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; window .global::() .set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32); } /// TRACES: FR-NC-6a /// Whether every image in the scoped collection is pinned. /// /// Read from the catalog rather than remembered, because a pin outlives the /// session that made it: reopening the library must show the button already /// active, or the user would pin the same collection twice. pub(super) fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool { if images.is_empty() { return false; } let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .join(","); let params: Vec = images .iter() .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) .collect(); let pinned: i64 = catalog .connection() .query_row( &format!( "SELECT count(*) FROM image_cache WHERE pinned = 1 AND image_id IN ({placeholders})" ), rusqlite::params_from_iter(params.iter()), |r| r.get(0), ) .unwrap_or(0); pinned as usize == images.len() } /// TRACES: FR-CAT-9 /// Paint the connectivity state into the window. /// /// Called wherever reachability may have moved, rather than by the state /// itself: `Reachability` is in `dr-sync` and knows nothing about a window, /// which is what keeps it testable without a display server. pub(super) fn refresh_offline(window: &AppWindow, ctl: &Rc) { let reach = ctl.reachability.borrow(); // TRACES: FR-PLAT-AND-2 // A lost root wins over a dead network, and does so even when both are // true — which is the ordinary case, since the scan that discovered the // grant was gone was also the last request the app made. Reported the // other way round the user is told to wait for a connection that is // working, and the thing that would actually fix it is never mentioned. let lost = ctl.root_lost.borrow(); let offline = reach.is_offline() || lost.is_some(); window.global::().set_library_offline(offline); window .global::() .set_library_offline_reason(match lost.as_deref() { Some(why) => why.into(), None => reach.reason().unwrap_or_default().into(), }); window.global::().set_library_offline_since( // A duration is what a network outage has and a revoked permission // does not: "for 4 minutes" invites waiting, and waiting is precisely // what will not help here. if lost.is_some() { slint::SharedString::default() } else { reach .offline_for(std::time::Instant::now()) .map(describe_duration) .unwrap_or_default() .into() }, ); drop(lost); // A stale scan error under an offline banner reports one problem twice. if offline { window.global::().set_library_error("".into()); } drop(reach); // TRACES: FR-CAT-9 // Back online: send whatever the outbox is still holding. // // Hung off the one function that paints connectivity rather than off each // of the seven places that move it — a drain that had to be remembered at // every call site is a drain that will be forgotten at one of them, and // the symptom is an edit that stays queued until the app is restarted. // // `start_outbox_drain` is a no-op when the outbox is empty and while one // is already running, so calling it on every repaint costs a directory // walk that finds nothing. if !offline { start_outbox_drain(window, ctl); } } /// TRACES: FR-CAT-9 | FR-NC-10 /// Upload the sidecars queued while this device had no connection. /// /// Guarded on the timer rather than on a flag: the timer *is* the "a drain is /// running" state, and a second one started beside it would drain the same /// channel twice. fn start_outbox_drain(window: &AppWindow, ctl: &Rc) { if ctl.outbox_timer.borrow().is_some() { return; } if !ctl.outbox_maybe_dirty.get() { return; } let Some(cache_dir) = ctl.sidecar_cache_dir() else { return; }; if crate::sidecar_cache::SidecarCache::open(cache_dir.clone()) .pending() .is_empty() { // Nothing there. Recorded so the next hundred repaints skip the walk; // a write that queues sets it again. ctl.outbox_maybe_dirty.set(false); return; } let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; let rx = library::spawn_outbox_drain(conn.clone(), cache_dir); let job = ctl .activity .begin(crate::activity::Kind::Upload, "Uploading queued edits"); let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(250), move || { let Some(w) = weak.upgrade() else { return }; match rx.try_recv() { Ok(library::SidecarMessage::Finished { written, queued, failed, last_error, }) => { if failed > 0 { log::warn!( "{failed} queued sidecar(s) still undelivered: {}", last_error.clone().unwrap_or_default() ); // Not `fail`: the edits are still safely queued, and // reporting this as a loss would be wrong. job.finish(format!("{written} uploaded · {queued} still queued")); } else if written > 0 { log::info!("{written} queued sidecar(s) uploaded"); job.finish(format!("{written} queued edit(s) uploaded")); w.global::().set_library_status( format!("{written} queued edit(s) uploaded").into(), ); } else { job.finish_quietly(); } ctl_cb.outbox_maybe_dirty.set(queued > 0); stop(&ctl_cb.outbox_timer); } Err(std::sync::mpsc::TryRecvError::Empty) => {} Err(std::sync::mpsc::TryRecvError::Disconnected) => { job.finish_quietly(); stop(&ctl_cb.outbox_timer); } } }, ); *ctl.outbox_timer.borrow_mut() = Some(timer); } /// A coarse "how long ago", for the offline banner. /// /// Deliberately imprecise: the user wants to know whether this just happened /// or has been true for a while, and a live-counting seconds display would /// draw the eye to a number that changes without meaning anything. fn describe_duration(d: std::time::Duration) -> String { let secs = d.as_secs(); if secs < 60 { "just now".to_string() } else if secs < 3600 { format!("{}m ago", secs / 60) } else { format!("{}h ago", secs / 3600) } }