# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/mcp.md
#	docs/parity-checklist.md
This commit is contained in:
Brandon Thomas
2026-10-14 21:55:01 -04:00
43 changed files with 689 additions and 58 deletions
+40 -21
View File
@@ -201,12 +201,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts:
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a
/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed
/// again (see [`lightcraft_catalog::Photo::preview_checked`]).
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only);
let same = info.content_hash == ph.content_hash
&& info.file_size == ph.file_size
&& (info.width, info.height) == (ph.width, ph.height)
&& info.preview_only == ph.preview_only;
&& info.preview_only == ph.preview_only
&& preview_checked == ph.preview_checked;
(!same).then_some(Op::SetContent {
id,
width: info.width,
@@ -214,6 +218,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo
file_size: info.file_size,
content_hash: info.content_hash,
preview_only: info.preview_only,
preview_checked,
})
}
@@ -271,6 +276,37 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
}
/// The ops that bring photo `id` and its virtual copies up to date with what their file is now
/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`.
/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it.
pub(crate) fn reload_ops(
cat: &lightcraft_catalog::Catalog,
id: lightcraft_catalog::PhotoId,
info: &crate::media::ProbeInfo,
ops: &mut Vec<Op>,
changed: &mut Vec<lightcraft_catalog::PhotoId>,
) {
let Some(ph) = cat.photo(id) else { return };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, info);
let mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, info)).chain(as_shot_wb_op(id, ph, info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
changed.push(id);
}
// virtual copies share the file
for c in cat.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
changed.push(c.id);
}
}
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
@@ -286,25 +322,8 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, &info);
let mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
reloaded.push(id);
}
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
reloaded.push(c.id);
}
}
let Ok(info) = info else { continue };
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
+2
View File
@@ -34,6 +34,7 @@ mod preset_files;
pub mod previews;
mod query;
mod slideshow;
pub mod recheck;
mod sync;
mod xmp;
@@ -159,6 +160,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(recheck::specs());
v.extend(sync::specs());
v.extend(metadata::specs());
v.extend(geocode::specs());
+129
View File
@@ -0,0 +1,129 @@
//! Preview-only raws rechecked after an update (issue #662): a raw imported while its format or
//! variant couldn't be decoded keeps [`Photo::preview_only`] until something re-reads its file.
//! Each flag is stamped with the app version that set it ([`Photo::preview_checked`]); when a
//! library opens in another version, its stale preview-only photos are probed again and get
//! exactly what Reload from Disk would give them: a file that decodes now loses the flag, one that
//! still doesn't gets the new reason and a fresh stamp, so it isn't probed again.
//!
//! The work is split so the app can probe on a worker thread: [`stale`] lists what to probe (no
//! file-system calls), [`probe`] reads the files without the session, and [`apply`] applies the
//! results as LightCraft's own bookkeeping (journaled, not an undo step). Agents and tests use
//! `library.recheckPreviewOnly`, which does all three at once.
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
use crate::media::{FileProbe, ProbeInfo};
use crate::{Result, Session};
/// A preview-only photo to probe, and what the library knew about it when it was listed (so a
/// result for a photo that changed meanwhile is dropped).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stale {
pub id: PhotoId,
pub path: String,
preview_only: Option<String>,
preview_checked: String,
}
impl Stale {
fn of(p: &Photo, path: &str) -> Stale {
Stale { id: p.id, path: path.to_string(), preview_only: p.preview_only.clone(), preview_checked: p.preview_checked.clone() }
}
/// The photo is still as it was listed: same file, flag and stamp.
fn current(&self, p: &Photo) -> bool {
crate::cmd::missing::checked_path(p) == Some(self.path.as_str())
&& p.preview_only == self.preview_only
&& p.preview_checked == self.preview_checked
}
}
/// The library's preview-only photos whose flag another app version set (or every preview-only
/// photo, with `all`): library photos with a file, not virtual copies (they follow the photo they
/// were copied from). No file-system calls.
pub fn stale(cat: &Catalog, all: bool) -> Vec<Stale> {
cat.photos()
.filter(|p| p.copy_of.is_none() && p.preview_only.is_some() && (all || p.preview_only_stale()))
.filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Stale::of(p, path)))
.collect()
}
/// The session's file probe, to [`probe`] with on a worker thread; `None` when the session has no
/// file access (a demo or browser session), where nothing can be rechecked.
pub fn file_probe(s: &Session) -> Option<FileProbe> {
s.media.file_probe.clone()
}
/// Probe each listed photo's file as Reload would. Doesn't touch the session.
pub fn probe(probe: &FileProbe, stale: Vec<Stale>) -> Vec<(Stale, std::result::Result<ProbeInfo, String>)> {
let paths: Vec<String> = stale.iter().map(|x| x.path.clone()).collect();
stale.into_iter().zip(crate::import::probe_with(probe, &paths)).collect()
}
/// What a recheck changed.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Rechecked {
/// Photos brought up to date (virtual copies included).
pub updated: Vec<PhotoId>,
/// Of those, the ones that develop from their raw data now.
pub cleared: Vec<PhotoId>,
}
impl Rechecked {
pub fn to_json(&self) -> Value {
json!({"updated": self.updated.iter().map(|i| i.0).collect::<Vec<_>>(), "cleared": self.cleared.iter().map(|i| i.0).collect::<Vec<_>>()})
}
}
/// Apply [`probe`]'s results: each photo gets the ops Reload would give it, all in one batch that is
/// journaled but isn't an undo step. A file that can't be read (missing, offline) changes nothing,
/// so it is tried again next time; a photo that changed since it was listed (relinked, reloaded,
/// removed) is skipped.
pub fn apply(s: &mut Session, results: Vec<(Stale, std::result::Result<ProbeInfo, String>)>) -> Result<Rechecked> {
let mut ops = Vec::new();
let mut out = Rechecked::default();
for (item, info) in results {
let Ok(info) = info else { continue };
if !s.catalog.photo(item.id).is_some_and(|p| item.current(p)) {
continue;
}
let first = out.updated.len();
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut out.updated);
if info.preview_only.is_none() {
out.cleared.extend_from_slice(&out.updated[first..]);
}
}
if !ops.is_empty() {
s.apply_system(Op::Batch { ops })?;
}
for id in &out.updated {
s.media.forget(*id);
}
Ok(out)
}
fn recheck(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.recheckPreviewOnly";
let all = p.get("all").and_then(Value::as_bool).unwrap_or(false);
// without file access every probe "succeeds" with nothing known, which would clear every flag
let fp = file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?;
let listed = stale(&s.catalog, all);
let n = listed.len();
let mut r = apply(s, probe(&fp, listed))?.to_json();
r["checked"] = json!(n);
Ok(r)
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"library.recheckPreviewOnly",
"Recheck Preview-Only Raws",
[],
None,
"{all?: bool} — probe again the raws shown from their embedded preview whose flag another LightCraft version set (the app does this in the background when a library opens; `all` rechecks every preview-only raw, e.g. when testing a decoder build of the same version), and bring each up to date as Reload from Disk would; not an undo step, develop settings and history are kept; files that can't be read are left for next time → {checked, updated: [ids], cleared: [ids] (develop from their raw data now)}",
always,
recheck
)]
}