From b9f7ceb2d7f77a7fc231e71ec71ec6429c436167 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:31:48 -0400 Subject: [PATCH] LR-LIB-BYDATE + LR-META-COPYRIGHT-DEFAULT: By Date day tree, default copyright/creator on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - By Date: year → month → day rows with counts and disclosure triangles; a row filters by that date prefix (catalog date_groups now carries days). - Settings → Import → Metadata: copyright and creator given to imported photos that don't carry their own (library.preferences {import: {copyright, creator}}), saved with the library. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/query.rs | 25 ++++++++++---- crates/catalog/src/tests.rs | 4 +++ crates/engine/src/cmd/prefs.rs | 8 ++++- crates/engine/src/import.rs | 12 +++++++ crates/engine/src/tests_prefs.rs | 26 +++++++++++++++ crates/ui-egui/src/panels/left.rs | 48 ++++++++++++++++++++++++--- crates/ui-egui/src/panels/settings.rs | 20 +++++++++++ docs/parity.md | 14 ++++---- 8 files changed, 139 insertions(+), 18 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 046378193..6e7eedaeb 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -235,18 +235,28 @@ impl Catalog { v.into_iter().map(|p| p.id).collect() } - /// Year → month → count tree for the "By Date" section. + /// Year → month → day counts for the "By Date" section (newest first). pub fn date_groups(&self) -> Vec { - let mut map: std::collections::BTreeMap> = Default::default(); + use std::collections::BTreeMap; + let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new(); for p in self.photos().filter(|p| !p.deleted) { let d = p.date(); - if d.len() >= 7 { - *map.entry(d[..4].to_string()).or_default().entry(d[..7].to_string()).or_default() += 1; + if d.len() >= 10 { + let (months, days) = years.entry(&d[..4]).or_default(); + *months.entry(&d[..7]).or_default() += 1; + *days.entry(&d[..10]).or_default() += 1; } } - map.into_iter() + let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::>(); + years + .into_iter() .rev() - .map(|(year, months)| DateGroup { count: months.values().sum(), months: months.into_iter().rev().collect(), year }) + .map(|(year, (months, days))| DateGroup { + year: year.to_string(), + count: months.values().sum(), + months: newest_first(months), + days: newest_first(days), + }) .collect() } @@ -266,5 +276,8 @@ impl Catalog { pub struct DateGroup { pub year: String, pub count: usize, + /// `YYYY-MM` and counts, newest first. pub months: Vec<(String, usize)>, + /// `YYYY-MM-DD` and counts, newest first. + pub days: Vec<(String, usize)>, } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 108307581..8f1ee5b64 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -102,6 +102,10 @@ fn filter_search_sort() { let g = c.date_groups(); assert_eq!(g[0].year, "2026"); assert_eq!(g[0].count, 2); + assert_eq!(g[0].months.iter().map(|m| m.1).sum::(), 2); + assert_eq!(g[0].days.iter().map(|d| d.1).sum::(), 2); + assert!(g[0].days.iter().all(|(d, _)| d.len() == 10 && d.starts_with("2026"))); + assert!(g[0].days.windows(2).all(|w| w[0].0 > w[1].0), "newest first"); assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]); } diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 01a157ffc..88209ee05 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -42,6 +42,12 @@ fn prefs(s: &mut Session, p: &Value) -> Result { if let Some(v) = i.get("perCamera").and_then(Value::as_bool) { d.per_camera = v; } + if let Some(v) = i.get("copyright").and_then(Value::as_str) { + d.copyright = v.trim().to_string(); + } + if let Some(v) = i.get("creator").and_then(Value::as_str) { + d.creator = v.trim().to_string(); + } if let Some(a) = i.get("cameras").and_then(Value::as_array) { d.cameras.clear(); for c in a { @@ -84,7 +90,7 @@ pub fn specs() -> Vec { "Library Preferences", [], None, - "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}]}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}", + "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}", always, prefs )] diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index e482a1a5e..ea030be84 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -120,6 +120,10 @@ pub struct ImportDefaults { pub cameras: Vec, /// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none). pub other_preset: Option, + /// Copyright notice given to imported photos that don't carry one (empty = none). + pub copyright: String, + /// Creator given to imported photos that don't name one (empty = none). + pub creator: String, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -149,6 +153,14 @@ impl ImportDefaults { /// Give a freshly imported photo its default settings: the camera defaults, then the matching /// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look. pub fn apply_import_defaults(s: &Session, p: &mut Photo) { + // metadata defaults fill gaps only: the file's own copyright / creator win + let d = &s.import_defaults; + if p.meta.copyright.trim().is_empty() && !d.copyright.trim().is_empty() { + p.meta.copyright = d.copyright.trim().to_string(); + } + if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() { + p.meta.creator = d.creator.trim().to_string(); + } let base = p.camera_defaults(); let raw = p.kind == lightcraft_catalog::MediaKind::Raw; let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index d4e3ec53c..235e64000 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -137,3 +137,29 @@ fn preferences_persist_with_the_library() { assert_eq!(s.import_defaults, Default::default()); let _ = std::fs::remove_dir_all(&lib); } + +#[test] +fn default_copyright_and_creator_fill_gaps_on_import() { + let dir = temp_dir("copyright"); + let lib = dir.join("lib"); + png(&dir.join("plain.png")); + // a DNG that names its own artist keeps it + let meta = lightcraft_meta::Metadata { artist: Some("Someone Else".into()), ..Default::default() }; + std::fs::write(dir.join("own.dng"), crate::tests_xmp::synthetic_dng_with(None, meta)).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.preferences", &json!({"import": {"copyright": " © 2026 Me ", "creator": "Me"}})).unwrap(); + assert_eq!(r["import"]["copyright"], "© 2026 Me"); + s.execute("library.import", &json!({"paths": [dir.join("plain.png").to_string_lossy(), dir.join("own.dng").to_string_lossy()]})).unwrap(); + let plain = photo_named(&s, "plain.png"); + assert_eq!((plain.meta.copyright.as_str(), plain.meta.creator.as_str()), ("© 2026 Me", "Me")); + let own = photo_named(&s, "own.dng"); + assert_eq!(own.meta.creator, "Someone Else", "the file's own creator wins"); + assert_eq!(own.meta.copyright, "© 2026 Me"); + // saved with the library + drop(s); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.import_defaults.creator, "Me"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 0970cbf39..6c07804db 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -102,10 +102,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label); for g in app.session.catalog.date_groups() { - let sel = app.session.filter.date.as_deref() == Some(g.year.as_str()); - if row(app, ui, &format!("year:{}", g.year), Icon::Clock, &g.year, Some(g.count), sel, 0.0).clicked() { - let v = if sel { serde_json::Value::Null } else { json!(g.year) }; - let _ = app.run("library.filter", json!({"date": v})); + // year → month → day; a click filters by that prefix, the triangle opens a level + if date_row(app, ui, &g.year, &g.year, g.count, 0.0) { + for (m, n) in &g.months { + let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string(); + if date_row(app, ui, m, &label, *n, 16.0) { + for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { + let label = lightcraft_catalog::dates::group_label(d); + // "Sunday, 20 September 2026" → "Sunday, 20" + let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(); + date_row(app, ui, d, &label, *n, 32.0); + } + } + } } } keywords_section(app, ui); @@ -119,6 +128,37 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open. +fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { + let t = Tokens::get(ui.ctx()); + let open_id = egui::Id::new(("date-open", key.to_string())); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + let sel = app.session.filter.date.as_deref() == Some(key); + let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); + if key.len() < 10 { + let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y); + let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tr = ui.interact(tri, egui::Id::new(("date-tri", key.to_string())), Sense::click()); + register(ui.ctx(), format!("dateToggle:{key}"), tri); + let col = if tr.hovered() { t.text } else { t.text_dim }; + let pts = if open { + vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] + } else { + vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE)); + if tr.clicked() { + open = !open; + ui.data_mut(|d| d.insert_temp(open_id, open)); + } + } + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(key) }; + let _ = app.run("library.filter", json!({"date": v})); + } + open +} + fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent: Option, indent: f32) { let mut kids: Vec<&Album> = all.iter().filter(|a| a.parent == parent).collect(); kids.sort_by_key(|a| (!a.folder, a.name.to_lowercase())); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index fad8ac511..cfd47a376 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -228,6 +228,26 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}})); } }); + heading(ui, t, "Metadata"); + hint(ui, t, "Added to photos you import that don't already have it."); + for (key, label, hint_text, value) in + [("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())] + { + row(ui, t, label, |ui| { + let id = egui::Id::new(("settingsMeta", key)); + let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0)); + register(ui.ctx(), format!("field:settings.{key}"), r.rect); + if r.lost_focus() && text.trim() != value { + let _ = app.run("library.preferences", json!({"import": {key: text.trim()}})); + } + if r.has_focus() { + ui.data_mut(|m| m.insert_temp(id, text)); + } else { + ui.data_mut(|m| m.remove::(id)); + } + }); + } heading(ui, t, "XMP sidecars"); let mut xmp = app.session.xmp; if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") { diff --git a/docs/parity.md b/docs/parity.md index 4d23a6f6b..dae6779c7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -22,10 +22,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 5 | 3 | 5 | 1 | 2/4 (50%) | 2/4 (50%) | -| B. Library management (LIB) | 17 | 2 | 6 | 2 | 9/9 (100%) | 7/9 (78%) | +| B. Library management (LIB) | 18 | 1 | 6 | 2 | 9/9 (100%) | 8/9 (89%) | | C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | -| E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | +| E. Metadata (META) | 3 | 2 | 1 | 0 | 2/2 (100%) | 1/2 (50%) | | F. Edit panel — global adjustments (EDIT) | 40 | 2 | 6 | 1 | 28/28 (100%) | 12/14 (86%) | | G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) | | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | @@ -42,13 +42,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 13 | 1 | 4 | 0 | 7/7 (100%) | 6/7 (86%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) | +| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 11 | 2 | 4 | 1 | 8/8 (100%) | 3/5 (60%) | | Y. Menus | 65 | 9 | 11 | 8 | 46/47 (98%) | 17/23 (74%) | | Z. Keyboard shortcuts (desktop) | 59 | 12 | 9 | 1 | 49/52 (94%) | 10/23 (43%) | | Lightroom Classic extras | 7 | 19 | 53 | 9 | — | 6/21 (29%) | -| **Total** | 289 | 67 | 144 | 37 | 192/198 (97%) | 87/144 (60%) | +| **Total** | 292 | 65 | 143 | 37 | 192/198 (97%) | 90/144 (63%) | ## Top gaps @@ -117,7 +117,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. |---|---|---|---|---|---| | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | | LR-LIB-RECENT-ADDED | Recently added | P1 | 🟡 | `cmd:library.source` (`recentlyAdded`) | not grouped by import session | -| LR-LIB-BYDATE | Browse by date | P1 | 🟡 | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` | years only; no month/day tree | +| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with counts; a row filters by that date | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs` | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`) | @@ -188,7 +188,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` | no flash, creator field, map snippet or people | -| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ⬜ | | | +| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`) | text field + GPS read; no map, no geocoding | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ⬜ | | | | LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | | @@ -449,7 +449,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | | LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | -| LR-PREF-WATERMARK | Watermark settings | P1 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` | per export in the dialog; not saved as a preference | +| LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | | LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra | | LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | | | LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | | |