Files
DarkRoom/ui/dr-ui/src/export.rs
T
dtourolleandClaude Opus 5 e00c99b864 Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is
the button, and the place the bytes go.

**Everything is staged first.** An export bound for the server is written to a
local outbox and uploaded afterwards; offline is not a special case, it is the
same path with a drain that finds the server absent. Doing it the other way —
upload directly, stage only on failure — makes the failure path the one that
is rarely exercised and always broken, and a network drop mid-batch leaves
some exports existing and some not with nothing recording which. Staged first,
an export is finished the moment it is written and the upload is a promise
kept later.

The outbox sits beside the catalog rather than under the cache. dr_catalog's
cache already draws that line: passive entries are a convenience and go under
LRU, pinned ones are a promise and never do. An export awaiting upload is a
promise — the user was told it succeeded — and sweeping it for disk would
destroy the only copy. Bytes are written before the destination record, so a
kill between the two leaves an orphan the drain ignores rather than a record
pointing at nothing.

The status line says "Queued for Exports/2026", never "Exported to Nextcloud",
until it has actually landed. There is a test asserting that wording, because
the tempting shorter sentence is a claim the app cannot keep.

The drain runs on the sync pass, before the shards: a thumbnail shard can be
rebuilt from the originals and the catalog is an index, but a queued export
exists nowhere else.

`DevelopSession::render_for_export` renders the framed size rather than reusing
the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding
that would hand the user a soft, screen-sized file with nothing to say anything
had been lost (FR-EXP-9).

One compromise, recorded rather than hidden: the export runs synchronously on
the UI thread, so the window is unresponsive for the few hundred milliseconds
a full-resolution render and encode takes. Moving a DevelopSession and its GPU
pass to a worker is a larger change than one button earns, and it is batch
export that makes the wait intolerable rather than merely noticeable.

Still missing: the Nextcloud folder *picker*. The destination is typed into
Settings for now. `FolderBrowser` in launch.rs is already the reusable model
for it — it browses a remote tree and nothing about it is specific to choosing
a library root — but wiring it into the settings page needs a listing worker
and browser UI there, which is its own piece of work.

Carries in-flight work from a parallel session — presets, the develop copy and
paste, and the node schema's `presentation` and `enum` support. One misplaced
callback in settings_ui.rs is moved from `render` to `wire`: registered in
`render` it borrowed a `&SettingsController` into a 'static closure and would
not compile, and that file's own docs say render pushes properties while wire
connects callbacks.

992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:58:17 +02:00

600 lines
21 KiB
Rust

