mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 21:18:11 -06:00
LR-LIB-CAPTURETIME: edit capture time (set, shift, time-zone shift) for a selection
Op::SetCaptured (validated, journaled), photo.setCaptureTime (the active photo gets the time, others keep their offsets; or each / shift / hours), Edit Capture Time dialog with preview (Photo menu, Info panel); engine + headless tests incl. op-log replay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
53c4b295b9
commit
1f3bc9c075
@@ -137,6 +137,11 @@ pub enum Op {
|
||||
photos: Vec<PhotoId>,
|
||||
collapsed: bool,
|
||||
},
|
||||
/// Set a photo's capture time (ISO 8601 local time; `None` = unknown).
|
||||
SetCaptured {
|
||||
id: PhotoId,
|
||||
captured: Option<String>,
|
||||
},
|
||||
/// Rename a photo: its file name and the source it points to. Applying the op never touches
|
||||
/// the disk — the engine moves the file before it commits (and on undo/redo).
|
||||
SetFile {
|
||||
@@ -428,6 +433,15 @@ impl Catalog {
|
||||
s.collapsed = collapsed;
|
||||
old
|
||||
}
|
||||
Op::SetCaptured { id, captured } => {
|
||||
if let Some(c) = &captured
|
||||
&& stacks::iso_seconds(c).is_none()
|
||||
{
|
||||
return Err(CatalogError::Invalid(format!("not a date: {c}")));
|
||||
}
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }
|
||||
}
|
||||
Op::SetFile { id, file_name, source } => {
|
||||
if file_name.trim().is_empty() {
|
||||
return Err(CatalogError::Invalid("empty file name".into()));
|
||||
|
||||
@@ -70,6 +70,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
||||
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") } }
|
||||
}
|
||||
9 => Op::SetCaptured { id: pid, captured: (!b.is_multiple_of(3)).then(|| format!("2026-0{}-1{}T10:{:02}:00", 1 + b % 9, b % 10, a % 60)) },
|
||||
15 => match c.photo(pid).cloned() {
|
||||
// a virtual copy: same source, own id, copy_of/copy_name set
|
||||
Some(src) => {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_selection, str_param};
|
||||
use lightcraft_catalog::Op;
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_selection, str_param};
|
||||
use crate::Result;
|
||||
|
||||
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
|
||||
@@ -31,5 +33,53 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"renamed": n, "plans": plans}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"photo.setCaptureTime",
|
||||
"Edit Capture Time",
|
||||
[],
|
||||
None,
|
||||
"{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..]}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso};
|
||||
let c = "photo.setCaptureTime";
|
||||
let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(bad(c, "no photos"));
|
||||
}
|
||||
// photos without a capture time start from their import time
|
||||
let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default();
|
||||
let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64;
|
||||
let mut each: Option<String> = None;
|
||||
if let Some(t) = str_param(p, "time") {
|
||||
let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?;
|
||||
if bool_or(p, "each", false) {
|
||||
each = Some(t);
|
||||
} else {
|
||||
let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]);
|
||||
let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||||
delta += iso_seconds(&t).unwrap_or(from) - from;
|
||||
}
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for id in &targets {
|
||||
let new = match &each {
|
||||
Some(t) => shift_iso(t, delta),
|
||||
None => shift_iso(&base(s, *id), delta),
|
||||
};
|
||||
let Some(new) = new else { continue };
|
||||
out.push(json!(new));
|
||||
if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {
|
||||
ops.push(Op::SetCaptured { id: *id, captured: Some(new) });
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit("Edit Capture Time", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n, "captured": out}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -186,3 +186,49 @@ fn rename_demo_photos_in_catalog() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap().file_name.starts_with("LC"));
|
||||
}
|
||||
|
||||
fn captured(s: &Session, id: u64) -> Option<String> {
|
||||
s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().captured.clone()
|
||||
}
|
||||
|
||||
/// Edit Capture Time: set (others shift along), set each, shift by an offset, time-zone shift;
|
||||
/// one undo step each; replayed from the op log; the grid order follows.
|
||||
#[test]
|
||||
fn capture_time_set_shift_undo_and_replay() {
|
||||
let dir = temp_dir("capture");
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
let ids: Vec<u64> = s.visible_cloned().iter().take(3).map(|p| p.0).collect();
|
||||
let before: Vec<Option<String>> = ids.iter().map(|i| captured(&s, *i)).collect();
|
||||
let secs = |t: &Option<String>| lightcraft_catalog::dates::iso_seconds(t.as_deref().unwrap()).unwrap();
|
||||
// set the active photo; the others keep their distance to it
|
||||
s.execute("library.select", &json!({"ids": ids, "active": ids[1]})).unwrap();
|
||||
let r = s.execute("photo.setCaptureTime", &json!({"time": "2020-01-02 03:04:05"})).unwrap();
|
||||
assert_eq!(r["changed"], 3, "{r}");
|
||||
assert_eq!(captured(&s, ids[1]).as_deref(), Some("2020-01-02T03:04:05"));
|
||||
assert_eq!(secs(&captured(&s, ids[0])) - secs(&captured(&s, ids[1])), secs(&before[0]) - secs(&before[1]));
|
||||
// undo restores all three in one step
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ids.iter().map(|i| captured(&s, *i)).collect::<Vec<_>>(), before);
|
||||
// shift by +1 h 30 min, then a time-zone shift of −2 h
|
||||
s.execute("photo.setCaptureTime", &json!({"shift": 5400})).unwrap();
|
||||
s.execute("photo.setCaptureTime", &json!({"hours": -2})).unwrap();
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
assert_eq!(secs(&captured(&s, *id)) - secs(&before[i]), 5400 - 7200);
|
||||
}
|
||||
// everybody the same time
|
||||
s.execute("photo.setCaptureTime", &json!({"time": "2026-10-01T08:00:00", "each": true})).unwrap();
|
||||
assert!(ids.iter().all(|i| captured(&s, *i).as_deref() == Some("2026-10-01T08:00:00")));
|
||||
// the grid order (capture date, newest first) follows: they're now the newest photos
|
||||
let vis = s.visible_cloned();
|
||||
assert!(vis[..3].iter().all(|v| ids.contains(&v.0)), "{vis:?}");
|
||||
// invalid dates are refused
|
||||
assert!(s.execute("photo.setCaptureTime", &json!({"time": "2026-02-30 10:00"})).is_err());
|
||||
assert!(s.execute("photo.setCaptureTime", &json!({"time": "yesterday"})).is_err());
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, false).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -456,6 +456,32 @@ mod tests {
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// Info panel → Edit Capture Time…: a time-zone shift moves the selected photos.
|
||||
#[test]
|
||||
fn capture_time_dialog_shifts_time_zone() {
|
||||
let mut h = demo([1200.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let id = h.app.session.visible_cloned()[0];
|
||||
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap();
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:editCaptureTime"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t);
|
||||
if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog {
|
||||
*zone = -3.0;
|
||||
} else {
|
||||
panic!("dialog not open: {:?}", h.app.ui.dialog);
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
let r = h.request("ui.dialog.confirm", json!({}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let after = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap();
|
||||
let secs = lightcraft_catalog::dates::iso_seconds;
|
||||
assert_eq!(secs(&after).unwrap() - secs(&before).unwrap(), -3 * 3600);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_request_is_answered_without_a_window() {
|
||||
let mut h = demo([800.0, 500.0]);
|
||||
|
||||
@@ -137,6 +137,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"photo.readMetadataFromFile",
|
||||
"app.showInFinder",
|
||||
"dialog.rename",
|
||||
"dialog.captureTime",
|
||||
"---",
|
||||
"photo.delete",
|
||||
],
|
||||
|
||||
@@ -77,6 +77,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("app.export", "Export Now", None, ""),
|
||||
("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
|
||||
("dialog.rename", "Rename Photos…", None, "Photo"),
|
||||
("dialog.captureTime", "Edit Capture Time…", None, "Photo"),
|
||||
("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"),
|
||||
];
|
||||
|
||||
@@ -330,6 +331,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.captureTime" => {
|
||||
let time = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.date().replace('T', " ")).unwrap_or_default();
|
||||
let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string();
|
||||
app.ui.dialog =
|
||||
Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.rename" => {
|
||||
let template = p.get("template").and_then(Value::as_str).unwrap_or("{name}").to_string();
|
||||
app.ui.dialog = Some(Dialog::Rename { template, start: p.get("start").and_then(Value::as_u64).unwrap_or(1) as u32 });
|
||||
@@ -445,7 +453,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
||||
match id {
|
||||
s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close",
|
||||
"app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.copySettings" => app.session.active().is_some(),
|
||||
"app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => {
|
||||
app.session.active().is_some()
|
||||
}
|
||||
"app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(),
|
||||
"app.showInFinder" => {
|
||||
app.services.reveal.is_some()
|
||||
|
||||
@@ -23,6 +23,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::NewAlbum { .. } => "Create Album",
|
||||
Dialog::RenameAlbum { .. } => "Rename Album",
|
||||
Dialog::Rename { .. } => "Rename Photos",
|
||||
Dialog::CaptureTime { .. } => "Edit Capture Time",
|
||||
Dialog::RenameKeyword { .. } => "Rename Keyword",
|
||||
Dialog::MergeKeywords { .. } => "Merge Keywords",
|
||||
Dialog::NewSmartAlbum { .. } => "Create Smart Album",
|
||||
@@ -73,6 +74,59 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => {
|
||||
let n = app.session.targets(&json!({})).len();
|
||||
field(ui, "Change", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() {
|
||||
if crate::widgets::text_button(ui, &format!("captureMode-{i}"), label, mode == m).clicked() {
|
||||
*mode = m.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
let params = match mode.as_str() {
|
||||
"set" => {
|
||||
field(ui, "New time", |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(time).hint_text("2026-09-30 14:05:00").desired_width(f32::INFINITY));
|
||||
crate::widgets::register(ui.ctx(), "field:captureTime", r.rect);
|
||||
});
|
||||
json!({"time": time})
|
||||
}
|
||||
"shift" => {
|
||||
field(ui, "Shift", |ui| {
|
||||
ui.add(egui::DragValue::new(days).suffix(" d"));
|
||||
ui.add(egui::DragValue::new(hours).suffix(" h"));
|
||||
ui.add(egui::DragValue::new(minutes).suffix(" min"));
|
||||
});
|
||||
json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60})
|
||||
}
|
||||
_ => {
|
||||
field(ui, "Hours", |ui| ui.add(egui::DragValue::new(zone).range(-26.0..=26.0).speed(0.25).fixed_decimals(2)));
|
||||
json!({"hours": zone})
|
||||
}
|
||||
};
|
||||
// preview on the active photo (the others move by the same amount)
|
||||
let active = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| (p.file_name.clone(), p.date().to_string()));
|
||||
if let Some((name, cur)) = active {
|
||||
let delta = match mode.as_str() {
|
||||
"set" => lightcraft_catalog::dates::normalize_iso(time)
|
||||
.and_then(|t| Some(lightcraft_catalog::dates::iso_seconds(&t)? - lightcraft_catalog::dates::iso_seconds(&cur)?)),
|
||||
"shift" => params["shift"].as_i64(),
|
||||
_ => Some((*zone as f64 * 3600.0).round() as i64),
|
||||
};
|
||||
let after = delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d));
|
||||
ui.label(
|
||||
egui::RichText::new(match after {
|
||||
Some(a) => format!("{name}: {} → {}", cur.replace('T', " "), a.replace('T', " ")),
|
||||
None => "Enter a date as YYYY-MM-DD HH:MM:SS".into(),
|
||||
})
|
||||
.color(t.text_label),
|
||||
);
|
||||
}
|
||||
if n > 1 {
|
||||
ui.label(egui::RichText::new(format!("All {n} selected photos move by the same amount.")).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
Dialog::Rename { template, start } => {
|
||||
let n = app.session.targets(&json!({})).len();
|
||||
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
|
||||
@@ -358,6 +412,14 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
match dlg {
|
||||
Dialog::NewAlbum { name, folder } => app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})),
|
||||
Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})),
|
||||
Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => app.run(
|
||||
"photo.setCaptureTime",
|
||||
match mode.as_str() {
|
||||
"set" => json!({"time": time}),
|
||||
"shift" => json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}),
|
||||
_ => json!({"hours": zone}),
|
||||
},
|
||||
),
|
||||
Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})),
|
||||
Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})),
|
||||
Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})),
|
||||
|
||||
@@ -348,7 +348,13 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
divider(ui);
|
||||
header(ui, "Camera");
|
||||
let m = &p.meta;
|
||||
label_row(ui, "Captured", p.captured.as_deref().unwrap_or("—"));
|
||||
let cap = p.captured.as_deref().map(|c| c.replace('T', " ")).unwrap_or_else(|| "—".into());
|
||||
label_row(ui, "Captured", &cap);
|
||||
padded(ui, |ui| {
|
||||
if text_button(ui, "editCaptureTime", "Edit Capture Time…", false).clicked() {
|
||||
let _ = app.run("dialog.captureTime", json!({}));
|
||||
}
|
||||
});
|
||||
label_row(ui, "Camera", &m.camera);
|
||||
label_row(ui, "Lens", &m.lens);
|
||||
label_row(ui, "Focal", &m.focal_mm.map(|f| format!("{f:.0} mm")).unwrap_or_default());
|
||||
|
||||
@@ -151,6 +151,16 @@ pub enum Dialog {
|
||||
id: u64,
|
||||
name: String,
|
||||
},
|
||||
/// Edit Capture Time: `mode` "set" (`time`; the others shift along), "shift" (by `days`,
|
||||
/// `hours`, `minutes`) or "zone" (time-zone shift by `zone` hours).
|
||||
CaptureTime {
|
||||
mode: String,
|
||||
time: String,
|
||||
days: i32,
|
||||
hours: i32,
|
||||
minutes: i32,
|
||||
zone: f32,
|
||||
},
|
||||
/// Batch rename the selected photos with a file-name template.
|
||||
Rename {
|
||||
template: String,
|
||||
|
||||
+3
-3
@@ -75,7 +75,7 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i
|
||||
12. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
|
||||
permissive pure-Rust decoder yet). High value, high effort (clean sources needed).
|
||||
13. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
|
||||
14. **LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — capture-time edit, label menu/names.
|
||||
14. **LR-LIB-LABEL UI** (P1) — label menu/names.
|
||||
15. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
|
||||
16. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
|
||||
|
||||
@@ -157,7 +157,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
|
||||
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ⬜ | | |
|
||||
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {seq:N} {date:%Y%m%d} {camera} {title}; preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow |
|
||||
| LR-LIB-CAPTURETIME | Edit capture time | P1 | ⬜ | | `photo.setMeta` has no capture-time field |
|
||||
| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” |
|
||||
| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ⬜ | | |
|
||||
| LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | |
|
||||
| LR-LIB-CULL | Assisted culling | P2 | ⬜ | | |
|
||||
@@ -588,7 +588,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
||||
| MENU-PHOTO-RESET | Reset edits / crop | P0 | ✅ | `cmd:develop.reset`, `cmd:crop.reset` | |
|
||||
| MENU-PHOTO-UPDATEAI | Update AI settings | P2 | ⬜ | | |
|
||||
| MENU-PHOTO-RENAME | Rename N photos… | P1 | ✅ | `cmd:dialog.rename` | live “Rename N Photos…” label |
|
||||
| MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ⬜ | | |
|
||||
| MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ✅ | `cmd:dialog.captureTime` | |
|
||||
| MENU-PHOTO-COVER | Set as album cover | P2 | ✅ | `cmd:album.setCover` | |
|
||||
| MENU-PHOTO-DELETE | Delete N photos… | P0 | ✅ | `cmd:photo.delete` | no confirmation; static label |
|
||||
| MENU-PHOTO-MOVETOCLOUD | Move/copy to cloud | OOS | 🚫 | | |
|
||||
|
||||
Reference in New Issue
Block a user