Merge PR #597
# Conflicts: # crates/catalog/src/journal.rs # crates/catalog/src/lib.rs # crates/catalog/src/local.rs # crates/catalog/src/tests_format_version.rs # crates/catalog/src/tests_journal.rs # crates/ui-egui/src/panels/left.rs # docs/whats-new.md
This commit is contained in:
commit
b785023073
|
|
@ -69,6 +69,11 @@ Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29
|
|||
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
|
||||
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
|
||||
|
||||
Library folders now have records of their own in the catalog (`Catalog.folder_records`, keyed by folder path; colour
|
||||
labels first). Photos still store full paths, so moving a whole disk or folder relinks each photo. If that becomes a
|
||||
problem (an "Update Folder Location" for a renamed or replaced drive, journal growth on large moves), the next step is
|
||||
the Classic model: photos point to a folder id (root + relative path), and the records keyed by path become those rows.
|
||||
|
||||
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
|
||||
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
|
||||
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
|
||||
|
|
|
|||
|
|
@ -23,15 +23,21 @@
|
|||
//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is
|
||||
//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts.
|
||||
//!
|
||||
//! A folder is otherwise only where photos are, but the library can also keep a
|
||||
//! [`FolderRecord`] about it (its colour label), stored under the folder's identity
|
||||
//! ([`crate::query::folder_key`]). A record follows its folder when the folder is renamed or moved
|
||||
//! here ([`Catalog::folder_records_follow`]); one about a folder that no longer holds a photo is
|
||||
//! kept (undo can bring the photos back) and shows again when the folder does.
|
||||
//!
|
||||
//! This is the library's own view of its photos; browsing any folder on disk without importing
|
||||
//! is Local's job (see [`crate::local`]).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::query::{folder_key, folder_within, split_path};
|
||||
use crate::{Catalog, Source};
|
||||
use crate::query::{folder_key, folder_rest, folder_within, key_within, split_path};
|
||||
use crate::{Catalog, ColorLabel, Op, Source};
|
||||
|
||||
/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its
|
||||
/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it)
|
||||
|
|
@ -58,9 +64,35 @@ pub struct FolderNode {
|
|||
/// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their
|
||||
/// path covers those disks' photos too, so choosing them is not offered.
|
||||
pub selectable: bool,
|
||||
/// The folder's colour label (see [`FolderRecord`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<ColorLabel>,
|
||||
pub children: Vec<FolderNode>,
|
||||
}
|
||||
|
||||
/// What the library keeps about one of its folders beyond the photos in it. A record with
|
||||
/// nothing in it is not kept.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FolderRecord {
|
||||
/// A colour label, as a photo has one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<ColorLabel>,
|
||||
}
|
||||
|
||||
impl FolderRecord {
|
||||
/// Whether the record says nothing (and so is not kept).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.label.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity a folder's record is kept under: its [`folder_key`]; `None` for a path that
|
||||
/// names no folder (empty, relative, `.`).
|
||||
pub(crate) fn record_key(path: &str) -> Option<String> {
|
||||
let key = folder_key(path);
|
||||
(split_path(path, true).absolute && !key.is_empty()).then_some(key)
|
||||
}
|
||||
|
||||
/// A folder while the tree is being built (children by identity key).
|
||||
struct Level {
|
||||
name: String,
|
||||
|
|
@ -151,6 +183,53 @@ pub fn folder_label(path: &str) -> String {
|
|||
}
|
||||
|
||||
impl Catalog {
|
||||
/// What the library keeps about folder `path`, however it is spelled.
|
||||
pub fn folder_record(&self, path: &str) -> Option<&FolderRecord> {
|
||||
record_key(path).and_then(|k| self.folder_records.get(&k))
|
||||
}
|
||||
|
||||
/// The colour label of folder `path`, however it is spelled.
|
||||
pub fn folder_color_label(&self, path: &str) -> Option<ColorLabel> {
|
||||
self.folder_record(path).and_then(|r| r.label)
|
||||
}
|
||||
|
||||
/// The op that gives folder `path` colour label `label` (`None`: takes it off), keeping the
|
||||
/// rest of its record. Applying it checks the path.
|
||||
pub fn folder_label_op(&self, path: &str, label: Option<ColorLabel>) -> Op {
|
||||
let mut record = self.folder_record(path).cloned().unwrap_or_default();
|
||||
record.label = label;
|
||||
Op::SetFolderRecord { folder: path.to_string(), record: Some(record) }
|
||||
}
|
||||
|
||||
/// The ops that carry the records of folder `from` and the folders inside it over to `to`
|
||||
/// (the same place below it), for a folder renamed or moved on disk: commit them with the
|
||||
/// photos' relinks so undo puts both back. Records already at or below `to` are left from a
|
||||
/// folder that is no longer there and are dropped (undo restores them). Empty when there is
|
||||
/// nothing to carry or drop.
|
||||
pub fn folder_records_follow(&self, from: &str, to: &str) -> Vec<Op> {
|
||||
let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() };
|
||||
if root == dest {
|
||||
return Vec::new();
|
||||
}
|
||||
let moved: Vec<(String, FolderRecord)> = self
|
||||
.folder_records
|
||||
.iter()
|
||||
.filter(|(k, _)| key_within(k, &root))
|
||||
.filter_map(|(k, r)| {
|
||||
let rest = folder_rest(k, &root)?;
|
||||
let base = dest.trim_end_matches('/');
|
||||
let path = if rest.is_empty() { dest.clone() } else { format!("{base}/{}", rest.join("/")) };
|
||||
Some((folder_key(&path), r.clone()))
|
||||
})
|
||||
.collect();
|
||||
// what was known at the old place goes, and so does what is left at the new one (also
|
||||
// when the moved folder carries nothing: the folder now there is not the one it was about)
|
||||
let stale = self.folder_records.keys().filter(|k| key_within(k, &root) || key_within(k, &dest));
|
||||
let mut ops: Vec<Op> = stale.map(|k| Op::SetFolderRecord { folder: k.clone(), record: None }).collect();
|
||||
ops.extend(moved.into_iter().map(|(k, r)| Op::SetFolderRecord { folder: k, record: Some(r) }));
|
||||
ops
|
||||
}
|
||||
|
||||
/// The library's volumes with their folders and photo counts: volumes by name
|
||||
/// (case-insensitive), each folder with its subfolders the same way.
|
||||
pub fn folder_tree(&self) -> Vec<FolderNode> {
|
||||
|
|
@ -196,7 +275,7 @@ impl Catalog {
|
|||
l.own += n;
|
||||
}
|
||||
}
|
||||
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels)).collect();
|
||||
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels, &self.folder_records)).collect();
|
||||
sort(&mut tree);
|
||||
// a folder that holds another disk's mount covers that disk's photos too
|
||||
let mounts: Vec<String> = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect();
|
||||
|
|
@ -206,12 +285,21 @@ impl Catalog {
|
|||
}
|
||||
|
||||
/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
|
||||
fn build(key: &str, levels: &HashMap<String, Level>) -> Option<FolderNode> {
|
||||
fn build(key: &str, levels: &HashMap<String, Level>, records: &BTreeMap<String, FolderRecord>) -> Option<FolderNode> {
|
||||
let l = levels.get(key)?;
|
||||
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).collect();
|
||||
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels, records)).collect();
|
||||
sort(&mut children);
|
||||
let count = l.own + children.iter().map(|c| c.count).sum::<usize>();
|
||||
Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children })
|
||||
Some(FolderNode {
|
||||
name: l.name.clone(),
|
||||
path: l.path.clone(),
|
||||
count,
|
||||
own: l.own,
|
||||
volume: l.volume,
|
||||
selectable: true,
|
||||
label: records.get(key).and_then(|r| r.label),
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
//! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` |
|
||||
//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` |
|
||||
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
|
||||
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
|
|
@ -72,7 +73,7 @@ pub const LOG: &str = "catalog.log";
|
|||
const FORMAT: &str = "lightcraft-catalog";
|
||||
/// The catalog format this build writes (and the newest it reads). See the module docs →
|
||||
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
|
||||
pub const VERSION: u32 = 5;
|
||||
pub const VERSION: u32 = 6;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use std::collections::BTreeMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use folders::FolderNode;
|
||||
pub use folders::{FolderNode, FolderRecord};
|
||||
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
|
@ -231,6 +231,12 @@ pub enum Op {
|
|||
path: String,
|
||||
info: Option<keywords::KeywordInfo>,
|
||||
},
|
||||
/// What the library keeps about one of its folders (see [`folders::FolderRecord`]), under
|
||||
/// its identity ([`query::folder_key`]); `None` or an empty record = keep nothing.
|
||||
SetFolderRecord {
|
||||
folder: String,
|
||||
record: Option<FolderRecord>,
|
||||
},
|
||||
/// Several ops as one step (undo applies the inverses in reverse).
|
||||
Batch {
|
||||
ops: Vec<Op>,
|
||||
|
|
@ -256,6 +262,9 @@ pub struct Catalog {
|
|||
/// Keywords listed on their own or given attributes, by lower-case path (see [`keywords`]).
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
keyword_list: BTreeMap<String, keywords::ListedKeyword>,
|
||||
/// What the library keeps about its folders (folder identity → record), see [`folders`].
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
folder_records: BTreeMap<String, FolderRecord>,
|
||||
/// Increments on every applied op.
|
||||
#[serde(skip)]
|
||||
pub revision: u64,
|
||||
|
|
@ -715,6 +724,14 @@ impl Catalog {
|
|||
};
|
||||
Op::SetBrowsed { folder, at: old }
|
||||
}
|
||||
Op::SetFolderRecord { folder, record } => {
|
||||
let key = folders::record_key(&folder).ok_or_else(|| CatalogError::Invalid(format!("not a folder: {folder}")))?;
|
||||
let old = match record.filter(|r| !r.is_empty()) {
|
||||
Some(r) => self.folder_records.insert(key.clone(), r),
|
||||
None => self.folder_records.remove(&key),
|
||||
};
|
||||
Op::SetFolderRecord { folder: key, record: old }
|
||||
}
|
||||
Op::Batch { ops } => {
|
||||
let mut inverses = Vec::with_capacity(ops.len());
|
||||
for op in ops {
|
||||
|
|
@ -816,6 +833,8 @@ mod tests_album_order;
|
|||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_folder_records;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
|
|
|
|||
|
|
@ -301,7 +301,8 @@ impl Op {
|
|||
| Op::RemoveStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::SetKeyword { .. }
|
||||
| Op::SetBrowsed { .. } => {}
|
||||
| Op::SetBrowsed { .. }
|
||||
| Op::SetFolderRecord { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
//! What the library keeps about its folders: a colour label, like a photo's (see
|
||||
//! [`crate::folders::FolderRecord`]).
|
||||
//!
|
||||
//! Scenarios, in the words of someone organising their library by folder:
|
||||
//!
|
||||
//! * Given a folder of the library, when I give it a colour label, its row in the folder tree
|
||||
//! shows that label; the folders around it show none.
|
||||
//! * However the folder is spelled, it is one folder with one label.
|
||||
//! * When I take the label off, the library keeps nothing about the folder.
|
||||
//! * Every change is one undo step: undo puts back the label the folder had before.
|
||||
//! * A path that names no folder is refused and nothing changes.
|
||||
//! * When the folder is renamed or moved, its label and those of the folders inside it go along;
|
||||
//! a folder beside it whose name merely starts the same way keeps its own. Undo brings them
|
||||
//! back, also a label that was left at the destination.
|
||||
//! * Labels survive closing and reopening the library.
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn add(c: &mut Catalog, path: &str) {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
|
||||
fn library() -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
for p in ["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip2/c.jpg", "/pics/home/d.jpg"] {
|
||||
add(&mut c, p);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn label(c: &mut Catalog, path: &str, l: Option<ColorLabel>) -> Op {
|
||||
let op = c.folder_label_op(path, l);
|
||||
c.apply(op).unwrap()
|
||||
}
|
||||
|
||||
/// The label of each row of the tree, by path, depth first (rows without one left out).
|
||||
fn tree_labels(c: &Catalog) -> Vec<(String, ColorLabel)> {
|
||||
fn walk(n: &FolderNode, out: &mut Vec<(String, ColorLabel)>) {
|
||||
if let Some(l) = n.label {
|
||||
out.push((n.path.clone(), l));
|
||||
}
|
||||
n.children.iter().for_each(|c| walk(c, out));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
c.folder_tree().iter().for_each(|n| walk(n, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_library_knows_nothing_about_its_folders() {
|
||||
let c = library();
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
assert_eq!(c.folder_color_label("/pics/trip"), None);
|
||||
assert!(tree_labels(&c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_labelled_folder_shows_its_label_in_the_tree_and_only_there() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
|
||||
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Red));
|
||||
assert_eq!(tree_labels(&c), vec![("/pics/trip".to_string(), ColorLabel::Red)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn however_a_folder_is_spelled_it_has_one_label() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics//trip/", Some(ColorLabel::Green));
|
||||
assert_eq!(c.folder_color_label("/pics/./trip"), Some(ColorLabel::Green));
|
||||
label(&mut c, "/pics/home/../trip", Some(ColorLabel::Blue));
|
||||
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Blue));
|
||||
assert_eq!(tree_labels(&c).len(), 1, "one folder, one record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taking_the_label_off_leaves_no_record() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/trip", Some(ColorLabel::Purple));
|
||||
label(&mut c, "/pics/trip", None);
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
assert!(!c.to_snapshot().contains("folder_records"), "nothing empty is saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_record_is_no_record() {
|
||||
let mut c = library();
|
||||
c.apply(Op::SetFolderRecord { folder: "/pics/trip".into(), record: Some(FolderRecord::default()) }).unwrap();
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_puts_back_the_label_the_folder_had() {
|
||||
let mut c = library();
|
||||
let undo_first = label(&mut c, "/pics/trip", Some(ColorLabel::Yellow));
|
||||
let undo_second = label(&mut c, "/pics/trip", Some(ColorLabel::Red));
|
||||
c.apply(undo_second).unwrap();
|
||||
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
|
||||
c.apply(undo_first).unwrap();
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_no_folder_is_refused() {
|
||||
let mut c = library();
|
||||
let before = c.to_snapshot();
|
||||
for bad in ["", "relative/trip", ".", "a/.."] {
|
||||
let op = Op::SetFolderRecord { folder: bad.into(), record: Some(FolderRecord { label: Some(ColorLabel::Red) }) };
|
||||
assert!(c.apply(op).is_err(), "{bad:?} names no folder");
|
||||
}
|
||||
assert_eq!(c.to_snapshot(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_folder_takes_its_labels_and_its_subfolders_labels_along() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
|
||||
label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green));
|
||||
label(&mut c, "/pics/trip2", Some(ColorLabel::Blue));
|
||||
let ops = c.folder_records_follow("/pics/trip", "/archive/2026/trip");
|
||||
c.apply(Op::Batch { ops }).unwrap();
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
assert_eq!(c.folder_color_label("/archive/2026/trip"), Some(ColorLabel::Red));
|
||||
assert_eq!(c.folder_color_label("/archive/2026/trip/day1"), Some(ColorLabel::Green));
|
||||
assert_eq!(c.folder_color_label("/pics/trip2"), Some(ColorLabel::Blue), "a neighbour that only starts the same stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_a_move_brings_back_every_label_also_one_left_at_the_destination() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
|
||||
// a folder that once was at the destination left its labels behind
|
||||
label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
|
||||
label(&mut c, "/pics/holiday/day9", Some(ColorLabel::Yellow));
|
||||
let before = c.to_snapshot();
|
||||
let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
|
||||
let undo = c.apply(Op::Batch { ops }).unwrap();
|
||||
assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Red), "the moved folder's label wins");
|
||||
assert_eq!(c.folder_record("/pics/holiday/day9"), None, "what was known about the old folder goes");
|
||||
c.apply(undo).unwrap();
|
||||
assert_eq!(c.to_snapshot(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_without_labels_moves_without_ops() {
|
||||
let c = library();
|
||||
assert!(c.folder_records_follow("/pics/trip", "/elsewhere/trip").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlabelled_folder_moved_onto_a_stale_label_does_not_inherit_it() {
|
||||
let mut c = library();
|
||||
// a folder that was at the destination once (its photos are gone) left its label behind
|
||||
label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
|
||||
let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
|
||||
let undo = c.apply(Op::Batch { ops }).unwrap();
|
||||
assert_eq!(c.folder_record("/pics/holiday"), None, "the folder now there is another one");
|
||||
c.apply(undo).unwrap();
|
||||
assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Purple));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_survive_the_journal_without_a_snapshot() {
|
||||
let m = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
{
|
||||
let p = "/pics/trip/a.jpg";
|
||||
let id = c.alloc_photo_id();
|
||||
let op = Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: p.into() }, "a.jpg", "JPEG", 6, 4, "2026-01-01")) };
|
||||
c.apply(op.clone()).unwrap();
|
||||
j.append(std::slice::from_ref(&op)).unwrap();
|
||||
}
|
||||
let op = c.folder_label_op("/pics/trip", Some(ColorLabel::Yellow));
|
||||
c.apply(op.clone()).unwrap();
|
||||
j.append(std::slice::from_ref(&op)).unwrap();
|
||||
drop(j);
|
||||
let (_, reopened, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert!(r.replayed >= 2, "{r:?}");
|
||||
assert_eq!(reopened.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_survive_closing_and_reopening_the_library() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/home", Some(ColorLabel::Green));
|
||||
let reopened = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
|
||||
assert_eq!(reopened.folder_color_label("/pics/home"), Some(ColorLabel::Green));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_saved_before_folder_records_opens_without_any() {
|
||||
let c = library();
|
||||
let old = c.to_snapshot();
|
||||
assert!(!old.contains("folder_records"));
|
||||
let reopened = Catalog::from_snapshot(&old).unwrap();
|
||||
assert_eq!(reopened.folder_record("/pics/trip"), None);
|
||||
}
|
||||
|
|
@ -209,8 +209,9 @@ fn op_variants_are_versioned() {
|
|||
Op::SetAlbumOrder { .. } => 3,
|
||||
Op::SetEmbeddedLens { .. } => 4,
|
||||
Op::SetKeyword { .. } => 5,
|
||||
Op::SetFolderRecord { .. } => 6,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetKeyword { path: "travel".into(), info: None });
|
||||
let newest = since(&Op::SetFolderRecord { folder: "/".into(), record: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
|||
..Default::default()
|
||||
}),
|
||||
},
|
||||
9 if b % 11 == 4 => c.folder_label_op(&format!("/p/f{}", a % 4), ColorLabel::ALL.get(usize::from(b % 6)).copied()),
|
||||
9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) },
|
||||
9 if b.is_multiple_of(2) => {
|
||||
Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } }
|
||||
|
|
|
|||
|
|
@ -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}”") };
|
||||
|
|
|
|||
|
|
@ -381,7 +381,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())
|
||||
),
|
||||
|
|
@ -429,6 +429,33 @@ 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 (also once its photos are gone); 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:?}")))?),
|
||||
};
|
||||
// taking a label off always works, also once the folder's photos are gone
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {
|
||||
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if label.is_none() && s.catalog.folder_record(path).is_none() {
|
||||
return Ok(json!({"path": path, "label": label}));
|
||||
}
|
||||
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,99 @@ 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"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &trip).as_deref(), Some("yellow"), "undo moves it back with the folder");
|
||||
assert_eq!(s.catalog.folder_record(&dir.path("archive/trip")), None);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &dir.path("archive/trip")).as_deref(), Some("yellow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_comes_off_a_folder_whose_photos_are_gone() {
|
||||
let mut s = session();
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).unwrap();
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "none"})).unwrap();
|
||||
assert_eq!(s.catalog.folder_record("/pics/trip"), None);
|
||||
assert!(s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).is_err(), "but a new one needs photos");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -801,9 +801,20 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
|
||||
pub use super::filterbar::label_color;
|
||||
|
||||
/// "Set Color Label" items (coloured dot + the label's name), shared by context menus.
|
||||
/// "Set Color Label" items for the active photo, shared by context menus.
|
||||
pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let current = app.session.active().and_then(|id| app.session.catalog.photo(id)).and_then(|p| p.label);
|
||||
if let Some(l) = label_items(app, ui, current, "photoLabel") {
|
||||
let _ = app.run("photo.label", json!({"label": label_param(l)}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour label items of a menu (coloured dot + the label's name, then None and Edit Label
|
||||
/// Names…), `current` checked; each colour is widget `<widget>:<colour>` (`<widget>:none`) for
|
||||
/// the control channel. Returns the choice (`Some(None)`: None).
|
||||
pub fn label_items(app: &mut LightcraftApp, ui: &mut egui::Ui, current: Option<ColorLabel>, widget: &str) -> Option<Option<ColorLabel>> {
|
||||
let mut chosen = None;
|
||||
for l in ColorLabel::ALL {
|
||||
let name = crate::i18n::color_label(&app.session.catalog, l);
|
||||
let resp = ui.horizontal(|ui| {
|
||||
|
|
@ -811,18 +822,26 @@ pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
ui.painter().circle_filled(r.center(), 5.0, label_color(l));
|
||||
ui.selectable_label(current == Some(l), name)
|
||||
});
|
||||
register(ui.ctx(), format!("{widget}:{}", label_param(Some(l))), resp.inner.rect);
|
||||
if resp.inner.clicked() {
|
||||
let _ = app.run("photo.label", json!({"label": format!("{l:?}").to_lowercase()}));
|
||||
ui.close();
|
||||
chosen = Some(Some(l));
|
||||
}
|
||||
}
|
||||
if ui.selectable_label(current.is_none(), crate::i18n::tr("None")).clicked() {
|
||||
let _ = app.run("photo.label", json!({"label": "none"}));
|
||||
let none = ui.selectable_label(current.is_none(), crate::i18n::tr("None"));
|
||||
register(ui.ctx(), format!("{widget}:none"), none.rect);
|
||||
if none.clicked() {
|
||||
chosen = Some(None);
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Edit Label Names…")).clicked() {
|
||||
let _ = app.run("dialog.labelNames", json!({}));
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
/// A label as the `label` parameter of `photo.label` / `folder.label` names it.
|
||||
pub fn label_param(l: Option<ColorLabel>) -> String {
|
||||
l.map_or_else(|| "none".to_string(), |l| format!("{l:?}").to_lowercase())
|
||||
}
|
||||
|
||||
/// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the
|
||||
|
|
|
|||
|
|
@ -53,10 +53,11 @@ fn row_named(
|
|||
selected: bool,
|
||||
indent: f32,
|
||||
) -> egui::Response {
|
||||
row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click())
|
||||
row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click(), None)
|
||||
}
|
||||
|
||||
/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`).
|
||||
/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`) and a colour
|
||||
/// label's dot (`mark`) before the count.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn row_sensed(
|
||||
app: &mut LightcraftApp,
|
||||
|
|
@ -69,6 +70,7 @@ fn row_sensed(
|
|||
selected: bool,
|
||||
indent: f32,
|
||||
sense: Sense,
|
||||
mark: Option<egui::Color32>,
|
||||
) -> egui::Response {
|
||||
let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
@ -102,8 +104,15 @@ fn row_sensed(
|
|||
let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim));
|
||||
let label_left = r.left() + 42.0 + indent;
|
||||
let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x);
|
||||
// a label's dot sits just before the count
|
||||
let mark_w = if mark.is_some() { MARK_W } else { 0.0 };
|
||||
if let Some(c) = mark {
|
||||
let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0));
|
||||
ui.painter().circle_filled(dot.center(), 4.0, c);
|
||||
register(ui.ctx(), format!("labelMark:{id}"), dot);
|
||||
}
|
||||
// the name gives way to the count: cut with an ellipsis, in full on hover
|
||||
let room = count_left - 8.0 - label_left;
|
||||
let room = count_left - mark_w - 8.0 - label_left;
|
||||
let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x;
|
||||
let full_w = measure(label);
|
||||
let shown = if full_w <= room { label.to_string() } else { crate::widgets::elide_head(label, room.max(0.0), measure) };
|
||||
|
|
@ -112,7 +121,7 @@ fn row_sensed(
|
|||
let resp = if shown != label { resp.on_hover_text(label) } else { resp };
|
||||
// a row asks for room for its name up to a share of a panel, so one very long name does not
|
||||
// make everything scroll: depth does that
|
||||
let mut needed = 42.0 + indent + full_w.min(MAX_NAME_NEED) + 18.0;
|
||||
let mut needed = 42.0 + indent + full_w.min(MAX_NAME_NEED) + 18.0 + mark_w;
|
||||
if let Some(galley) = count_galley {
|
||||
let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size());
|
||||
needed += rect.width() + 16.0;
|
||||
|
|
@ -694,7 +703,8 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare
|
|||
open = true;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, true));
|
||||
}
|
||||
let resp = row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag());
|
||||
let resp =
|
||||
row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None);
|
||||
// a folder is no source, so its row folds it too; the triangle is the same click, aimed
|
||||
let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty());
|
||||
if resp.drag_started() {
|
||||
|
|
@ -730,7 +740,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare
|
|||
// a smart album whose rules no longer check (an album they test was deleted) says so
|
||||
let problems = app.caches.smart_album_problems(&app.session.catalog).get(&a.id).cloned().unwrap_or_default();
|
||||
let label = if problems.is_empty() { label } else { format!("⚠ {label}") };
|
||||
let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag());
|
||||
let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag(), None);
|
||||
if !problems.is_empty() {
|
||||
crate::widgets::register(ui.ctx(), format!("albumProblem:{}", a.id.0), resp.rect);
|
||||
}
|
||||
|
|
@ -798,6 +808,9 @@ fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Room a colour label's dot takes before a row's count.
|
||||
const MARK_W: f32 = 12.0;
|
||||
|
||||
/// How long a dragged album must rest on a closed folder before it opens.
|
||||
const HOVER_OPEN_SECS: f64 = 0.6;
|
||||
|
||||
|
|
@ -1191,7 +1204,14 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode],
|
|||
&& app.session.source == LibrarySource::LibraryFolder
|
||||
&& app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path));
|
||||
let name = if selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() };
|
||||
let resp = row_named(app, ui, &format!("libfolder:{}", n.path), Icon::Folder, &name, Some(&name), Some(n.count), sel, indent);
|
||||
// the label is said with the name, as it is shown beside it
|
||||
let spoken = match n.label {
|
||||
Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)),
|
||||
None => name.clone(),
|
||||
};
|
||||
let mark = n.label.map(crate::theme::label_color);
|
||||
let id = format!("libfolder:{}", n.path);
|
||||
let resp = row_sensed(app, ui, &id, Icon::Folder, &name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark);
|
||||
let mut toggled = false;
|
||||
if !n.children.is_empty() {
|
||||
let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("libfolder-tri", key)), format!("libraryFolderToggle:{}", n.path));
|
||||
|
|
@ -1230,6 +1250,8 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
|||
folder_menu_for_library(app, resp, n);
|
||||
} else if n.path != "/" {
|
||||
resp.context_menu(|ui| {
|
||||
folder_label_menu(app, ui, n);
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(crate::i18n::tr("Remove Disk from Library…"))
|
||||
.on_hover_text(crate::i18n::tr("Moves every photo imported from this disk to Recently Deleted; no file is touched"))
|
||||
|
|
@ -1242,6 +1264,20 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`,
|
||||
/// its colours `folderLabel:<colour>`).
|
||||
fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) {
|
||||
let menu = ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| {
|
||||
if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") {
|
||||
if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
register(ui.ctx(), "folderLabelMenu", menu.response.rect);
|
||||
}
|
||||
|
||||
/// The context menu of a folder row: the folder's disk actions (the same as Local's, photos
|
||||
/// follow) and taking its photos out of the library.
|
||||
fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
||||
|
|
@ -1278,6 +1314,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
|
|||
let _ = f(path);
|
||||
ui.close();
|
||||
}
|
||||
folder_label_menu(app, ui, n);
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(crate::i18n::tr("Remove from Library…"))
|
||||
|
|
|
|||
|
|
@ -924,6 +924,29 @@ fn right_click_opens_a_folders_menu_from_the_triangle_too() {
|
|||
assert!(!popup_open(&h), "the startup disk offers nothing");
|
||||
}
|
||||
|
||||
/// Right-click a folder ▸ Set Color Label ▸ a colour: the folder's row shows the label's dot (a
|
||||
/// row without a label shows none), and None takes it off again.
|
||||
#[test]
|
||||
fn a_folder_takes_a_colour_label_from_its_menu() {
|
||||
let mut h = folders_app(&["/pics/trip/a.jpg", "/pics/home/b.jpg"]);
|
||||
click(&mut h, "libraryFolderToggle:/pics");
|
||||
assert!(!has(&h, "labelMark:libfolder:/pics/trip"), "no label, no dot");
|
||||
right_click(&mut h, "source:libfolder:/pics/trip");
|
||||
click(&mut h, "folderLabelMenu");
|
||||
click(&mut h, "folderLabel:red");
|
||||
assert_eq!(h.app.session.catalog.folder_color_label("/pics/trip"), Some(lightcraft_catalog::ColorLabel::Red));
|
||||
assert!(!popup_open(&h), "choosing closes the menu");
|
||||
h.step();
|
||||
assert!(has(&h, "labelMark:libfolder:/pics/trip"), "the row shows its label");
|
||||
assert!(!has(&h, "labelMark:libfolder:/pics/home"), "its neighbour does not");
|
||||
right_click(&mut h, "source:libfolder:/pics/trip");
|
||||
click(&mut h, "folderLabelMenu");
|
||||
click(&mut h, "folderLabel:none");
|
||||
assert_eq!(h.app.session.catalog.folder_color_label("/pics/trip"), None);
|
||||
h.step();
|
||||
assert!(!has(&h, "labelMark:libfolder:/pics/trip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_disk_from_the_library_asks_first() {
|
||||
use crate::state::Dialog;
|
||||
|
|
|
|||
|
|
@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
|||
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs |
|
||||
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
|
||||
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here |
|
||||
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸ and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records) |
|
||||
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
|
||||
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents |
|
||||
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
|
||||
|
|
|
|||
|
|
@ -86,6 +86,15 @@
|
|||
Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit
|
||||
Settings.
|
||||
|
||||
### Folder colour labels
|
||||
- Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row
|
||||
shows the label's dot before its photo count. Agents use `folder.label` (`path`, `label`); `library.folders`
|
||||
reports each row's `label`.
|
||||
- A label follows its folder when it is renamed or moved in LightCraft, together with the labels of the folders
|
||||
inside it; undo and redo carry them back and forth.
|
||||
- The catalog format is now version 4 (`Catalog.folder_records`, `Op::SetFolderRecord`). Once this version has
|
||||
opened a library, older versions refuse it, as with every format change.
|
||||
|
||||
### Masking
|
||||
- Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated
|
||||
ellipses and radial components in compound masks. Edge and rotation handles keep their existing
|
||||
|
|
|
|||
Loading…
Reference in New Issue