//! TRACES: FR-EXP-6 | FR-EXP-7 | FR-NC-10
//! Placing an exported file, and the cache that makes offline unremarkable.
//!
//! [`dr_export`] turns a frame into bytes and a name and stops there, because
//! where those bytes go differs by more than a path. This is the other half:
//! it decides the destination and gets them there.
//!
//! # Everything is staged first
//!
//! An export to the server is written to a local **outbox** before any upload
//! is attempted, and the upload drains that outbox afterwards. Not a fallback
//! for the offline case — the *only* path, with offline merely meaning the
//! drain finds nothing to do.
//!
//! Doing it the other way, uploading directly and staging only on failure,
//! looks simpler and has two bad properties. The failure path is then the one
//! that is rarely exercised and always broken, and the moment the network
//! drops mid-batch some exports exist and some do not with nothing recording
//! which. Staging first means an export is *finished* the instant it is
//! written; the upload is a separate promise the app keeps later.
//!
//! # Why the outbox is not in the cache
//!
//! It lives beside the catalog, with the thumbnail shards, rather than under
//! the evictable cache. `dr_catalog::cache` draws the line already: passively
//! cached originals are a convenience and go under LRU, pinned ones are a
//! promise and never do. An export waiting to upload is a promise — the user
//! was told the export succeeded — and sweeping it away to reclaim disk would
//! destroy work that no longer exists anywhere else.
use std::path::{Path, PathBuf};
use dr_export::Encoded;
use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
use dr_types::ExportTarget;
/// Where exports wait for a server that is not there yet.
///
/// Beside the catalog, for the reason in the module docs. Per account,
/// because the destination folder is a path on one particular server and an
/// entry queued for one account is meaningless to another.
pub fn outbox_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
.parent()
.map(|p| p.join("outbox"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-outbox"))
}
/// One export waiting to go up.
///
/// The record sits beside the bytes as `<name>.dest`, holding the remote
/// folder it belongs in. A single flat file rather than a database: the queue
/// is small, the entries are independent, and the recovery story for a
/// half-written text file is to ignore it — which is exactly what parsing it
/// does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pending {
/// The staged bytes on this device.
pub local: PathBuf,
/// Remote folder, relative to the library root. Empty means the root.
pub remote_dir: String,
/// The filename to give it there.
pub name: String,
}
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 part in segment.split('/') {
if !part.is_empty() {
parts.push(part);
}
}
}
parts.push(&self.name);
RemotePath::new(parts.join("/"))
}
/// 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 part in segment.split('/') {
if !part.is_empty() {
parts.push(part);
}
}
}
RemotePath::new(parts.join("/"))
}
}
/// Where an export was put, for the interface to report.
#[derive(Debug, Clone, PartialEq, Eq)]
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 },
}
impl Placed {
/// A sentence for the status line.
pub fn describe(&self) -> String {
match self {
Placed::Device(path) => format!(
"Exported {}",
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
),
// Named as queued rather than exported: the file is real and
// finished, but it is not yet where the user asked for it, and
// saying "exported to Nextcloud" before it has uploaded would be
// a claim the app cannot keep if the disk is pulled.
Placed::Queued { remote_dir, .. } => {
let dir = if remote_dir.is_empty() {
"the library root".to_string()
} else {
remote_dir.clone()
};
format!("Queued for {dir}")
}
}
}
}
/// Write an encoded export to wherever the settings say it goes.
///
/// `destination` is a filesystem path for [`ExportTarget::Device`] and a
/// remote folder for [`ExportTarget::Remote`] — the widening `ExportSettings`
/// documents, resolved here because this is the layer that knows what a path
/// means on this platform.
pub fn place(
encoded: &Encoded,
target: ExportTarget,
destination: &str,
outbox: &Path,
) -> Result<Placed, String> {
match target {
ExportTarget::Device => {
if destination.trim().is_empty() {
return Err("No export folder is set. Choose one in Settings.".into());
}
let dir = PathBuf::from(destination);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(&encoded.name);
std::fs::write(&path, &encoded.bytes)
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Placed::Device(path))
}
ExportTarget::Remote => {
let local = stage(encoded, destination, outbox)?;
Ok(Placed::Queued {
local,
remote_dir: destination.to_string(),
})
}
}
}
/// Write bytes and their destination record into the outbox.
fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
// The staged name is the export's name, deduplicated against the outbox
// rather than against the server: two exports queued before either has
// uploaded would otherwise overwrite each other here, and the second one
// would silently replace the first before anybody saw it.
let mut candidate = outbox.join(&encoded.name);
let mut n = 1;
while candidate.exists() {
let stem = Path::new(&encoded.name)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "export".into());
let ext = Path::new(&encoded.name)
.extension()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
candidate = outbox.join(format!("{stem}-{n}.{ext}"));
n += 1;
if n > 10_000 {
return Err("the outbox is full of files by this name".into());
}
}
// Bytes first, then the record. The order matters on a process that may
// be killed between the two: an orphan payload with no record is ignored
// by the drain and swept later, where a record naming bytes that were
// never written would be a permanent failure retried forever.
std::fs::write(&candidate, &encoded.bytes)
.map_err(|e| format!("{}: {e}", candidate.display()))?;
let record = candidate.with_extension(format!(
"{}.dest",
candidate
.extension()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
));
// 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))
.map_err(|e| format!("{}: {e}", record.display()))?;
Ok(candidate)
}
/// Everything currently waiting in the outbox.
///
/// A payload with no record is skipped rather than guessed at — see the write
/// order in [`stage`].
pub fn pending(outbox: &Path) -> Vec<Pending> {
let Ok(entries) = std::fs::read_dir(outbox) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("dest") {
continue;
}
// `photo.jpg.dest` describes `photo.jpg`.
let local = path.with_extension("");
if !local.exists() {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let mut lines = text.lines();
let remote_dir = lines.next().unwrap_or("").to_string();
let name = lines.next().unwrap_or("").to_string();
if name.is_empty() {
continue;
}
out.push(Pending {
local,
remote_dir,
name,
});
}
// Stable order so a drain is reproducible and a stuck entry is obvious
// rather than appearing to move around the queue.
out.sort_by(|a, b| a.local.cmp(&b.local));
out
}
/// How many exports are waiting. For the interface to show, and cheap enough
/// to call on a redraw.
pub fn pending_count(outbox: &Path) -> usize {
pending(outbox).len()
}
/// Remove an entry and its record, once it is safely on the server.
fn clear(entry: &Pending) {
let record = PathBuf::from(format!("{}.dest", entry.local.display()));
let _ = std::fs::remove_file(&entry.local);
let _ = std::fs::remove_file(&record);
}
/// Progress from the upload worker.
#[derive(Debug)]
pub enum UploadMessage {
Status(String),
/// Uploaded, still pending, and the first error if there was one.
Finished {
uploaded: usize,
remaining: usize,
error: Option<String>,
},
}
/// Drain the outbox to the server.
///
/// Its own thread with its own runtime, like every other network path here —
/// the Slint loop must never block (NFR-P9).
///
/// A failure leaves the entry in place and stops the run. Continuing past a
/// network error would burn the whole queue against a server that is not
/// answering, and the next pass costs nothing.
pub fn spawn_upload(
creds: AppCredentials,
user_id: String,
root: String,
outbox: PathBuf,
) -> std::sync::mpsc::Receiver<UploadMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(UploadMessage::Finished {
uploaded: 0,
remaining: pending_count(&outbox),
error: Some(e.to_string()),
});
return;
}
};
rt.block_on(async {
let backend = match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(UploadMessage::Finished {
uploaded: 0,
remaining: pending_count(&outbox),
error: Some(e.to_string()),
});
return;
}
};
let queue = pending(&outbox);
let total = queue.len();
let mut uploaded = 0;
let mut error = None;
for (i, entry) in queue.iter().enumerate() {
let _ = tx.send(UploadMessage::Status(format!(
"uploading {} ({}/{total})",
entry.name,
i + 1
)));
let Ok(bytes) = std::fs::read(&entry.local) else {
// The payload vanished under us. Drop the record too;
// retrying forever against a file that is gone helps
// nobody.
clear(entry);
continue;
};
// The folder may not exist — this is the first export into it
// — and `create_dir` treats "already there" as success, so it
// is unconditional rather than guarded by a check that would
// cost a request every time.
if let Err(e) = backend.create_dir(&entry.remote_folder(&root)).await {
error = Some(e.to_string());
break;
}
match backend.put(&entry.remote_path(&root), bytes, None).await {
Ok(_) => {
clear(entry);
uploaded += 1;
}
Err(e) => {
error = Some(e.to_string());
break;
}
}
}
let _ = tx.send(UploadMessage::Finished {
uploaded,
remaining: pending_count(&outbox),
error,
});
});
});
rx
}
#[cfg(test)]
mod tests {
use super::*;
fn encoded(name: &str, bytes: &[u8]) -> Encoded {
Encoded {
name: name.to_string(),
bytes: bytes.to_vec(),
width: 4,
height: 4,
}
}
fn tmp() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-outbox-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_device_export_writes_the_file() {
let dir = tmp();
let target = dir.join("exports");
let placed = place(
&encoded("a.jpg", b"hello"),
ExportTarget::Device,
target.to_str().unwrap(),
&dir.join("outbox"),
)
.unwrap();
assert_eq!(placed, Placed::Device(target.join("a.jpg")));
assert_eq!(std::fs::read(target.join("a.jpg")).unwrap(), b"hello");
}
#[test]
fn a_device_export_creates_a_folder_that_is_not_there() {
// Exporting into a folder the user typed but has not made is the
// common case, not an error.
let dir = tmp();
let target = dir.join("deep/nested/exports");
assert!(place(
&encoded("a.jpg", b"x"),
ExportTarget::Device,
target.to_str().unwrap(),
&dir,
)
.is_ok());
assert!(target.join("a.jpg").exists());
}
#[test]
fn a_device_export_with_no_folder_says_so() {
// Rather than writing to the process's working directory, which is
// 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}");
}
#[test]
fn a_remote_export_is_staged_rather_than_sent() {
// The property the offline story rests on: the export is complete on
// disk before any network call is attempted.
let dir = tmp();
let outbox = dir.join("outbox");
let placed = place(
&encoded("a.jpg", b"hello"),
ExportTarget::Remote,
"Exports/2026",
&outbox,
)
.unwrap();
match placed {
Placed::Queued { local, remote_dir } => {
assert_eq!(std::fs::read(&local).unwrap(), b"hello");
assert_eq!(remote_dir, "Exports/2026");
}
other => panic!("expected a queued export, got {other:?}"),
}
}
#[test]
fn a_staged_export_is_found_again_with_its_destination() {
// What survives a process death: the drain has to be able to
// reconstruct where a file was going from the disk alone.
let dir = tmp();
let outbox = dir.join("outbox");
place(
&encoded("a.jpg", b"one"),
ExportTarget::Remote,
"Exports",
&outbox,
)
.unwrap();
let queue = pending(&outbox);
assert_eq!(queue.len(), 1);
assert_eq!(queue[0].remote_dir, "Exports");
assert_eq!(queue[0].name, "a.jpg");
}
#[test]
fn two_exports_of_the_same_name_both_survive_the_outbox() {
// Both were asked for and neither has uploaded, so the second must
// not overwrite the first while it waits.
let dir = tmp();
let outbox = dir.join("outbox");
place(
&encoded("a.jpg", b"one"),
ExportTarget::Remote,
"E",
&outbox,
)
.unwrap();
place(
&encoded("a.jpg", b"two"),
ExportTarget::Remote,
"E",
&outbox,
)
.unwrap();
let queue = pending(&outbox);
assert_eq!(queue.len(), 2);
// Both still claim the name they should arrive under; only the local
// staging name differs.
assert!(queue.iter().all(|p| p.name == "a.jpg"));
assert_ne!(queue[0].local, queue[1].local);
}
#[test]
fn a_payload_with_no_record_is_ignored() {
// The window a kill between the two writes leaves behind. It must not
// become an upload to nowhere.
let dir = tmp();
let outbox = dir.join("outbox");
std::fs::create_dir_all(&outbox).unwrap();
std::fs::write(outbox.join("orphan.jpg"), b"x").unwrap();
assert!(pending(&outbox).is_empty());
}
#[test]
fn a_record_with_no_payload_is_ignored() {
let dir = tmp();
let outbox = dir.join("outbox");
std::fs::create_dir_all(&outbox).unwrap();
std::fs::write(outbox.join("ghost.jpg.dest"), "E\nghost.jpg\n").unwrap();
assert!(pending(&outbox).is_empty());
}
#[test]
fn an_empty_outbox_is_not_an_error() {
// Called on every sync pass, including before anything is exported
// and on a device where the directory has never been created.
assert!(pending(Path::new("/nonexistent/darkroom/outbox")).is_empty());
assert_eq!(pending_count(Path::new("/nonexistent/darkroom/outbox")), 0);
}
#[test]
fn the_remote_path_joins_root_folder_and_name() {
let entry = Pending {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "Exports/2026".into(),
name: "a.jpg".into(),
};
assert_eq!(
entry.remote_path("Photos").as_str(),
"Photos/Exports/2026/a.jpg"
);
assert_eq!(
entry.remote_folder("Photos").as_str(),
"Photos/Exports/2026"
);
}
#[test]
fn an_empty_folder_exports_to_the_library_root() {
// "Ask each time" is not set here — an empty remote folder means the
// root, and it must not produce a double slash the server rejects.
let entry = Pending {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: String::new(),
name: "a.jpg".into(),
};
assert_eq!(entry.remote_path("Photos").as_str(), "Photos/a.jpg");
}
#[test]
fn stray_slashes_do_not_produce_an_unusable_path() {
// The folder comes from a picker or a text field, and either can hand
// over a leading or trailing slash.
let entry = Pending {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "/Exports/".into(),
name: "a.jpg".into(),
};
assert_eq!(
entry.remote_path("/Photos/").as_str(),
"Photos/Exports/a.jpg"
);
}
#[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,
// and saying so before it is would be a promise the app cannot keep.
let queued = Placed::Queued {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "Exports".into(),
};
let text = queued.describe();
assert!(text.contains("Queued"), "{text}");
assert!(!text.contains("Exported"), "{text}");
assert!(Placed::Device(PathBuf::from("/tmp/a.jpg"))
.describe()
.contains("Exported"));
}
}