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
+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.
#[derive(Clone, PartialEq, Eq)]
pub(super) struct LibraryFacts {
pub(super) scope: Option<dr_types::CollectionId>,
pub(super) scope: Option<library::Scope>,
pub(super) filter: library::RatingFilter,
pub(super) trash: bool,
pub(super) total: usize,
@@ -202,6 +202,11 @@ pub struct LibraryController {
/// 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.
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
/// How to open a photograph in develop.
///
@@ -460,6 +465,7 @@ impl LibraryController {
sync_timer: RefCell::new(None),
session: RefCell::new(None),
scope: RefCell::new(None),
album: std::cell::Cell::new(None),
open_image: RefCell::new(None),
place_store: RefCell::new(None),
place_device: RefCell::new(String::new()),
@@ -847,7 +853,7 @@ impl LibraryController {
};
library::read_ids_span(
catalog,
*self.scope.borrow(),
self.grid_scope(),
&self.filter.borrow(),
self.viewing_trash.get(),
first,
@@ -922,13 +928,28 @@ impl LibraryController {
/// 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.
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)
.into_iter()
.map(|(id, path)| crate::export::Source::Library {
path,
cache: self.cache_context_for(id),
.map(|(id, path)| {
(
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.
@@ -939,9 +960,36 @@ impl LibraryController {
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`.
pub fn set_scope(&self, scope: Option<dr_types::CollectionId>) {
*self.scope.borrow_mut() = scope;
self.album.set(None);
// Selecting a collection is leaving the trash. Without this, picking a
// collection while the trash was open would keep listing trashed images
// under that collection's name.
@@ -961,6 +1009,7 @@ impl LibraryController {
self.viewing_trash.set(viewing);
if viewing {
*self.scope.borrow_mut() = None;
self.album.set(None);
}
*self.offset.borrow_mut() = 0;
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
// 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.
let scope = *ctl.scope.borrow();
let scope = ctl.grid_scope();
let filter = ctl.filter.borrow().clone();
// Passed whole, date range included, and both queries below lift that
// 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
// handles doing the narrowing — each drag re-scaling the axis, so the next
// 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
@@ -848,7 +848,7 @@ fn resolve_place(
if !place.path.is_empty() {
match library::ordinal_of_path(
catalog,
*ctl.scope.borrow(),
ctl.grid_scope(),
&ctl.filter.borrow(),
ctl.viewing_trash.get(),
&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
// looking at, which on a remote library is the cost FR-NC-3 exists to
// avoid.
let scope = *ctl.scope.borrow();
let scope = ctl.grid_scope();
let filter = ctl.filter.borrow().clone();
// The trash lists what every other view excludes, so it takes its own
// 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
.global::<Library>()
.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(|images| scope_is_pinned(catalog, &images))
.unwrap_or(false),
None => false,
.map(|images| scope_is_pinned(catalog, &images))
.unwrap_or(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
@@ -385,9 +389,9 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// by when, and it is not a collection at all.
window.global::<Library>().set_library_reorderable(
!trash
&& scope.is_some_and(|c| {
dr_catalog::collections::orders_manually(catalog.connection(), c).unwrap_or(false)
}),
&& matches!(scope, Some(library::Scope::Collection(c))
if dr_catalog::collections::orders_manually(catalog.connection(), c)
.unwrap_or(false)),
);
let cells = if trash {