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
+111 -18
View File
@@ -96,6 +96,13 @@ pub struct Pending {
pub local: PathBuf,
/// Remote folder, relative to the library root. Empty means the root.
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.
pub name: String,
}
@@ -104,7 +111,7 @@ impl Pending {
/// Full remote path for this entry, under `root`.
fn remote_path(&self, root: &str) -> RemotePath {
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('/') {
if !part.is_empty() {
parts.push(part);
@@ -118,7 +125,7 @@ impl Pending {
/// The folder this entry's file belongs in, as a remote path.
fn remote_folder(&self, root: &str) -> RemotePath {
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('/') {
if !part.is_empty() {
parts.push(part);
@@ -127,6 +134,15 @@ impl Pending {
}
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.
@@ -134,11 +150,27 @@ impl Pending {
pub enum Placed {
/// Written straight to a folder on this device.
Device(PathBuf),
/// Staged locally, awaiting upload to the named remote folder.
Queued { local: PathBuf, remote_dir: String },
/// Staged locally, awaiting upload to the named remote folder as `name`.
/// The staged copy may be called something else — see [`stage`].
Queued {
local: PathBuf,
remote_dir: String,
name: String,
},
}
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.
pub fn describe(&self) -> String {
match self {
@@ -156,7 +188,7 @@ impl Placed {
let dir = if remote_dir.is_empty() {
"the library root".to_string()
} else {
remote_dir.clone()
remote_dir.trim_start_matches('/').to_string()
};
format!("Queued for {dir}")
}
@@ -179,7 +211,7 @@ pub fn place(
match target {
ExportTarget::Device => {
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);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
@@ -193,6 +225,7 @@ pub fn place(
Ok(Placed::Queued {
local,
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
// 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.
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()))?;
Ok(candidate)
@@ -280,6 +319,7 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
let mut lines = text.lines();
let remote_dir = lines.next().unwrap_or("").to_string();
let name = lines.next().unwrap_or("").to_string();
let account = lines.next() == Some("account");
if name.is_empty() {
continue;
}
@@ -287,6 +327,7 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
local,
remote_dir,
name,
account,
});
}
// 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.
pub struct BatchRequest {
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
/// library is open, which is fine for a [`Source::Rendered`] and fatal for
/// anything that has to be fetched.
@@ -567,6 +613,8 @@ pub enum BatchMessage {
/// One image is finished, for better or worse.
Item {
name: String,
/// The catalog image it came from, where the request said.
image: Option<dr_types::ImageId>,
outcome: Result<Placed, ItemError>,
},
Finished {
@@ -625,7 +673,12 @@ fn run(mut request: BatchRequest, cancel: &Cancel, tx: &Sender<BatchMessage>) {
Ok(_) => exported += 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 {
@@ -1034,11 +1087,12 @@ pub fn drain_batch(
rx: Receiver<BatchMessage>,
total: usize,
reporting: Reporting,
// Run when the batch finishes having written something. A queued export
// is finished on disk but not where the user asked for it, and waiting
// for the next sync pass to notice reads — correctly — as an export that
// did not upload.
on_exported: impl Fn() + 'static,
// Run when the batch finishes having written something, with each file
// it wrote and the image that file came from. A queued export is finished
// on disk but not where the user asked for it, and waiting for the next
// sync pass to notice reads — correctly — as an export that did not
// 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(
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
// the rest are in the log.
let mut first_failure: Option<String> = None;
let mut written: Vec<(dr_types::ImageId, String)> = Vec::new();
let timer = slint::Timer::default();
let held = slot.clone();
@@ -1085,8 +1140,17 @@ pub fn drain_batch(
&format!("Exporting {name} ({}/{total})", done + 1),
);
}
BatchMessage::Item { name, outcome } => match outcome {
Ok(placed) => log::info!("{name}: {}", placed.describe()),
BatchMessage::Item {
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) => {
// Every failure is logged, not only the first: the
// summary is a sentence and this is the record of which
@@ -1109,7 +1173,7 @@ pub fn drain_batch(
}
settle(&w, reporting, &text);
if exported > 0 {
on_exported();
on_exported(std::mem::take(&mut written));
}
stop_timer(&held);
return;
@@ -1231,7 +1295,8 @@ mod tests {
// wherever the app happened to be launched from.
let dir = tmp();
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]
@@ -1249,7 +1314,9 @@ mod tests {
.unwrap();
match placed {
Placed::Queued { local, remote_dir } => {
Placed::Queued {
local, remote_dir, ..
} => {
assert_eq!(std::fs::read(&local).unwrap(), b"hello");
assert_eq!(remote_dir, "Exports/2026");
}
@@ -1340,6 +1407,7 @@ mod tests {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "Exports/2026".into(),
name: "a.jpg".into(),
account: false,
};
assert_eq!(
entry.remote_path("Photos").as_str(),
@@ -1359,6 +1427,7 @@ mod tests {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: String::new(),
name: "a.jpg".into(),
account: false,
};
assert_eq!(entry.remote_path("Photos").as_str(), "Photos/a.jpg");
}
@@ -1371,6 +1440,7 @@ mod tests {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "/Exports/".into(),
name: "a.jpg".into(),
account: false,
};
assert_eq!(
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]
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,
@@ -1385,6 +1476,7 @@ mod tests {
let queued = Placed::Queued {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "Exports".into(),
name: "a.jpg".into(),
};
let text = queued.describe();
assert!(text.contains("Queued"), "{text}");
@@ -1412,6 +1504,7 @@ mod tests {
fn request(settings: ExportSettings, sources: Vec<Source>) -> BatchRequest {
BatchRequest {
sources,
images: Vec::new(),
conn: None,
settings,
outbox: std::env::temp_dir().join("dr-batch-test-outbox"),