Files
DarkRoom/ui/dr-ui/src/albums_ui.rs
T
dtourolle 23a2f13b46 Apply the collision policy to exports bound for the server
A queued export could not see the server, so its name check always
answered "free" and the upload PUT over whatever was there: Increment
and Skip behaved as Overwrite on Nextcloud, and two exports of the same
name queued before either uploaded landed on one file.

The batch now names around what the album records of earlier exports
and what the outbox already holds for that folder. The outbox record
carries the policy, and the drain lists each destination folder once
and applies it against what the server holds: Increment steps past a
taken name and re-points the album's row, Skip drops the entry. A
record without a policy (older builds, a merge's composite) is sent as
named, as before. The album is recorded before the drain starts so a
rename has a row to move.
2026-10-02 19:40:27 -04:00

799 lines
29 KiB
Rust

//! 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<AlbumId>,
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<LibraryController>,
settings: Rc<SettingsController>,
/// The live albums, as last read, in the order they are drawn.
rows: RefCell<Vec<Album>>,
/// The album scoping the grid.
selected: Cell<Option<AlbumId>>,
sheet: RefCell<Option<Sheet>>,
}
impl AlbumsController {
pub fn new(library: Rc<LibraryController>, settings: Rc<SettingsController>) -> Rc<Self> {
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<AlbumId> {
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::<Albums>().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
)),
}
}
/// The file names an album already records, for a batch bound for its
/// server folder to name around. Empty where the catalog cannot say.
pub fn file_names(&self, album: AlbumId) -> std::collections::HashSet<String> {
let catalog = self.library.catalog();
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
return Default::default();
};
albums::file_names(cat.connection(), album).unwrap_or_else(|e| {
log::warn!("reading the files of album {}: {e}", album.0);
Default::default()
})
}
/// 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<AlbumRow> = 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>();
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::<ExportOptions>();
options.set_album_labels(ModelRc::new(VecModel::from(
rows.iter()
.map(|a| SharedString::from(a.name.as_str()))
.collect::<Vec<_>>(),
)));
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::<Albums>();
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::<Vec<_>>(),
)));
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<AppWindow>, ctl: Rc<AlbumsController>, 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<Vec<String>, 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<AppWindow>,
ctl: &Rc<AlbumsController>,
path: &str,
result: Result<Vec<String>, 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<AlbumsController>, editing: Option<AlbumId>) {
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<AlbumsController>) {
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::<Collections>()
.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<AlbumsController>) {
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<AlbumsController>,
collections: Rc<crate::collections_ui::CollectionsController>,
) {
let albums = window.global::<Albums>();
// --- 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::<Collections>()
.set_collection_scope_label(name.into());
w.global::<Albums>().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::<ExportOptions>().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::<ExportOptions>().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");
}
}