mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-LIB-BYDATE + LR-META-COPYRIGHT-DEFAULT: By Date day tree, default copyright/creator on import
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
852e8fca45
commit
b9f7ceb2d7
@@ -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<DateGroup> {
|
||||
let mut map: std::collections::BTreeMap<String, std::collections::BTreeMap<String, usize>> = 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::<Vec<_>>();
|
||||
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)>,
|
||||
}
|
||||
|
||||
@@ -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::<usize>(), 2);
|
||||
assert_eq!(g[0].days.iter().map(|d| d.1).sum::<usize>(), 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)]);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<CommandSpec> {
|
||||
"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
|
||||
)]
|
||||
|
||||
@@ -120,6 +120,10 @@ pub struct ImportDefaults {
|
||||
pub cameras: Vec<CameraDefault>,
|
||||
/// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none).
|
||||
pub other_preset: Option<String>,
|
||||
/// 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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<AlbumId>, 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()));
|
||||
|
||||
@@ -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::<String>(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") {
|
||||
|
||||
+7
-7
@@ -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%) |
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 | 🚫 | | |
|
||||
|
||||
Reference in New Issue
Block a user