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>
This commit is contained in:
2026-08-16 23:58:17 +02:00
co-authored by Claude Opus 5
parent 23f0c4b76a
commit e00c99b864
19 changed files with 2845 additions and 85 deletions
+200 -6
View File
@@ -314,6 +314,38 @@ impl LibraryController {
.map(|id| dr_types::ImageId(*id as u64))
}
/// TRACES: FR-NC-8 | FR-DEV-6
/// The default version's uuid for an image, by its remote path.
///
/// Taken from the catalog rather than generated, and rather than read off
/// whatever the sidecar happens to contain. The uuid is the identity a
/// cross-device merge keys on (FR-NC-8): a develop session that invented
/// one would write a *second* version beside the one the cull is stored
/// in, and the photograph would arrive on the other device holding two
/// edits that never merge.
///
/// Creates the version if the image has none, by the same route a rating
/// does — see `dr_catalog::rating::default_version_id` for why an image
/// can legitimately arrive without one.
pub fn version_uuid_for_path(&self, path: &str) -> Option<String> {
let image = self.image_id_for_path(path)?;
let borrow = self.catalog.borrow();
let catalog = borrow.as_ref()?;
let conn = catalog.connection();
let id = match dr_catalog::rating::default_version_id(conn, image) {
Ok(id) => id,
Err(e) => {
log::debug!("no version for {path}: {e}");
return None;
}
};
conn.query_row("SELECT uuid FROM versions WHERE id = ?1", [id], |r| {
r.get(0)
})
.ok()
}
/// TRACES: FR-NC-6a
/// Where cached originals live for the open library.
///
@@ -1379,6 +1411,118 @@ fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagSt
}
}
/// TRACES: FR-DEV-6
/// Apply copied develop settings to a selection of images.
///
/// # Why this goes straight to the sidecars
///
/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the
/// catalog holds no parameters at all — only a `graph_hash` — so there is
/// nothing here for the catalog to record. Nor is any image opened: applying
/// to forty frames by loading forty develop sessions would mean forty RAW
/// downloads and forty demosaics to move some numbers between two maps, which
/// is not a thing to ask of a phone. See [`crate::presets`].
///
/// # What the user sees
///
/// Nothing in the grid changes — a thumbnail is rendered from the server's
/// preview and does not reflect an edit — so the status line is the only
/// confirmation, exactly as it is for a bulk judgement. The applied settings
/// appear when a target is next opened in develop, which is what reads the
/// sidecar back.
pub fn paste_settings_to_selection(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
preset: &dr_pipeline::Preset,
scope: dr_pipeline::Scope,
) {
if images.is_empty() {
// Said out loud rather than ignored, matching what a judgement
// keystroke does with an empty selection.
window.set_library_status("Select an image first".into());
return;
}
let writes = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// A never-judged image may have no version row yet, and the query
// below joins on one. Ratings create them as a side effect; a paste
// is the first write path that can reach an image which has never
// been rated, so it has to ask for them itself.
if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) {
log::debug!("ensuring versions before a paste: {e}");
}
collect_settings_writes(catalog, images, preset, scope)
};
if writes.is_empty() {
window.set_library_error("Could not find those images in the catalog.".into());
return;
}
let count = writes.len();
window.set_library_status(
format!(
"Applied settings to {count} image{}.",
if count == 1 { "" } else { "s" }
)
.into(),
);
start_sidecar_writes(window, ctl, writes);
}
/// Gather one settings write per image, addressed by remote path and version.
///
/// The uuid comes from the catalog for the same reason a judgement's does: it
/// is the identity a cross-device merge keys on, and a generated one would
/// write a second version beside the one the image already has (FR-NC-8).
fn collect_settings_writes(
catalog: &Catalog,
images: &[dr_types::ImageId],
preset: &dr_pipeline::Preset,
scope: dr_pipeline::Scope,
) -> Vec<library::SidecarWrite> {
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT i.source_ref, v.uuid
FROM images i
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
WHERE i.id IN ({placeholders})"
);
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
return Vec::new();
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(library::SidecarWrite {
image_path: r.get(0)?,
version_uuid: r.get(1)?,
amendment: library::Amendment::Settings {
preset: preset.clone(),
scope,
},
})
});
match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("collecting settings writes: {e}");
Vec::new()
}
}
}
/// Gather what the sidecar writer needs for each judged image.
///
/// The version uuid comes from the catalog rather than being generated here:
@@ -1388,7 +1532,7 @@ fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagSt
fn collect_sidecar_writes(
catalog: &Catalog,
images: &[dr_types::ImageId],
) -> Vec<library::JudgementWrite> {
) -> Vec<library::SidecarWrite> {
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
@@ -1407,11 +1551,13 @@ fn collect_sidecar_writes(
return Vec::new();
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(library::JudgementWrite {
Ok(library::SidecarWrite {
image_path: r.get(0)?,
version_uuid: r.get(1)?,
rating: r.get::<_, i64>(2)? as u8,
flag: r.get::<_, i64>(3)? as u8,
amendment: library::Amendment::Judgement {
rating: r.get::<_, i64>(2)? as u8,
flag: r.get::<_, i64>(3)? as u8,
},
})
});
@@ -1425,10 +1571,10 @@ fn collect_sidecar_writes(
}
/// Push judgements out to sidecars on a worker, reporting once at the end.
fn start_sidecar_writes(
pub(crate) fn start_sidecar_writes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
writes: Vec<library::JudgementWrite>,
writes: Vec<library::SidecarWrite>,
) {
if writes.is_empty() {
return;
@@ -1842,6 +1988,45 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
.unwrap_or_else(std::env::temp_dir);
let _ = std::fs::create_dir_all(&scratch);
// TRACES: FR-EXP-7 | FR-NC-10
// Drain the export outbox on the same pass, and before the shards. An
// export the user was told had succeeded is waiting here, and it is the
// one thing in this directory that exists nowhere else — a thumbnail
// shard can be rebuilt from the originals, and the catalog is an index.
//
// Fire-and-forget rather than reported: it runs on its own thread and
// clears entries as they land, so a partial run leaves the rest queued
// for next time and nothing is lost by not watching it. It reports
// through the log until an export has a place in the activity list.
{
let outbox = crate::export::outbox_dir(&session.server, &session.user_id);
if crate::export::pending_count(&outbox) > 0 {
let rx = crate::export::spawn_upload(
creds.clone(),
session.user_id.clone(),
session.root.clone(),
outbox,
);
std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
crate::export::UploadMessage::Status(s) => log::info!("export: {s}"),
crate::export::UploadMessage::Finished {
uploaded,
remaining,
error,
} => {
log::info!("export: {uploaded} uploaded, {remaining} still queued");
if let Some(e) = error {
log::warn!("export upload stopped: {e}");
}
}
}
}
});
}
}
window.set_library_syncing(true);
let rx = crate::derived_sync::spawn_sync(
creds,
@@ -2448,6 +2633,7 @@ pub fn wire<F>(
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
on_open_image: F,
on_leave_develop: Rc<dyn Fn()>,
) where
F: Fn(String) + 'static,
{
@@ -2806,6 +2992,14 @@ pub fn wire<F>(
window.on_back_to_library(move || {
let Some(w) = weak.upgrade() else { return };
// TRACES: FR-CAT-8
// The edit is persisted on the way out rather than on every
// slider move: a save is a network round-trip, and one per drag
// frame would put an upload inside the gesture NFR-P5 governs.
// This is the moment the image stops being the open one, so it is
// the last moment its edit can be written.
on_leave_develop();
let resume = ctl.resume_at.get();
if resume > 0 {
// Through the same channel a scrub uses, and for the same