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:
+129
-14
@@ -286,17 +286,45 @@ fn flag_code(f: dr_types::FlagState) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-8 | FR-NC-8 | FR-CULL-4
|
||||
/// One image's judgement, on its way to a sidecar.
|
||||
/// TRACES: FR-CAT-8 | FR-NC-8 | FR-CULL-4 | FR-DEV-6
|
||||
/// One amendment to one image's sidecar, on its way to the server.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JudgementWrite {
|
||||
pub struct SidecarWrite {
|
||||
/// Remote path of the *image*. The sidecar sits beside it, with the
|
||||
/// extension replaced — that adjacency is what makes a sidecar findable
|
||||
/// without an index (ARCH §6.12).
|
||||
pub image_path: String,
|
||||
pub version_uuid: String,
|
||||
pub rating: u8,
|
||||
pub flag: u8,
|
||||
pub amendment: Amendment,
|
||||
}
|
||||
|
||||
/// What a write changes about the version it names.
|
||||
///
|
||||
/// An enum rather than a struct of optional fields because the two are written
|
||||
/// by different actions with different failure costs, and because a write must
|
||||
/// never carry a *stale* copy of what it is not changing. A settings write that
|
||||
/// also carried a rating would have to have read one from somewhere, and the
|
||||
/// obvious somewhere — the catalog, moments earlier — is exactly how a cull
|
||||
/// made between the read and the write gets silently reverted.
|
||||
///
|
||||
/// Everything not named by the variant is left as the file had it, which is
|
||||
/// what makes the read-modify-write in [`write_one_sidecar`] a genuine
|
||||
/// amendment rather than a replacement.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Amendment {
|
||||
/// A star rating and a pick/reject flag — the cull.
|
||||
Judgement { rating: u8, flag: u8 },
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Copied develop settings, applied within `scope`.
|
||||
///
|
||||
/// Carries the [`Scope`] rather than a pre-filtered preset so the target's
|
||||
/// own framing can be spared *at the file*: excluding framing means
|
||||
/// leaving the keys already in the sidecar untouched, which cannot be
|
||||
/// expressed by the parameter list alone.
|
||||
Settings {
|
||||
preset: dr_pipeline::Preset,
|
||||
scope: dr_pipeline::Scope,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where an image's sidecar lives.
|
||||
@@ -316,7 +344,7 @@ pub fn sidecar_path(image_path: &str) -> String {
|
||||
format!("{stem}.{}", dr_pipeline::sidecar::EXTENSION)
|
||||
}
|
||||
|
||||
/// Persist judgements to sidecars beside their images.
|
||||
/// Persist amendments to sidecars beside their images.
|
||||
///
|
||||
/// # Why this reads before it writes
|
||||
///
|
||||
@@ -337,7 +365,7 @@ pub fn sidecar_path(image_path: &str) -> String {
|
||||
pub fn spawn_sidecar_writes(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
writes: Vec<JudgementWrite>,
|
||||
writes: Vec<SidecarWrite>,
|
||||
) -> Receiver<SidecarMessage> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
@@ -406,7 +434,7 @@ pub enum SidecarMessage {
|
||||
}
|
||||
|
||||
/// Read-modify-write one sidecar.
|
||||
async fn write_one_sidecar(backend: &NextcloudBackend, w: &JudgementWrite) -> Result<(), String> {
|
||||
async fn write_one_sidecar(backend: &NextcloudBackend, w: &SidecarWrite) -> Result<(), String> {
|
||||
let path = RemotePath::new(sidecar_path(&w.image_path));
|
||||
let id = RemoteId::Path(path.clone());
|
||||
|
||||
@@ -444,7 +472,7 @@ async fn write_one_sidecar(backend: &NextcloudBackend, w: &JudgementWrite) -> Re
|
||||
}
|
||||
}
|
||||
|
||||
// Amend the version this judgement belongs to, creating it if the file did
|
||||
// Amend the version this write belongs to, creating it if the file did
|
||||
// not have one. The uuid comes from the catalog, so the same photograph
|
||||
// keeps one identity across devices (FR-NC-8).
|
||||
let mut version = sidecar
|
||||
@@ -459,11 +487,24 @@ async fn write_one_sidecar(backend: &NextcloudBackend, w: &JudgementWrite) -> Re
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
version.rating = w.rating;
|
||||
version.flag = w.flag;
|
||||
// A judgement is an edit as far as the merge is concerned: without the
|
||||
// bump, a device that rated the same frame earlier would win on revision
|
||||
// and this rating would be discarded at the next sync (FR-NC-9).
|
||||
// Only what the amendment names. Everything else in the version — the
|
||||
// rating a settings write must not touch, the crop an adjustments-only
|
||||
// paste must spare, the unknown keys of an operation this build lacks —
|
||||
// survives because it was read from the file and is written back.
|
||||
match &w.amendment {
|
||||
Amendment::Judgement { rating, flag } => {
|
||||
version.rating = *rating;
|
||||
version.flag = *flag;
|
||||
}
|
||||
Amendment::Settings { preset, scope } => {
|
||||
preset.amend(&mut version.params, *scope);
|
||||
}
|
||||
}
|
||||
|
||||
// A judgement is an edit as far as the merge is concerned, and so is a
|
||||
// paste: without the bump, a device that touched the same frame earlier
|
||||
// would win on revision and this write would be discarded at the next sync
|
||||
// (FR-NC-9).
|
||||
version.revision = version.revision.saturating_add(1);
|
||||
version.modified = now_secs();
|
||||
|
||||
@@ -1031,6 +1072,80 @@ pub struct CacheContext {
|
||||
///
|
||||
/// Returns the bytes on a channel rather than blocking: the download runs on
|
||||
/// its own thread and the UI stays live, exactly as thumbnail fetching does.
|
||||
/// TRACES: FR-CAT-8 | FR-DEV-6
|
||||
/// Fetch and parse the sidecar beside one image.
|
||||
///
|
||||
/// # Why the edit is read from the file rather than the catalog
|
||||
///
|
||||
/// The catalog carries a `graph_hash` and no parameters, and it is
|
||||
/// *disposable* (ARCH §6.12) — a rebuild would silently return every
|
||||
/// photograph to neutral. The sidecar is the authoritative store, so it is
|
||||
/// what an open reads, and that is also what makes an edit pasted on the
|
||||
/// desktop appear when the same frame is opened on the phone.
|
||||
///
|
||||
/// # Why absence and failure are the same answer here
|
||||
///
|
||||
/// `None` means "open this image at its defaults", which is right for a
|
||||
/// photograph that has never been edited — the overwhelmingly common case on a
|
||||
/// fresh library — and equally right when the network is down. The alternative,
|
||||
/// refusing to open the image because its sidecar could not be read, would make
|
||||
/// an unreachable server also mean an unviewable library.
|
||||
///
|
||||
/// The one case that is *not* harmless is a sidecar that exists but does not
|
||||
/// parse. That still opens at defaults, but the write path
|
||||
/// ([`write_one_sidecar`]) independently refuses to overwrite a file it could
|
||||
/// not read, so an edit this build failed to understand is never destroyed by
|
||||
/// having been opened.
|
||||
pub fn spawn_sidecar_fetch(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
image_path: String,
|
||||
) -> Receiver<Option<dr_pipeline::Sidecar>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = match crate::net_runtime::build() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::debug!("sidecar fetch runtime: {e}");
|
||||
let _ = tx.send(None);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::debug!("sidecar fetch backend: {e}");
|
||||
let _ = tx.send(None);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let path = RemotePath::new(sidecar_path(&image_path));
|
||||
let id = RemoteId::Path(path.clone());
|
||||
|
||||
// A 404 is the normal case on a library that has never been
|
||||
// edited, so this is `ok()` rather than an error path.
|
||||
let parsed = backend.get(&id, None).await.ok().and_then(|bytes| {
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
match dr_pipeline::Sidecar::parse(&text) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
log::warn!("sidecar at {} is unreadable ({e})", path.as_str());
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = tx.send(parsed);
|
||||
});
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
pub fn spawn_full_fetch(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
|
||||
Reference in New Issue
Block a user