mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 06:27:53 -06:00
Merge PR #974
# 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:
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
}
|
||||
@@ -1048,7 +1048,7 @@ impl crate::Session {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the
|
||||
/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides
|
||||
/// them; an exported `travel|Italy|Rome` comes back as itself.
|
||||
@@ -1633,7 +1633,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box.
|
||||
fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
|
||||
pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec();
|
||||
@@ -2157,6 +2157,9 @@ mod tests {
|
||||
let p = s.catalog.photo(id).unwrap().clone();
|
||||
let why = p.preview_only.clone().expect("marked preview only");
|
||||
assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}");
|
||||
// stamped with the version that decided it, so a later version rechecks it (#662)
|
||||
assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION);
|
||||
assert!(!p.preview_only_stale());
|
||||
assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)");
|
||||
assert!(!p.develops_raw());
|
||||
// rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults
|
||||
@@ -2174,14 +2177,17 @@ mod tests {
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
|
||||
assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION);
|
||||
// the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back
|
||||
std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
|
||||
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(r["reloaded"], json!([id.0]), "{r}");
|
||||
assert!(s.catalog.photo(id).unwrap().develops_raw());
|
||||
assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag");
|
||||
assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
|
||||
assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION);
|
||||
// relinking to a file that decodes clears it too
|
||||
let dng = files.join("DSC_0001.dng");
|
||||
std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
|
||||
|
||||
@@ -470,6 +470,12 @@ pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo
|
||||
probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel)
|
||||
}
|
||||
|
||||
/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread.
|
||||
pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
let progress = ScanProgress::default();
|
||||
probe_all(Some(probe), paths, &progress.done, &progress.cancel)
|
||||
}
|
||||
|
||||
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
|
||||
/// `cancel` is set: the files not reached come back as errors.
|
||||
fn probe_all(
|
||||
@@ -1386,7 +1392,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
p.set_preview_only(info.preview_only.clone());
|
||||
apply_import_defaults(s, &mut p);
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
|
||||
@@ -585,8 +585,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background
|
||||
/// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the
|
||||
/// redo stack alone.
|
||||
/// scan, a raw's as-shot white read by a decode, raws rechecked after an update): journaled like any op, but not
|
||||
/// an undo step, and it leaves the redo stack alone.
|
||||
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
|
||||
let fwd = op.clone();
|
||||
self.catalog.apply(op)?;
|
||||
@@ -1090,6 +1090,8 @@ mod tests_presets_scale;
|
||||
#[cfg(test)]
|
||||
mod tests_process;
|
||||
#[cfg(test)]
|
||||
mod tests_recheck;
|
||||
#[cfg(test)]
|
||||
mod tests_segment;
|
||||
#[cfg(test)]
|
||||
mod tests_settings_files;
|
||||
|
||||
@@ -1749,6 +1749,7 @@ mod thumbnail_hash_tests {
|
||||
file_size: 99,
|
||||
content_hash: Some("replacement".into()),
|
||||
preview_only: None,
|
||||
preview_checked: String::new(),
|
||||
})
|
||||
.unwrap();
|
||||
s.media.forget(PhotoId(1));
|
||||
|
||||
@@ -705,6 +705,7 @@ fn a_photo_whose_file_changed_has_no_picture_until_it_is_made_again() {
|
||||
file_size: p.file_size + 1,
|
||||
content_hash: Some("ffffffffffffffffffffffffffffffff".into()),
|
||||
preview_only: None,
|
||||
preview_checked: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Issue #662: preview-only raws are rechecked when a library opens in another app version.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lightcraft_catalog::{APP_VERSION, Op, PhotoId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::recheck;
|
||||
use crate::files::tests::cr3_with_preview;
|
||||
|
||||
/// A version other than this build's: the flag was set by an older (or newer) LightCraft.
|
||||
const OTHER: &str = "0.5.0";
|
||||
|
||||
struct Lib {
|
||||
s: Session,
|
||||
dir: PathBuf,
|
||||
lib: PathBuf,
|
||||
file: PathBuf,
|
||||
id: PhotoId,
|
||||
}
|
||||
|
||||
impl Drop for Lib {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// A library with one preview-only CR3, its flag stamped by [`OTHER`] as a library saved by
|
||||
/// another version would be.
|
||||
fn library(name: &str) -> Lib {
|
||||
let dir = std::env::temp_dir().join(format!("lc-recheck-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (lib, files) = (dir.join("lib"), dir.join("files"));
|
||||
std::fs::create_dir_all(&files).unwrap();
|
||||
let file = files.join("IMG_0001.cr3");
|
||||
std::fs::write(&file, cr3_with_preview(96, 64)).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let r = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
let id = PhotoId(r["imported"][0].as_u64().unwrap());
|
||||
let p = s.catalog.photo(id).unwrap().clone();
|
||||
assert!(p.preview_only.is_some() && !p.preview_only_stale(), "just imported: decided by this version");
|
||||
s.apply_system(Op::SetContent {
|
||||
id,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
file_size: p.file_size,
|
||||
content_hash: p.content_hash.clone(),
|
||||
preview_only: p.preview_only.clone(),
|
||||
preview_checked: OTHER.into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().preview_only_stale());
|
||||
Lib { s, dir, lib, file, id }
|
||||
}
|
||||
|
||||
fn decodable_now(x: &Lib) {
|
||||
std::fs::write(&x.file, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
|
||||
}
|
||||
|
||||
/// The acceptance test: a stale flag on a file that decodes now is cleared without a manual
|
||||
/// Reload, adds no undo step, leaves develop settings and history alone, and survives a reopen.
|
||||
#[test]
|
||||
fn stale_flag_on_a_file_that_decodes_now_is_cleared() {
|
||||
let mut x = library("decodes");
|
||||
x.s.execute("develop.set", &json!({"ids": [x.id.0], "control": "light.exposure", "value": 0.5})).unwrap();
|
||||
let before = x.s.catalog.photo(x.id).unwrap().clone();
|
||||
let undo = x.s.undo.len();
|
||||
decodable_now(&x);
|
||||
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": [x.id.0]}));
|
||||
let p = x.s.catalog.photo(x.id).unwrap().clone();
|
||||
assert_eq!(p.preview_only, None);
|
||||
assert_eq!(p.preview_checked, "", "the stamp goes with the flag");
|
||||
assert!(p.develops_raw());
|
||||
assert_eq!(x.s.undo.len(), undo, "not an undo step");
|
||||
assert_eq!(*p.develop, *before.develop);
|
||||
assert_eq!(p.history.len(), before.history.len());
|
||||
// journaled: a reopen keeps it
|
||||
x.s.persist().unwrap();
|
||||
x.s = Session::new().with_fs();
|
||||
x.s.open_library(&x.lib, false).unwrap();
|
||||
assert_eq!(x.s.catalog.photo(x.id).unwrap().preview_only, None);
|
||||
}
|
||||
|
||||
/// A file that still doesn't decode is stamped by this version (with the decoder's current
|
||||
/// reason), so the next open doesn't probe it again.
|
||||
#[test]
|
||||
fn still_undecodable_file_gets_a_fresh_stamp_and_isnt_probed_again() {
|
||||
let mut x = library("still");
|
||||
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": []}));
|
||||
let p = x.s.catalog.photo(x.id).unwrap();
|
||||
assert!(p.preview_only.is_some());
|
||||
assert_eq!(p.preview_checked, APP_VERSION);
|
||||
assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["checked"], 0);
|
||||
// `all` ignores stamps (testing a decoder build of the same version)
|
||||
assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({"all": true})).unwrap()["checked"], 1);
|
||||
}
|
||||
|
||||
/// A file that can't be read (unplugged drive) changes nothing and is tried again next time.
|
||||
#[test]
|
||||
fn missing_file_keeps_its_flag_and_stamp_and_is_retried() {
|
||||
let mut x = library("missing");
|
||||
std::fs::remove_file(&x.file).unwrap();
|
||||
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"checked": 1, "updated": [], "cleared": []}));
|
||||
let p = x.s.catalog.photo(x.id).unwrap();
|
||||
assert!(p.preview_only.is_some());
|
||||
assert_eq!(p.preview_checked, OTHER);
|
||||
// back again, and decodable now
|
||||
decodable_now(&x);
|
||||
assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["cleared"], json!([x.id.0]));
|
||||
}
|
||||
|
||||
/// A photo that changed between the probe and applying it (here: relinked) is left alone.
|
||||
#[test]
|
||||
fn photo_changed_while_probing_is_skipped() {
|
||||
let mut x = library("changed");
|
||||
let listed = recheck::stale(&x.s.catalog, false);
|
||||
assert_eq!(listed.iter().map(|i| i.id).collect::<Vec<_>>(), vec![x.id]);
|
||||
let probed = recheck::probe(&recheck::file_probe(&x.s).unwrap(), listed);
|
||||
decodable_now(&x);
|
||||
let other = x.dir.join("files").join("IMG_0001-moved.cr3");
|
||||
std::fs::write(&other, cr3_with_preview(96, 64)).unwrap();
|
||||
x.s.execute("photo.relink", &json!({"id": x.id.0, "path": other.to_string_lossy()})).unwrap();
|
||||
let relinked = x.s.catalog.photo(x.id).unwrap().clone();
|
||||
assert_eq!(recheck::apply(&mut x.s, probed).unwrap(), recheck::Rechecked::default());
|
||||
assert_eq!(*x.s.catalog.photo(x.id).unwrap().clone(), *relinked);
|
||||
}
|
||||
|
||||
/// Virtual copies share the file: they follow the photo they were copied from.
|
||||
#[test]
|
||||
fn virtual_copies_follow_their_photo() {
|
||||
let mut x = library("copies");
|
||||
let r = x.s.execute("photo.virtualCopy", &json!({"ids": [x.id.0]})).unwrap();
|
||||
let copy = PhotoId(r["ids"][0].as_u64().unwrap());
|
||||
assert!(x.s.catalog.photo(copy).unwrap().preview_only.is_some());
|
||||
assert_eq!(recheck::stale(&x.s.catalog, true).len(), 1, "copies aren't probed on their own");
|
||||
decodable_now(&x);
|
||||
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
|
||||
assert_eq!(r["cleared"], json!([x.id.0, copy.0]));
|
||||
assert!(x.s.catalog.photo(copy).unwrap().develops_raw());
|
||||
}
|
||||
|
||||
/// Without file access every probe would "succeed" knowing nothing and clear every flag: refused.
|
||||
#[test]
|
||||
fn session_without_file_access_refuses() {
|
||||
let mut x = library("nofs");
|
||||
x.s.media.file_probe = None;
|
||||
let e = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no file access"), "{e}");
|
||||
assert!(x.s.catalog.photo(x.id).unwrap().preview_only.is_some());
|
||||
}
|
||||
Reference in New Issue
Block a user