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:
+200
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user