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
+3 -245
View File
@@ -26,14 +26,13 @@ use std::rc::Rc;
use dr_types::settings::budget;
use dr_types::{
CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, FaceDetector,
CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, FaceDetector,
GroupNavigation, LibrarySettings, OutputSharpening, ScreenSize, Settings, SizingMode,
};
use slint::ComponentHandle;
use crate::settings_store::SettingsStore;
use crate::{Adjustments, AppWindow, ExportOptions, Page};
use dr_sync::Connection;
/// Shared settings state for the running window.
pub struct SettingsController {
@@ -46,24 +45,6 @@ pub struct SettingsController {
/// How much disk the cache is currently using, as a label. Supplied by
/// whoever owns the catalog — this module has no connection to query.
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 {
@@ -75,8 +56,6 @@ impl SettingsController {
store,
error: RefCell::new(None),
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()
}
/// 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.
pub fn set_usage_label(&self, label: String) {
*self.usage_label.borrow_mut() = label;
@@ -362,78 +326,6 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
.global::<ExportOptions>()
.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());
}
@@ -889,34 +781,8 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
});
}
{
let weak = window.as_weak();
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);
});
}
// The export destination is an album, chosen and rendered by
// `albums_ui`, which has the catalog this page does not.
}
/// --- 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)]
mod tests {
use super::*;
@@ -1066,8 +828,6 @@ mod tests {
store,
error: RefCell::new(None),
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")),
error: RefCell::new(None),
usage_label: RefCell::new(String::new()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
};
broken.edit(|s| s.export.quality = 50);