diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index e5074d9ed..6406db479 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -286,7 +286,7 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result: return Err("the library's own folders can't be moved here".into()); } rename_folder_on_disk(from, to)?; - let ops: Vec = s + let mut ops: Vec = s .catalog .photos() .filter_map(|p| match &p.source { @@ -301,6 +301,8 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result: }) .collect(); let n = ops.len(); + // what the library knows about the folder (and those inside it) goes along, in the same step + ops.extend(s.catalog.folder_records_follow(from, to)); let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); let renamed = src.parent() == dst.parent(); let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") }; diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 09d19f0dd..0ae6d4ba0 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -339,7 +339,7 @@ pub fn specs() -> Vec { "Library Folders", [], None, - "{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", + "{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", always, |s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default()) ), @@ -387,6 +387,29 @@ pub fn specs() -> Vec { Ok(json!({"removed": ids.len()})) } ), + cmd!( + "folder.label", + "Set Folder Color Label", + [], + None, + "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off; one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}", + always, + |s, p| { + const C: &str = "folder.label"; + let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? { + "none" => None, + x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?), + }; + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + if s.catalog.query(&f, &Sort::default()).is_empty() { + return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); + } + let op = s.catalog.folder_label_op(path, label); + s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?; + Ok(json!({"path": path, "label": label})) + } + ), cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { s.filter = Default::default(); Ok(json!({"count": s.visible().len()})) diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index a94921373..2e60ce1ad 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -8,6 +8,11 @@ //! * Removing a folder from the library moves its photos to Recently Deleted (one undo step) and //! leaves every file where it is. //! * Renaming or moving a folder on disk keeps the chosen folder chosen. +//! * Given a folder of the library, an agent gives it a colour label with `folder.label`; the +//! folder list shows it, undo takes it back, and only folders the library holds photos in can +//! be labelled. +//! * A labelled folder keeps its label when it is renamed or moved on disk, and so do the folders +//! inside it; undo puts the labels back where they were. use lightcraft_catalog::{Op, Photo, Source}; use serde_json::json; @@ -463,3 +468,84 @@ fn a_blank_folder_filter_is_no_filter() { s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap(); assert_eq!(s.filter, lightcraft_catalog::Filter::default(), "no hidden 'filters active' state"); } + +/// The label `library.folders` reports for the row at `path`. +fn listed_label(s: &mut Session, path: &str) -> Option { + fn find(rows: &serde_json::Value, key: &str) -> Option { + rows.as_array()?.iter().find_map(|r| { + if r["path"].as_str().map(lightcraft_catalog::query::folder_key).as_deref() == Some(key) { + Some(r.clone()) + } else { + find(&r["children"], key) + } + }) + } + let rows = s.execute("library.folders", &json!({})).unwrap(); + let row = find(&rows, &lightcraft_catalog::query::folder_key(path)).unwrap_or_else(|| panic!("no row for {path}: {rows}")); + row["label"].as_str().map(str::to_string) +} + +#[test] +fn a_labelled_folder_shows_its_label_in_the_folder_list_until_undone() { + let mut s = session(); + s.execute("folder.label", &json!({"path": "/pics/trip", "label": "green"})).unwrap(); + assert_eq!(listed_label(&mut s, "/pics/trip").as_deref(), Some("green")); + assert_eq!(listed_label(&mut s, "/pics/home"), None, "its neighbour has none"); + s.execute("folder.label", &json!({"path": "/pics/trip", "label": "none"})).unwrap(); + assert_eq!(listed_label(&mut s, "/pics/trip"), None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, "/pics/trip").as_deref(), Some("green"), "undo puts the label back"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, "/pics/trip"), None); +} + +#[test] +fn only_a_folder_of_the_library_takes_a_known_label() { + let mut s = session(); + let before = s.catalog.to_snapshot(); + for (p, why) in [ + (json!({"path": "/pics/trip", "label": "mauve"}), "unknown label"), + (json!({"path": "/pics/trip"}), "missing label"), + (json!({"label": "red"}), "missing path"), + (json!({"path": " ", "label": "red"}), "blank path"), + (json!({"path": "/elsewhere", "label": "red"}), "no photo was imported from it"), + (json!({"path": "pics/trip", "label": "red"}), "a relative path"), + ] { + assert!(s.execute("folder.label", &p).is_err(), "{why}"); + } + assert_eq!(s.catalog.to_snapshot(), before, "nothing changed"); +} + +#[test] +fn labels_follow_a_renamed_folder_and_undo_puts_them_back() { + let dir = Scratch::new("label-rename"); + std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap(); + let (trip, day1) = (dir.path("trip"), dir.path("trip/day1")); + let mut s = Session::new(); + add(&mut s, &format!("{trip}/a.jpg")); + add(&mut s, &format!("{day1}/b.jpg")); + s.execute("folder.label", &json!({"path": trip, "label": "red"})).unwrap(); + s.execute("folder.label", &json!({"path": day1, "label": "blue"})).unwrap(); + s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap(); + assert_eq!(listed_label(&mut s, &dir.path("holiday")).as_deref(), Some("red")); + assert_eq!(listed_label(&mut s, &dir.path("holiday/day1")).as_deref(), Some("blue"), "the folder inside goes along"); + assert_eq!(s.catalog.folder_record(&trip), None, "nothing stays behind"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, &trip).as_deref(), Some("red")); + assert_eq!(listed_label(&mut s, &day1).as_deref(), Some("blue")); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, &dir.path("holiday")).as_deref(), Some("red"), "redo carries them again"); +} + +#[test] +fn labels_follow_a_moved_folder() { + let dir = Scratch::new("label-move"); + std::fs::create_dir_all(dir.0.join("trip")).unwrap(); + std::fs::create_dir_all(dir.0.join("archive")).unwrap(); + let trip = dir.path("trip"); + let mut s = Session::new(); + add(&mut s, &format!("{trip}/a.jpg")); + s.execute("folder.label", &json!({"path": trip, "label": "yellow"})).unwrap(); + s.execute("folder.move", &json!({"path": trip, "into": dir.path("archive")})).unwrap(); + assert_eq!(listed_label(&mut s, &dir.path("archive/trip")).as_deref(), Some("yellow")); +}