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
+15
View File
@@ -126,6 +126,11 @@ pub struct CollectionsController {
/// `scope` as a sentinel id would put that inversion inside a type that
/// means "a collection".
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.
pub(super) dragging: RefCell<Vec<ImageId>>,
/// 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
/// user cannot see is a selection they will act on by accident. Clearing on
/// 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) {
self.selection.borrow_mut().clear();
*self.anchor.borrow_mut() = None;