Reuse Slides: choose which slides of the other file to insert (#1103)

Reuse Slides… used to insert every slide of the picked presentation. It now opens a list
of that presentation's slides (number and title, all ticked), with Select All / Select None
and a Keep source formatting option, and inserts only the ticked ones after the current
slide. New query slide.reuseList {path | data} gives the list; slide.reuse already took
`slides`.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Faabyy
2026-10-21 08:04:19 +00:00
co-authored by Claude
parent a31b990d32
commit fdfdb9e058
3 changed files with 133 additions and 10 deletions
+58 -9
View File
@@ -17,15 +17,34 @@ use super::*;
use crate::{Result, Session, media_refs};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"slide.reuse",
"Reuse Slides…",
["Home", "Slides", "New Slide"],
None,
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
has_doc,
reuse
)]
vec![
cmd!(
query "slide.reuseList",
"List Slides to Reuse",
[],
None,
"{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in",
has_doc,
reuse_list
),
cmd!(
"slide.reuse",
"Reuse Slides…",
["Home", "Slides", "New Slide"],
None,
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
has_doc,
reuse
),
]
}
fn reuse_list(_s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?;
let src = super::file::open_presentation(&name, &bytes)?;
let slides: Vec<Value> =
src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect();
Ok(json!({"name": name, "slides": slides}))
}
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
@@ -397,4 +416,34 @@ mod tests {
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
}
/// The list that lets a dialog offer the slides of the other file; picking from it brings in
/// exactly those.
#[test]
fn reuse_list_names_the_slides_and_the_picked_ones_come_in() {
let mut src = Session::with_new();
for t in ["Alpha", "Beta", "Gamma"] {
src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new");
}
let last_slide = src.doc().expect("doc").doc.slides.len() - 1;
src.execute("slide.hide", &json!({"index": last_slide})).expect("hide");
let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save");
let data = super::super::base64_encode(&bytes);
let mut s = Session::with_new();
let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list");
let slides = listed["slides"].as_array().expect("slides");
let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string();
assert!(slides.len() >= 3);
let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1));
assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma"));
assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true)));
assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing");
let last = slides.len() - 1;
s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse");
let titles: Vec<String> = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect();
assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma"));
assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out");
}
}
+61
View File
@@ -42,6 +42,7 @@ fn title(id: &str) -> &'static str {
"altText" => "Alt Text",
"outline" => "Slides from Outline",
"renameSection" => "Rename Section",
"reuseSlides" => "Reuse Slides",
"renameShape" => "Rename",
"renameLayout" => "Rename Layout",
"paragraph" => "Paragraph",
@@ -1522,6 +1523,66 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
"reuseSlides" => {
let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string();
let slides: Vec<(usize, String)> = d
.params
.get("slides")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| {
Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string()))
})
.collect()
})
.unwrap_or_default();
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
ui.label(format!("Insert slides from {name} after the current slide:"));
// Unticked slides are remembered by index; everything starts ticked.
let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1");
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
for (i, _) in &slides {
d.fields.remove(&format!("off{i}"));
}
}
if ui.small_button("Select None").clicked() {
for (i, _) in &slides {
d.fields.insert(format!("off{i}"), "1".into());
}
}
});
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (i, title) in &slides {
let mut on = !off(d, *i);
let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) };
if ui.checkbox(&mut on, label).changed() {
if on {
d.fields.remove(&format!("off{i}"));
} else {
d.fields.insert(format!("off{i}"), "1".into());
}
}
}
});
let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false);
if ui.checkbox(&mut keep, "Keep source formatting").changed()
&& let Some(o) = d.params.as_object_mut()
{
o.insert("keep".into(), json!(keep));
}
let picked: Vec<usize> = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect();
let (ok, cancel) = buttons(ui, "Insert");
if ok {
if picked.is_empty() {
app.set_status("Tick at least one slide to insert".to_string());
return false;
}
app.reuse_slides(&path, keep, &picked);
}
ok || cancel
}
"renameSection" | "renameShape" | "renameLayout" => {
let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();
field(ui, d, "Name:", "name", &cur);
+14 -1
View File
@@ -1233,7 +1233,20 @@ impl SlideApp {
return;
};
let Some(path) = pick("open") else { return };
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep})) {
// Offer the file's slides to choose from; all of them are ticked to begin with.
match self.session.execute("slide.reuseList", &json!({"path": path})) {
Ok(list) => {
let mut d = dialogs::Dialog::new("reuseSlides");
d.params = json!({"path": path, "keep": keep, "slides": list["slides"]});
self.dialog = Some(d);
}
Err(e) => self.set_status(e.to_string()),
}
}
/// Insert the slides `picked` (indices) of the presentation at `path` after the current slide.
pub fn reuse_slides(&mut self, path: &str, keep: bool, picked: &[usize]) {
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep, "slides": picked})) {
Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])),
Err(e) => self.set_status(e.to_string()),
}