M9.7: virtual copies — new catalog entries sharing the original file
Photo.copy_of / copy_name. photo.virtualCopy (Cmd+') clones the photo with independent settings and history, names it "Copy N" (numbered per master), adds it to the original's albums and stacks it (expanded) right after the original; one undo step for several copies (Session::merge_undo). Copies never write XMP sidecars. "Copy N" badge in the grid, Info panel line, copy:yes search token. Also: Info panel stars laid out in a row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5434c5d172
commit
0571d8e438
|
|
@ -174,6 +174,13 @@ pub struct Photo {
|
|||
/// "Enable Profile Corrections" is on.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
|
||||
/// A virtual copy: the photo it was copied from (it shares that photo's file but has its own
|
||||
/// settings, metadata and history).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub copy_of: Option<PhotoId>,
|
||||
/// Virtual copies' name ("Copy 1").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub copy_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
|
|
@ -202,6 +209,8 @@ impl Photo {
|
|||
as_shot_wb: None,
|
||||
content_hash: None,
|
||||
embedded_lens: None,
|
||||
copy_of: None,
|
||||
copy_name: None,
|
||||
}
|
||||
}
|
||||
pub fn is_edited(&self) -> bool {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
|||
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
|
||||
"edited" => (val == "true" || val == "yes") == p.is_edited(),
|
||||
"date" => p.date().starts_with(val),
|
||||
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
|
||||
"name" | "file" => p.file_name.to_lowercase().contains(val),
|
||||
_ => false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
|||
text: if b.is_multiple_of(5) { format!("{}", b % 10) } else { String::new() },
|
||||
..Default::default()
|
||||
};
|
||||
match kind % 15 {
|
||||
match kind % 16 {
|
||||
0 | 1 => {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01");
|
||||
|
|
@ -67,6 +67,18 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
|||
}
|
||||
13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) },
|
||||
14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }),
|
||||
15 => match c.photo(pid).cloned() {
|
||||
// a virtual copy: same source, own id, copy_of/copy_name set
|
||||
Some(src) => {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut v = (*src).clone();
|
||||
v.id = id;
|
||||
v.copy_of = Some(pid);
|
||||
v.copy_name = Some(format!("Copy {b}"));
|
||||
Op::AddPhoto { photo: Box::new(v) }
|
||||
}
|
||||
None => Op::Batch { ops: vec![] },
|
||||
},
|
||||
_ => c.delete_permanently_ops(pid),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Organizing commands: stacks (group, ungroup, set top, expand/collapse, auto-stack by capture
|
||||
//! time).
|
||||
//! time) and virtual copies.
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, StackId};
|
||||
use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection};
|
||||
|
|
@ -58,8 +58,73 @@ fn set_collapsed(s: &mut Session, stacks: Vec<StackId>, collapsed: bool, label:
|
|||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// "Copy 3" → 3.
|
||||
fn copy_number(name: Option<&str>) -> u32 {
|
||||
name.and_then(|n| n.strip_prefix("Copy ")).and_then(|n| n.trim().parse().ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and
|
||||
/// metadata, added to the same albums and stacked right after it (the stack is expanded so the
|
||||
/// copy is visible).
|
||||
fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
|
||||
let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) };
|
||||
let master = orig.copy_of.unwrap_or(src);
|
||||
let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0);
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let mut c = (*orig).clone();
|
||||
c.id = id;
|
||||
c.copy_of = Some(master);
|
||||
c.copy_name = Some(format!("Copy {n}"));
|
||||
c.versions.clear();
|
||||
c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }];
|
||||
c.deleted = false;
|
||||
let mut ops = vec![Op::AddPhoto { photo: Box::new(c) }];
|
||||
for a in s.catalog.albums().filter(|a| !a.is_smart() && a.photos.contains(&src)) {
|
||||
let mut photos = a.photos.clone();
|
||||
let at = photos.iter().position(|p| *p == src).map_or(photos.len(), |i| i + 1);
|
||||
photos.insert(at, id);
|
||||
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
|
||||
}
|
||||
match s.catalog.stack_of(src).cloned() {
|
||||
Some(st) => {
|
||||
let mut photos = st.photos.clone();
|
||||
let at = st.position(src).map_or(photos.len(), |i| i + 1);
|
||||
photos.insert(at, id);
|
||||
ops.push(Op::SetStack { id: st.id, photos, collapsed: false });
|
||||
}
|
||||
None => {
|
||||
let sid = s.catalog.alloc_stack_id();
|
||||
ops.push(Op::AddStack { stack: Stack { id: sid, photos: vec![src, id], collapsed: false } });
|
||||
}
|
||||
}
|
||||
s.commit("Create Virtual Copy", Op::Batch { ops })?;
|
||||
Ok(Some(id))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"photo.virtualCopy",
|
||||
"Create Virtual Copy",
|
||||
["Photo"],
|
||||
Some("Cmd+'"),
|
||||
"{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let mut made = Vec::new();
|
||||
for t in targets {
|
||||
if let Some(id) = virtual_copy(s, t)? {
|
||||
made.push(id);
|
||||
}
|
||||
}
|
||||
s.merge_undo(made.len(), "Create Virtual Copy");
|
||||
if let Some(last) = made.last() {
|
||||
s.selection = Selection { ids: made.clone(), active: Some(*last) };
|
||||
}
|
||||
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"stack.group",
|
||||
"Group into Stack",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ pub fn photo_summary(p: &Photo) -> Value {
|
|||
"keywords": p.meta.keywords,
|
||||
"camera": p.meta.camera,
|
||||
"deleted": p.deleted,
|
||||
"copyOf": p.copy_of.map(|c| c.0),
|
||||
"copyName": p.copy_name,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,17 @@ impl Session {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
|
||||
/// depends on the state the previous one left).
|
||||
pub fn merge_undo(&mut self, n: usize, label: &str) {
|
||||
if n < 2 || n > self.undo.len() {
|
||||
return;
|
||||
}
|
||||
let tail = self.undo.split_off(self.undo.len() - n);
|
||||
let ops = tail.into_iter().rev().map(|e| e.op).collect();
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops } });
|
||||
}
|
||||
|
||||
/// Apply without recording undo (interactive previews).
|
||||
fn apply_silent(&mut self, op: Op) -> Result<()> {
|
||||
self.catalog.apply(op)?;
|
||||
|
|
|
|||
|
|
@ -241,6 +241,9 @@ impl Session {
|
|||
/// Write the photo's XMP sidecar; returns its path.
|
||||
pub fn save_sidecar(&self, id: PhotoId) -> Result<PathBuf> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
if p.copy_of.is_some() {
|
||||
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
let path = sidecar_path(orig, self.xmp.naming);
|
||||
write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
|
|
@ -272,7 +275,7 @@ impl Session {
|
|||
let mut ids = Vec::new();
|
||||
ops.iter().for_each(|o| op_photos(o, &mut ids));
|
||||
for id in ids {
|
||||
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some())
|
||||
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
|
||||
&& let Err(e) = self.save_sidecar(id)
|
||||
{
|
||||
log::warn!("auto-write XMP: {e}");
|
||||
|
|
|
|||
|
|
@ -79,6 +79,52 @@ fn smart_album_from_smart_album_view_keeps_its_rules() {
|
|||
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_copies_are_independent_and_persist() {
|
||||
let dir = temp_dir("vc");
|
||||
let mut s = open(&dir);
|
||||
let src = s.visible_cloned()[1];
|
||||
let album = s.execute("album.create", &json!({"name": "Keep", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.addPhotos", &json!({"id": album, "ids": [src.0]})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [src.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
|
||||
let undo_before = s.undo.len();
|
||||
let r = s.execute("photo.virtualCopy", &json!({})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo_before + 1);
|
||||
let c1 = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap());
|
||||
let r2 = s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap();
|
||||
let c2 = lightcraft_catalog::PhotoId(r2["ids"][0].as_u64().unwrap());
|
||||
let (p, a, b) = (s.catalog.photo(src).unwrap().clone(), s.catalog.photo(c1).unwrap().clone(), s.catalog.photo(c2).unwrap().clone());
|
||||
assert_eq!((a.copy_of, a.copy_name.as_deref()), (Some(src), Some("Copy 1")));
|
||||
assert_eq!(b.copy_name.as_deref(), Some("Copy 2"));
|
||||
assert_eq!(a.source, p.source, "shares the original file");
|
||||
assert_eq!(a.develop.light.exposure, 0.5, "starts from the current settings");
|
||||
// a copy of a copy names itself after the master
|
||||
let r3 = s.execute("photo.virtualCopy", &json!({"ids": [c1.0]})).unwrap();
|
||||
let c3 = s.catalog.photo(lightcraft_catalog::PhotoId(r3["ids"][0].as_u64().unwrap())).unwrap().clone();
|
||||
assert_eq!((c3.copy_of, c3.copy_name.as_deref()), (Some(src), Some("Copy 3")));
|
||||
// independent settings
|
||||
s.execute("library.select", &json!({"ids": [c1.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap();
|
||||
assert_eq!(s.catalog.photo(src).unwrap().develop.light.exposure, 0.5);
|
||||
assert_eq!(s.catalog.photo(c1).unwrap().develop.light.exposure, -1.0);
|
||||
// same album, stacked (expanded) with the original, visible in the grid
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.starts_with(&[src, c2, c1, c3.id]));
|
||||
assert_eq!(s.catalog.stack_of(c1).unwrap().photos, vec![src, c2, c1, c3.id]);
|
||||
assert!(s.visible_cloned().contains(&c2));
|
||||
let found = s.execute("catalog.query", &json!({"filter": {"text": "copy:yes"}})).unwrap();
|
||||
assert_eq!(found["total"], 3);
|
||||
// undo removes a copy completely
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // exposure
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // third copy
|
||||
assert!(s.catalog.photo(c3.id).is_none());
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
let s2 = open(&dir);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_group_collapse_and_replay() {
|
||||
let dir = temp_dir("stacks");
|
||||
|
|
|
|||
|
|
@ -198,6 +198,23 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
|||
paint(p, Rect::from_min_size(pos2(bar.right() - 20.0, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::Sliders, t.text_label);
|
||||
}
|
||||
}
|
||||
if let Some(name) = &photo.copy_name {
|
||||
// virtual copy: a folded-corner tag at the top right
|
||||
let g = p.layout_no_wrap(name.clone(), t.semibold(10.5), Color32::WHITE);
|
||||
let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) };
|
||||
let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0));
|
||||
p.rect_filled(br, 10.0, Color32::from_black_alpha(165));
|
||||
let c = pos2(br.left() + 13.0, br.center().y);
|
||||
let s = 5.0;
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)],
|
||||
Color32::TRANSPARENT,
|
||||
Stroke::new(1.2, Color32::WHITE),
|
||||
));
|
||||
p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE));
|
||||
p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE));
|
||||
p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
|
||||
}
|
||||
if photo.flag == Flag::Reject {
|
||||
p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110));
|
||||
}
|
||||
|
|
@ -280,6 +297,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
|||
}
|
||||
}
|
||||
});
|
||||
if ui.button("Create Virtual Copy").clicked() {
|
||||
let _ = app.run("photo.virtualCopy", json!({}));
|
||||
}
|
||||
ui.menu_button("Stack", |ui| {
|
||||
let stacked = app.session.catalog.stack_of(id).is_some();
|
||||
let several = app.session.selection.ids.len() > 1;
|
||||
|
|
|
|||
|
|
@ -243,8 +243,12 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
|||
padded(ui, |ui| {
|
||||
ui.label(egui::RichText::new(&p.file_name).font(t.semibold(14.0)).color(t.text));
|
||||
ui.label(egui::RichText::new(format!("{} × {} · {}", p.width, p.height, p.format)).color(t.text_dim));
|
||||
if let Some(name) = &p.copy_name {
|
||||
let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into());
|
||||
ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if let Some(r) = crate::widgets::stars(ui, "info", p.rating, 20.0) {
|
||||
if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner {
|
||||
let _ = app.run("photo.rate", json!({"rating": r}));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
|
|||
"-" => Some(Key::Minus),
|
||||
"[" => Some(Key::OpenBracket),
|
||||
"]" => Some(Key::CloseBracket),
|
||||
"'" => Some(Key::Quote),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue