From 034991a08151dee24cb6083a1e66b4fec5617527 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 17:26:35 -0400 Subject: [PATCH] Engine: folder.label command; labels follow folder rename and move folder.label {path, label} gives a library folder a colour label (or takes it off) as one undo step; only folders the library holds photos in are accepted. library.folders reports each row's label. folder.rename and folder.move commit the folder records with the photos' relinks, so the labels of the folder and the folders inside it go along and undo/redo carry them back and forth. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/browse.rs | 4 +- crates/engine/src/cmd/library.rs | 25 ++++++++- crates/engine/src/tests_folders.rs | 86 ++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) 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")); +}