Export to an album instead of a folder in the settings

Export took a path typed into the settings page, or a folder inside the
library on the server. The first is how exports end up somewhere nobody
looks; the second put JPEGs into the tree a scan catalogues, where they
came back as photographs beside the RAWs they were made from.

The destination is now an album (FR-EXP-10), chosen by name in the
export sheet. Albums are listed under the collections in the sidebar;
"+" there, or "New album…" in the sheet, opens a sheet for its name and
its folder — on this device through the platform's dialogue, or on the
server through the browser with "New folder". A server folder inside
the library is refused, and the sheet says why. Selecting an album
narrows the grid to the photographs behind its files: library::Scope
is Collection or Album, and scope_clause is the one place the two are
spelled, which also retires the two copies of the collection predicate
total_images_scoped and read_cells_scoped had inlined.

A batch resolves the album when it starts, and refuses in words when
none is chosen, it has gone, or its folder is local to another device.
Each item reports the image it came from, and the files written are
recorded against the album in one transaction when the batch ends.

A server album lives outside the library, so its queued uploads are
relative to the account root. That is a third line in the outbox's
.dest record rather than a leading slash, because a record written
before albums may carry a stray slash and must keep the meaning it was
written with.

An export folder set before albums becomes an album called "Exports"
on first open, so upgrading does not lose where exports were going.
The old destination fields stay in ExportSettings so older settings
files still read.
This commit is contained in:
2026-09-26 14:13:53 -04:00
parent 2eb06b1064
commit 7cbcacc02e
19 changed files with 1757 additions and 672 deletions
+13
View File
@@ -727,6 +727,18 @@ pub struct ExportSettings {
/// [`Self::destination`], where empty means "ask each time" because there /// [`Self::destination`], where empty means "ask each time" because there
/// is no sensible folder to assume on a filesystem. /// is no sensible folder to assume on a filesystem.
pub remote_destination: String, pub remote_destination: String,
/// TRACES: FR-EXP-10
/// The album exports go to, by its catalog uuid. Empty until one is
/// chosen, and an export with none is refused and says so.
///
/// This replaced [`Self::target`] and the two destination fields as what
/// the export sheet chooses: a destination is now a named album in the
/// catalog, which records what was exported into it. The three older
/// fields stay so a settings file written before albums still reads, and
/// so the first album can be made from the folder they named; the batch
/// fills them from the album when it runs.
pub album: String,
} }
impl Default for ExportSettings { impl Default for ExportSettings {
@@ -748,6 +760,7 @@ impl Default for ExportSettings {
target: ExportTarget::default(), target: ExportTarget::default(),
destination: String::new(), destination: String::new(),
remote_destination: String::new(), remote_destination: String::new(),
album: String::new(),
} }
} }
} }
+763
View File
@@ -0,0 +1,763 @@
//! 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);
};
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");
}
}
+15
View File
@@ -126,6 +126,11 @@ pub struct CollectionsController {
/// `scope` as a sentinel id would put that inversion inside a type that /// `scope` as a sentinel id would put that inversion inside a type that
/// means "a collection". /// means "a collection".
pub(super) viewing_trash: std::cell::Cell<bool>, pub(super) viewing_trash: std::cell::Cell<bool>,
/// TRACES: FR-EXP-10
/// The albums section below the tree, refreshed whenever the tree is:
/// every open, scan, sync and merge that can change collections can
/// change albums too. `None` until `lib.rs` wires it.
pub(crate) albums: RefCell<Option<Rc<crate::albums_ui::AlbumsController>>>,
/// The live drag: what it carries. Empty means no drag. /// The live drag: what it carries. Empty means no drag.
pub(super) dragging: RefCell<Vec<ImageId>>, pub(super) dragging: RefCell<Vec<ImageId>>,
/// The collection being dragged, when the drag is a tree rearrangement /// The collection being dragged, when the drag is a tree rearrangement
@@ -424,6 +429,16 @@ impl CollectionsController {
/// Selection is by id and survives a window change, but a selection the /// Selection is by id and survives a window change, but a selection the
/// user cannot see is a selection they will act on by accident. Clearing on /// user cannot see is a selection they will act on by accident. Clearing on
/// a deliberate navigation is the safer of the two behaviours. /// a deliberate navigation is the safer of the two behaviours.
/// TRACES: FR-EXP-10
/// An album was chosen in the sidebar: nothing of this panel scopes the
/// grid any more. The selection goes too, as it does on any scope change —
/// a selection the user cannot see is one they will act on by accident.
pub(crate) fn leave_for_album(&self) {
self.viewing_trash.set(false);
*self.scope.borrow_mut() = None;
self.clear_selection();
}
pub fn clear_selection(&self) { pub fn clear_selection(&self) {
self.selection.borrow_mut().clear(); self.selection.borrow_mut().clear();
*self.anchor.borrow_mut() = None; *self.anchor.borrow_mut() = None;
+5
View File
@@ -24,6 +24,11 @@ use super::controller::CollectionsController;
/// every ancestor — diffing that against the model would be more code than the /// every ancestor — diffing that against the model would be more code than the
/// query costs, and the tree is tens of rows, not thousands. /// query costs, and the tree is tens of rows, not thousands.
pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog: &Catalog) { pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog: &Catalog) {
// The albums share the sidebar, and every caller of this is a moment
// they may have changed too — a merge brings both.
if let Some(albums) = ctl.albums.borrow().as_ref() {
crate::albums_ui::refresh_from(window, albums, catalog);
}
let rows = match coll::tree(catalog.connection()) { let rows = match coll::tree(catalog.connection()) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
+66 -7
View File
@@ -211,6 +211,21 @@ fn wire_presets(window: &AppWindow, w: &DevelopWiring) {
presets::render(window, &clipboard, &settings); presets::render(window, &clipboard, &settings);
} }
/// An export that cannot start, said where the button that asked for it is,
/// and the button given back.
fn refuse(window: &AppWindow, to: export::Reporting, message: &str) {
match to {
export::Reporting::Develop => {
window.set_export_status(message.into());
window.set_export_busy(false);
}
export::Reporting::Library => {
window.global::<Library>().set_library_status(message.into());
window.global::<Library>().set_library_exporting(false);
}
}
}
/// --- export (FR-EXP-6, FR-EXP-7, FR-EXP-9) --------------------------- /// --- export (FR-EXP-6, FR-EXP-7, FR-EXP-9) ---------------------------
/// ///
/// Two buttons, one worker. The develop view exports the image on screen and /// Two buttons, one worker. The develop view exports the image on screen and
@@ -229,6 +244,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
let collections = w.collections.clone(); let collections = w.collections.clone();
let activity = w.activity.clone(); let activity = w.activity.clone();
let gpu = w.gpu.clone(); let gpu = w.gpu.clone();
let open_image = w.open_image.clone();
// Replaced at each start, so the button always cancels the run it is // Replaced at each start, so the button always cancels the run it is
// sitting on and a stale token cancels nothing. // sitting on and a stale token cancels nothing.
@@ -244,10 +260,37 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
let gpu = gpu.clone(); let gpu = gpu.clone();
let cancel = cancel.clone(); let cancel = cancel.clone();
let drain = drain.clone(); let drain = drain.clone();
let collections = collections.clone();
Rc::new( Rc::new(
move |window: &AppWindow, sources: Vec<export::Source>, to: export::Reporting| { move |window: &AppWindow,
sources: Vec<export::Source>,
images: Vec<Option<dr_types::ImageId>>,
to: export::Reporting| {
let total = sources.len(); let total = sources.len();
let request = batch_request(sources, &settings, &library, gpu.as_ref());
// TRACES: FR-EXP-10
// Where it goes is an album, resolved now rather than when
// the sheet was last touched: the album may have been
// deleted, merged away, or have no folder on this device.
// Refused before anything is rendered, in the words the
// photographer needs to fix it.
let albums = collections.albums.borrow().clone();
let resolved = match albums.as_ref() {
Some(a) => a.export_destination(),
None => Err("Albums are not ready yet.".into()),
};
let (album, target, folder) = match resolved {
Ok(found) => found,
Err(e) => {
refuse(window, to, &e);
return;
}
};
let mut request = batch_request(sources, &settings, &library, gpu.as_ref());
request.settings.target = target;
request.settings.destination = folder;
request.images = images;
let token = export::Cancel::default(); let token = export::Cancel::default();
*cancel.borrow_mut() = token.clone(); *cancel.borrow_mut() = token.clone();
@@ -261,6 +304,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
// "Queued for Exports" sitting unchanged until somebody // "Queued for Exports" sitting unchanged until somebody
// presses Sync reads as an export that did not upload. // presses Sync reads as an export that did not upload.
let library_for_drain = library.clone(); let library_for_drain = library.clone();
let weak = window.as_weak();
export::drain_batch( export::drain_batch(
window.as_weak(), window.as_weak(),
&activity, &activity,
@@ -268,7 +312,14 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
rx, rx,
total, total,
to, to,
move || drain_outbox(&library_for_drain), move |written| {
drain_outbox(&library_for_drain);
// What the album now holds, and which photograph
// each file came from.
if let (Some(albums), Some(w)) = (albums.as_ref(), weak.upgrade()) {
albums.record(&w, album, written);
}
},
); );
}, },
) )
@@ -278,6 +329,8 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
let weak = window.as_weak(); let weak = window.as_weak();
let session = session.clone(); let session = session.clone();
let settings = settings.clone(); let settings = settings.clone();
let library = library.clone();
let open_image = open_image.clone();
let start = start.clone(); let start = start.clone();
window.on_export_image(move || { window.on_export_image(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
@@ -295,14 +348,20 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
// would never be drawn in the "Rendering…" state at all. // would never be drawn in the "Rendering…" state at all.
w.set_export_status("Rendering…".into()); w.set_export_status("Rendering…".into());
// The photograph in the catalog, so the album can link its file
// back to it. A file opened from the command line has none.
let image = match &*open_image.borrow() {
presets::Stored::Remote { path, .. } => library.image_id_for_path(path),
_ => None,
};
match render_open_frame(&w, &session, settings.snapshot().export.colour_space) { match render_open_frame(&w, &session, settings.snapshot().export.colour_space) {
Ok(source) => start(&w, vec![source], export::Reporting::Develop), Ok(source) => start(&w, vec![source], vec![image], export::Reporting::Develop),
Err(e) => { Err(e) => {
w.set_export_status(format!("Export failed: {e}").into()); w.set_export_status(format!("Export failed: {e}").into());
w.set_export_busy(false); w.set_export_busy(false);
} }
} }
refresh_export_label(&w, &settings); refresh_export_label(&w);
}); });
} }
@@ -329,7 +388,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
return; return;
} }
let sources = library.export_sources(&collections.selected()); let (sources, images) = library.export_sources_with_ids(&collections.selected());
if sources.is_empty() { if sources.is_empty() {
// Said out loud rather than ignored, matching what a paste // Said out loud rather than ignored, matching what a paste
// or a judgement keystroke does with an empty selection. // or a judgement keystroke does with an empty selection.
@@ -341,7 +400,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
w.global::<Library>().set_library_exporting(true); w.global::<Library>().set_library_exporting(true);
w.global::<Library>() w.global::<Library>()
.set_library_status(format!("Exporting {} images…", sources.len()).into()); .set_library_status(format!("Exporting {} images…", sources.len()).into());
start(&w, sources, export::Reporting::Library); start(&w, sources, images, export::Reporting::Library);
}); });
} }
+111 -18
View File
@@ -96,6 +96,13 @@ pub struct Pending {
pub local: PathBuf, pub local: PathBuf,
/// Remote folder, relative to the library root. Empty means the root. /// Remote folder, relative to the library root. Empty means the root.
pub remote_dir: String, pub remote_dir: String,
/// TRACES: FR-EXP-10
/// `remote_dir` is relative to the *account* root instead: an album on
/// the server lives outside the library, where a scan would not
/// catalogue its JPEGs as photographs. A third line in the record, so a
/// record written before albums — two lines, a stray `/` and all — keeps
/// the meaning it was written with.
pub account: bool,
/// The filename to give it there. /// The filename to give it there.
pub name: String, pub name: String,
} }
@@ -104,7 +111,7 @@ impl Pending {
/// Full remote path for this entry, under `root`. /// Full remote path for this entry, under `root`.
fn remote_path(&self, root: &str) -> RemotePath { fn remote_path(&self, root: &str) -> RemotePath {
let mut parts: Vec<&str> = Vec::new(); let mut parts: Vec<&str> = Vec::new();
for segment in [root, self.remote_dir.as_str()] { for segment in [self.base(root), self.remote_dir.as_str()] {
for part in segment.split('/') { for part in segment.split('/') {
if !part.is_empty() { if !part.is_empty() {
parts.push(part); parts.push(part);
@@ -118,7 +125,7 @@ impl Pending {
/// The folder this entry's file belongs in, as a remote path. /// The folder this entry's file belongs in, as a remote path.
fn remote_folder(&self, root: &str) -> RemotePath { fn remote_folder(&self, root: &str) -> RemotePath {
let mut parts: Vec<&str> = Vec::new(); let mut parts: Vec<&str> = Vec::new();
for segment in [root, self.remote_dir.as_str()] { for segment in [self.base(root), self.remote_dir.as_str()] {
for part in segment.split('/') { for part in segment.split('/') {
if !part.is_empty() { if !part.is_empty() {
parts.push(part); parts.push(part);
@@ -127,6 +134,15 @@ impl Pending {
} }
RemotePath::new(parts.join("/")) RemotePath::new(parts.join("/"))
} }
/// What `remote_dir` is relative to: the library root, or the account's.
fn base<'a>(&self, root: &'a str) -> &'a str {
if self.account {
""
} else {
root
}
}
} }
/// Where an export was put, for the interface to report. /// Where an export was put, for the interface to report.
@@ -134,11 +150,27 @@ impl Pending {
pub enum Placed { pub enum Placed {
/// Written straight to a folder on this device. /// Written straight to a folder on this device.
Device(PathBuf), Device(PathBuf),
/// Staged locally, awaiting upload to the named remote folder. /// Staged locally, awaiting upload to the named remote folder as `name`.
Queued { local: PathBuf, remote_dir: String }, /// The staged copy may be called something else — see [`stage`].
Queued {
local: PathBuf,
remote_dir: String,
name: String,
},
} }
impl Placed { impl Placed {
/// The file's name where it was asked to go — what an album records.
pub fn file_name(&self) -> String {
match self {
Placed::Device(path) => path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
Placed::Queued { name, .. } => name.clone(),
}
}
/// A sentence for the status line. /// A sentence for the status line.
pub fn describe(&self) -> String { pub fn describe(&self) -> String {
match self { match self {
@@ -156,7 +188,7 @@ impl Placed {
let dir = if remote_dir.is_empty() { let dir = if remote_dir.is_empty() {
"the library root".to_string() "the library root".to_string()
} else { } else {
remote_dir.clone() remote_dir.trim_start_matches('/').to_string()
}; };
format!("Queued for {dir}") format!("Queued for {dir}")
} }
@@ -179,7 +211,7 @@ pub fn place(
match target { match target {
ExportTarget::Device => { ExportTarget::Device => {
if destination.trim().is_empty() { if destination.trim().is_empty() {
return Err("No export folder is set. Choose one in Settings.".into()); return Err("No export folder is set. Choose an album to export to.".into());
} }
let dir = PathBuf::from(destination); let dir = PathBuf::from(destination);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
@@ -193,6 +225,7 @@ pub fn place(
Ok(Placed::Queued { Ok(Placed::Queued {
local, local,
remote_dir: destination.to_string(), remote_dir: destination.to_string(),
name: encoded.name.clone(),
}) })
} }
} }
@@ -235,7 +268,13 @@ fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf,
// The remote folder and the intended name, one per line. Not JSON: two // The remote folder and the intended name, one per line. Not JSON: two
// strings do not need a parser, and a format a human can repair by hand // strings do not need a parser, and a format a human can repair by hand
// is worth something for a queue holding the only copy of someone's work. // is worth something for a queue holding the only copy of someone's work.
std::fs::write(&record, format!("{remote_dir}\n{}\n", encoded.name)) // A folder spelled from `/` is an album's, relative to the account; it
// is recorded as such on a third line — see `Pending::account`.
let text = match remote_dir.strip_prefix('/') {
Some(dir) => format!("{dir}\n{}\naccount\n", encoded.name),
None => format!("{remote_dir}\n{}\n", encoded.name),
};
std::fs::write(&record, text)
.map_err(|e| format!("{}: {e}", record.display()))?; .map_err(|e| format!("{}: {e}", record.display()))?;
Ok(candidate) Ok(candidate)
@@ -280,6 +319,7 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
let mut lines = text.lines(); let mut lines = text.lines();
let remote_dir = lines.next().unwrap_or("").to_string(); let remote_dir = lines.next().unwrap_or("").to_string();
let name = lines.next().unwrap_or("").to_string(); let name = lines.next().unwrap_or("").to_string();
let account = lines.next() == Some("account");
if name.is_empty() { if name.is_empty() {
continue; continue;
} }
@@ -287,6 +327,7 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
local, local,
remote_dir, remote_dir,
name, name,
account,
}); });
} }
// Stable order so a drain is reproducible and a stuck entry is obvious // Stable order so a drain is reproducible and a stuck entry is obvious
@@ -505,6 +546,11 @@ impl Source {
/// catalog. Passing the finished answers means the worker borrows nothing. /// catalog. Passing the finished answers means the worker borrows nothing.
pub struct BatchRequest { pub struct BatchRequest {
pub sources: Vec<Source>, pub sources: Vec<Source>,
/// TRACES: FR-EXP-10
/// The catalog image behind each source, by position, where there is one
/// — what an album records its files against. Shorter than `sources`, or
/// empty, where the caller does not know.
pub images: Vec<Option<dr_types::ImageId>>,
/// Credentials for the account the library is open on. `None` where no /// Credentials for the account the library is open on. `None` where no
/// library is open, which is fine for a [`Source::Rendered`] and fatal for /// library is open, which is fine for a [`Source::Rendered`] and fatal for
/// anything that has to be fetched. /// anything that has to be fetched.
@@ -567,6 +613,8 @@ pub enum BatchMessage {
/// One image is finished, for better or worse. /// One image is finished, for better or worse.
Item { Item {
name: String, name: String,
/// The catalog image it came from, where the request said.
image: Option<dr_types::ImageId>,
outcome: Result<Placed, ItemError>, outcome: Result<Placed, ItemError>,
}, },
Finished { Finished {
@@ -625,7 +673,12 @@ fn run(mut request: BatchRequest, cancel: &Cancel, tx: &Sender<BatchMessage>) {
Ok(_) => exported += 1, Ok(_) => exported += 1,
Err(_) => failed += 1, Err(_) => failed += 1,
} }
let _ = tx.send(BatchMessage::Item { name, outcome }); let image = request.images.get(i).copied().flatten();
let _ = tx.send(BatchMessage::Item {
name,
image,
outcome,
});
} }
let _ = tx.send(BatchMessage::Finished { let _ = tx.send(BatchMessage::Finished {
@@ -1034,11 +1087,12 @@ pub fn drain_batch(
rx: Receiver<BatchMessage>, rx: Receiver<BatchMessage>,
total: usize, total: usize,
reporting: Reporting, reporting: Reporting,
// Run when the batch finishes having written something. A queued export // Run when the batch finishes having written something, with each file
// is finished on disk but not where the user asked for it, and waiting // it wrote and the image that file came from. A queued export is finished
// for the next sync pass to notice reads — correctly — as an export that // on disk but not where the user asked for it, and waiting for the next
// did not upload. // sync pass to notice reads — correctly — as an export that did not
on_exported: impl Fn() + 'static, // upload; the files are what an album records (FR-EXP-10).
on_exported: impl Fn(Vec<(dr_types::ImageId, String)>) + 'static,
) { ) {
let job = activity.begin( let job = activity.begin(
crate::activity::Kind::Export, crate::activity::Kind::Export,
@@ -1054,6 +1108,7 @@ pub fn drain_batch(
// every image failed for the same reason should say that reason once, and // every image failed for the same reason should say that reason once, and
// the rest are in the log. // the rest are in the log.
let mut first_failure: Option<String> = None; let mut first_failure: Option<String> = None;
let mut written: Vec<(dr_types::ImageId, String)> = Vec::new();
let timer = slint::Timer::default(); let timer = slint::Timer::default();
let held = slot.clone(); let held = slot.clone();
@@ -1085,8 +1140,17 @@ pub fn drain_batch(
&format!("Exporting {name} ({}/{total})", done + 1), &format!("Exporting {name} ({}/{total})", done + 1),
); );
} }
BatchMessage::Item { name, outcome } => match outcome { BatchMessage::Item {
Ok(placed) => log::info!("{name}: {}", placed.describe()), name,
image,
outcome,
} => match outcome {
Ok(placed) => {
log::info!("{name}: {}", placed.describe());
if let Some(image) = image {
written.push((image, placed.file_name()));
}
}
Err(e) => { Err(e) => {
// Every failure is logged, not only the first: the // Every failure is logged, not only the first: the
// summary is a sentence and this is the record of which // summary is a sentence and this is the record of which
@@ -1109,7 +1173,7 @@ pub fn drain_batch(
} }
settle(&w, reporting, &text); settle(&w, reporting, &text);
if exported > 0 { if exported > 0 {
on_exported(); on_exported(std::mem::take(&mut written));
} }
stop_timer(&held); stop_timer(&held);
return; return;
@@ -1231,7 +1295,8 @@ mod tests {
// wherever the app happened to be launched from. // wherever the app happened to be launched from.
let dir = tmp(); let dir = tmp();
let err = place(&encoded("a.jpg", b"x"), ExportTarget::Device, " ", &dir).unwrap_err(); let err = place(&encoded("a.jpg", b"x"), ExportTarget::Device, " ", &dir).unwrap_err();
assert!(err.contains("Settings"), "unhelpful message: {err}"); // Says what to do about it: the destination is an album now.
assert!(err.contains("album"), "unhelpful message: {err}");
} }
#[test] #[test]
@@ -1249,7 +1314,9 @@ mod tests {
.unwrap(); .unwrap();
match placed { match placed {
Placed::Queued { local, remote_dir } => { Placed::Queued {
local, remote_dir, ..
} => {
assert_eq!(std::fs::read(&local).unwrap(), b"hello"); assert_eq!(std::fs::read(&local).unwrap(), b"hello");
assert_eq!(remote_dir, "Exports/2026"); assert_eq!(remote_dir, "Exports/2026");
} }
@@ -1340,6 +1407,7 @@ mod tests {
local: PathBuf::from("/tmp/a.jpg"), local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "Exports/2026".into(), remote_dir: "Exports/2026".into(),
name: "a.jpg".into(), name: "a.jpg".into(),
account: false,
}; };
assert_eq!( assert_eq!(
entry.remote_path("Photos").as_str(), entry.remote_path("Photos").as_str(),
@@ -1359,6 +1427,7 @@ mod tests {
local: PathBuf::from("/tmp/a.jpg"), local: PathBuf::from("/tmp/a.jpg"),
remote_dir: String::new(), remote_dir: String::new(),
name: "a.jpg".into(), name: "a.jpg".into(),
account: false,
}; };
assert_eq!(entry.remote_path("Photos").as_str(), "Photos/a.jpg"); assert_eq!(entry.remote_path("Photos").as_str(), "Photos/a.jpg");
} }
@@ -1371,6 +1440,7 @@ mod tests {
local: PathBuf::from("/tmp/a.jpg"), local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "/Exports/".into(), remote_dir: "/Exports/".into(),
name: "a.jpg".into(), name: "a.jpg".into(),
account: false,
}; };
assert_eq!( assert_eq!(
entry.remote_path("/Photos/").as_str(), entry.remote_path("/Photos/").as_str(),
@@ -1378,6 +1448,27 @@ mod tests {
); );
} }
#[test]
fn an_album_on_the_server_is_reached_from_the_account_not_the_library() {
// FR-EXP-10: an album lives outside the library, so its folder is
// spelled from the account root and the library root is not put in
// front of it — through the record on disk, as a drain after a
// restart would read it.
let dir = std::env::temp_dir().join(format!(
"dr-export-album-outbox-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
place(&encoded("a.jpg", b"x"), ExportTarget::Remote, "/Shared/Web", &dir).unwrap();
let entry = pending(&dir).pop().expect("one staged export");
assert!(entry.account);
assert_eq!(entry.remote_path("Photos").as_str(), "Shared/Web/a.jpg");
assert_eq!(entry.remote_folder("Photos").as_str(), "Shared/Web");
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn the_status_line_never_claims_an_upload_that_has_not_happened() { fn the_status_line_never_claims_an_upload_that_has_not_happened() {
// A queued export is real and finished, but it is not on the server, // A queued export is real and finished, but it is not on the server,
@@ -1385,6 +1476,7 @@ mod tests {
let queued = Placed::Queued { let queued = Placed::Queued {
local: PathBuf::from("/tmp/a.jpg"), local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "Exports".into(), remote_dir: "Exports".into(),
name: "a.jpg".into(),
}; };
let text = queued.describe(); let text = queued.describe();
assert!(text.contains("Queued"), "{text}"); assert!(text.contains("Queued"), "{text}");
@@ -1412,6 +1504,7 @@ mod tests {
fn request(settings: ExportSettings, sources: Vec<Source>) -> BatchRequest { fn request(settings: ExportSettings, sources: Vec<Source>) -> BatchRequest {
BatchRequest { BatchRequest {
sources, sources,
images: Vec::new(),
conn: None, conn: None,
settings, settings,
outbox: std::env::temp_dir().join("dr-batch-test-outbox"), outbox: std::env::temp_dir().join("dr-batch-test-outbox"),
+37 -128
View File
@@ -20,6 +20,7 @@
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
mod activity; mod activity;
mod albums_ui;
#[cfg(all(feature = "automation", unix))] #[cfg(all(feature = "automation", unix))]
mod automation; mod automation;
mod bursts; mod bursts;
@@ -703,6 +704,7 @@ fn batch_request(
export::BatchRequest { export::BatchRequest {
sources, sources,
images: Vec::new(),
conn: library.credentials(), conn: library.credentials(),
settings: stored.export, settings: stored.export,
outbox: match library.session() { outbox: match library.session() {
@@ -766,23 +768,28 @@ fn drain_outbox(library: &Rc<library_ui::LibraryController>) {
/// The label carries the destination because the button is the only place the /// The label carries the destination because the button is the only place the
/// distinction is visible from: "Export" alone gives no hint whether the file /// distinction is visible from: "Export" alone gives no hint whether the file
/// lands on this device or is queued for a server that may be unreachable. /// lands on this device or is queued for a server that may be unreachable.
fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) { pub(crate) fn refresh_export_label(window: &AppWindow) {
let stored = settings.snapshot(); use slint::Model as _;
let remote = stored.export.target == dr_types::ExportTarget::Remote; // The album the choice in `ExportOptions` points at, as `albums_ui` last
// drew it — the one place that knows both the settings and the catalog.
let options = window.global::<ExportOptions>();
let album = usize::try_from(options.get_album_selected())
.ok()
.and_then(|i| options.get_album_labels().row_data(i))
.map(|s| s.to_string())
.unwrap_or_default();
window.set_export_label( window.set_export_label(
if remote { if album.is_empty() {
// Not the connector's name: this is a Nextcloud account for some "Export".to_string()
// libraries and a folder on a mount for others.
"Export to the library"
} else { } else {
"Export" format!("Export to {album}")
} }
.into(), .into(),
); );
// The grid's button says the same thing about a selection, but it composes // The grid's button says the same thing about a selection, but it composes
// its own label around a count — so it is given the fact rather than the // its own label around a count — so it is given the name rather than the
// sentence (FR-EXP-7). // sentence (FR-EXP-7).
window.set_export_to_server(remote); window.set_export_album(album.into());
} }
/// Push current parameter values back to the interface. /// Push current parameter values back to the interface.
@@ -1610,6 +1617,14 @@ fn construct_screens(
// each save over the other. // each save over the other.
let settings = settings_ui::SettingsController::new(); let settings = settings_ui::SettingsController::new();
// TRACES: FR-EXP-10
// Albums: the export folders under the collections. Held by the
// collections controller, which refreshes the sidebar they share, and
// reached through it by the export button.
let albums = albums_ui::AlbumsController::new(library.clone(), settings.clone());
*collections.albums.borrow_mut() = Some(albums.clone());
albums_ui::wire(window, albums.clone(), collections.clone());
// TRACES: FR-PLAT-AND-5 // TRACES: FR-PLAT-AND-5
// The thumbnail tier. Registered here, beside the thing it frees, so that // The thumbnail tier. Registered here, beside the thing it frees, so that
// a controller which grows another cache is one line from offering it up. // a controller which grows another cache is one line from offering it up.
@@ -1697,6 +1712,16 @@ fn construct_screens(
// survive the call. // survive the call.
lib.set_scope(coll.scope()); lib.set_scope(coll.scope());
lib.set_viewing_trash(coll.viewing_trash()); lib.set_viewing_trash(coll.viewing_trash());
// An album is a third kind of scope, beside the two
// above, and the sidebar still has one selection:
// anything but an album there leaves the album.
if let Some(albums) = coll.albums.borrow().as_ref() {
if w.get_collection_selected() == albums_ui::ALBUM_SCOPE {
lib.set_album(albums.selected());
} else {
albums.deselect(&w);
}
}
library_ui::reload(&w, &lib); library_ui::reload(&w, &lib);
}, },
move || lib_ids.visible_ids(), move || lib_ids.visible_ids(),
@@ -1946,7 +1971,7 @@ fn wire_settings_screen(
settings_ui::render(window, &settings); settings_ui::render(window, &settings);
// The export button carries its destination, so it has to be correct // The export button carries its destination, so it has to be correct
// before the first click rather than after the first settings edit. // before the first click rather than after the first settings edit.
refresh_export_label(window, &settings); refresh_export_label(window);
// Apply what is on disk before anything can use it. Without this the // Apply what is on disk before anything can use it. Without this the
// controller's defaults stand until the user happens to open the // controller's defaults stand until the user happens to open the
@@ -1962,122 +1987,6 @@ fn wire_settings_screen(
library.set_face_model_id(inference::model_id(stored.faces.detector)); library.set_face_model_id(inference::model_id(stored.faces.detector));
} }
// --- the export folder picker ----------------------------------------
//
// Wired here rather than inside `settings_ui::wire` because listing a
// remote folder needs credentials, and the settings page deliberately
// holds no session — it is reachable before a library is opened and must
// not depend on one existing.
{
let weak = window.as_weak();
let ctl = settings.clone();
let library = library.clone();
// Every entry point needs the same three things, so they are fetched
// once here rather than at four call sites.
let start = move |ctl: &Rc<settings_ui::SettingsController>,
weak: &slint::Weak<AppWindow>,
library: &Rc<library_ui::LibraryController>,
path: String| {
match library.session() {
Some(conn) => {
settings_ui::spawn_folder_list(weak.clone(), ctl.clone(), conn, path);
}
None => {
// No account, so nothing to browse. Said plainly rather
// than left as an empty list, which would read as a server
// with no folders on it.
ctl.set_error("Sign in to a library before choosing a folder on it.");
ctl.browser.replace(None);
}
}
};
{
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
window
.global::<ExportOptions>()
.on_browse_open_picker(move || {
let Some(w) = weak.upgrade() else { return };
// Opens on the library root rather than on whatever the
// destination field happens to contain: a half-typed path
// would list nothing and look like a broken picker.
ctl.browser.replace(Some(launch::FolderBrowser {
path: String::new(),
entries: Vec::new(),
loading: true,
}));
start(&ctl, &weak, &library, String::new());
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
window
.global::<ExportOptions>()
.on_browse_into(move |name| {
let Some(w) = weak.upgrade() else { return };
let path = {
let mut browser = ctl.browser.borrow_mut();
let Some(b) = browser.as_mut() else { return };
let path = b.child_path(&name);
b.path = path.clone();
b.entries.clear();
b.loading = true;
path
};
start(&ctl, &weak, &library, path);
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
window.global::<ExportOptions>().on_browse_up(move || {
let Some(w) = weak.upgrade() else { return };
let path = {
let mut browser = ctl.browser.borrow_mut();
let Some(b) = browser.as_mut() else { return };
let Some(path) = b.parent_path() else { return };
b.path = path.clone();
b.entries.clear();
b.loading = true;
path
};
start(&ctl, &weak, &library, path);
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl) = (weak.clone(), ctl.clone());
window.global::<ExportOptions>().on_browse_confirm(move || {
let Some(w) = weak.upgrade() else { return };
// The folder being *shown* is the one chosen, matching the
// library picker — so "use this one" means the same thing in
// both places rather than depending on a selection the list
// does not have.
let chosen = ctl.browser.borrow().as_ref().map(|b| b.path.clone());
if let Some(path) = chosen {
ctl.set_destination(path);
}
ctl.browser.replace(None);
settings_ui::render(&w, &ctl);
refresh_export_label(&w, &ctl);
});
}
{
let (weak, ctl) = (weak.clone(), ctl.clone());
window.global::<ExportOptions>().on_browse_cancel(move || {
let Some(w) = weak.upgrade() else { return };
ctl.browser.replace(None);
settings_ui::render(&w, &ctl);
});
}
}
let lib = library.clone(); let lib = library.clone();
let ctl = settings.clone(); let ctl = settings.clone();
let weak = window.as_weak(); let weak = window.as_weak();
@@ -2093,7 +2002,7 @@ fn wire_settings_screen(
lib.set_fetch_ahead(s.cache.fetch_ahead); lib.set_fetch_ahead(s.cache.fetch_ahead);
lib.set_write_xmp_sidecars(s.library.write_xmp_sidecars); lib.set_write_xmp_sidecars(s.library.write_xmp_sidecars);
if let Some(w) = weak.upgrade() { if let Some(w) = weak.upgrade() {
refresh_export_label(&w, &ctl); refresh_export_label(&w);
} }
// The axis is cut into bars when the window is loaded, so a new // The axis is cut into bars when the window is loaded, so a new
+14 -22
View File
@@ -2,6 +2,8 @@
//! for, trash listing, and the ordinal a single photograph sits at. //! for, trash listing, and the ordinal a single photograph sits at.
use dr_catalog::Catalog; use dr_catalog::Catalog;
use super::Scope;
#[cfg(test)] #[cfg(test)]
use dr_sync::RemotePath; use dr_sync::RemotePath;
#[cfg(test)] #[cfg(test)]
@@ -82,39 +84,29 @@ pub fn read_cells(
/// positions are unrelated integers. /// positions are unrelated integers.
pub fn read_cells_scoped( pub fn read_cells_scoped(
catalog: &Catalog, catalog: &Catalog,
scope: Option<dr_types::CollectionId>, scope: Option<Scope>,
filter: &RatingFilter, filter: &RatingFilter,
offset: usize, offset: usize,
limit: usize, limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> { ) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let Some(scope) = scope else { if scope.is_none() {
return read_cells_all(catalog, filter, offset, limit); return read_cells_all(catalog, filter, offset, limit);
}; }
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; let (clause, mut params) = scope_clause(catalog, scope)?;
// Placeholders are generated from the *count* of ids, never from user text.
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let rated = filter.sql(); let rated = filter.sql();
let folded = uncollapsed("i"); let folded = uncollapsed("i");
let (order, order_params) = grid_order_for(catalog, Some(scope)); let (order, order_params) = grid_order_for(catalog, scope);
let sql = format!( let sql = format!(
"SELECT {CELL_COLUMNS} "SELECT {CELL_COLUMNS}
FROM images i FROM images i
WHERE {VISIBLE}{rated}{folded} WHERE {VISIBLE}{rated}{folded}{clause}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))
{order} {order}
LIMIT ? OFFSET ?" LIMIT ? OFFSET ?"
); );
// Bound in the order the `?`s appear: the scope's ids in the WHERE, then // Bound in the order the `?`s appear: the scope's in the WHERE, then
// whatever the ORDER BY needs, then the window. // whatever the ORDER BY needs, then the window.
let mut params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect();
params.extend(order_params); params.extend(order_params);
params.push(rusqlite::types::Value::Integer(limit as i64)); params.push(rusqlite::types::Value::Integer(limit as i64));
params.push(rusqlite::types::Value::Integer(offset as i64)); params.push(rusqlite::types::Value::Integer(offset as i64));
@@ -225,7 +217,7 @@ pub fn total_trashed(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogErro
/// the two the same, because both leave the selection where it was. /// the two the same, because both leave the selection where it was.
pub fn read_ids_span( pub fn read_ids_span(
catalog: &Catalog, catalog: &Catalog,
scope: Option<dr_types::CollectionId>, scope: Option<Scope>,
filter: &RatingFilter, filter: &RatingFilter,
trash: bool, trash: bool,
first: usize, first: usize,
@@ -310,7 +302,7 @@ pub fn read_ids_span(
/// narrows. Swapping the two silently looks up a collection by an image id. /// narrows. Swapping the two silently looks up a collection by an image id.
pub fn ordinal_of_path( pub fn ordinal_of_path(
catalog: &Catalog, catalog: &Catalog,
scope: Option<dr_types::CollectionId>, scope: Option<Scope>,
filter: &RatingFilter, filter: &RatingFilter,
trash: bool, trash: bool,
path: &str, path: &str,
@@ -669,18 +661,18 @@ mod tests {
.unwrap(); .unwrap();
let filter = RatingFilter::default(); let filter = RatingFilter::default();
let cells = read_cells_scoped(&catalog, Some(coll), &filter, 0, 100).unwrap(); let cells = read_cells_scoped(&catalog, Some(Scope::Collection(coll)), &filter, 0, 100).unwrap();
assert_eq!(cells.len(), 2); assert_eq!(cells.len(), 2);
for (i, cell) in cells.iter().enumerate() { for (i, cell) in cells.iter().enumerate() {
assert_eq!( assert_eq!(
ordinal_of_path(&catalog, Some(coll), &filter, false, &cell.remote_path).unwrap(), ordinal_of_path(&catalog, Some(Scope::Collection(coll)), &filter, false, &cell.remote_path).unwrap(),
Some(i), Some(i),
"{} is drawn at row {i} of the collection", "{} is drawn at row {i} of the collection",
cell.remote_path cell.remote_path
); );
} }
assert_eq!( assert_eq!(
ordinal_of_path(&catalog, Some(coll), &filter, false, "2019/a.CR2").unwrap(), ordinal_of_path(&catalog, Some(Scope::Collection(coll)), &filter, false, "2019/a.CR2").unwrap(),
None, None,
"not a member" "not a member"
); );
+12
View File
@@ -49,6 +49,18 @@ pub use thumbnails_fetch::*;
pub use thumbnails_gen::*; pub use thumbnails_gen::*;
pub use xmp::*; pub use xmp::*;
/// What narrows the grid, when something does.
///
/// A collection is a set of originals the photographer files; an album is the
/// photographs whose exports went into one folder. The grid reads either the
/// same way — see `spans::scope_clause`, the one place the difference is
/// spelled — and the whole library is `None`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Collection(dr_types::CollectionId),
Album(dr_catalog::AlbumId),
}
#[cfg(test)] #[cfg(test)]
pub(crate) mod test_support { pub(crate) mod test_support {
//! Fixtures shared with the modules that build on this one's catalog //! Fixtures shared with the modules that build on this one's catalog
+100 -55
View File
@@ -3,6 +3,8 @@
use dr_catalog::Catalog; use dr_catalog::Catalog;
use super::Scope;
use super::cells::uncollapsed; use super::cells::uncollapsed;
#[cfg(test)] #[cfg(test)]
use super::cells::{read_cells_all, read_cells_scoped, read_ids_span}; use super::cells::{read_cells_all, read_cells_scoped, read_ids_span};
@@ -16,36 +18,26 @@ use super::filters::{RatingFilter, GRID_ORDER, VISIBLE};
/// the user scrolling through empty rows. /// the user scrolling through empty rows.
pub fn total_images_scoped( pub fn total_images_scoped(
catalog: &Catalog, catalog: &Catalog,
scope: Option<dr_types::CollectionId>, scope: Option<Scope>,
filter: &RatingFilter, filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> { ) -> Result<usize, dr_catalog::CatalogError> {
let Some(scope) = scope else { if scope.is_none() {
return total_images_filtered(catalog, filter); return total_images_filtered(catalog, filter);
}; }
let (clause, params) = scope_clause(catalog, scope)?;
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let rated = filter.sql(); let rated = filter.sql();
// DISTINCT: an image in both a parent and a child is one photograph, and a // DISTINCT: an image in both a parent and a child is one photograph, and a
// count that disagrees with the number of cells drawn is worse than either // count that disagrees with the number of cells drawn is worse than either
// number alone. // number alone.
// //
// Counted through `images` rather than over `collection_members` alone, so // Counted through `images` rather than over the membership table alone, so
// `VISIBLE` applies — a trashed photograph is still a member row, and // `VISIBLE` applies — a trashed photograph is still a member row, and
// counting it made the header claim images the grid would not draw. // counting it made the header claim images the grid would not draw.
let folded = uncollapsed("i"); let folded = uncollapsed("i");
let sql = format!( let sql = format!(
"SELECT count(DISTINCT i.id) FROM images i "SELECT count(DISTINCT i.id) FROM images i
WHERE {VISIBLE}{rated}{folded} WHERE {VISIBLE}{rated}{folded}{clause}"
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))"
); );
let params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect();
let n: i64 = let n: i64 =
catalog catalog
.connection() .connection()
@@ -162,9 +154,11 @@ pub fn reordered(
/// `collection_members`' primary key, which is `(collection_id, image_id)`. /// `collection_members`' primary key, which is `(collection_id, image_id)`.
pub(super) fn grid_order_for( pub(super) fn grid_order_for(
catalog: &Catalog, catalog: &Catalog,
scope: Option<dr_types::CollectionId>, scope: Option<Scope>,
) -> (String, Vec<rusqlite::types::Value>) { ) -> (String, Vec<rusqlite::types::Value>) {
let Some(id) = scope else { // An album has no order of its own to keep: it is a record of what was
// exported, and capture time is how its photographs read best.
let Some(Scope::Collection(id)) = scope else {
return (GRID_ORDER.to_string(), Vec::new()); return (GRID_ORDER.to_string(), Vec::new());
}; };
@@ -195,32 +189,47 @@ pub(super) fn grid_order_for(
) )
} }
/// The SQL restricting a query to `scope` and its descendants, with the bound /// The SQL restricting a query to `scope`, with the bound parameters to go
/// parameters to go with it. /// with it: a collection and its descendants, or the photographs an album's
/// files were exported from.
/// ///
/// Shared by the span and the histogram so the two cannot drift: an axis drawn /// Every scoped reader goes through this, so the count, the cells, the span
/// over one set of images and bars counted over another puts the bars in the /// and the histogram cannot drift: an axis drawn over one set of images and
/// wrong place. /// bars counted over another puts the bars in the wrong place, and a header
/// count that disagrees with the cells drawn leaves empty rows to scroll.
pub(super) fn scope_clause( pub(super) fn scope_clause(
catalog: &Catalog, catalog: &Catalog,
scope: Option<dr_types::CollectionId>, scope: Option<Scope>,
) -> Result<(String, Vec<rusqlite::types::Value>), dr_catalog::CatalogError> { ) -> Result<(String, Vec<rusqlite::types::Value>), dr_catalog::CatalogError> {
let Some(scope) = scope else { match scope {
return Ok((String::new(), Vec::new())); None => Ok((String::new(), Vec::new())),
}; Some(Scope::Collection(scope)) => {
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
let placeholders = std::iter::repeat_n("?", ids.len()) // Placeholders are generated from the *count* of ids, never from
.collect::<Vec<_>>() // user text.
.join(","); let placeholders = std::iter::repeat_n("?", ids.len())
Ok(( .collect::<Vec<_>>()
format!( .join(",");
" AND i.id IN (SELECT image_id FROM collection_members Ok((
WHERE collection_id IN ({placeholders}))" format!(
), " AND i.id IN (SELECT image_id FROM collection_members
ids.iter() WHERE collection_id IN ({placeholders}))"
.map(|c| rusqlite::types::Value::Integer(c.0 as i64)) ),
.collect(), ids.iter()
)) .map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect(),
))
}
Some(Scope::Album(album)) => {
// The table exists only once an album has; an album scope with no
// table behind it has nothing in it, and the query must still run.
dr_catalog::albums::ensure_tables(catalog.connection())?;
Ok((
" AND i.id IN (SELECT image_id FROM album_exports WHERE album_id = ?)".to_string(),
vec![rusqlite::types::Value::Integer(album.0 as i64)],
))
}
}
} }
/// Earliest and latest capture time within `scope`, honouring the filter. /// Earliest and latest capture time within `scope`, honouring the filter.
@@ -231,7 +240,7 @@ pub(super) fn scope_clause(
/// collection's bars crushed into a sliver of it. /// collection's bars crushed into a sliver of it.
pub fn span_scoped( pub fn span_scoped(
catalog: &Catalog, catalog: &Catalog,
scope: Option<dr_types::CollectionId>, scope: Option<Scope>,
filter: &RatingFilter, filter: &RatingFilter,
) -> Option<(i64, i64)> { ) -> Option<(i64, i64)> {
let (clause, params) = scope_clause(catalog, scope).ok()?; let (clause, params) = scope_clause(catalog, scope).ok()?;
@@ -281,7 +290,7 @@ pub fn span_scoped(
/// SQL, and the caller's number comes from a hand-editable settings file. /// SQL, and the caller's number comes from a hand-editable settings file.
pub fn timeline_uniform( pub fn timeline_uniform(
catalog: &Catalog, catalog: &Catalog,
scope: Option<dr_types::CollectionId>, scope: Option<Scope>,
filter: &RatingFilter, filter: &RatingFilter,
from: i64, from: i64,
to: i64, to: i64,
@@ -400,6 +409,42 @@ mod tests {
use super::*; use super::*;
use crate::library::test_support::*; use crate::library::test_support::*;
#[test]
fn an_album_scopes_the_grid_to_what_was_exported_into_it() {
use dr_catalog::albums::{self, Place};
let catalog = with_images(10);
let ids = image_ids(&catalog);
let album = albums::create(catalog.connection(), "Web", &Place::Local("/out".into()))
.unwrap();
// Two files from one photograph, one from another: two cells.
albums::record_exports(
catalog.connection(),
album,
&[
(ids[1], "a.jpg".into()),
(ids[1], "a-crop.jpg".into()),
(ids[7], "b.jpg".into()),
],
)
.unwrap();
let scope = Some(Scope::Album(album));
let cells = read_cells_scoped(&catalog, scope, &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 2);
assert_eq!(
total_images_scoped(&catalog, scope, &RatingFilter::default()).unwrap(),
2
);
assert_eq!(
read_ids_span(&catalog, scope, &RatingFilter::default(), false, 0, 1)
.unwrap()
.len(),
2,
"a shift-click over the album selects what the grid drew"
);
}
#[test] #[test]
fn a_scoped_grid_shows_only_that_collections_images() { fn a_scoped_grid_shows_only_that_collections_images() {
use dr_catalog::collections::{self as coll, CollectionKind}; use dr_catalog::collections::{self as coll, CollectionKind};
@@ -415,10 +460,10 @@ mod tests {
.unwrap(); .unwrap();
coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap(); coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap();
let cells = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap(); let cells = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 3); assert_eq!(cells.len(), 3);
assert_eq!( assert_eq!(
total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(),
3 3
); );
// Unscoped is still the whole library. // Unscoped is still the whole library.
@@ -449,10 +494,10 @@ mod tests {
// The parent itself has no direct members at all. // The parent itself has no direct members at all.
let cells = let cells =
read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 4, "the set shows what its children hold"); assert_eq!(cells.len(), 4, "the set shows what its children hold");
assert_eq!( assert_eq!(
total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(),
4 4
); );
} }
@@ -478,10 +523,10 @@ mod tests {
coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap(); coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap();
let cells = let cells =
read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 3, "images 0..3, each once"); assert_eq!(cells.len(), 3, "images 0..3, each once");
assert_eq!( assert_eq!(
total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(),
3 3
); );
} }
@@ -497,7 +542,7 @@ mod tests {
let c = coll::create(catalog.connection(), "Big", None, CollectionKind::Manual).unwrap(); let c = coll::create(catalog.connection(), "Big", None, CollectionKind::Manual).unwrap();
coll::add_images(catalog.connection(), c, &ids).unwrap(); coll::add_images(catalog.connection(), c, &ids).unwrap();
let page = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 10, 5).unwrap(); let page = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 10, 5).unwrap();
assert_eq!(page.len(), 5); assert_eq!(page.len(), 5);
assert_eq!(page[0].name, "img010.CR2"); assert_eq!(page[0].name, "img010.CR2");
} }
@@ -512,12 +557,12 @@ mod tests {
let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap(); let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap();
assert!( assert!(
read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120) read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120)
.unwrap() .unwrap()
.is_empty() .is_empty()
); );
assert_eq!( assert_eq!(
total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(),
0 0
); );
} }
@@ -626,7 +671,7 @@ mod tests {
let wanted: Vec<_> = all.iter().rev().copied().collect(); let wanted: Vec<_> = all.iter().rev().copied().collect();
dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap(); dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap();
let cells = read_cells_scoped(&catalog, Some(id), &RatingFilter::default(), 0, 50).unwrap(); let cells = read_cells_scoped(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), 0, 50).unwrap();
let drawn: Vec<_> = cells let drawn: Vec<_> = cells
.iter() .iter()
.map(|c| dr_types::ImageId(c.image_id as u64)) .map(|c| dr_types::ImageId(c.image_id as u64))
@@ -634,7 +679,7 @@ mod tests {
assert_eq!(drawn, wanted, "the grid draws the order that was written"); assert_eq!(drawn, wanted, "the grid draws the order that was written");
let spanned = let spanned =
read_ids_span(&catalog, Some(id), &RatingFilter::default(), false, 0, 4).unwrap(); read_ids_span(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), false, 0, 4).unwrap();
assert_eq!(spanned, wanted, "and a range resolves through the same one"); assert_eq!(spanned, wanted, "and a range resolves through the same one");
assert_eq!( assert_eq!(
@@ -671,7 +716,7 @@ mod tests {
dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap(); dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap();
let cells = let cells =
read_cells_scoped(&catalog, Some(parent), &RatingFilter::default(), 0, 50).unwrap(); read_cells_scoped(&catalog, Some(Scope::Collection(parent)), &RatingFilter::default(), 0, 50).unwrap();
let drawn: Vec<_> = cells let drawn: Vec<_> = cells
.iter() .iter()
.map(|c| dr_types::ImageId(c.image_id as u64)) .map(|c| dr_types::ImageId(c.image_id as u64))
@@ -691,7 +736,7 @@ mod tests {
dr_catalog::collections::CollectionKind::Smart, dr_catalog::collections::CollectionKind::Smart,
) )
.unwrap(); .unwrap();
let (order, params) = grid_order_for(&catalog, Some(id)); let (order, params) = grid_order_for(&catalog, Some(Scope::Collection(id)));
assert_eq!(order, GRID_ORDER); assert_eq!(order, GRID_ORDER);
assert!(params.is_empty()); assert!(params.is_empty());
} }
+55 -6
View File
@@ -74,7 +74,7 @@ pub(super) const MAX_CELL_SIZE: f32 = 420.0;
/// Not `Copy` since `RatingFilter` stopped being — it holds a set of people. /// Not `Copy` since `RatingFilter` stopped being — it holds a set of people.
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub(super) struct LibraryFacts { pub(super) struct LibraryFacts {
pub(super) scope: Option<dr_types::CollectionId>, pub(super) scope: Option<library::Scope>,
pub(super) filter: library::RatingFilter, pub(super) filter: library::RatingFilter,
pub(super) trash: bool, pub(super) trash: bool,
pub(super) total: usize, pub(super) total: usize,
@@ -202,6 +202,11 @@ pub struct LibraryController {
/// and read here. Shared rather than passed per call because a rescan, a /// and read here. Shared rather than passed per call because a rescan, a
/// scrub and a drop all reload the window and must all honour it. /// scrub and a drop all reload the window and must all honour it.
pub(super) scope: RefCell<Option<dr_types::CollectionId>>, pub(super) scope: RefCell<Option<dr_types::CollectionId>>,
/// TRACES: FR-EXP-10
/// Which album narrows the grid instead, owned by
/// [`crate::collections_ui`] beside `scope`. At most one of the two is set:
/// the sidebar has one selection.
pub(super) album: std::cell::Cell<Option<dr_catalog::AlbumId>>,
/// TRACES: FR-UI-8 /// TRACES: FR-UI-8
/// How to open a photograph in develop. /// How to open a photograph in develop.
/// ///
@@ -460,6 +465,7 @@ impl LibraryController {
sync_timer: RefCell::new(None), sync_timer: RefCell::new(None),
session: RefCell::new(None), session: RefCell::new(None),
scope: RefCell::new(None), scope: RefCell::new(None),
album: std::cell::Cell::new(None),
open_image: RefCell::new(None), open_image: RefCell::new(None),
place_store: RefCell::new(None), place_store: RefCell::new(None),
place_device: RefCell::new(String::new()), place_device: RefCell::new(String::new()),
@@ -847,7 +853,7 @@ impl LibraryController {
}; };
library::read_ids_span( library::read_ids_span(
catalog, catalog,
*self.scope.borrow(), self.grid_scope(),
&self.filter.borrow(), &self.filter.borrow(),
self.viewing_trash.get(), self.viewing_trash.get(),
first, first,
@@ -922,13 +928,28 @@ impl LibraryController {
/// Assembled here because a worker thread can reach neither the catalog nor /// Assembled here because a worker thread can reach neither the catalog nor
/// the session, and both are needed to say where a cached original lives. /// the session, and both are needed to say where a cached original lives.
pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec<crate::export::Source> { pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec<crate::export::Source> {
self.export_sources_with_ids(images).0
}
/// TRACES: FR-EXP-10
/// [`Self::export_sources`], and the image each source is, in the same
/// order — what an album records its files against.
pub fn export_sources_with_ids(
&self,
images: &[dr_types::ImageId],
) -> (Vec<crate::export::Source>, Vec<Option<dr_types::ImageId>>) {
self.selected_image_paths(images) self.selected_image_paths(images)
.into_iter() .into_iter()
.map(|(id, path)| crate::export::Source::Library { .map(|(id, path)| {
path, (
cache: self.cache_context_for(id), crate::export::Source::Library {
path,
cache: self.cache_context_for(id),
},
Some(id),
)
}) })
.collect() .unzip()
} }
/// The open library's connection, for a full-file fetch. /// The open library's connection, for a full-file fetch.
@@ -939,9 +960,36 @@ impl LibraryController {
self.session.borrow().as_ref().map(|(c, _)| c.clone()) self.session.borrow().as_ref().map(|(c, _)| c.clone())
} }
/// What the grid is narrowed to, collection or album. Every reader of the
/// grid's images goes through this rather than `scope`, so an album is
/// honoured by the count, the cells, the timeline and a shift-click alike.
pub(crate) fn grid_scope(&self) -> Option<library::Scope> {
match (self.album.get(), *self.scope.borrow()) {
(Some(album), _) => Some(library::Scope::Album(album)),
(None, Some(c)) => Some(library::Scope::Collection(c)),
(None, None) => None,
}
}
/// TRACES: FR-EXP-10
/// Narrow the grid to the photographs behind an album's files.
///
/// Clears the collection scope and the trash: the sidebar has one
/// selection, and an album is not inside either.
pub fn set_album(&self, album: Option<dr_catalog::AlbumId>) {
self.album.set(album);
if album.is_some() {
*self.scope.borrow_mut() = None;
self.viewing_trash.set(false);
}
*self.offset.borrow_mut() = 0;
self.requested.borrow_mut().clear();
}
/// Narrow the grid to a collection, or to the whole library with `None`. /// Narrow the grid to a collection, or to the whole library with `None`.
pub fn set_scope(&self, scope: Option<dr_types::CollectionId>) { pub fn set_scope(&self, scope: Option<dr_types::CollectionId>) {
*self.scope.borrow_mut() = scope; *self.scope.borrow_mut() = scope;
self.album.set(None);
// Selecting a collection is leaving the trash. Without this, picking a // Selecting a collection is leaving the trash. Without this, picking a
// collection while the trash was open would keep listing trashed images // collection while the trash was open would keep listing trashed images
// under that collection's name. // under that collection's name.
@@ -961,6 +1009,7 @@ impl LibraryController {
self.viewing_trash.set(viewing); self.viewing_trash.set(viewing);
if viewing { if viewing {
*self.scope.borrow_mut() = None; *self.scope.borrow_mut() = None;
self.album.set(None);
} }
*self.offset.borrow_mut() = 0; *self.offset.borrow_mut() = 0;
self.requested.borrow_mut().clear(); self.requested.borrow_mut().clear();
+3 -3
View File
@@ -82,7 +82,7 @@ pub(super) fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<L
// Scoped to whatever the grid is showing. A collection's histogram drawn // Scoped to whatever the grid is showing. A collection's histogram drawn
// over the whole library's span said almost nothing: every bar for a // over the whole library's span said almost nothing: every bar for a
// fortnight in Arosa landed in one column of a fifteen-year axis. // fortnight in Arosa landed in one column of a fifteen-year axis.
let scope = *ctl.scope.borrow(); let scope = ctl.grid_scope();
let filter = ctl.filter.borrow().clone(); let filter = ctl.filter.borrow().clone();
// Passed whole, date range included, and both queries below lift that // Passed whole, date range included, and both queries below lift that
// range themselves — see `span_scoped` and `timeline_uniform`. Every other // range themselves — see `span_scoped` and `timeline_uniform`. Every other
@@ -376,7 +376,7 @@ pub(super) fn catalog_span(catalog: &Catalog, ctl: &Rc<LibraryController>) -> Op
// through the range, narrowing it would move the ground under the very // through the range, narrowing it would move the ground under the very
// handles doing the narrowing — each drag re-scaling the axis, so the next // handles doing the narrowing — each drag re-scaling the axis, so the next
// one meant something else. // one meant something else.
library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow()) library::span_scoped(catalog, ctl.grid_scope(), &ctl.filter.borrow())
} }
/// TRACES: NFR-P5 /// TRACES: NFR-P5
@@ -848,7 +848,7 @@ fn resolve_place(
if !place.path.is_empty() { if !place.path.is_empty() {
match library::ordinal_of_path( match library::ordinal_of_path(
catalog, catalog,
*ctl.scope.borrow(), ctl.grid_scope(),
&ctl.filter.borrow(), &ctl.filter.borrow(),
ctl.viewing_trash.get(), ctl.viewing_trash.get(),
&place.path, &place.path,
+12 -8
View File
@@ -306,7 +306,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// and filtering later would fetch thumbnails for images the user is not // and filtering later would fetch thumbnails for images the user is not
// looking at, which on a remote library is the cost FR-NC-3 exists to // looking at, which on a remote library is the cost FR-NC-3 exists to
// avoid. // avoid.
let scope = *ctl.scope.borrow(); let scope = ctl.grid_scope();
let filter = ctl.filter.borrow().clone(); let filter = ctl.filter.borrow().clone();
// The trash lists what every other view excludes, so it takes its own // The trash lists what every other view excludes, so it takes its own
// query rather than another predicate threaded through the scoped one. // query rather than another predicate threaded through the scoped one.
@@ -340,11 +340,15 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
window window
.global::<Library>() .global::<Library>()
.set_library_scope_pinned(match scope { .set_library_scope_pinned(match scope {
Some(id) => dr_catalog::collections::descendants(catalog.connection(), id) Some(library::Scope::Collection(id)) => {
dr_catalog::collections::descendants(catalog.connection(), id)
.map(|ids| collection_images(catalog, &ids)) .map(|ids| collection_images(catalog, &ids))
.map(|images| scope_is_pinned(catalog, &images)) .map(|images| scope_is_pinned(catalog, &images))
.unwrap_or(false), .unwrap_or(false)
None => false, }
// An album is not pinned as a unit; its photographs are
// pinned where they are filed.
Some(library::Scope::Album(_)) | None => false,
}); });
} }
// Read before it is overwritten: the property still holds what the library // Read before it is overwritten: the property still holds what the library
@@ -385,9 +389,9 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// by when, and it is not a collection at all. // by when, and it is not a collection at all.
window.global::<Library>().set_library_reorderable( window.global::<Library>().set_library_reorderable(
!trash !trash
&& scope.is_some_and(|c| { && matches!(scope, Some(library::Scope::Collection(c))
dr_catalog::collections::orders_manually(catalog.connection(), c).unwrap_or(false) if dr_catalog::collections::orders_manually(catalog.connection(), c)
}), .unwrap_or(false)),
); );
let cells = if trash { let cells = if trash {
+3 -245
View File
@@ -26,14 +26,13 @@ use std::rc::Rc;
use dr_types::settings::budget; use dr_types::settings::budget;
use dr_types::{ use dr_types::{
CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, FaceDetector, CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, FaceDetector,
GroupNavigation, LibrarySettings, OutputSharpening, ScreenSize, Settings, SizingMode, GroupNavigation, LibrarySettings, OutputSharpening, ScreenSize, Settings, SizingMode,
}; };
use slint::ComponentHandle; use slint::ComponentHandle;
use crate::settings_store::SettingsStore; use crate::settings_store::SettingsStore;
use crate::{Adjustments, AppWindow, ExportOptions, Page}; use crate::{Adjustments, AppWindow, ExportOptions, Page};
use dr_sync::Connection;
/// Shared settings state for the running window. /// Shared settings state for the running window.
pub struct SettingsController { pub struct SettingsController {
@@ -46,24 +45,6 @@ pub struct SettingsController {
/// How much disk the cache is currently using, as a label. Supplied by /// How much disk the cache is currently using, as a label. Supplied by
/// whoever owns the catalog — this module has no connection to query. /// whoever owns the catalog — this module has no connection to query.
usage_label: RefCell<String>, usage_label: RefCell<String>,
/// TRACES: FR-EXP-6
/// The remote folder picker, while it is open.
///
/// The same [`FolderBrowser`](crate::launch::FolderBrowser) the launch
/// screen uses to choose a library root, reused rather than reimplemented:
/// it browses a remote tree and nothing about it is specific to what the
/// chosen folder is *for*. `None` means the picker is closed, which is
/// also the only state a device destination ever has — a path on this
/// machine is typed or chosen by the platform, not walked through a
/// backend.
pub browser: RefCell<Option<crate::launch::FolderBrowser>>,
/// Polls the folder listing while one is in flight.
///
/// Held here rather than in the function that starts it: a `slint::Timer`
/// stops the moment it is dropped, so a timer local to `spawn_folder_list`
/// would be collected before the listing it is waiting on ever arrived.
/// The same place `LaunchController` keeps its own poll timer.
poll_timer: RefCell<Option<slint::Timer>>,
} }
impl SettingsController { impl SettingsController {
@@ -75,8 +56,6 @@ impl SettingsController {
store, store,
error: RefCell::new(None), error: RefCell::new(None),
usage_label: RefCell::new(String::new()), usage_label: RefCell::new(String::new()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
}) })
} }
@@ -85,21 +64,6 @@ impl SettingsController {
self.settings.borrow().clone() self.settings.borrow().clone()
} }
/// Adopt a folder chosen in the picker as the export destination.
///
/// Goes through `edit` like every other change, so it is saved the moment
/// it is chosen — the page has no Save button and a destination that
/// survived only until the window closed would be the one setting that
/// behaved differently from all the others.
pub fn set_destination(&self, path: String) {
self.edit(|s| s.export.set_active_destination(path));
}
/// Report a failure onto the page's error line.
pub fn set_error(&self, message: impl Into<String>) {
*self.error.borrow_mut() = Some(message.into());
}
/// Show what the cache is holding. Empty hides the line. /// Show what the cache is holding. Empty hides the line.
pub fn set_usage_label(&self, label: String) { pub fn set_usage_label(&self, label: String) {
*self.usage_label.borrow_mut() = label; *self.usage_label.borrow_mut() = label;
@@ -362,78 +326,6 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
.global::<ExportOptions>() .global::<ExportOptions>()
.set_strip_location(s.export.strip_location); .set_strip_location(s.export.strip_location);
// `available()`, not `ALL`: Android cannot write to a device folder, and
// offering a target that fails at the last step is what made export look
// broken there rather than absent.
let targets = ExportTarget::available();
window
.global::<ExportOptions>()
.set_target_labels(labels(targets.iter().map(|t| t.label())));
window
.global::<ExportOptions>()
.set_target_selected(index_of(targets, &s.export.target));
window
.global::<ExportOptions>()
.set_destination(s.export.active_destination().into());
// Derived from the target itself, never from its position in a list whose
// length differs by platform.
window
.global::<ExportOptions>()
.set_browse_available(s.export.target.is_remote());
// The field means different things either side of the choice, and a
// placeholder saying which is cheaper than a paragraph under it.
// The placeholder names what an empty field *means*, which differs by
// target: on a filesystem it is a question, on the server it is the
// library root.
window
.global::<ExportOptions>()
.set_destination_hint(s.export.destination_label().into());
// --- the remote folder picker --------------------------------------
{
let browser = controller.browser.borrow();
window
.global::<ExportOptions>()
.set_browse_open(browser.is_some());
match browser.as_ref() {
Some(b) => {
// The root is shown as a word rather than as an empty string,
// which would read as a control that had lost its value.
window.global::<ExportOptions>().set_browse_path(
if b.path.is_empty() {
"Library root".to_string()
} else {
b.path.clone()
}
.into(),
);
window
.global::<ExportOptions>()
.set_browse_loading(b.loading);
window
.global::<ExportOptions>()
.set_browse_at_root(b.parent_path().is_none());
window
.global::<ExportOptions>()
.set_browse_entries(slint::ModelRc::new(slint::VecModel::from(
b.entries
.iter()
.map(|e| slint::SharedString::from(e.as_str()))
.collect::<Vec<_>>(),
)));
}
None => {
window
.global::<ExportOptions>()
.set_browse_entries(slint::ModelRc::new(slint::VecModel::from(Vec::<
slint::SharedString,
>::new(
))));
window.global::<ExportOptions>().set_browse_loading(false);
}
}
}
window.set_settings_error(controller.error.borrow().clone().unwrap_or_default().into()); window.set_settings_error(controller.error.borrow().clone().unwrap_or_default().into());
} }
@@ -889,34 +781,8 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
}); });
} }
{ // The export destination is an album, chosen and rendered by
let weak = window.as_weak(); // `albums_ui`, which has the catalog this page does not.
let ctl = controller.clone();
window
.global::<ExportOptions>()
.on_destination_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.set_active_destination(text.to_string()));
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.global::<ExportOptions>().on_target_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(&t) = ExportTarget::available().get(i.max(0) as usize) {
// Nothing is cleared. Each target keeps its own destination
// (see `ExportSettings::remote_destination`), so switching to
// look at the other option no longer throws away the one
// already set — which is what made this setting appear not to
// stick.
ctl.edit(|s| s.export.target = t);
}
render(&w, &ctl);
});
}
} }
/// --- reset --------------------------------------------------------- /// --- reset ---------------------------------------------------------
@@ -938,110 +804,6 @@ fn wire_reset(
} }
} }
/// TRACES: FR-EXP-6
/// List the folders under `path`, for the export destination picker.
///
/// A near-twin of `launch_ui::spawn_folder_list` and deliberately not shared
/// with it. That one reaches into the `LaunchController` for its account and
/// reports failures onto the launch screen's error line; this one is handed a
/// connection and writes to the settings page. Factoring them together would
/// mean a function taking both controllers, or a trait implemented twice to
/// abstract two call sites — more machinery than the twenty lines it saves.
///
/// The *model* is shared, which is the part that matters: both drive a
/// [`FolderBrowser`](crate::launch::FolderBrowser), so navigation behaves
/// identically in both places.
pub fn spawn_folder_list(
weak: slint::Weak<AppWindow>,
ctl: Rc<SettingsController>,
conn: Connection,
path: String,
) {
use dr_sync::RemotePath;
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
std::thread::spawn(move || {
// Multi-thread, for the reason the login worker records: a
// current-thread runtime left reqwest's connection future unpolled on
// Android, and the await never resolved.
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_io()
.enable_time()
.build();
let Ok(rt) = rt else {
let _ = tx.send(Err("runtime".into()));
return;
};
rt.block_on(async {
match crate::remote::connect(&conn) {
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
Ok(entries) => {
let mut dirs: Vec<String> = entries
.iter()
.filter(|e| e.kind == dr_sync::EntryKind::Directory)
.map(|e| e.path.name().to_string())
.collect();
dirs.sort_by_key(|d| d.to_ascii_lowercase());
let _ = tx.send(Ok(dirs));
}
Err(e) => {
let _ = tx.send(Err(e.to_string()));
}
},
Err(e) => {
let _ = tx.send(Err(e.to_string()));
}
}
});
});
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(150),
move || {
let Some(w) = weak.upgrade() else { return };
match rx.try_recv() {
Ok(Ok(dirs)) => {
if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() {
b.entries = dirs;
b.loading = false;
}
render(&w, &ctl_cb);
}
Ok(Err(e)) => {
// The picker stays open showing the folder it was on. A
// listing that failed is not a reason to discard where the
// user had navigated to.
if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() {
b.loading = false;
}
*ctl_cb.error.borrow_mut() = Some(format!("Could not list folders: {e}"));
render(&w, &ctl_cb);
}
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() {
b.loading = false;
}
render(&w, &ctl_cb);
}
}
// The channel has delivered, so there is nothing left to poll
// for. Stopping it here rather than leaving it running is what
// keeps a page opened and closed twenty times from accumulating
// twenty timers.
if let Some(t) = ctl_cb.poll_timer.borrow().as_ref() {
t.stop();
}
},
);
*ctl.poll_timer.borrow_mut() = Some(timer);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1066,8 +828,6 @@ mod tests {
store, store,
error: RefCell::new(None), error: RefCell::new(None),
usage_label: RefCell::new(String::new()), usage_label: RefCell::new(String::new()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
}) })
} }
@@ -1138,8 +898,6 @@ mod tests {
store: SettingsStore::open_at(blocker.join("settings.json")), store: SettingsStore::open_at(blocker.join("settings.json")),
error: RefCell::new(None), error: RefCell::new(None),
usage_label: RefCell::new(String::new()), usage_label: RefCell::new(String::new()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
}; };
broken.edit(|s| s.export.quality = 50); broken.edit(|s| s.export.quality = 50);
+480
View File
@@ -0,0 +1,480 @@
// TRACES: FR-EXP-10 | FR-EXP-6
// Albums: named export folders, in the sidebar under the collections.
//
// An album is where finished pictures go. Its folder holds only the exported
// files; the catalog remembers which photograph each came from, so selecting
// an album shows the originals behind it, and the export sheet sends a
// selection to one by name rather than to a path typed into a field.
//
// The folder is on this device (chosen in the platform's dialogue, or on
// Android the system's folder picker) or on the library's server (chosen in
// the browser below, which can make a folder as well as open one). Not inside
// the library itself: a JPEG exported there would be catalogued by the next
// scan as a photograph of its own.
import { Theme } from "theme.slint";
import { Button, Caption, Field, Icon, PanelHeading, Value } from "widgets.slint";
import { Segmented } from "controls.slint";
export struct AlbumRow {
id: int,
name: string,
// Photographs exported into it — what the grid shows when it is opened.
count: int,
// Where its files go, in words: "On this device · ~/Exports/Web",
// "On the server · Shared/Web", or that this device has no folder for it.
detail: string,
// The album has no folder on this device yet: made elsewhere with a
// folder local to that device.
homeless: bool,
}
export global Albums {
in property <[AlbumRow]> rows;
/// The album scoping the grid, 0 for none.
in property <int> selected: 0;
callback select(int);
/// Open the sheet on a new album.
callback create();
/// Open the sheet on an existing one.
callback edit(int);
// --- the sheet ------------------------------------------------------
in property <bool> sheet-open: false;
/// Editing an existing album rather than making one.
in property <bool> sheet-editing: false;
in property <string> sheet-name;
/// Whether the library has a server to put a folder on. A folder library
/// has only this device.
in property <bool> server-available: false;
/// 0: this device. 1: the server.
in property <int> sheet-where: 0;
/// The folder chosen on this device, for display. Empty until chosen.
in property <string> sheet-device-folder;
/// Something to say — a refusal, a failed request.
in property <string> sheet-error;
/// Delete has been pressed once and waits to be confirmed.
in property <bool> sheet-confirming-delete: false;
/// What saving would do, when the button alone would not say.
in property <bool> sheet-can-save: false;
callback sheet-where-picked(int);
callback sheet-choose-device-folder();
callback sheet-name-edited(string);
callback sheet-save();
callback sheet-cancel();
callback sheet-delete();
// --- the server folder, while the sheet is choosing one ---------------
in property <string> browse-path;
in property <[string]> browse-entries;
in property <bool> browse-loading: false;
in property <bool> browse-at-root: true;
/// Why the folder shown cannot be used, or empty when it can.
in property <string> browse-refusal;
callback browse-into(string);
callback browse-up();
callback browse-make(string);
}
// A folder on the server: where it is, what is in it, and a way to make one.
//
// Confirms the folder *shown*, not one selected in the list — the rule the
// launch screen's picker already follows — so walking into a folder is
// choosing it, and there is no second selection that could disagree.
export component ServerFolderBrowser inherits VerticalLayout {
in property <string> path;
in property <[string]> entries;
in property <bool> loading;
in property <bool> at-root: true;
callback into(string);
callback up();
callback make(string);
property <bool> naming: false;
spacing: Theme.gap-sm;
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "↑ Up";
// Disabled rather than hidden at the root: a control that
// vanishes moves everything beside it.
enabled: !root.at-root && !root.loading;
clicked => { root.up(); }
}
Value {
text: root.path == "" ? "/" : "/" + root.path;
overflow: elide;
horizontal-stretch: 1;
min-width: 0px;
vertical-alignment: center;
}
Button {
text: "New folder";
enabled: !root.loading && !root.naming;
clicked => { root.naming = true; }
}
}
if root.naming: HorizontalLayout {
spacing: Theme.gap-sm;
name := Field {
horizontal-stretch: 1;
label: "New folder name";
placeholder: "Folder name";
accepted(t) => {
if (t != "") {
root.make(t);
root.naming = false;
}
}
init => { self.take-focus(); }
}
Button {
text: "Make";
enabled: name.text != "";
clicked => {
root.make(name.text);
root.naming = false;
}
}
Button {
text: "Cancel";
clicked => { root.naming = false; }
}
}
// A fixed height rather than one that grows with the listing: a folder
// with sixty children would otherwise push the buttons off the sheet.
Rectangle {
height: 200px;
background: Theme.ground;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
if root.loading: Caption {
text: "Listing…";
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
if !root.loading: Flickable {
x: 4px;
y: 4px;
width: parent.width - 8px;
height: parent.height - 8px;
viewport-height: list.preferred-height;
list := VerticalLayout {
width: 100%;
spacing: 2px;
alignment: start;
if root.entries.length == 0: Caption {
text: "No folders here. Use this one, make one, or go up.";
wrap: word-wrap;
}
for folder in root.entries: Rectangle {
height: 32px;
background: row-touch.has-hover ? Theme.hover : transparent;
border-radius: Theme.radius-sm;
accessible-role: button;
accessible-label: folder;
accessible-action-default => { root.into(folder); }
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Icon {
name: "chevron-right";
ink: Theme.ink-faint;
size: 10px;
y: (parent.height - self.height) / 2;
}
Value {
text: folder;
overflow: elide;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
row-touch := TouchArea { clicked => { root.into(folder); } }
}
}
}
}
}
// One album in the sidebar.
component AlbumListRow inherits Rectangle {
in property <AlbumRow> entry;
in property <bool> selected;
callback clicked();
callback edit();
height: Theme.row-height;
background: root.selected ? Theme.surface-raised
: (touch.has-hover ? Theme.hover : transparent);
border-radius: Theme.radius-sm;
accessible-role: button;
accessible-label: root.entry.name + ", " + root.entry.detail;
accessible-action-default => { root.clicked(); }
touch := TouchArea {
clicked => { root.clicked(); }
double-clicked => { root.edit(); }
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down && ev.button == PointerEventButton.right) {
root.edit();
}
}
}
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Text {
text: root.entry.name;
color: root.entry.homeless ? Theme.ink-faint
: (root.selected ? Theme.ink : Theme.ink-dim);
font-size: Theme.text;
font-weight: root.selected ? 600 : 400;
overflow: elide;
vertical-alignment: center;
horizontal-stretch: 1;
}
Text {
text: root.entry.count > 0 ? root.entry.count : "";
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
// The way in to rename, move or delete, where there is no right
// button and no double click: a finger.
Rectangle {
width: 22px;
accessible-role: button;
accessible-label: "Edit " + root.entry.name;
accessible-action-default => { root.edit(); }
Icon {
name: "chevron-right";
ink: Theme.ink-faint;
size: 10px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
TouchArea { clicked => { root.edit(); } }
}
}
}
// The sidebar's albums: a heading with "+", and the list.
export component AlbumsSection inherits VerticalLayout {
spacing: 2px;
HorizontalLayout {
height: 26px;
spacing: Theme.gap-sm;
Text {
text: "ALBUMS";
color: Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
vertical-alignment: center;
horizontal-stretch: 1;
}
Rectangle {
width: 22px;
height: 22px;
y: (parent.height - self.height) / 2;
background: add-touch.pressed ? Theme.pressed
: (add-touch.has-hover ? Theme.hover : transparent);
border-radius: Theme.radius-sm;
accessible-role: button;
accessible-label: "New album";
accessible-action-default => { Albums.create(); }
Icon {
name: "plus";
ink: Theme.ink-dim;
size: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
add-touch := TouchArea { clicked => { Albums.create(); } }
}
}
if Albums.rows.length == 0: Text {
text: "Press + to make an export folder. Exports sent to it are listed here.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
// At most five rows tall, then it scrolls: the albums share the column
// with the collection tree, which is the thing that should grow.
if Albums.rows.length > 0: Flickable {
height: min(Albums.rows.length, 5) * (Theme.row-height + 2px);
viewport-height: Albums.rows.length * (Theme.row-height + 2px);
for row[i] in Albums.rows: AlbumListRow {
y: i * (Theme.row-height + 2px);
width: parent.width;
entry: row;
selected: row.id == Albums.selected;
clicked => { Albums.select(row.id); }
edit => { Albums.edit(row.id); }
}
}
}
// Making an album, or changing one: its name and where its files go.
export component AlbumSheet inherits Rectangle {
background: #000000CC;
TouchArea { clicked => { Albums.sheet-cancel(); } }
Rectangle {
width: min(560px, parent.width - 2 * Theme.gap-lg);
height: min(body.preferred-height + 2 * Theme.gap-lg, parent.height - 2 * Theme.gap-lg);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
TouchArea { }
Flickable {
viewport-height: body.preferred-height + 2 * Theme.gap-lg;
body := VerticalLayout {
x: Theme.gap-lg;
y: Theme.gap-lg;
width: parent.width - 2 * Theme.gap-lg;
spacing: Theme.gap;
alignment: start;
Text {
text: Albums.sheet-editing ? "Album" : "New album";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
}
PanelHeading { text: "NAME"; }
Field {
label: "Album name";
text: Albums.sheet-name;
placeholder: "Web, Prints, For Anna…";
edited(t) => { Albums.sheet-name-edited(t); }
accepted(t) => { Albums.sheet-name-edited(t); }
// The keyboard comes here when the sheet opens. Left on
// the grid, a name typed at once is read as shortcuts —
// P picks, R rejects, digits rate.
init => { self.take-focus(); }
}
PanelHeading { text: "FILES GO TO"; }
if Albums.server-available: Segmented {
label: "Where";
options: ["This device", "The server"];
selected: Albums.sheet-where;
picked(i) => { Albums.sheet-where-picked(i); }
}
if Albums.sheet-where == 0: HorizontalLayout {
spacing: Theme.gap-sm;
Value {
text: Albums.sheet-device-folder != "" ? Albums.sheet-device-folder : "No folder chosen";
placeholder: Albums.sheet-device-folder == "";
overflow: elide;
vertical-alignment: center;
horizontal-stretch: 1;
min-width: 0px;
}
Button {
text: Albums.sheet-device-folder != "" ? "Change…" : "Choose folder…";
clicked => { Albums.sheet-choose-device-folder(); }
}
}
if Albums.sheet-where == 0: Caption {
text: "The dialogue can make a new folder too. Only the exported files are written there.";
wrap: word-wrap;
}
if Albums.sheet-where == 1: ServerFolderBrowser {
path: Albums.browse-path;
entries: Albums.browse-entries;
loading: Albums.browse-loading;
at-root: Albums.browse-at-root;
into(name) => { Albums.browse-into(name); }
up => { Albums.browse-up(); }
make(name) => { Albums.browse-make(name); }
}
if Albums.sheet-where == 1 && Albums.browse-refusal != "": Caption {
text: Albums.browse-refusal;
warn: true;
wrap: word-wrap;
}
if Albums.sheet-error != "": Caption {
text: Albums.sheet-error;
warn: true;
wrap: word-wrap;
}
if Albums.sheet-confirming-delete: Caption {
text: "The album goes; the files already in its folder stay where they are.";
wrap: word-wrap;
}
HorizontalLayout {
spacing: Theme.gap-sm;
if Albums.sheet-editing: Button {
text: Albums.sheet-confirming-delete ? "Delete album" : "Delete…";
clicked => { Albums.sheet-delete(); }
}
Rectangle { horizontal-stretch: 1; }
Button {
text: "Cancel";
clicked => { Albums.sheet-cancel(); }
}
Button {
text: Albums.sheet-editing ? "Save" : "Make album";
primary: true;
enabled: Albums.sheet-can-save;
clicked => { Albums.sheet-save(); }
}
}
}
}
}
}
+15 -4
View File
@@ -16,6 +16,7 @@ import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint"; import { RecoveryPrompt } from "recovery.slint";
import { CopySheet, PresetOrigin, PresetRow, PresetSheet, ScopeChips, ScopeKind } from "presets.slint"; import { CopySheet, PresetOrigin, PresetRow, PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { ExportOptions, ExportSheet } from "export.slint"; import { ExportOptions, ExportSheet } from "export.slint";
import { Albums, AlbumSheet, AlbumRow } from "albums.slint";
import { FocusMarks, FocusPanel, Peaking } from "peaking.slint"; import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
import { SettingsPage } from "settings.slint"; import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint"; import { ImportPage } from "import.slint";
@@ -37,6 +38,7 @@ export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Mask
export { Collections } export { Collections }
export { Library } export { Library }
export { ExportOptions } export { ExportOptions }
export { Albums, AlbumRow }
export { Pickers } from "pickers.slint"; export { Pickers } from "pickers.slint";
export { Duplicates, DuplicateRow, DuplicateCopy } export { Duplicates, DuplicateRow, DuplicateCopy }
export { Scrolling } export { Scrolling }
@@ -939,7 +941,7 @@ in property <bool> panel-visible: true;
/// this device. The grid says the same thing as the develop button, but it /// this device. The grid says the same thing as the develop button, but it
/// composes its own label around a selection count — so it is given the /// composes its own label around a selection count — so it is given the
/// fact and not the sentence. /// fact and not the sentence.
in property <bool> export-to-server: false; in property <string> export-album;
/// Show or hide the develop column. /// Show or hide the develop column.
callback toggle-panel(); callback toggle-panel();
/// Export the image on screen, using the settings as they stand. /// Export the image on screen, using the settings as they stand.
@@ -1516,7 +1518,7 @@ in property <bool> panel-visible: true;
// TRACES: FR-EXP-7 // TRACES: FR-EXP-7
exporting: Library.library-exporting; exporting: Library.library-exporting;
export-to-server: root.export-to-server; export-album: root.export-album;
export-selection => { Library.library-export-selection(); } export-selection => { Library.library-export-selection(); }
cancel-export => { Library.library-cancel-export(); } cancel-export => { Library.library-cancel-export(); }
// TRACES: FR-MRG-1 // TRACES: FR-MRG-1
@@ -3452,8 +3454,8 @@ in property <bool> panel-visible: true;
error: root.settings-error; error: root.settings-error;
action-label: root.active-view == View.develop action-label: root.active-view == View.develop
? root.export-label ? root.export-label
: (root.export-to-server : (root.export-album != ""
? "Export " + Library.library-selected-count + " to the library" ? "Export " + Library.library-selected-count + " to " + root.export-album
: "Export " + Library.library-selected-count); : "Export " + Library.library-selected-count);
can-export: root.export-sheet-can-export; can-export: root.export-sheet-can-export;
export => { root.export-from-sheet(); } export => { root.export-from-sheet(); }
@@ -3541,6 +3543,15 @@ in property <bool> panel-visible: true;
dismiss() => { Collections.collection-menu-dismiss(); } dismiss() => { Collections.collection-menu-dismiss(); }
} }
// TRACES: FR-EXP-10
// Above the export sheet, which opens it for "New album…", and above
// the sidebar that opens it otherwise — which the compact layout
// hides, so it cannot be drawn inside it.
if Albums.sheet-open: AlbumSheet {
width: 100%;
height: 100%;
}
// Last, and therefore over everything including the settings page and // Last, and therefore over everything including the settings page and
// the offline prompt. Not a preference about layering: this is asked // the offline prompt. Not a preference about layering: this is asked
// before the grid exists, and nothing else in the window is about a // before the grid exists, and nothing else in the window is about a
+14
View File
@@ -35,6 +35,7 @@ import { Theme } from "theme.slint";
import { Duplicates } from "duplicates.slint"; import { Duplicates } from "duplicates.slint";
import { Keys } from "keys.slint"; import { Keys } from "keys.slint";
import { Button, Icon, ScrollBar } from "widgets.slint"; import { Button, Icon, ScrollBar } from "widgets.slint";
import { AlbumsSection } from "albums.slint";
// One row of the collection tree. // One row of the collection tree.
export struct CollectionRow { export struct CollectionRow {
@@ -783,6 +784,19 @@ export component CollectionsPanel inherits Rectangle {
clicked => { root.toggle-pin-scope(); } clicked => { root.toggle-pin-scope(); }
} }
// --- albums -------------------------------------------------------
//
// TRACES: FR-EXP-10
// Under the collections and above the trash: a selection target like
// a collection, but not a drop target — photographs reach an album by
// being exported to it, never by being filed.
Rectangle {
height: 1px;
background: Theme.rule;
}
AlbumsSection {}
// --- trash -------------------------------------------------------- // --- trash --------------------------------------------------------
// //
// TRACES: FR-CAT-15 // TRACES: FR-CAT-15
+35 -173
View File
@@ -43,33 +43,16 @@ export global ExportOptions {
in property <[string]> collision-labels; in property <[string]> collision-labels;
in property <int> collision-selected: 0; in property <int> collision-selected: 0;
in property <bool> strip-location: true; in property <bool> strip-location: true;
in-out property <string> destination; // --- where it goes (FR-EXP-10) -----------------------------------
// What the destination field means depends on this, so the placeholder
// comes from Rust alongside it rather than being written twice here.
in property <string> destination-hint;
in property <[string]> target-labels;
in property <int> target-selected: 0;
// --- the remote folder picker ------------------------------------
// //
// The same navigation the launch screen uses to choose a library root, // An album, by name. The folder behind it — on this device or on the
// driven by the same `FolderBrowser` model in Rust. A folder on the // server — is chosen once, when the album is made, in the platform's
// server is not something anyone can be expected to type from memory. // dialogue or the server browser; exporting only says which album.
in property <bool> browse-open: false; in property <[string]> album-labels;
in property <string> browse-path; /// -1 while none is chosen, and an export is refused until one is.
in property <[string]> browse-entries; in property <int> album-selected: -1;
in property <bool> browse-loading: false; /// Where the chosen album's files go, in words.
/// At the library root, so there is nowhere up to go. in property <string> album-detail;
in property <bool> browse-at-root: true;
/// Whether the destination is one that can be walked.
///
/// A boolean from Rust rather than a test on `target-selected`. The index
/// was hardcoded to 1, which was Remote's position while both targets were
/// offered — and the moment Android's list narrowed to Remote alone, that
/// index became 0 and the button vanished on the one platform where it is
/// the *only* way to set a destination. An index into a list whose length
/// varies is not a fact about the target.
in property <bool> browse-available: false;
callback format-picked(int); callback format-picked(int);
callback quality-changed(int); callback quality-changed(int);
@@ -83,13 +66,9 @@ export global ExportOptions {
callback template-changed(string); callback template-changed(string);
callback collision-picked(int); callback collision-picked(int);
callback strip-location-toggled(bool); callback strip-location-toggled(bool);
callback destination-changed(string); callback album-picked(int);
callback target-picked(int); /// Make a new album, from here rather than from the sidebar.
callback browse-open-picker(); callback album-new();
callback browse-into(string);
callback browse-up();
callback browse-confirm();
callback browse-cancel();
} }
// What the image becomes: format, quality, colour, size, sharpening. The // What the image becomes: format, quality, colour, size, sharpening. The
@@ -222,152 +201,35 @@ export component ExportFileOptions inherits VerticalLayout {
picked(i) => { ExportOptions.collision-picked(i); } picked(i) => { ExportOptions.collision-picked(i); }
} }
// Where the file lands, before what it is called: on // TRACES: FR-EXP-10
// Android the answer decides whether an export needs // Where the file lands, before what it is called: an album, which
// the Storage Access Framework at all, and on any // names a folder on this device or on the server and remembers
// platform a server destination is reached over a // what was sent to it. Not the library — a JPEG written into the
// network that may not be there. // library's own tree would be catalogued by the next scan as a
Segmented { // photograph of its own.
label: "Export to"; if ExportOptions.album-labels.length > 0: Segmented {
options: ExportOptions.target-labels; label: "Album";
selected: ExportOptions.target-selected; options: ExportOptions.album-labels;
picked(i) => { ExportOptions.target-picked(i); } selected: ExportOptions.album-selected;
columns: 3;
picked(i) => { ExportOptions.album-picked(i); }
} }
TextRow { if ExportOptions.album-labels.length == 0: Caption {
label: "Destination"; text: "No album yet. An album is a folder exports go to — on this device or on the server — and the photographs sent to it are listed under Albums in the library.";
// What an empty field does, not what it was once wrap: word-wrap;
// going to do: nothing asks, and an export with
// no folder is refused and says so in the header.
hint: "a folder on this device; exports are refused until one is set";
text <=> ExportOptions.destination;
field-width: 320px;
placeholder: ExportOptions.destination-hint;
accepted(t) => { ExportOptions.destination-changed(t); }
} }
// Offered only for a server destination. A folder on if ExportOptions.album-detail != "": Caption {
// this device is chosen by the platform's own dialogue text: ExportOptions.album-detail;
// or typed; a folder on the server can only be found wrap: word-wrap;
// by walking it, and expecting anyone to recall the }
// exact spelling of a path three levels down is how a
// destination silently becomes a new folder at the HorizontalLayout {
// root.
if ExportOptions.browse-available && !ExportOptions.browse-open: HorizontalLayout {
alignment: start; alignment: start;
Button { Button {
text: "Choose folder…"; text: "New album…";
clicked => { ExportOptions.browse-open-picker(); } clicked => { ExportOptions.album-new(); }
}
}
if ExportOptions.browse-open: Rectangle {
background: Theme.ground;
border-radius: Theme.radius;
height: picker.preferred-height + 2 * Theme.gap;
picker := VerticalLayout {
x: Theme.gap;
y: Theme.gap;
width: parent.width - 2 * Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "↑ Up";
// Disabled rather than hidden at the
// root: a control that vanishes moves
// everything beside it, and the row
// would jump as the user navigates.
enabled: !ExportOptions.browse-at-root;
clicked => { ExportOptions.browse-up(); }
}
Value {
text: ExportOptions.browse-path;
overflow: elide;
horizontal-stretch: 1;
vertical-alignment: center;
}
Caption {
text: ExportOptions.browse-loading ? "Listing…" : "";
vertical-alignment: center;
}
}
// A fixed height rather than one that grows
// with the listing: a folder with sixty
// children would otherwise push the rest of
// the settings page off the bottom.
Rectangle {
height: 180px;
background: Theme.surface;
border-radius: Theme.radius;
Flickable {
x: 4px;
y: 4px;
width: parent.width - 8px;
height: parent.height - 8px;
viewport-height: folders.preferred-height;
folders := VerticalLayout {
width: 100%;
spacing: 2px;
alignment: start;
if ExportOptions.browse-entries.length == 0
&& !ExportOptions.browse-loading: Caption {
text: "No folders here. "
+ "Use this one, or go up.";
}
for name in ExportOptions.browse-entries: Rectangle {
height: 32px;
background: touch.has-hover
? Theme.surface-raised
: transparent;
border-radius: Theme.radius;
Label {
x: Theme.gap-sm;
text: name;
vertical-alignment: center;
overflow: elide;
width: parent.width - 2 * Theme.gap-sm;
}
touch := TouchArea {
clicked => { ExportOptions.browse-into(name); }
}
}
}
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: end;
Button {
text: "Cancel";
clicked => { ExportOptions.browse-cancel(); }
}
// Confirms the folder currently *shown*,
// not one selected in the list — the same
// rule the library picker follows, so
// "use this one" means the same thing in
// both places.
Button {
text: "Use this folder";
active: true;
clicked => { ExportOptions.browse-confirm(); }
}
}
} }
} }
+4 -3
View File
@@ -2006,7 +2006,8 @@ export component LibraryGrid inherits Rectangle {
// where the files go nor the worker that writes them — it reports what is // where the files go nor the worker that writes them — it reports what is
// selected and asks, exactly as it does for a paste. // selected and asks, exactly as it does for a paste.
in property <bool> exporting: false; in property <bool> exporting: false;
in property <bool> export-to-server: false; /// The album an export goes to, by name; empty when none is chosen.
in property <string> export-album;
callback export-selection(); callback export-selection();
callback cancel-export(); callback cancel-export();
/// TRACES: FR-EXP-7 | FR-UI-5 /// TRACES: FR-EXP-7 | FR-UI-5
@@ -5217,8 +5218,8 @@ export component LibraryGrid inherits Rectangle {
if !root.ranging: Button { if !root.ranging: Button {
text: root.exporting text: root.exporting
? "Cancel export" ? "Cancel export"
: (root.export-to-server : (root.export-album != ""
? "Export " + root.selected-count + " to the library" ? "Export " + root.selected-count + " to " + root.export-album
: "Export " + root.selected-count); : "Export " + root.selected-count);
active: root.exporting; active: root.exporting;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;