From b8d9f82f0eec708206788e27cb58f69a02b751a4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:27:11 -0400 Subject: [PATCH] LRC-LIB-SMARTCOLL: one reading of rule dates; refuse time zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some date values passed the check but never matched. "2026-10-01 10:00" (a space, not T) and "…T10:00:00+02:00" (a zone) were accepted, while the matcher compared them as written against capture times stored as "2026-10-01T10:00:00". rules::rule_date is now the one reading for the check and the matcher: 2026, 2026-08, 2026-08-14, then T10 / T10:00 / T10:00:00, with a space working as the T, and a real date and time (25 o'clock and minute 61 are refused too). A time zone or fractions of a second can never match a local, whole-second capture time. They get their own issue, DateWithZone, translated in all seven locales, instead of matching nothing silently. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 80 ++++++++++++++++++++++++----- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- docs/parity.md | 2 +- 9 files changed, 82 insertions(+), 21 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 8eaa7f894..64029fd82 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -178,6 +178,7 @@ pub enum Issue { NeedsNumber, NeedsShutterSpeed, NeedsDate, + DateWithZone, DatesReversed, NeedsTwoValues, NeedsCount, @@ -198,13 +199,14 @@ pub enum Issue { } impl Issue { - pub const ALL: [Issue; 22] = [ + pub const ALL: [Issue; 23] = [ Issue::UnknownField, Issue::NoSuchOperator, Issue::NotYesNo, Issue::NeedsNumber, Issue::NeedsShutterSpeed, Issue::NeedsDate, + Issue::DateWithZone, Issue::DatesReversed, Issue::NeedsTwoValues, Issue::NeedsCount, @@ -232,6 +234,7 @@ impl Issue { Issue::NeedsNumber => "needs a number", Issue::NeedsShutterSpeed => "needs a time like 1/250 or 2", Issue::NeedsDate => "needs a date like 2026, 2026-08 or 2026-08-14", + Issue::DateWithZone => "can't have a time zone or fractions of a second", Issue::DatesReversed => "has the first date after the second", Issue::NeedsTwoValues => "needs two values", Issue::NeedsCount => "needs a number more than 0", @@ -328,21 +331,40 @@ fn album_problem(v: &Value, cat: &Catalog) -> Found { } } -/// A date as rules take it: `2026`, `2026-08`, `2026-08-14` or a full ISO time, and a real one -/// (no month 13, no 30 February). -fn is_rule_date(s: &str) -> bool { - let s = s.trim(); - let full = match s.len() { +/// A rule date in its one reading: `2026`, `2026-08`, `2026-08-14`, then optionally `T10`, +/// `T10:00` or `T10:00:00` (a space works as the `T`), and a real date and time (no month 13, no +/// 30 February, no 25 o'clock). Capture times are local and to the second, so a time zone or +/// fractions of a second ([`Issue::DateWithZone`]) could never match; anything else is +/// [`Issue::NeedsDate`]. The check and the matcher both read dates this way. +pub fn rule_date(s: &str) -> Result { + const PATTERN: &str = "dddd-dd-ddTdd:dd:dd"; + let s: String = s.trim().char_indices().map(|(i, c)| if i == 10 && c == ' ' { 'T' } else { c }).collect(); + let shaped = |t: &str| { + matches!(t.len(), 4 | 7 | 10 | 13 | 16 | 19) + && t.chars().zip(PATTERN.chars()).all(|(c, p)| if p == 'd' { c.is_ascii_digit() } else { c == p }) + }; + if !shaped(&s) { + // a whole date and time with something after it: a zone (+02:00, -05:00, Z) or a fraction + let zoned = [19, 16, 13].iter().any(|&n| { + s.get(..n).is_some_and(shaped) + && s.get(n..).and_then(|rest| rest.chars().next()).is_some_and(|c| matches!(c, '+' | '-' | 'Z' | 'z' | '.')) + }); + return Err(if zoned { Issue::DateWithZone } else { Issue::NeedsDate }); + } + let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::().ok()); + let time_ok = part(11, 13).is_none_or(|h| h < 24) && part(14, 16).is_none_or(|m| m < 60) && part(17, 19).is_none_or(|x| x < 60); + let day = match s.len() { 4 => format!("{s}-01-01"), 7 => format!("{s}-01"), - _ => s.to_string(), + _ => s.get(..10).unwrap_or("").to_string(), }; - s.len() >= 4 && crate::dates::normalize_iso(&full).is_some() + if time_ok && crate::dates::normalize_iso(&day).is_some() { Ok(s) } else { Err(Issue::NeedsDate) } } fn date_problem(field: &str, op: &str, value: &Value) -> Found { - let date = |v: &Value| match v.as_str() { - Some(s) if is_rule_date(s) => None, + let date = |v: &Value| match v.as_str().map(rule_date) { + Some(Ok(_)) => None, + Some(Err(Issue::DateWithZone)) => Some((Issue::DateWithZone, format!("`{field}` can't have a time zone or fractions of a second, not {v}"))), _ => Some((Issue::NeedsDate, format!("`{field}` needs a date like 2026, 2026-08 or 2026-08-14, not {v}"))), }; match op { @@ -358,9 +380,9 @@ fn date_problem(field: &str, op: &str, value: &Value) -> Found { } "between" => match value { Value::Array(a) if a.len() == 2 => a.iter().find_map(date).or_else(|| { - let bound = |i: usize| a.get(i).and_then(Value::as_str).unwrap_or("").trim(); + let bound = |i: usize| a.get(i).and_then(Value::as_str).and_then(|d| rule_date(d).ok()).unwrap_or_default(); let (from, to) = (bound(0), bound(1)); - (from > to && !from.starts_with(to)).then(|| (Issue::DatesReversed, format!("`{field}`: the first date is after the second"))) + (from > to && !from.starts_with(&to)).then(|| (Issue::DatesReversed, format!("`{field}`: the first date is after the second"))) }), _ => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two dates, not {value}"))), }, @@ -621,7 +643,8 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool { return have.is_none_or(str::is_empty); } let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" }; - let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string(); + // the rule's date as the check reads it (a space as the "T"); one it can't read as written + let s = |v: &Value| v.as_str().map(|d| rule_date(d).unwrap_or_else(|_| d.trim().to_string())).unwrap_or_default(); match op { // a prefix: 2026, 2026-04, 2026-04-12 "is" => { @@ -1436,6 +1459,37 @@ mod tests { assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently"); } + /// A rule date reads one way for the check and the matcher: a space works as the "T" + /// ("2026-10-01 10:00" matches a photo taken then); a time zone or fractions of a second can + /// never match a capture time (stored local, to the second), so they are refused with their + /// own issue rather than accepted and silently matching nothing. + #[test] + fn rule_dates_have_one_reading() { + let cat = Catalog::new(); + let mut p = photo(1); + p.captured = Some("2026-10-01T10:00:00".into()); + let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]})); + for (op, v) in [ + ("is", json!("2026-10-01 10:00")), + ("is", json!(" 2026-10-01T10 ")), + ("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])), + ("after", json!("2026-10-01 09:59")), + ] { + let r = rule(op, v.clone()); + assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}"); + } + for v in ["2026-08-14T10:00:00+02:00", "2026-08-14T10:00:00Z", "2026-08-14T10:00:00.25", "2026-08-14 10:00-05:00"] { + let p = rule("is", json!(v)).check(&cat); + assert_eq!(p.first().map(|p| p.issue), Some(Issue::DateWithZone), "{v}"); + } + for v in ["2026-8-14", "2026-08-14T1", "2026-08-14X10", "26-08-14", "2026-08-14T25:00"] { + let p = rule("is", json!(v)).check(&cat); + assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}"); + } + assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string())); + assert_eq!(rule_date("2026"), Ok("2026".to_string())); + } + /// The field menu shows every rule field once: at the top level or in exactly one group, /// and [`FIELDS`] lists them in menu order. #[test] diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 782e2a441..81477a183 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1922,5 +1922,6 @@ "can't test a folder; choose an album in it": "kann keinen Ordner prüfen; wähle ein Album darin", "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie.", "needs something to look for (or use “are empty”)": "braucht einen Suchbegriff (oder „sind leer“)", - "needs something to look for (or use “aren't empty”)": "braucht einen Suchbegriff (oder „sind nicht leer“)" + "needs something to look for (or use “aren't empty”)": "braucht einen Suchbegriff (oder „sind nicht leer“)", + "can't have a time zone or fractions of a second": "darf keine Zeitzone und keine Sekundenbruchteile haben" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 61dacae42..b397dee5a 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1767,5 +1767,6 @@ "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elige un álbum dentro", "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", - "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)" + "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)", + "can't have a time zone or fractions of a second": "no puede tener zona horaria ni fracciones de segundo" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 99f0e8f27..8b974e667 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1771,5 +1771,6 @@ "can't test a folder; choose an album in it": "フォルダーは判定できません。中のアルバムを選択してください", "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。", "needs something to look for (or use “are empty”)": "検索する語句が必要です(または「が空」を使用)", - "needs something to look for (or use “aren't empty”)": "検索する語句が必要です(または「が空でない」を使用)" + "needs something to look for (or use “aren't empty”)": "検索する語句が必要です(または「が空でない」を使用)", + "can't have a time zone or fractions of a second": "タイムゾーンや秒未満の値は使えません" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 3fddf2c9e..9330a93f6 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1767,5 +1767,6 @@ "can't test a folder; choose an album in it": "não pode testar uma pasta; escolha um álbum dentro dela", "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o.", "needs something to look for (or use “are empty”)": "precisa de algo para procurar (ou use “estão vazios”)", - "needs something to look for (or use “aren't empty”)": "precisa de algo para procurar (ou use “não estão vazios”)" + "needs something to look for (or use “aren't empty”)": "precisa de algo para procurar (ou use “não estão vazios”)", + "can't have a time zone or fractions of a second": "não pode ter fuso horário nem frações de segundo" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 516c2636f..dd2b1edac 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1459,5 +1459,6 @@ "isn't": "не равно", "isn't empty": "не пусто", "isn't in the last": "не за последние", - "starts with": "начинается с" + "starts with": "начинается с", + "can't have a time zone or fractions of a second": "не может содержать часовой пояс или доли секунды" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index e0b44b54b..1b631961c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1767,5 +1767,6 @@ "can't test a folder; choose an album in it": "无法判断文件夹;请选择其中的相册", "This group is empty: add a rule or remove it.": "此组为空:请添加规则或将其删除。", "needs something to look for (or use “are empty”)": "需要要查找的内容(或使用“为空”)", - "needs something to look for (or use “aren't empty”)": "需要要查找的内容(或使用“不为空”)" + "needs something to look for (or use “aren't empty”)": "需要要查找的内容(或使用“不为空”)", + "can't have a time zone or fractions of a second": "不能包含时区或不足一秒的部分" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ec8e63449..37a975a67 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1767,5 +1767,6 @@ "can't test a folder; choose an album in it": "無法判斷資料夾;請選擇其中的相簿", "This group is empty: add a rule or remove it.": "此群組為空:請新增規則或將其移除。", "needs something to look for (or use “are empty”)": "需要要尋找的內容(或使用「為空」)", - "needs something to look for (or use “aren't empty”)": "需要要尋找的內容(或使用「不為空」)" + "needs something to look for (or use “aren't empty”)": "需要要尋找的內容(或使用「不為空」)", + "can't have a time zone or fractions of a second": "不能包含時區或不足一秒的部分" } diff --git a/docs/parity.md b/docs/parity.md index 6aee99619..139dc09fa 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | 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/`), NFS (`/Network/Servers/`), 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-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, 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, membership of smart collections. 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`, a full time; no 30 February), 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 plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 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 from a list of the plain albums, named with their folder and sorted by name (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-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, 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, membership of smart collections. 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`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher), 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 plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 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 from a list of the plain albums, named with their folder and sorted by name (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 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down |