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:
+37
-128
@@ -20,6 +20,7 @@
|
||||
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
|
||||
|
||||
mod activity;
|
||||
mod albums_ui;
|
||||
#[cfg(all(feature = "automation", unix))]
|
||||
mod automation;
|
||||
mod bursts;
|
||||
@@ -703,6 +704,7 @@ fn batch_request(
|
||||
|
||||
export::BatchRequest {
|
||||
sources,
|
||||
images: Vec::new(),
|
||||
conn: library.credentials(),
|
||||
settings: stored.export,
|
||||
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
|
||||
/// 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.
|
||||
fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) {
|
||||
let stored = settings.snapshot();
|
||||
let remote = stored.export.target == dr_types::ExportTarget::Remote;
|
||||
pub(crate) fn refresh_export_label(window: &AppWindow) {
|
||||
use slint::Model as _;
|
||||
// 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(
|
||||
if remote {
|
||||
// Not the connector's name: this is a Nextcloud account for some
|
||||
// libraries and a folder on a mount for others.
|
||||
"Export to the library"
|
||||
if album.is_empty() {
|
||||
"Export".to_string()
|
||||
} else {
|
||||
"Export"
|
||||
format!("Export to {album}")
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
// 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).
|
||||
window.set_export_to_server(remote);
|
||||
window.set_export_album(album.into());
|
||||
}
|
||||
|
||||
/// Push current parameter values back to the interface.
|
||||
@@ -1610,6 +1617,14 @@ fn construct_screens(
|
||||
// each save over the other.
|
||||
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
|
||||
// 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.
|
||||
@@ -1697,6 +1712,16 @@ fn construct_screens(
|
||||
// survive the call.
|
||||
lib.set_scope(coll.scope());
|
||||
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);
|
||||
},
|
||||
move || lib_ids.visible_ids(),
|
||||
@@ -1946,7 +1971,7 @@ fn wire_settings_screen(
|
||||
settings_ui::render(window, &settings);
|
||||
// The export button carries its destination, so it has to be correct
|
||||
// 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
|
||||
// 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));
|
||||
}
|
||||
|
||||
// --- 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 ctl = settings.clone();
|
||||
let weak = window.as_weak();
|
||||
@@ -2093,7 +2002,7 @@ fn wire_settings_screen(
|
||||
lib.set_fetch_ahead(s.cache.fetch_ahead);
|
||||
lib.set_write_xmp_sidecars(s.library.write_xmp_sidecars);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user