//! TRACES: FR-EXP-10 | FR-EXP-6 //! Albums in the window: the sidebar section, the sheet that makes and edits //! one, and the album the export sheet sends to. //! //! The catalog half is [`dr_catalog::albums`]. This half turns an album into //! somewhere a batch can write — a folder on this device, or a folder on the //! server reached through the outbox — and records what the batch wrote, so //! the album can show the originals behind its files. //! //! # Why an album and not a destination field //! //! Export used to take a path typed into the settings page, or a folder //! inside the library on the server. The first is how a destination silently //! becomes a new folder nobody meant; the second put JPEGs inside the tree a //! scan catalogues, where they came back as photographs of their own. An //! album is chosen once, by pointing — the platform's folder dialogue, or the //! server browser here, both able to make a folder — and outside the //! library. After that, exporting is picking a name. use std::cell::{Cell, RefCell}; use std::rc::Rc; use dr_catalog::albums::{self, Album, AlbumId, Place}; use dr_types::{ExportTarget, ImageId}; use slint::{ComponentHandle, ModelRc, SharedString, VecModel}; use crate::library_ui::LibraryController; use crate::settings_ui::SettingsController; use crate::{AlbumRow, Albums, AppWindow, Collections, ExportOptions}; /// `collection-selected` while an album, rather than a collection, the whole /// library or the trash, scopes the grid. The sidebar has one selection; the /// album itself is in `Albums.selected`. pub const ALBUM_SCOPE: i32 = -2; /// Where a sheet is putting the album's files. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Where { Device, Server, } /// The sheet, while it is open. struct Sheet { /// `None` for a new album. editing: Option, name: String, at: Where, /// A folder chosen on this device: a path, or on Android a SAF tree. device_folder: String, /// The server folder being browsed, which is the one chosen. browser: crate::launch::FolderBrowser, error: String, confirming_delete: bool, } pub struct AlbumsController { library: Rc, settings: Rc, /// The live albums, as last read, in the order they are drawn. rows: RefCell>, /// The album scoping the grid. selected: Cell>, sheet: RefCell>, } impl AlbumsController { pub fn new(library: Rc, settings: Rc) -> Rc { Rc::new(Self { library, settings, rows: RefCell::new(Vec::new()), selected: Cell::new(None), sheet: RefCell::new(None), }) } /// The album scoping the grid, if one is. pub fn selected(&self) -> Option { self.selected.get() } /// Stop scoping the grid to an album — something else in the sidebar was /// chosen. pub fn deselect(&self, window: &AppWindow) { if self.selected.replace(None).is_some() { window.global::().set_selected(0); } } /// Where the export sheet's album writes, from this device: the album, /// and the target and folder a batch understands. /// /// Refused, with a sentence to show, when no album is chosen, when it is /// gone, or when its folder is local to another device. pub fn export_destination(&self) -> Result<(AlbumId, ExportTarget, String), String> { let uuid = self.settings.snapshot().export.album; if uuid.is_empty() { return Err("Choose an album to export to (Ctrl+E), or make one.".into()); } let catalog = self.library.catalog(); let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return Err("Open a library before exporting to an album.".into()); }; let conn = cat.connection(); let album = albums::id_for_uuid(conn, &uuid) .ok() .flatten() .and_then(|id| albums::get(conn, id).ok().flatten()) .ok_or("The album exports went to is gone. Choose another (Ctrl+E).")?; match album.place { Some(Place::Local(folder)) => Ok((album.id, ExportTarget::Device, folder)), // Spelled from `/`: relative to the account, not the library — // see `export::Pending::remote_dir`. Some(Place::Server(path)) => Ok((album.id, ExportTarget::Remote, format!("/{path}"))), None => Err(format!( "“{}” has no folder on this device yet. Choose one: open the album from the sidebar.", album.name )), } } /// Record what a batch wrote into an album, and redraw what counts it. pub fn record(&self, window: &AppWindow, album: AlbumId, files: Vec<(ImageId, String)>) { { let catalog = self.library.catalog(); let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; if let Err(e) = albums::record_exports(cat.connection(), album, &files) { log::warn!( "recording {} exports in album {}: {e}", files.len(), album.0 ); return; } } refresh(window, self); // The grid is showing this album: it has just gained photographs. if self.selected.get() == Some(album) { crate::library_ui::reload(window, &self.library); } } } /// Re-read the albums and redraw everything that shows them: the sidebar /// section and the export sheet's choice. pub fn refresh(window: &AppWindow, ctl: &AlbumsController) { let catalog = ctl.library.catalog(); let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; refresh_from(window, ctl, cat); } /// [`refresh`], with the catalog already in hand — for the callers that hold /// its borrow already. pub fn refresh_from(window: &AppWindow, ctl: &AlbumsController, cat: &dr_catalog::Catalog) { adopt_old_destination(ctl, cat); match albums::list(cat.connection()) { Ok(rows) => { // An album deleted elsewhere, and merged away, cannot stay the // scope: the grid would be narrowed to nothing under a name that // is no longer in the list. if let Some(id) = ctl.selected.get() { if !rows.iter().any(|a| a.id == id) { ctl.selected.set(None); } } *ctl.rows.borrow_mut() = rows; } Err(e) => log::warn!("reading albums: {e}"), } render(window, ctl); } /// A destination folder set before albums existed becomes the first album, /// once, so upgrading does not lose where exports were going. /// /// Only a folder on this device. A server destination was inside the library, /// which is exactly where an album may not be. fn adopt_old_destination(ctl: &AlbumsController, cat: &dr_catalog::Catalog) { let export = ctl.settings.snapshot().export; if !export.album.is_empty() || export.target != ExportTarget::Device || export.destination.trim().is_empty() { return; } let conn = cat.connection(); if !albums::list(conn).map(|a| a.is_empty()).unwrap_or(false) { return; } let place = Place::Local(export.destination.trim().to_string()); match albums::create(conn, "Exports", &place).and_then(|id| albums::get(conn, id)) { Ok(Some(album)) => { log::info!( "the export folder {} is now the album “Exports”", export.destination ); ctl.settings.edit(|s| s.export.album = album.uuid); } Ok(None) => {} Err(e) => log::warn!("making an album of the old export folder: {e}"), } } fn render(window: &AppWindow, ctl: &AlbumsController) { let rows = ctl.rows.borrow(); let sidebar: Vec = rows .iter() .map(|a| AlbumRow { id: a.id.0 as i32, name: a.name.as_str().into(), count: a.sources as i32, detail: describe(a.place.as_ref()).into(), homeless: a.place.is_none(), }) .collect(); let albums = window.global::(); albums.set_rows(ModelRc::new(VecModel::from(sidebar))); albums.set_selected(ctl.selected.get().map(|a| a.0 as i32).unwrap_or(0)); let chosen = ctl.settings.snapshot().export.album; let index = rows.iter().position(|a| a.uuid == chosen); let options = window.global::(); options.set_album_labels(ModelRc::new(VecModel::from( rows.iter() .map(|a| SharedString::from(a.name.as_str())) .collect::>(), ))); options.set_album_selected(index.map(|i| i as i32).unwrap_or(-1)); options.set_album_detail( match index { Some(i) => describe(rows[i].place.as_ref()), None if rows.is_empty() => String::new(), None => "No album chosen: exports are refused until one is.".into(), } .into(), ); drop(rows); crate::refresh_export_label(window); } /// Where an album's files go, as the sidebar and the export sheet say it. fn describe(place: Option<&Place>) -> String { match place { Some(Place::Local(folder)) => format!("On this device · {}", display_folder(folder)), Some(Place::Server(path)) => format!("On the server · /{path}"), None => "No folder on this device yet".into(), } } /// A local folder as a person reads it. A SAF tree URI is not a path, and /// its last segment — `primary:Pictures/Web` — is the part worth showing. fn display_folder(folder: &str) -> String { if folder.starts_with("content://") { let tail = folder.rsplit('/').next().unwrap_or(folder); let decoded = tail .replace("%3A", ":") .replace("%2F", "/") .replace("%20", " "); return decoded .split_once(':') .map(|(_, p)| p.to_string()) .unwrap_or(decoded); } folder.to_string() } fn render_sheet(window: &AppWindow, ctl: &AlbumsController) { let albums = window.global::(); let sheet = ctl.sheet.borrow(); let Some(s) = sheet.as_ref() else { albums.set_sheet_open(false); return; }; let server = ctl .library .credentials() .is_some_and(|c| !c.account.login.is_empty()); let refusal = if s.at == Where::Server { refusal(&s.browser.path, ctl) } else { String::new() }; let can_save = !s.name.trim().is_empty() && match s.at { Where::Device => !s.device_folder.is_empty(), Where::Server => !s.browser.loading && refusal.is_empty(), }; albums.set_sheet_open(true); albums.set_sheet_editing(s.editing.is_some()); albums.set_sheet_name(s.name.as_str().into()); albums.set_server_available(server); albums.set_sheet_where(if s.at == Where::Server { 1 } else { 0 }); albums.set_sheet_device_folder(display_folder(&s.device_folder).into()); albums.set_sheet_error(s.error.as_str().into()); albums.set_sheet_confirming_delete(s.confirming_delete); albums.set_sheet_can_save(can_save); albums.set_browse_path(s.browser.path.as_str().into()); albums.set_browse_entries(ModelRc::new(VecModel::from( s.browser .entries .iter() .map(|e| SharedString::from(e.as_str())) .collect::>(), ))); albums.set_browse_loading(s.browser.loading); albums.set_browse_at_root(s.browser.parent_path().is_none()); albums.set_browse_refusal(refusal.into()); } /// Why a server folder cannot hold an album, or empty when it can. /// /// Inside the library is refused: the next scan would catalogue every JPEG /// exported there as a photograph, beside the RAW it was made from. fn refusal(path: &str, ctl: &AlbumsController) -> String { let Some(conn) = ctl.library.credentials() else { return "Sign in to the library's server to choose a folder there.".into(); }; let root = conn.account.root.trim_matches('/'); if inside(path, root) { return if root.is_empty() { "The whole account is the library here, so every folder on it is inside the library. Put this album on this device instead.".into() } else { format!("This is inside the library (/{root}), where exported files would be catalogued as photographs. Choose a folder outside it.") }; } String::new() } /// Whether `path` is `root` or beneath it. An empty root is the whole account. fn inside(path: &str, root: &str) -> bool { let path = path.trim_matches('/'); root.is_empty() || path == root || path.starts_with(&format!("{root}/")) } /// List the sheet's server folder, and redraw when the answer comes. fn browse(weak: slint::Weak, ctl: Rc, path: String) { let Some(conn) = ctl.library.credentials() else { return; }; if let Some(s) = ctl.sheet.borrow_mut().as_mut() { s.browser.path = path.clone(); s.browser.entries.clear(); s.browser.loading = true; s.error.clear(); } let arrived = { let ctl = ctl.clone(); let weak = weak.clone(); let path = path.clone(); move |result: Result, String>| settle(&weak, &ctl, &path, result) }; crate::remote_folders::list(conn, path, arrived); if let Some(w) = weak.upgrade() { render_sheet(&w, &ctl); } } /// A listing arrived for `path`. Ignored if the sheet has moved on: two quick /// clicks must not leave the second folder showing the first one's children. fn settle( weak: &slint::Weak, ctl: &Rc, path: &str, result: Result, String>, ) { let Some(w) = weak.upgrade() else { return }; if let Some(s) = ctl.sheet.borrow_mut().as_mut() { if s.browser.path != path { return; } s.browser.loading = false; match result { Ok(dirs) => s.browser.entries = dirs, // The browser stays where it was: a listing that failed is not a // reason to discard where the user had navigated to. Err(e) => s.error = format!("Could not list folders: {e}"), } } render_sheet(&w, ctl); } /// Open the sheet — on a new album, or on `editing`. fn open_sheet(window: &AppWindow, ctl: &Rc, editing: Option) { let album = editing.and_then(|id| ctl.rows.borrow().iter().find(|a| a.id == id).cloned()); let server = ctl .library .credentials() .is_some_and(|c| !c.account.login.is_empty()); let (at, device_folder, server_path) = match album.as_ref().and_then(|a| a.place.clone()) { Some(Place::Local(f)) => (Where::Device, f, String::new()), Some(Place::Server(p)) => (Where::Server, String::new(), p), None => (Where::Device, String::new(), String::new()), }; *ctl.sheet.borrow_mut() = Some(Sheet { editing, name: album.as_ref().map(|a| a.name.clone()).unwrap_or_default(), at: if server { at } else { Where::Device }, device_folder, browser: crate::launch::FolderBrowser { path: server_path.clone(), ..Default::default() }, error: String::new(), confirming_delete: false, }); if server && at == Where::Server { browse(window.as_weak(), ctl.clone(), server_path); } render_sheet(window, ctl); } /// Make or update the album the sheet describes. fn save(window: &AppWindow, ctl: &Rc) { let (editing, name, place) = { let sheet = ctl.sheet.borrow(); let Some(s) = sheet.as_ref() else { return }; let place = match s.at { Where::Device => Place::Local(s.device_folder.clone()), Where::Server => Place::Server(s.browser.path.trim_matches('/').to_string()), }; (s.editing, s.name.clone(), place) }; let outcome = { let catalog = ctl.library.catalog(); let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; let conn = cat.connection(); match editing { Some(id) => albums::rename(conn, id, &name).and_then(|()| { let current = albums::get(conn, id)?.and_then(|a| a.place); if current.as_ref() != Some(&place) { albums::set_place(conn, id, &place)?; } Ok(id) }), None => albums::create(conn, &name, &place), } .and_then(|id| Ok((id, albums::get(conn, id)?))) }; match outcome { Ok((_, album)) => { // A new album is the one the next export goes to: it was made to // be exported into, often from the export sheet itself. if editing.is_none() { if let Some(album) = album.as_ref() { ctl.settings.edit(|s| s.export.album = album.uuid.clone()); } } *ctl.sheet.borrow_mut() = None; refresh(window, ctl); render_sheet(window, ctl); // The grid's title is the album's name while it is open, and // the name has just changed. if let (Some(id), Some(album)) = (editing, album) { if ctl.selected.get() == Some(id) { window .global::() .set_collection_scope_label(album.name.into()); } } } Err(e) => { if let Some(s) = ctl.sheet.borrow_mut().as_mut() { s.error = e.to_string(); } render_sheet(window, ctl); } } } /// Ask for a folder on this device, and put it in the sheet. fn choose_device_folder(window: &AppWindow, ctl: &Rc) { let start = ctl .sheet .borrow() .as_ref() .map(|s| s.device_folder.clone()) .unwrap_or_default(); let weak = window.as_weak(); let ctl = ctl.clone(); let chosen = move |folder: String| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = ctl.sheet.borrow_mut().as_mut() { s.device_folder = folder; s.error.clear(); } render_sheet(&w, &ctl); }; // Android has no filesystem dialogue; its folder picker hands back a // tree the app is granted, which is what an export there writes into. #[cfg(target_os = "android")] { let _ = (window, start); crate::saf::pick_tree(chosen); } #[cfg(not(target_os = "android"))] crate::folder_dialog::ask( window, "Folder for this album", crate::folder_dialog::Pick::Folder, Some(&start), move |path| chosen(path.to_string_lossy().into_owned()), ); } pub fn wire( window: &AppWindow, ctl: Rc, collections: Rc, ) { let albums = window.global::(); // --- the sidebar -------------------------------------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_select(move |id| { let Some(w) = weak.upgrade() else { return }; let id = AlbumId(id.max(0) as u64); let name = ctl .rows .borrow() .iter() .find(|a| a.id == id) .map(|a| a.name.clone()) .unwrap_or_default(); ctl.selected.set(Some(id)); collections.leave_for_album(); w.set_collection_selected(ALBUM_SCOPE); w.global::() .set_collection_scope_label(name.into()); w.global::().set_selected(id.0 as i32); ctl.library.set_album(Some(id)); crate::library_ui::reload(&w, &ctl.library); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_create(move || { let Some(w) = weak.upgrade() else { return }; open_sheet(&w, &ctl, None); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_edit(move |id| { let Some(w) = weak.upgrade() else { return }; open_sheet(&w, &ctl, Some(AlbumId(id.max(0) as u64))); }); } // --- the sheet ------------------------------------------------------------ { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_sheet_name_edited(move |text| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = ctl.sheet.borrow_mut().as_mut() { s.name = text.to_string(); } render_sheet(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_sheet_where_picked(move |i| { let Some(w) = weak.upgrade() else { return }; let at = if i == 1 { Where::Server } else { Where::Device }; let start = { let mut sheet = ctl.sheet.borrow_mut(); let Some(s) = sheet.as_mut() else { return }; s.at = at; s.error.clear(); (at == Where::Server && s.browser.entries.is_empty() && !s.browser.loading) .then(|| s.browser.path.clone()) }; if let Some(path) = start { browse(w.as_weak(), ctl.clone(), path); } render_sheet(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_sheet_choose_device_folder(move || { let Some(w) = weak.upgrade() else { return }; choose_device_folder(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_sheet_save(move || { let Some(w) = weak.upgrade() else { return }; save(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_sheet_cancel(move || { let Some(w) = weak.upgrade() else { return }; *ctl.sheet.borrow_mut() = None; render_sheet(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_sheet_delete(move || { let Some(w) = weak.upgrade() else { return }; let id = { let mut sheet = ctl.sheet.borrow_mut(); let Some(s) = sheet.as_mut() else { return }; let Some(id) = s.editing else { return }; // Asked twice: the second press is the one that deletes. if !s.confirming_delete { s.confirming_delete = true; drop(sheet); render_sheet(&w, &ctl); return; } id }; let uuid = ctl .rows .borrow() .iter() .find(|a| a.id == id) .map(|a| a.uuid.clone()); let result = { let catalog = ctl.library.catalog(); let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; albums::delete(cat.connection(), id) }; if let Err(e) = result { if let Some(s) = ctl.sheet.borrow_mut().as_mut() { s.error = e.to_string(); } render_sheet(&w, &ctl); return; } if uuid.is_some_and(|u| u == ctl.settings.snapshot().export.album) { ctl.settings.edit(|s| s.export.album.clear()); } *ctl.sheet.borrow_mut() = None; render_sheet(&w, &ctl); // The grid was showing the album just deleted: back to the whole // library, the way deleting the scoped collection does. if ctl.selected.get() == Some(id) { w.invoke_collection_select(0); } refresh(&w, &ctl); }); } // --- the server browser --------------------------------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_browse_into(move |name| { let path = ctl .sheet .borrow() .as_ref() .map(|s| s.browser.child_path(&name)); if let Some(path) = path { browse(weak.clone(), ctl.clone(), path); } }); } { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_browse_up(move || { let path = ctl .sheet .borrow() .as_ref() .and_then(|s| s.browser.parent_path()); if let Some(path) = path { browse(weak.clone(), ctl.clone(), path); } }); } { let weak = window.as_weak(); let ctl = ctl.clone(); albums.on_browse_make(move |name| { let Some(w) = weak.upgrade() else { return }; let Some(conn) = ctl.library.credentials() else { return; }; let parent = { let mut sheet = ctl.sheet.borrow_mut(); let Some(s) = sheet.as_mut() else { return }; s.browser.loading = true; s.error.clear(); s.browser.path.clone() }; render_sheet(&w, &ctl); // Made, then walked into: a folder somebody has just named is the // one they mean to use. let name = name.trim().to_string(); let (weak, ctl2) = (weak.clone(), ctl.clone()); let child = name.clone(); crate::remote_folders::make(conn, parent.clone(), name, move |result| match result { Ok(_) => { let path = if parent.is_empty() { child } else { format!("{parent}/{child}") }; browse(weak, ctl2, path); } Err(e) => settle(&weak, &ctl2, &parent, Err(e)), }); }); } // --- the export sheet's choice -------------------------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); window.global::().on_album_picked(move |i| { let Some(w) = weak.upgrade() else { return }; let uuid = ctl .rows .borrow() .get(i.max(0) as usize) .map(|a| a.uuid.clone()); if let Some(uuid) = uuid { ctl.settings.edit(|s| s.export.album = uuid); } render(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.global::().on_album_new(move || { let Some(w) = weak.upgrade() else { return }; open_sheet(&w, &ctl, None); }); } } #[cfg(test)] mod tests { use super::{display_folder, inside}; #[test] fn a_folder_inside_the_library_is_refused_and_one_beside_it_is_not() { assert!(inside("Photos", "Photos")); assert!(inside("Photos/2026/Web", "Photos")); assert!(!inside("PhotosWeb", "Photos"), "a sibling sharing a prefix"); assert!(!inside("Shared/Web", "Photos")); assert!(inside("Anything", ""), "an empty root is the whole account"); } #[test] fn a_saf_tree_reads_as_its_folder() { assert_eq!( display_folder( "content://com.android.externalstorage.documents/tree/primary%3APictures%2FWeb" ), "Pictures/Web" ); assert_eq!(display_folder("/home/me/Web"), "/home/me/Web"); } }