rustfmt over the files the albums work touched, and the album merge's incoming row as a named struct rather than an eight-field tuple, which clippy's type_complexity refused.
785 lines
28 KiB
Rust
785 lines
28 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
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
}
|