mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
309d659b99
commit
034991a081
@@ -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<Op> = s
|
||||
let mut ops: Vec<Op> = 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}”") };
|
||||
|
||||
@@ -339,7 +339,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
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()}))
|
||||
|
||||
@@ -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<String> {
|
||||
fn find(rows: &serde_json::Value, key: &str) -> Option<serde_json::Value> {
|
||||
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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user