# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-14 22:33:11 -04:00
7 changed files with 193 additions and 13 deletions
+76 -4
View File
@@ -6,11 +6,14 @@
//! Each browse records when the folders it listed were browsed. Untouched records of folders
//! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with
//! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files
//! and sidecars on disk are never touched, and browsing the folder again brings them back.
//! and sidecars on disk are never touched, and browsing the folder again brings them back. A
//! browse also forgets at once the untouched records of the folder's files that are gone from
//! the disk (deleted or moved by another app), so the folder shows what is on disk.
use std::path::Path;
use lightcraft_catalog::{Op, PhotoId};
use lightcraft_catalog::query::in_folder;
use lightcraft_catalog::{Op, PhotoId, Source};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
@@ -42,11 +45,72 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
// looking at a folder never brings its deleted photos back (importing the files does)
let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
let report = import_with(s, &files, &opts)?;
let forgotten = forget_gone(s, &dir_s, subfolders, &files);
stamp_browsed(s, &dir_s, &files);
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
s.source = LibrarySource::Folder;
let shown = s.visible().len();
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
Ok(json!({
"path": dir_s,
"subfolders": subfolders,
"photos": shown,
"new": report.imported.len(),
"failed": report.failed.len(),
"forgotten": forgotten,
}))
}
impl Session {
/// Forget the untouched Local records of files gone from the folder `dir` (with its subfolders when
/// `subfolders`) that a browse just listed as `files`: what `library.browse` does after reading a folder, for a
/// UI that reads it on a worker thread instead. Returns how many went.
pub fn forget_gone_from(&mut self, dir: &str, subfolders: bool, files: &[String]) -> usize {
forget_gone(self, dir, subfolders, files)
}
}
/// Local records in the browsed folder whose files are no longer on disk (deleted or moved by
/// another app) and that nothing was changed on are forgotten, as `library.forgetLocal` forgets
/// them: the folder shows what is on disk, not photos that fail to load (and log an error) on
/// every visit (issue #1392). Records the user changed stay, shown as missing, and so do those
/// the session holds (selected, edited, in the undo history) or with a smart preview. Returns how
/// many went.
fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -> usize {
let listed: std::collections::HashSet<&str> = files.iter().map(String::as_str).collect();
let held = held(s);
let smart = s.media.smart_dir.clone();
let gone: Vec<PhotoId> = s
.catalog
.photos()
.filter(|p| p.local && !p.deleted && !held.contains(&p.id))
// a smart preview may be all that is left of the picture
.filter(|p| !smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()))
.filter(|p| match &p.source {
// a file left out of the listing is checked on disk: another spelling of its path, or
// a listing that missed it, keeps it
Source::File { path } => in_folder(path, dir, subfolders) && !listed.contains(path.as_str()) && !Path::new(path).exists(),
Source::Demo { .. } => false,
})
.map(|p| p.id)
.collect();
if gone.is_empty() {
return 0;
}
let mut forgotten = 0;
for chunk in s.catalog.forgettable_local(&gone).chunks(2000) {
let op = Op::Batch { ops: chunk.iter().map(|id| Op::RemovePhoto { id: *id }).collect() };
match s.catalog.apply(op.clone()) {
Ok(_) => {
s.pending_log.push(op);
forgotten += chunk.len();
}
Err(e) => log::warn!("forget Local records of files gone from {dir}: {e}"),
}
}
if forgotten > 0 {
log::info!("forgot {forgotten} untouched Local record(s) of files gone from {dir}");
}
forgotten
}
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
@@ -82,7 +146,15 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
/// Photos the session still needs, whatever their state: shown, selected, being edited, or
/// referred to by an undo / redo step.
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect();
let mut keep = held(s);
keep.extend(s.visible().iter().copied());
keep
}
/// Photos the session holds whatever is shown: selected, being edited, or referred to by an undo
/// / redo step.
fn held(s: &Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = std::collections::HashSet::new();
keep.extend(s.selection.ids.iter().copied());
keep.extend(s.selection.active);
keep.extend(s.previous_active);
+43
View File
@@ -216,3 +216,46 @@ fn upgraded_catalogs_forget_nothing_at_first() {
assert!(local_paths(&s).is_empty(), "the untouched legacy record goes once 30 days passed");
let _ = std::fs::remove_dir_all(&root);
}
/// Issue #1392: files deleted by another app after their folder was browsed in Local stayed in
/// it, failing to load (and logging an error) on every visit. Browsing the folder again forgets
/// the untouched records of files that are gone; a rated one stays (shown as missing), so does a
/// photo added to the library, and nothing on disk is touched.
#[test]
fn browsing_again_forgets_untouched_records_of_deleted_files() {
let root = temp_dir("gone");
let (a, lib) = (root.join("a"), root.join("lib"));
for (i, n) in ["a1.png", "a2.png", "a3.png", "a4.png"].iter().enumerate() {
png(&a.join(n), i as u8);
}
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
let mut s = open(&lib, &clock);
let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
assert_eq!((r["new"].as_u64(), r["forgotten"].as_u64()), (Some(4), Some(0)), "{r}");
let id = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
let (a2, a4) = (id(&s, "a2.png"), id(&s, "a4.png"));
s.selection = Selection::single(a2);
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
s.execute("photo.addToLibrary", &json!({"ids": [a4.0]})).unwrap();
look_elsewhere(&mut s);
for n in ["a1.png", "a2.png", "a4.png"] {
std::fs::remove_file(a.join(n)).unwrap();
}
let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
assert_eq!((r["new"].as_u64(), r["forgotten"].as_u64(), r["photos"].as_u64()), (Some(0), Some(1), Some(3)), "{r}");
assert_eq!(local_paths(&s), vec!["a2.png", "a3.png"], "the untouched record of a1 is forgotten, the rated a2 stays");
assert!(s.catalog.photo(a4).is_some_and(|p| !p.local), "a library photo of a deleted file stays");
assert!(a.join("a3.png").exists());
assert!(s.undo.is_empty(), "not an undo step");
// browsing once more changes nothing
let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap();
assert_eq!(r["forgotten"], 0, "{r}");
// journaled: the library opens in the same state
let expect = s.catalog.to_snapshot();
drop(s);
let s = open(&lib, &clock);
assert_eq!(s.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&root);
}