1use crate::base::CellRef;
11use crate::model::{Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, ObjKind, Sheet, Workbook};
12use serde_json::{Value as Json, json};
13
14use super::{
15 CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command,
16 has_doc, split_sheet, stash, str_param, u32_param,
17};
18use crate::engine::macros::policy::{Policy, macro_policy};
19
20pub fn specs() -> Vec<CommandSpec> {
21 vec![
22 cmd!(
23 "insert.button",
24 "Button",
25 ["Insert", "Controls"],
26 None,
27 "{at?: \"B2\" (or cell), dx?, dy?, width?, height?, text?, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\", command?: id, params?: {..}} places a button; pressing it runs the macro or the command → {control: id}",
28 has_doc,
29 insert_button
30 ),
31 cmd!(
32 "insert.slider",
33 "Slider",
34 ["Insert", "Controls"],
35 None,
36 "{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\" | \"Sheet2!$B$1\", min?: 0, max?: 100, step?: 1, page?: 10, value?, horizontal?: true} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min, page ≥ step → {control: id}",
37 has_doc,
38 |s, p| insert_number_control(s, p, ControlKind::Slider)
39 ),
40 cmd!(
41 "insert.spinButton",
42 "Spin Button",
43 ["Insert", "Controls"],
44 None,
45 "{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\", min?: 0, max?: 100, step?: 1, value?} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min → {control: id}",
46 has_doc,
47 |s, p| insert_number_control(s, p, ControlKind::Spin)
48 ),
49 cmd!(
50 "control.format",
51 "Format Control",
52 [],
53 None,
54 "{id, text?, name?, link?: \"$B$1\" | \"\" (no link), min?, max?, step?, page?, value?, horizontal?, print?, locked?} → the control (as control.list)",
55 has_doc,
56 format
57 ),
58 cmd!(
59 "control.assign",
60 "Assign Action",
61 [],
62 None,
63 "{id, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\" | command?: id, params?: {..} | clear?: true} sets what a button runs → the control",
64 has_doc,
65 assign
66 ),
67 cmd!(
68 "control.setValue",
69 "Change Control Value",
70 [],
71 None,
72 "{id, value} clamps the value to min..max, snaps it to the step, and writes it to the linked cell → {value}",
73 has_doc,
74 set_value
75 ),
76 cmd!(
77 "control.press",
78 "Press Button",
79 [],
80 None,
81 "{id, approve?: bool, onConflict?, overwrite?} runs a button's macro or command (through the macro policy, with the workbook's trust; a workbook from a file asks first) → what the macro or command returned",
82 has_doc,
83 press
84 ),
85 cmd!(query "control.list", "Form Controls", [], None, "{} → the active sheet's controls: [{id, kind, name, text, at, link?, value, min, max, step, page, horizontal, action?}]", has_doc, list),
86 ]
87}
88
89const MAX_TEXT: usize = 1_024;
91
92fn control_json(c: &FormControl) -> Json {
93 let mut o = json!({
94 "id": c.id,
95 "kind": match c.kind { ControlKind::Button => "button", ControlKind::Slider => "slider", ControlKind::Spin => "spinButton" },
96 "name": c.display_name(),
97 "at": c.anchor.cell.a1(),
98 "width": c.anchor.width,
99 "height": c.anchor.height,
100 "print": c.print,
101 "locked": c.locked,
102 });
103 if c.kind == ControlKind::Button {
104 o["text"] = json!(c.text);
105 if let Some(a) = &c.action {
106 o["action"] = serde_json::to_value(a).unwrap_or(Json::Null);
107 }
108 } else {
109 o["value"] = json!(c.value);
110 o["min"] = json!(c.min);
111 o["max"] = json!(c.max);
112 o["step"] = json!(c.step);
113 if c.kind == ControlKind::Slider {
114 o["page"] = json!(c.page);
115 o["horizontal"] = json!(c.horizontal);
116 }
117 }
118 if let Some(l) = &c.link {
119 o["link"] = json!(l);
120 }
121 o
122}
123
124fn list(s: &mut Session, _: &Json) -> Result<Json> {
125 let sh = s.doc()?.wb.active().ok_or(EngineError::NoDocument)?;
126 Ok(Json::Array(sh.controls.iter().map(control_json).collect()))
127}
128
129fn whole(cmd: &str, p: &Json, key: &str) -> Result<Option<u32>> {
132 let v = match p.get(key) {
133 None | Some(Json::Null) => return Ok(None),
134 Some(Json::Number(n)) => n.as_f64(),
135 Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
136 Some(_) => None,
137 };
138 match v {
139 Some(x) if x.is_finite() && x.fract() == 0.0 && (0.0..=CONTROL_MAX as f64).contains(&x) => Ok(Some(x as u32)),
140 _ => Err(bad(cmd, format!("`{key}` must be a whole number from 0 to {CONTROL_MAX}"))),
141 }
142}
143
144fn text_param(cmd: &str, p: &Json, key: &str) -> Result<Option<String>> {
145 match p.get(key) {
146 None | Some(Json::Null) => Ok(None),
147 Some(Json::String(t)) if t.chars().count() <= MAX_TEXT => Ok(Some(t.clone())),
148 Some(Json::String(_)) => Err(bad(cmd, format!("`{key}` is longer than {MAX_TEXT} characters"))),
149 Some(_) => Err(bad(cmd, format!("`{key}` must be text"))),
150 }
151}
152
153pub fn link_target(wb: &Workbook, host: usize, link: &str) -> Option<(usize, CellRef)> {
156 let text = link.trim().trim_start_matches('=');
157 let (sheet, body) = split_sheet(text);
158 let si = match sheet {
159 Some(n) => wb.sheet_index(&n)?,
160 None => host,
161 };
162 wb.sheet(si)?;
163 Some((si, CellRef::parse(body.trim())?))
164}
165
166fn link_param(cmd: &str, wb: &Workbook, host: usize, p: &Json) -> Result<Option<Option<String>>> {
169 let text = match p.get("link") {
170 None => return Ok(None),
171 Some(Json::Null) => return Ok(Some(None)),
172 Some(Json::String(t)) => t.trim(),
173 Some(_) => return Err(bad(cmd, "`link` must be a cell reference such as $B$1")),
174 };
175 if text.is_empty() {
176 return Ok(Some(None));
177 }
178 let (si, c) = link_target(wb, host, text)
179 .ok_or_else(|| bad(cmd, format!("`{text}` isn't a cell of this workbook; the link must be one cell, such as $B$1")))?;
180 let abs = format!("${}${}", crate::base::col_to_letters(c.col), c.row as u64 + 1);
181 Ok(Some(Some(if si == host {
182 abs
183 } else {
184 let name = wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default();
185 format!("{}!{abs}", crate::formula::quote_sheet(&name))
186 })))
187}
188
189fn new_anchor(s: &Session, p: &Json, size: (f32, f32)) -> Result<Anchor> {
191 let cell = match cell_param(s, p, "at")? {
192 Some(c) => c,
193 None => cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active),
194 };
195 let dim = |k: &str, v: f32| f64_param(p, k).filter(|x| x.is_finite()).map_or(v, |x| x.clamp(4.0, 10_000.0) as f32);
196 let off = |k: &str| f64_param(p, k).filter(|x| x.is_finite()).map_or(0.0, |x| x.clamp(-10_000.0, 10_000.0) as f32);
197 Ok(Anchor {
198 cell,
199 dx: off("dx"),
200 dy: off("dy"),
201 width: dim("width", size.0),
202 height: dim("height", size.1),
203 mode: anchor_mode_param(p).unwrap_or(AnchorMode::MoveOnly),
204 })
205}
206
207fn insert_button(s: &mut Session, p: &Json) -> Result<Json> {
208 const CMD: &str = "insert.button";
209 let anchor = new_anchor(s, p, (96.0, 28.0))?;
210 let action = action_param(s, CMD, p)?;
211 let d = s.doc()?;
212 let id = d.wb.next_object_id();
213 let text = text_param(CMD, p, "text")?.unwrap_or_else(|| format!("Button {id}"));
214 let sheet = d.wb.active_sheet();
215 let control = FormControl { id, kind: ControlKind::Button, anchor, text, action, ..FormControl::default() };
216 edit(s, |cx| {
217 cx.sheet_mut(sheet)?.controls.push(control);
218 Ok(json!({"control": id}))
219 })
220}
221
222fn insert_number_control(s: &mut Session, p: &Json, kind: ControlKind) -> Result<Json> {
223 let cmd = if kind == ControlKind::Slider { "insert.slider" } else { "insert.spinButton" };
224 let horizontal = bool_param(p, "horizontal").unwrap_or(true);
225 let size = match kind {
226 ControlKind::Slider if horizontal => (144.0, 18.0),
227 ControlKind::Slider => (18.0, 144.0),
228 _ => (18.0, 36.0),
229 };
230 let anchor = new_anchor(s, p, size)?;
231 let d = s.doc()?;
232 let sheet = d.wb.active_sheet();
233 let id = d.wb.next_object_id();
234 let mut c = FormControl { id, kind, anchor, horizontal, ..FormControl::default() };
235 numbers(cmd, p, &mut c)?;
236 if let Some(link) = link_param(cmd, &d.wb, sheet, p)? {
237 c.link = link;
238 }
239 let given = whole(cmd, p, "value")?;
240 let start = given.map(f64::from).or_else(|| linked_number(&d.wb, sheet, &c)).unwrap_or(c.min as f64);
242 c.value = c.snap(start);
243 let value = c.value;
244 let link = c.link.clone();
245 s.grouped(&format!("Insert {}", kind.label()), |s| {
246 edit(s, |cx| {
247 cx.sheet_mut(sheet)?.controls.push(c);
248 Ok(())
249 })?;
250 if given.is_some() {
251 write_link(s, sheet, link.as_deref(), value)?;
252 }
253 Ok(json!({"control": id}))
254 })
255}
256
257fn numbers(cmd: &str, p: &Json, c: &mut FormControl) -> Result<()> {
259 for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
260 if let Some(v) = whole(cmd, p, key)? {
261 *slot = v;
262 }
263 }
264 if p.get("page").is_none() && c.page < c.step {
266 c.page = c.step;
267 }
268 c.check_numbers().map_err(|e| bad(cmd, e))
269}
270
271fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option<f64> {
273 let (si, cell) = link_target(wb, host, c.link.as_deref()?)?;
274 wb.sheet(si)?.cell(cell)?.value.as_f64()
275}
276
277fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> {
282 let Some(link) = link else { return Ok(()) };
283 let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) };
284 let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| c.value.as_f64());
285 if holds == Some(value as f64) {
286 return Ok(());
287 }
288 s.execute("cell.set", json!({"sheet": si, "cell": cell.a1(), "input": value.to_string()}))?;
289 Ok(())
290}
291
292fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> {
294 let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (a control id from control.list)"))?;
295 let d = s.doc()?;
296 let sheet = d.wb.active_sheet();
297 let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
298 let i = sh.controls.iter().position(|c| c.id == id).ok_or_else(|| bad(cmd, format!("there is no control {id} on this sheet")))?;
299 let c = sh.controls.get(i).cloned().ok_or_else(|| bad(cmd, "no such control"))?;
300 Ok((sheet, i, c))
301}
302
303pub(crate) fn check_objects_unlocked(s: &Session, sheet: usize, kind: ObjKind, ids: &[u32]) -> Result<()> {
308 if kind != ObjKind::Control {
309 return Ok(());
310 }
311 let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Ok(()) };
312 if sh.is_protected() && sh.controls.iter().any(|c| c.locked && ids.contains(&c.id)) {
313 return Err(EngineError::Other(PROTECTED.into()));
314 }
315 Ok(())
316}
317
318fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> {
319 if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) {
320 return Err(EngineError::Other(PROTECTED.into()));
321 }
322 Ok(())
323}
324
325fn format(s: &mut Session, p: &Json) -> Result<Json> {
326 const CMD: &str = "control.format";
327 let (sheet, i, mut c) = find(s, CMD, p)?;
328 check_unlocked(s, sheet, &c)?;
329 if let Some(t) = text_param(CMD, p, "text")? {
330 c.text = t;
331 }
332 if let Some(n) = text_param(CMD, p, "name")? {
333 c.name = n.trim().to_string();
334 }
335 for (key, slot) in [("print", &mut c.print), ("locked", &mut c.locked), ("horizontal", &mut c.horizontal)] {
336 if let Some(v) = bool_param(p, key) {
337 *slot = v;
338 }
339 }
340 let mut given = None;
341 if c.kind != ControlKind::Button {
342 for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
343 if let Some(v) = whole(CMD, p, key)? {
344 *slot = v;
345 }
346 }
347 c.check_numbers().map_err(|e| bad(CMD, e))?;
348 given = whole(CMD, p, "value")?;
349 }
350 if let Some(link) = link_param(CMD, &s.doc()?.wb, sheet, p)? {
351 c.link = link;
352 }
353 c.value = c.snap(given.map_or(c.value as f64, f64::from));
355 let number = c.kind != ControlKind::Button;
356 let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some());
357 let (value, link) = (c.value, c.link.clone());
358 s.grouped("Format Control", |s| {
359 edit(s, |cx| {
360 if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
361 *slot = c;
362 }
363 Ok(())
364 })?;
365 if write {
366 write_link(s, sheet, link.as_deref(), value)?;
367 }
368 Ok(())
369 })?;
370 let (_, _, c) = find(s, CMD, p)?;
373 Ok(control_json(&c))
374}
375
376const BUTTON_DENIED: &[(&str, &str)] = &[("link.open", "buttons don't open web pages")];
381
382fn button_policy(id: &str, params: &Json, trust: crate::engine::Trust) -> Policy {
384 match BUTTON_DENIED.iter().find(|(d, _)| *d == id) {
385 Some((_, why)) => Policy::Deny((*why).into()),
386 None => macro_policy(id, params, trust),
387 }
388}
389
390pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Option<String> {
394 use crate::engine::UiRequest;
395 let mut blocked = None;
396 let asked: Vec<UiRequest> = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect();
397 for q in asked {
398 match q {
399 UiRequest::Dialog(d, _) => blocked = Some(format!("'{name}' would open the {d} dialog; give its command the parameters it needs.")),
400 UiRequest::OpenUrl(_) => blocked = Some(format!("'{name}' would open a web page, which buttons don't do.")),
401 UiRequest::EditCell(_) | UiRequest::InsertName(_) => {}
402 m @ UiRequest::Message(_) => s.ui_requests.push(m),
403 }
404 }
405 blocked
406}
407
408fn takes_no_inputs(wb: &Workbook, name: &str) -> Option<String> {
413 crate::engine::macros::find(wb, name).and_then(|i| wb.macros.get(i)).filter(|m| m.inputs.is_empty()).map(|m| m.name.clone())
414}
415
416fn action_param(s: &Session, cmd: &str, p: &Json) -> Result<Option<ControlAction>> {
418 if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) {
419 if name.chars().count() > MAX_MACRO_NAME {
420 return Err(bad(cmd, "`macro` is not a macro name"));
421 }
422 let input = match str_param(p, "input").map(str::trim) {
423 None | Some("") => MacroInput::Selection,
424 Some(t) if t.eq_ignore_ascii_case("selection") => MacroInput::Selection,
425 Some(t) => {
426 let (_, body) = split_sheet(t);
427 if crate::base::RangeRef::parse(body).is_none() {
428 return Err(bad(cmd, format!("`input` must be \"selection\" or a range such as B2:D9, not `{t}`")));
429 }
430 if let Some(stored) = takes_no_inputs(&s.doc()?.wb, name) {
431 return Err(bad(cmd, format!("macro '{stored}' takes no inputs, so `input` must be \"selection\"")));
432 }
433 MacroInput::Fixed(t.to_string())
434 }
435 };
436 let output = str_param(p, "output").map(str::trim).filter(|o| !o.is_empty()).map(str::to_string);
437 if let Some(o) = &output
438 && crate::base::RangeRef::parse(split_sheet(o).1).is_none()
439 {
440 return Err(bad(cmd, format!("`output` must be a cell such as F2, not `{o}`")));
441 }
442 return Ok(Some(ControlAction::Macro { name: name.to_string(), input, output }));
443 }
444 if let Some(id) = str_param(p, "command").map(str::trim).filter(|n| !n.is_empty()) {
445 if find_command(id).is_none() {
446 return Err(bad(cmd, format!("there is no command `{id}`")));
447 }
448 let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
449 if !params.is_object() {
450 return Err(bad(cmd, "`params` must be an object"));
451 }
452 let trust = s.doc()?.trust;
453 if let Policy::Deny(why) = button_policy(id, ¶ms, trust) {
454 return Err(EngineError::Other(format!("A button can't run {id}: {why}.")));
455 }
456 return Ok(Some(ControlAction::Command { id: id.to_string(), params }));
457 }
458 Ok(None)
459}
460
461fn assign(s: &mut Session, p: &Json) -> Result<Json> {
462 const CMD: &str = "control.assign";
463 let (sheet, i, mut c) = find(s, CMD, p)?;
464 check_unlocked(s, sheet, &c)?;
465 if c.kind != ControlKind::Button {
466 return Err(bad(CMD, "only buttons run actions; a slider or spin button writes its value to its linked cell"));
467 }
468 let action = action_param(s, CMD, p)?;
469 if action.is_none() && bool_param(p, "clear") != Some(true) {
470 return Err(bad(CMD, "pass `macro` or `command` (or `clear: true` to remove the action)"));
471 }
472 c.action = action;
473 let out = control_json(&c);
474 edit(s, |cx| {
475 if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
476 *slot = c;
477 }
478 Ok(out)
479 })
480}
481
482fn set_value(s: &mut Session, p: &Json) -> Result<Json> {
483 const CMD: &str = "control.setValue";
484 let (sheet, i, c) = find(s, CMD, p)?;
485 if c.kind == ControlKind::Button {
486 return Err(bad(CMD, "a button has no value; use control.press"));
487 }
488 let v = match p.get("value") {
489 Some(Json::Number(n)) => n.as_f64(),
490 Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
491 _ => None,
492 }
493 .filter(|v| v.is_finite())
494 .ok_or_else(|| bad(CMD, "`value` must be a number"))?;
495 let value = c.snap(v);
496 let label = format!("Change {}", c.kind.label());
497 s.grouped(&label, |s| {
498 if c.value != value {
499 edit(s, |cx| {
500 if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
501 slot.value = value;
502 }
503 Ok(())
504 })?;
505 }
506 write_link(s, sheet, c.link.as_deref(), value)?;
507 Ok(json!({"value": value}))
508 })
509}
510
511fn press(s: &mut Session, p: &Json) -> Result<Json> {
512 const CMD: &str = "control.press";
513 let (_, _, c) = find(s, CMD, p)?;
514 if c.kind != ControlKind::Button {
515 return Err(bad(CMD, "only buttons can be pressed; use control.setValue for a slider or spin button"));
516 }
517 let name = c.display_name();
518 match c.action {
519 None => Err(EngineError::Other(format!("'{name}' has no action. Right-click it and choose Assign Action."))),
520 Some(ControlAction::Macro { name: mac, input, output }) => {
521 let mut params = json!({"name": mac});
522 if let MacroInput::Fixed(r) = input {
523 params["inputs"] = json!(r);
524 }
525 if let Some(o) = output {
526 params["output"] = json!(o);
527 }
528 for key in ["approve", "onConflict", "overwrite", "timeLimit"] {
529 if let Some(v) = p.get(key) {
530 params[key] = v.clone();
531 }
532 }
533 crate::engine::macros::run::run(s, &mac, ¶ms)
535 }
536 Some(ControlAction::Command { id, params }) => {
537 let d = s.doc()?;
538 let (doc, trust) = (d.uid, d.trust);
539 let policy = button_policy(&id, ¶ms, trust);
540 let file_command = matches!(policy, Policy::NeedsPermission(_));
541 let why = match policy {
542 Policy::Allow => None,
543 Policy::Ask(why) | Policy::NeedsPermission(why) => Some(why),
544 Policy::Deny(why) => return Err(EngineError::Other(format!("'{name}' can't run {id}: {why}."))),
545 };
546 if let Some(why) = why {
547 let key = (doc, approval_key(&id, ¶ms));
548 if !s.macro_approvals.contains(&key) {
549 if bool_param(p, "approve") != Some(true) {
550 return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}{}.", path_note(¶ms))));
551 }
552 s.macro_approvals.insert(key);
553 }
554 }
555 let _sandbox = (!file_command).then(crate::engine::io::ScriptSandbox::enter);
558 s.grouped(&name, |s| {
559 let queued = s.ui_requests.len();
560 let r = s.execute(&id, params.clone());
561 let blocked = take_requests(s, queued, &name);
564 let r = r?;
565 match blocked {
566 Some(why) => Err(EngineError::Other(why)),
567 None => Ok(r),
568 }
569 })
570 }
571 }
572}
573
574fn path_note(params: &Json) -> String {
578 let text = match params.get("path") {
579 None | Some(Json::Null) => return String::new(),
580 Some(Json::String(t)) => t.clone(),
581 Some(other) => other.to_string(),
582 };
583 let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
584 let n = text.chars().count();
585 let shown = if n > 200 {
586 let head: String = text.chars().take(100).collect();
587 let tail: String = text.chars().skip(n - 99).collect();
588 format!("{head}…{tail}")
589 } else {
590 text
591 };
592 format!(" ({shown})")
593}
594
595fn approval_key(id: &str, params: &Json) -> String {
598 format!("control-command\u{0}{id}\u{0}{params}")
599}
600
601pub(crate) fn sync_linked_values(wb: &mut Workbook) {
605 for si in 0..wb.sheets.len() {
606 let updates: Vec<(usize, u32)> = match wb.sheet(si) {
607 Some(sh) if sh.controls.iter().any(|c| c.link.is_some()) => sh
608 .controls
609 .iter()
610 .enumerate()
611 .filter(|(_, c)| c.kind != ControlKind::Button)
612 .filter_map(|(i, c)| linked_number(wb, si, c).map(|n| (i, c.snap(n))).filter(|(_, v)| *v != c.value))
613 .collect(),
614 _ => continue,
615 };
616 if updates.is_empty() {
617 continue;
618 }
619 if let Some(sh) = wb.sheet_mut(si) {
620 for (i, v) in updates {
621 if let Some(c) = sh.controls.get_mut(i) {
622 c.value = v;
623 }
624 }
625 }
626 }
627}
628
629pub(crate) const ACTIONS_KEY: &str = "control_actions";
633
634fn plain(a: &ControlAction) -> bool {
636 matches!(a, ControlAction::Macro { input: MacroInput::Selection, output: None, .. })
637}
638
639pub(crate) fn with_action_names(wb: &Workbook) -> Result<Option<Workbook>> {
644 let mut items = Vec::new();
645 for sh in &wb.sheets {
646 for (i, c) in sh.controls.iter().enumerate() {
647 if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) {
648 items.push(json!({"sheet": sh.name, "index": i, "name": c.display_name(), "at": c.anchor.cell.a1(), "action": a}));
649 }
650 }
651 }
652 let stale = stash::get(wb, ACTIONS_KEY).is_some();
653 if items.is_empty() && !stale {
654 return Ok(None);
655 }
656 let mut out = wb.clone();
657 stash::remove(&mut out, ACTIONS_KEY);
658 if !items.is_empty() {
659 stash::set_json(&mut out, ACTIONS_KEY, &Json::Array(items))?;
660 }
661 Ok(Some(out))
662}
663
664pub(crate) fn load_action_names(wb: &mut Workbook) {
672 let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return };
673 stash::remove(wb, ACTIONS_KEY);
674 for item in items.as_array().into_iter().flatten().take(100_000) {
675 let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue };
676 let Ok(action) = serde_json::from_value::<ControlAction>(item.get("action").cloned().unwrap_or(Json::Null)) else { continue };
677 let valid = match &action {
678 ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME,
679 ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(),
680 };
681 let Some(si) = wb.sheet_index(sheet) else { continue };
682 let Ok(index) = usize::try_from(index) else { continue };
683 let text = |k: &str| item.get(k).and_then(Json::as_str);
684 let same = |c: &FormControl| {
685 c.kind == ControlKind::Button
686 && text("name").is_some_and(|n| n == c.display_name())
687 && text("at").is_some_and(|a| a == c.anchor.cell.a1())
688 && match (&c.action, &action) {
689 (None, ControlAction::Command { .. }) => true,
690 (Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept),
691 _ => false,
692 }
693 };
694 if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) {
695 continue;
696 }
697 let action = match action {
698 ControlAction::Macro { name, input: MacroInput::Fixed(_), output } if takes_no_inputs(wb, &name).is_some() => {
701 ControlAction::Macro { name, input: MacroInput::Selection, output }
702 }
703 other => other,
704 };
705 if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) {
706 c.action = Some(action);
707 }
708 }
709}