Skip to main content

excel_mcp_server/tools/
expanded.rs

1//! New tools: page setup, comments, hyperlinks, defined names, sheet settings,
2//! active sheet, insert/delete rows/cols, grouping, protection, autofit,
3//! enhanced charts, pivot tables, read comments, rich text.
4
5use super::common::workbook_not_found;
6use crate::store::WorkbookStore;
7use crate::types::enums::{ChartType, LegendPosition};
8use crate::types::inputs::*;
9use crate::types::responses::*;
10
11fn find_sheet(wb: &zavora_xlsx::Workbook, name: &str) -> Option<usize> {
12    wb.sheet_names().iter().position(|n| *n == name)
13}
14fn sheet_err(name: &str) -> String {
15    error(
16        ErrorCategory::NotFound,
17        &format!("Sheet '{}' not found", name),
18        "Check sheet name.",
19    )
20}
21
22// ── Batch 1 ──
23
24pub fn set_page_setup(
25    store: &mut WorkbookStore,
26    input: SetPageSetupInput,
27) -> Result<String, anyhow::Error> {
28    let entry = match store.get_mut(&input.workbook_id) {
29        Some(e) => e,
30        None => return Ok(workbook_not_found(store, &input.workbook_id)),
31    };
32    let idx = match find_sheet(&entry.data, &input.sheet_name) {
33        Some(i) => i,
34        None => return Ok(sheet_err(&input.sheet_name)),
35    };
36    let ws = entry
37        .data
38        .worksheet(idx)
39        .map_err(|e| anyhow::anyhow!("{e}"))?;
40    if input.landscape == Some(true) {
41        ws.set_landscape();
42    } else if input.landscape == Some(false) {
43        ws.set_portrait();
44    }
45    if let Some(ps) = input.paper_size {
46        ws.set_paper_size(ps);
47    }
48    if let Some(ref m) = input.margins {
49        ws.set_margins(
50            m.top.unwrap_or(0.75),
51            m.bottom.unwrap_or(0.75),
52            m.left.unwrap_or(0.7),
53            m.right.unwrap_or(0.7),
54        );
55    }
56    if let Some(ref f) = input.fit_to_pages {
57        ws.set_fit_to_page(f.width, f.height);
58    }
59    if let Some(s) = input.print_scale {
60        ws.set_print_scale(s);
61    }
62    if let Some(ref pa) = input.print_area {
63        let (r1, c1, r2, c2) =
64            zavora_xlsx::utility::parse_range_ref(pa).map_err(|e| anyhow::anyhow!("{e}"))?;
65        ws.set_print_area(r1, c1, r2, c2);
66    }
67    if let Some(ref rr) = input.repeat_rows {
68        ws.set_repeat_rows(rr.first, rr.last);
69    }
70    if let Some(ref h) = input.header {
71        ws.set_header(h);
72    }
73    if let Some(ref f) = input.footer {
74        ws.set_footer(f);
75    }
76    if let Some(true) = input.print_gridlines {
77        ws.set_print_settings(&zavora_xlsx::PrintSettings::new().print_gridlines(true));
78    }
79    if input.center_horizontally == Some(true) {
80        ws.set_print_settings(&zavora_xlsx::PrintSettings::new().center_horizontally(true));
81    }
82    Ok(success_no_data("Page setup configured"))
83}
84
85pub fn add_comment(
86    store: &mut WorkbookStore,
87    input: AddCommentInput,
88) -> Result<String, anyhow::Error> {
89    let entry = match store.get_mut(&input.workbook_id) {
90        Some(e) => e,
91        None => return Ok(workbook_not_found(store, &input.workbook_id)),
92    };
93    let idx = match find_sheet(&entry.data, &input.sheet_name) {
94        Some(i) => i,
95        None => return Ok(sheet_err(&input.sheet_name)),
96    };
97    let (row, col) =
98        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
99    let ws = entry
100        .data
101        .worksheet(idx)
102        .map_err(|e| anyhow::anyhow!("{e}"))?;
103    if let Some(ref author) = input.author {
104        ws.add_comment_with_author(row, col, &input.text, author);
105    } else {
106        ws.add_comment(row, col, &input.text);
107    }
108    Ok(success_no_data(&format!("Comment added at {}", input.cell)))
109}
110
111pub fn add_hyperlink(
112    store: &mut WorkbookStore,
113    input: AddHyperlinkInput,
114) -> Result<String, anyhow::Error> {
115    let entry = match store.get_mut(&input.workbook_id) {
116        Some(e) => e,
117        None => return Ok(workbook_not_found(store, &input.workbook_id)),
118    };
119    let idx = match find_sheet(&entry.data, &input.sheet_name) {
120        Some(i) => i,
121        None => return Ok(sheet_err(&input.sheet_name)),
122    };
123    let (row, col) =
124        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
125    let ws = entry
126        .data
127        .worksheet(idx)
128        .map_err(|e| anyhow::anyhow!("{e}"))?;
129    let display = input.display_text.as_deref().unwrap_or(&input.url);
130    ws.write(row, col, display)
131        .map_err(|e| anyhow::anyhow!("{e}"))?;
132    ws.write_url(
133        row,
134        col,
135        &input.url,
136        input.display_text.as_deref().unwrap_or(&input.url),
137    )?;
138    Ok(success_no_data(&format!(
139        "Hyperlink added at {}",
140        input.cell
141    )))
142}
143
144pub fn add_defined_name(
145    store: &mut WorkbookStore,
146    input: AddDefinedNameInput,
147) -> Result<String, anyhow::Error> {
148    let entry = match store.get_mut(&input.workbook_id) {
149        Some(e) => e,
150        None => return Ok(workbook_not_found(store, &input.workbook_id)),
151    };
152    entry.data.define_name(&input.name, &input.formula);
153    Ok(success_no_data(&format!(
154        "Defined name '{}' added",
155        input.name
156    )))
157}
158
159pub fn list_defined_names(
160    store: &mut WorkbookStore,
161    input: ListDefinedNamesInput,
162) -> Result<String, anyhow::Error> {
163    let entry = match store.get(&input.workbook_id) {
164        Some(e) => e,
165        None => return Ok(workbook_not_found(store, &input.workbook_id)),
166    };
167    let names: Vec<serde_json::Value> = entry
168        .data
169        .defined_names()
170        .iter()
171        .map(|(n, f)| serde_json::json!({"name": n, "formula": f}))
172        .collect();
173    Ok(success("Defined names listed", names))
174}
175
176pub fn set_sheet_settings(
177    store: &mut WorkbookStore,
178    input: SetSheetSettingsInput,
179) -> Result<String, anyhow::Error> {
180    let entry = match store.get_mut(&input.workbook_id) {
181        Some(e) => e,
182        None => return Ok(workbook_not_found(store, &input.workbook_id)),
183    };
184    let idx = match find_sheet(&entry.data, &input.sheet_name) {
185        Some(i) => i,
186        None => return Ok(sheet_err(&input.sheet_name)),
187    };
188    let ws = entry
189        .data
190        .worksheet(idx)
191        .map_err(|e| anyhow::anyhow!("{e}"))?;
192    if input.hidden == Some(true) {
193        ws.set_hidden();
194    }
195    if input.very_hidden == Some(true) {
196        ws.set_very_hidden();
197    }
198    if let Some(z) = input.zoom {
199        ws.set_zoom(z);
200    }
201    if input.hide_gridlines == Some(true) {
202        ws.hide_gridlines();
203    }
204    if input.hide_headings == Some(true) {
205        ws.hide_headings();
206    }
207    if let Some(ref c) = input.tab_color {
208        ws.set_tab_color(c.as_str());
209    }
210    if input.right_to_left == Some(true) {
211        ws.set_right_to_left();
212    }
213    Ok(success_no_data("Sheet settings updated"))
214}
215
216pub fn set_active_sheet(
217    store: &mut WorkbookStore,
218    input: SetActiveSheetInput,
219) -> Result<String, anyhow::Error> {
220    let entry = match store.get_mut(&input.workbook_id) {
221        Some(e) => e,
222        None => return Ok(workbook_not_found(store, &input.workbook_id)),
223    };
224    entry.data.set_active_sheet(input.sheet_index);
225    Ok(success_no_data(&format!(
226        "Active sheet set to index {}",
227        input.sheet_index
228    )))
229}
230
231// ── Batch 2 ──
232
233pub fn insert_rows(
234    store: &mut WorkbookStore,
235    input: InsertDeleteRowsInput,
236) -> Result<String, anyhow::Error> {
237    let entry = match store.get_mut(&input.workbook_id) {
238        Some(e) => e,
239        None => return Ok(workbook_not_found(store, &input.workbook_id)),
240    };
241    let idx = match find_sheet(&entry.data, &input.sheet_name) {
242        Some(i) => i,
243        None => return Ok(sheet_err(&input.sheet_name)),
244    };
245    entry
246        .data
247        .worksheet(idx)
248        .map_err(|e| anyhow::anyhow!("{e}"))?
249        .insert_rows(input.at_row.saturating_sub(1), input.count)
250        .map_err(|e| anyhow::anyhow!("{e}"))?;
251    Ok(success_no_data(&format!(
252        "{} rows inserted at row {}",
253        input.count, input.at_row
254    )))
255}
256
257pub fn delete_rows(
258    store: &mut WorkbookStore,
259    input: InsertDeleteRowsInput,
260) -> Result<String, anyhow::Error> {
261    let entry = match store.get_mut(&input.workbook_id) {
262        Some(e) => e,
263        None => return Ok(workbook_not_found(store, &input.workbook_id)),
264    };
265    let idx = match find_sheet(&entry.data, &input.sheet_name) {
266        Some(i) => i,
267        None => return Ok(sheet_err(&input.sheet_name)),
268    };
269    entry
270        .data
271        .worksheet(idx)
272        .map_err(|e| anyhow::anyhow!("{e}"))?
273        .remove_rows(input.at_row.saturating_sub(1), input.count)
274        .map_err(|e| anyhow::anyhow!("{e}"))?;
275    Ok(success_no_data(&format!(
276        "{} rows deleted at row {}",
277        input.count, input.at_row
278    )))
279}
280
281pub fn insert_columns(
282    store: &mut WorkbookStore,
283    input: InsertDeleteColumnsInput,
284) -> Result<String, anyhow::Error> {
285    let entry = match store.get_mut(&input.workbook_id) {
286        Some(e) => e,
287        None => return Ok(workbook_not_found(store, &input.workbook_id)),
288    };
289    let idx = match find_sheet(&entry.data, &input.sheet_name) {
290        Some(i) => i,
291        None => return Ok(sheet_err(&input.sheet_name)),
292    };
293    let col = zavora_xlsx::utility::col_from_letter(&input.at_column)
294        .map_err(|e| anyhow::anyhow!("{e}"))?;
295    entry
296        .data
297        .worksheet(idx)
298        .map_err(|e| anyhow::anyhow!("{e}"))?
299        .insert_columns(col, input.count)
300        .map_err(|e| anyhow::anyhow!("{e}"))?;
301    Ok(success_no_data(&format!(
302        "{} columns inserted at {}",
303        input.count, input.at_column
304    )))
305}
306
307pub fn delete_columns(
308    store: &mut WorkbookStore,
309    input: InsertDeleteColumnsInput,
310) -> Result<String, anyhow::Error> {
311    let entry = match store.get_mut(&input.workbook_id) {
312        Some(e) => e,
313        None => return Ok(workbook_not_found(store, &input.workbook_id)),
314    };
315    let idx = match find_sheet(&entry.data, &input.sheet_name) {
316        Some(i) => i,
317        None => return Ok(sheet_err(&input.sheet_name)),
318    };
319    let col = zavora_xlsx::utility::col_from_letter(&input.at_column)
320        .map_err(|e| anyhow::anyhow!("{e}"))?;
321    entry
322        .data
323        .worksheet(idx)
324        .map_err(|e| anyhow::anyhow!("{e}"))?
325        .remove_columns(col, input.count)
326        .map_err(|e| anyhow::anyhow!("{e}"))?;
327    Ok(success_no_data(&format!(
328        "{} columns deleted at {}",
329        input.count, input.at_column
330    )))
331}
332
333pub fn group_rows(
334    store: &mut WorkbookStore,
335    input: GroupRowsInput,
336) -> Result<String, anyhow::Error> {
337    let entry = match store.get_mut(&input.workbook_id) {
338        Some(e) => e,
339        None => return Ok(workbook_not_found(store, &input.workbook_id)),
340    };
341    let idx = match find_sheet(&entry.data, &input.sheet_name) {
342        Some(i) => i,
343        None => return Ok(sheet_err(&input.sheet_name)),
344    };
345    entry
346        .data
347        .worksheet(idx)
348        .map_err(|e| anyhow::anyhow!("{e}"))?
349        .group_rows(
350            input.start.saturating_sub(1),
351            input.end.saturating_sub(1),
352            input.level,
353        );
354    Ok(success_no_data(&format!(
355        "Rows {}-{} grouped at level {}",
356        input.start, input.end, input.level
357    )))
358}
359
360pub fn group_columns(
361    store: &mut WorkbookStore,
362    input: GroupColumnsInput,
363) -> Result<String, anyhow::Error> {
364    let entry = match store.get_mut(&input.workbook_id) {
365        Some(e) => e,
366        None => return Ok(workbook_not_found(store, &input.workbook_id)),
367    };
368    let idx = match find_sheet(&entry.data, &input.sheet_name) {
369        Some(i) => i,
370        None => return Ok(sheet_err(&input.sheet_name)),
371    };
372    let c1 =
373        zavora_xlsx::utility::col_from_letter(&input.start).map_err(|e| anyhow::anyhow!("{e}"))?;
374    let c2 =
375        zavora_xlsx::utility::col_from_letter(&input.end).map_err(|e| anyhow::anyhow!("{e}"))?;
376    entry
377        .data
378        .worksheet(idx)
379        .map_err(|e| anyhow::anyhow!("{e}"))?
380        .group_columns(c1, c2, input.level);
381    Ok(success_no_data(&format!(
382        "Columns {}-{} grouped at level {}",
383        input.start, input.end, input.level
384    )))
385}
386
387pub fn protect_sheet(
388    store: &mut WorkbookStore,
389    input: ProtectSheetInput,
390) -> Result<String, anyhow::Error> {
391    let entry = match store.get_mut(&input.workbook_id) {
392        Some(e) => e,
393        None => return Ok(workbook_not_found(store, &input.workbook_id)),
394    };
395    let idx = match find_sheet(&entry.data, &input.sheet_name) {
396        Some(i) => i,
397        None => return Ok(sheet_err(&input.sheet_name)),
398    };
399    let ws = entry
400        .data
401        .worksheet(idx)
402        .map_err(|e| anyhow::anyhow!("{e}"))?;
403    if let Some(ref pw) = input.password {
404        ws.protect_with_password(pw);
405    } else {
406        ws.protect();
407    }
408    Ok(success_no_data(&format!(
409        "Sheet '{}' protected",
410        input.sheet_name
411    )))
412}
413
414pub fn protect_workbook(
415    store: &mut WorkbookStore,
416    input: ProtectWorkbookInput,
417) -> Result<String, anyhow::Error> {
418    let entry = match store.get_mut(&input.workbook_id) {
419        Some(e) => e,
420        None => return Ok(workbook_not_found(store, &input.workbook_id)),
421    };
422    if let Some(ref pw) = input.password {
423        entry.data.protect_with_password(pw);
424    } else {
425        entry.data.protect();
426    }
427    Ok(success_no_data("Workbook protected"))
428}
429
430pub fn autofit_columns(
431    store: &mut WorkbookStore,
432    input: AutofitColumnsInput,
433) -> Result<String, anyhow::Error> {
434    let entry = match store.get_mut(&input.workbook_id) {
435        Some(e) => e,
436        None => return Ok(workbook_not_found(store, &input.workbook_id)),
437    };
438    let idx = match find_sheet(&entry.data, &input.sheet_name) {
439        Some(i) => i,
440        None => return Ok(sheet_err(&input.sheet_name)),
441    };
442    entry
443        .data
444        .worksheet(idx)
445        .map_err(|e| anyhow::anyhow!("{e}"))?
446        .autofit()
447        .map_err(|e| anyhow::anyhow!("{e}"))?;
448    Ok(success_no_data("Columns autofitted"))
449}
450
451// ── Batch 3 ──
452
453pub fn add_chart_enhanced(
454    store: &mut WorkbookStore,
455    input: AddChartEnhancedInput,
456) -> Result<String, anyhow::Error> {
457    let entry = match store.get_mut(&input.workbook_id) {
458        Some(e) => e,
459        None => return Ok(workbook_not_found(store, &input.workbook_id)),
460    };
461    let idx = match find_sheet(&entry.data, &input.sheet_name) {
462        Some(i) => i,
463        None => return Ok(sheet_err(&input.sheet_name)),
464    };
465    let ct = match input.chart_type {
466        ChartType::Bar => zavora_xlsx::ChartType::Bar,
467        ChartType::Column => zavora_xlsx::ChartType::Column,
468        ChartType::Line => zavora_xlsx::ChartType::Line,
469        ChartType::Pie => zavora_xlsx::ChartType::Pie,
470        ChartType::Scatter => zavora_xlsx::ChartType::Scatter,
471        ChartType::Area => zavora_xlsx::ChartType::Area,
472        ChartType::Doughnut => zavora_xlsx::ChartType::Doughnut,
473    };
474    let mut chart = zavora_xlsx::Chart::new(ct);
475    if let Some(ref t) = input.title {
476        chart.set_title(t);
477    }
478    if let Some(ref x) = input.x_axis_label {
479        chart.set_x_axis_name(x);
480    }
481    if let Some(ref y) = input.y_axis_label {
482        chart.set_y_axis_name(y);
483    }
484    if let Some(ref lp) = input.legend_position {
485        chart.set_legend_position(match lp {
486            LegendPosition::Top => zavora_xlsx::LegendPosition::Top,
487            LegendPosition::Bottom => zavora_xlsx::LegendPosition::Bottom,
488            LegendPosition::Left => zavora_xlsx::LegendPosition::Left,
489            LegendPosition::Right => zavora_xlsx::LegendPosition::Right,
490            LegendPosition::None => zavora_xlsx::LegendPosition::None,
491        });
492    }
493    chart.set_width(input.width);
494    chart.set_height(input.height);
495    if let Some(ref ps) = input.pivot_source {
496        chart.set_pivot_source(&ps.pivot_table, &ps.sheet);
497    }
498    // Chart enhancements
499    if input.show_data_table == Some(true) {
500        chart.show_data_table(true);
501    }
502    if let Some(ref v) = input.view_3d {
503        let mut view = zavora_xlsx::View3D::default();
504        if let Some(rx) = v.rot_x {
505            view.rot_x = rx;
506        }
507        if let Some(ry) = v.rot_y {
508            view.rot_y = ry;
509        }
510        if let Some(p) = v.perspective {
511            view.perspective = p;
512        }
513        chart.set_view3d(view);
514    }
515    if let Some(s) = input.style {
516        chart.set_style(s);
517    }
518    if let Some(ref at) = input.alt_text {
519        chart.set_alt_text(&at.title, &at.description);
520    }
521    if let Some(v) = input.y_axis_min {
522        chart.set_y_axis_min(v);
523    }
524    if let Some(v) = input.y_axis_max {
525        chart.set_y_axis_max(v);
526    }
527    if let Some(v) = input.y_axis_log_base {
528        chart.set_y_axis_log_base(v);
529    }
530    if input.x_axis_reverse == Some(true) {
531        chart.set_x_axis_reverse();
532    }
533    if input.y_axis_reverse == Some(true) {
534        chart.set_y_axis_reverse();
535    }
536    if let Some(ref fmt) = input.x_axis_format {
537        let af = zavora_xlsx::AxisFormat {
538            num_format: Some(fmt.clone()),
539            ..Default::default()
540        };
541        chart.set_x_axis_format(af);
542    }
543    if let Some(ref fmt) = input.y_axis_format {
544        let af = zavora_xlsx::AxisFormat {
545            num_format: Some(fmt.clone()),
546            ..Default::default()
547        };
548        chart.set_y_axis_format(af);
549    }
550    if input.drop_lines == Some(true) {
551        chart.set_drop_lines(true);
552    }
553    if input.high_low_lines == Some(true) {
554        chart.set_high_low_lines(true);
555    }
556    if let Some(ref fill) = input.plot_area_fill {
557        let rgb = parse_hex_color(fill);
558        let paf = zavora_xlsx::PlotAreaFormat {
559            fill: Some(rgb),
560            border: None,
561            gradient: None,
562        };
563        chart.set_plot_area_format(paf);
564    }
565    // Add series
566    if !input.series.is_empty() {
567        for si in &input.series {
568            let s = chart.add_series();
569            s.set_values(&si.values);
570            if let Some(ref c) = si.categories {
571                s.set_categories(c);
572            }
573            if let Some(ref n) = si.name {
574                s.set_name(n);
575            }
576            if let Some(ref c) = si.color {
577                s.set_color(c.as_str());
578            }
579            if si.data_labels == Some(true) {
580                s.set_data_labels(true);
581            }
582            if si.secondary_axis == Some(true) {
583                s.set_secondary_axis(true);
584            }
585            if let Some(ref t) = si.trendline {
586                let tt = match t.as_str() {
587                    "exponential" => zavora_xlsx::TrendlineType::Exponential,
588                    "power" => zavora_xlsx::TrendlineType::Power,
589                    "logarithmic" => zavora_xlsx::TrendlineType::Logarithmic,
590                    _ => zavora_xlsx::TrendlineType::Linear,
591                };
592                s.set_trendline(tt);
593            }
594            if let Some(ref m) = si.marker {
595                let mt = match m.as_str() {
596                    "circle" => zavora_xlsx::MarkerType::Circle,
597                    "diamond" => zavora_xlsx::MarkerType::Diamond,
598                    "square" => zavora_xlsx::MarkerType::Square,
599                    "triangle" => zavora_xlsx::MarkerType::Triangle,
600                    _ => zavora_xlsx::MarkerType::None,
601                };
602                s.set_marker(mt);
603            }
604            if let Some(lw) = si.line_width {
605                s.set_line_width(lw);
606            }
607            if let Some(ref ds) = si.dash_style {
608                let style = match ds.as_str() {
609                    "dash" => zavora_xlsx::DashStyle::Dash,
610                    "dot" => zavora_xlsx::DashStyle::Dot,
611                    "dash_dot" => zavora_xlsx::DashStyle::DashDot,
612                    "long_dash" => zavora_xlsx::DashStyle::LongDash,
613                    "long_dash_dot" => zavora_xlsx::DashStyle::LongDashDot,
614                    _ => zavora_xlsx::DashStyle::Solid,
615                };
616                s.set_dash_style(style);
617            }
618            if let Some(ref g) = si.gradient {
619                let stops: Vec<([u8; 3], f64)> = g
620                    .iter()
621                    .map(|gs| (parse_hex_color(&gs.color), gs.position))
622                    .collect();
623                s.set_gradient(stops);
624            }
625            if let Some(ref bs) = si.bubble_sizes {
626                s.set_bubble_sizes(bs);
627            }
628            if let Some(ref eb) = si.error_bars {
629                let bt = match eb.bar_type.as_str() {
630                    "plus" => zavora_xlsx::ErrorBarType::Plus,
631                    "minus" => zavora_xlsx::ErrorBarType::Minus,
632                    _ => zavora_xlsx::ErrorBarType::Both,
633                };
634                let vt = match eb.value_type.as_str() {
635                    "percentage" => zavora_xlsx::ErrorBarValueType::Percentage,
636                    "std_dev" => zavora_xlsx::ErrorBarValueType::StandardDeviation,
637                    "std_error" => zavora_xlsx::ErrorBarValueType::StandardError,
638                    _ => zavora_xlsx::ErrorBarValueType::FixedValue,
639                };
640                s.set_error_bars(zavora_xlsx::ErrorBar::new(bt, vt, eb.value));
641            }
642        }
643    } else if let Some(ref dr) = input.data_range {
644        chart.add_series().set_values(dr);
645    }
646    let (row, col) = if let Some(ref c) = input.cell {
647        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
648    } else {
649        (0, 0)
650    };
651    entry
652        .data
653        .worksheet(idx)
654        .map_err(|e| anyhow::anyhow!("{e}"))?
655        .insert_chart(row, col, &chart)?;
656    Ok(success_no_data(&format!(
657        "Chart added to '{}'",
658        input.sheet_name
659    )))
660}
661
662pub fn add_pivot_table(
663    store: &mut WorkbookStore,
664    input: AddPivotTableInput,
665) -> Result<String, anyhow::Error> {
666    let entry = match store.get_mut(&input.workbook_id) {
667        Some(e) => e,
668        None => return Ok(workbook_not_found(store, &input.workbook_id)),
669    };
670    let idx = match find_sheet(&entry.data, &input.sheet_name) {
671        Some(i) => i,
672        None => return Ok(sheet_err(&input.sheet_name)),
673    };
674    let mut pt = zavora_xlsx::PivotTable::new(&input.name, &input.source_range);
675    for f in &input.row_fields {
676        pt = pt.add_row_field(f);
677    }
678    for f in &input.column_fields {
679        pt = pt.add_column_field(f);
680    }
681    for vf in &input.value_fields {
682        let agg = match vf.aggregation.as_str() {
683            "count" => zavora_xlsx::PivotAggregation::Count,
684            "average" => zavora_xlsx::PivotAggregation::Average,
685            "max" => zavora_xlsx::PivotAggregation::Max,
686            "min" => zavora_xlsx::PivotAggregation::Min,
687            "product" => zavora_xlsx::PivotAggregation::Product,
688            _ => zavora_xlsx::PivotAggregation::Sum,
689        };
690        pt = pt.add_value_field(&vf.field, agg);
691    }
692    for f in &input.filter_fields {
693        pt = pt.add_filter_field(f);
694    }
695    if let Some(ref s) = input.style {
696        pt = pt.set_style_name(s);
697    }
698    if let Some(ref l) = input.layout {
699        let layout = match l.as_str() {
700            "outline" => zavora_xlsx::PivotLayout::Outline,
701            "tabular" => zavora_xlsx::PivotLayout::Tabular,
702            _ => zavora_xlsx::PivotLayout::Compact,
703        };
704        pt = pt.set_layout(layout);
705    }
706    // Pivot enhancements
707    for cf in &input.calculated_fields {
708        pt = pt.add_calculated_field(&cf.name, &cf.formula);
709    }
710    for dg in &input.date_groups {
711        let levels: Vec<zavora_xlsx::DateGroupLevel> = dg
712            .levels
713            .iter()
714            .map(|l| match l.as_str() {
715                "years" => zavora_xlsx::DateGroupLevel::Years,
716                "quarters" => zavora_xlsx::DateGroupLevel::Quarters,
717                "months" => zavora_xlsx::DateGroupLevel::Months,
718                "days" => zavora_xlsx::DateGroupLevel::Days,
719                "hours" => zavora_xlsx::DateGroupLevel::Hours,
720                "minutes" => zavora_xlsx::DateGroupLevel::Minutes,
721                "seconds" => zavora_xlsx::DateGroupLevel::Seconds,
722                _ => zavora_xlsx::DateGroupLevel::Months,
723            })
724            .collect();
725        pt = pt.group_by_date(&dg.field, &levels);
726    }
727    for rg in &input.range_groups {
728        pt = pt.group_by_range(&rg.field, rg.start, rg.end, rg.interval);
729    }
730    for vf in &input.value_formats {
731        pt = pt.set_value_format(&vf.field, &vf.format);
732    }
733    for st in &input.subtotals {
734        pt = pt.show_subtotals(&st.field, st.show);
735    }
736    if let (Some(rows), Some(cols)) = (input.grand_total_rows, input.grand_total_cols) {
737        pt = pt.show_grand_totals(rows, cols);
738    } else if let Some(rows) = input.grand_total_rows {
739        pt = pt.show_grand_totals(rows, true);
740    } else if let Some(cols) = input.grand_total_cols {
741        pt = pt.show_grand_totals(true, cols);
742    }
743    if let Some(v) = input.show_row_headers {
744        pt = pt.show_row_headers(v);
745    }
746    if let Some(v) = input.show_column_headers {
747        pt = pt.show_column_headers(v);
748    }
749    if let Some(v) = input.show_row_stripes {
750        pt = pt.show_row_stripes(v);
751    }
752    let (row, col) = if let Some(ref c) = input.cell {
753        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
754    } else {
755        (0, 0)
756    };
757    entry
758        .data
759        .worksheet(idx)
760        .map_err(|e| anyhow::anyhow!("{e}"))?
761        .add_pivot_table(row, col, &pt)?;
762    Ok(success_no_data(&format!(
763        "Pivot table '{}' added",
764        input.name
765    )))
766}
767
768// ── Batch 4 ──
769
770pub fn read_comments(
771    store: &mut WorkbookStore,
772    input: ReadCommentsInput,
773) -> Result<String, anyhow::Error> {
774    let entry = match store.get_mut(&input.workbook_id) {
775        Some(e) => e,
776        None => return Ok(workbook_not_found(store, &input.workbook_id)),
777    };
778    let idx = match find_sheet(&entry.data, &input.sheet_name) {
779        Some(i) => i,
780        None => return Ok(sheet_err(&input.sheet_name)),
781    };
782    let ws = entry
783        .data
784        .worksheet(idx)
785        .map_err(|e| anyhow::anyhow!("{e}"))?;
786    let comments: Vec<serde_json::Value> = ws.comments().iter().map(|c| {
787        serde_json::json!({ "cell": zavora_xlsx::utility::to_a1(c.row, c.col), "author": c.author, "text": c.text })
788    }).collect();
789    Ok(success("Comments read", comments))
790}
791
792pub fn write_rich_text(
793    store: &mut WorkbookStore,
794    input: WriteRichTextInput,
795) -> Result<String, anyhow::Error> {
796    let entry = match store.get_mut(&input.workbook_id) {
797        Some(e) => e,
798        None => return Ok(workbook_not_found(store, &input.workbook_id)),
799    };
800    let idx = match find_sheet(&entry.data, &input.sheet_name) {
801        Some(i) => i,
802        None => return Ok(sheet_err(&input.sheet_name)),
803    };
804    let (row, col) =
805        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
806    let mut rt = zavora_xlsx::RichText::new();
807    for run in &input.runs {
808        let mut built = zavora_xlsx::RichTextRun {
809            text: run.text.clone(),
810            bold: run.bold.unwrap_or(false),
811            italic: run.italic.unwrap_or(false),
812            font_size: run.font_size,
813            font_name: None,
814            color: None,
815            superscript: false,
816            subscript: false,
817        };
818        if let Some(ref c) = run.color {
819            built.color = Some(c.clone());
820        }
821        rt.runs.push(built);
822    }
823    entry
824        .data
825        .worksheet(idx)
826        .map_err(|e| anyhow::anyhow!("{e}"))?
827        .write_rich_text(row, col, &rt)
828        .map_err(|e| anyhow::anyhow!("{e}"))?;
829    Ok(success_no_data(&format!(
830        "Rich text written to {}",
831        input.cell
832    )))
833}
834
835// ══════════════════════════════════════════════════════════════════
836// Batch 5–8: Remaining 22 tools
837// ══════════════════════════════════════════════════════════════════
838
839fn build_format(
840    bold: Option<bool>,
841    italic: Option<bool>,
842    font_size: Option<f64>,
843    font_color: Option<&str>,
844    bg: Option<&str>,
845    nf: Option<&str>,
846) -> zavora_xlsx::Format {
847    let mut f = zavora_xlsx::Format::new();
848    if bold == Some(true) {
849        f = f.bold();
850    }
851    if italic == Some(true) {
852        f = f.italic();
853    }
854    if let Some(s) = font_size {
855        f = f.font_size(s);
856    }
857    if let Some(c) = font_color {
858        f = f.font_color(c);
859    }
860    if let Some(c) = bg {
861        f = f.background_color(c);
862    }
863    if let Some(n) = nf {
864        f = f.num_format(n);
865    }
866    f
867}
868
869pub fn set_column_format(
870    store: &mut WorkbookStore,
871    input: SetColumnFormatInput,
872) -> Result<String, anyhow::Error> {
873    let entry = match store.get_mut(&input.workbook_id) {
874        Some(e) => e,
875        None => return Ok(workbook_not_found(store, &input.workbook_id)),
876    };
877    let idx = match find_sheet(&entry.data, &input.sheet_name) {
878        Some(i) => i,
879        None => return Ok(sheet_err(&input.sheet_name)),
880    };
881    let col =
882        zavora_xlsx::utility::col_from_letter(&input.column).map_err(|e| anyhow::anyhow!("{e}"))?;
883    let fmt = build_format(
884        input.bold,
885        input.italic,
886        input.font_size,
887        input.font_color.as_deref(),
888        input.background_color.as_deref(),
889        input.number_format.as_deref(),
890    );
891    entry
892        .data
893        .worksheet(idx)
894        .map_err(|e| anyhow::anyhow!("{e}"))?
895        .set_column_format(col, &fmt);
896    Ok(success_no_data(&format!(
897        "Column {} format set",
898        input.column
899    )))
900}
901
902pub fn set_row_format(
903    store: &mut WorkbookStore,
904    input: SetRowFormatInput,
905) -> Result<String, anyhow::Error> {
906    let entry = match store.get_mut(&input.workbook_id) {
907        Some(e) => e,
908        None => return Ok(workbook_not_found(store, &input.workbook_id)),
909    };
910    let idx = match find_sheet(&entry.data, &input.sheet_name) {
911        Some(i) => i,
912        None => return Ok(sheet_err(&input.sheet_name)),
913    };
914    let fmt = build_format(
915        input.bold,
916        input.italic,
917        input.font_size,
918        input.font_color.as_deref(),
919        input.background_color.as_deref(),
920        input.number_format.as_deref(),
921    );
922    entry
923        .data
924        .worksheet(idx)
925        .map_err(|e| anyhow::anyhow!("{e}"))?
926        .set_row_format(input.row.saturating_sub(1), &fmt);
927    Ok(success_no_data(&format!("Row {} format set", input.row)))
928}
929
930pub fn set_column_hidden(
931    store: &mut WorkbookStore,
932    input: SetColumnHiddenInput,
933) -> Result<String, anyhow::Error> {
934    let entry = match store.get_mut(&input.workbook_id) {
935        Some(e) => e,
936        None => return Ok(workbook_not_found(store, &input.workbook_id)),
937    };
938    let idx = match find_sheet(&entry.data, &input.sheet_name) {
939        Some(i) => i,
940        None => return Ok(sheet_err(&input.sheet_name)),
941    };
942    let col =
943        zavora_xlsx::utility::col_from_letter(&input.column).map_err(|e| anyhow::anyhow!("{e}"))?;
944    entry
945        .data
946        .worksheet(idx)
947        .map_err(|e| anyhow::anyhow!("{e}"))?
948        .set_column_hidden(col, input.hidden);
949    Ok(success_no_data(&format!(
950        "Column {} hidden={}",
951        input.column, input.hidden
952    )))
953}
954
955pub fn set_row_hidden(
956    store: &mut WorkbookStore,
957    input: SetRowHiddenInput,
958) -> Result<String, anyhow::Error> {
959    let entry = match store.get_mut(&input.workbook_id) {
960        Some(e) => e,
961        None => return Ok(workbook_not_found(store, &input.workbook_id)),
962    };
963    let idx = match find_sheet(&entry.data, &input.sheet_name) {
964        Some(i) => i,
965        None => return Ok(sheet_err(&input.sheet_name)),
966    };
967    entry
968        .data
969        .worksheet(idx)
970        .map_err(|e| anyhow::anyhow!("{e}"))?
971        .set_row_hidden(input.row.saturating_sub(1), input.hidden);
972    Ok(success_no_data(&format!(
973        "Row {} hidden={}",
974        input.row, input.hidden
975    )))
976}
977
978pub fn set_column_range_width(
979    store: &mut WorkbookStore,
980    input: SetColumnRangeWidthInput,
981) -> Result<String, anyhow::Error> {
982    let entry = match store.get_mut(&input.workbook_id) {
983        Some(e) => e,
984        None => return Ok(workbook_not_found(store, &input.workbook_id)),
985    };
986    let idx = match find_sheet(&entry.data, &input.sheet_name) {
987        Some(i) => i,
988        None => return Ok(sheet_err(&input.sheet_name)),
989    };
990    let c1 = zavora_xlsx::utility::col_from_letter(&input.first_column)
991        .map_err(|e| anyhow::anyhow!("{e}"))?;
992    let c2 = zavora_xlsx::utility::col_from_letter(&input.last_column)
993        .map_err(|e| anyhow::anyhow!("{e}"))?;
994    entry
995        .data
996        .worksheet(idx)
997        .map_err(|e| anyhow::anyhow!("{e}"))?
998        .set_column_range_width(c1, c2, input.width);
999    Ok(success_no_data(&format!(
1000        "Columns {}:{} width set to {}",
1001        input.first_column, input.last_column, input.width
1002    )))
1003}
1004
1005pub fn set_default_row_height(
1006    store: &mut WorkbookStore,
1007    input: SetDefaultRowHeightInput,
1008) -> Result<String, anyhow::Error> {
1009    let entry = match store.get_mut(&input.workbook_id) {
1010        Some(e) => e,
1011        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1012    };
1013    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1014        Some(i) => i,
1015        None => return Ok(sheet_err(&input.sheet_name)),
1016    };
1017    entry
1018        .data
1019        .worksheet(idx)
1020        .map_err(|e| anyhow::anyhow!("{e}"))?
1021        .set_default_row_height(input.height);
1022    Ok(success_no_data(&format!(
1023        "Default row height set to {}",
1024        input.height
1025    )))
1026}
1027
1028pub fn set_selection(
1029    store: &mut WorkbookStore,
1030    input: SetSelectionInput,
1031) -> Result<String, anyhow::Error> {
1032    let entry = match store.get_mut(&input.workbook_id) {
1033        Some(e) => e,
1034        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1035    };
1036    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1037        Some(i) => i,
1038        None => return Ok(sheet_err(&input.sheet_name)),
1039    };
1040    let (row, col) =
1041        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
1042    entry
1043        .data
1044        .worksheet(idx)
1045        .map_err(|e| anyhow::anyhow!("{e}"))?
1046        .set_selection(row, col);
1047    Ok(success_no_data(&format!("Selection set to {}", input.cell)))
1048}
1049
1050pub fn set_autofilter(
1051    store: &mut WorkbookStore,
1052    input: SetAutofilterInput,
1053) -> Result<String, anyhow::Error> {
1054    let entry = match store.get_mut(&input.workbook_id) {
1055        Some(e) => e,
1056        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1057    };
1058    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1059        Some(i) => i,
1060        None => return Ok(sheet_err(&input.sheet_name)),
1061    };
1062    let (r1, c1, r2, c2) =
1063        zavora_xlsx::utility::parse_range_ref(&input.range).map_err(|e| anyhow::anyhow!("{e}"))?;
1064    entry
1065        .data
1066        .worksheet(idx)
1067        .map_err(|e| anyhow::anyhow!("{e}"))?
1068        .set_autofilter(r1, c1, r2, c2);
1069    Ok(success_no_data(&format!(
1070        "Autofilter set on {}",
1071        input.range
1072    )))
1073}
1074
1075pub fn filter_column(
1076    store: &mut WorkbookStore,
1077    input: FilterColumnInput,
1078) -> Result<String, anyhow::Error> {
1079    let entry = match store.get_mut(&input.workbook_id) {
1080        Some(e) => e,
1081        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1082    };
1083    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1084        Some(i) => i,
1085        None => return Ok(sheet_err(&input.sheet_name)),
1086    };
1087    let col =
1088        zavora_xlsx::utility::col_from_letter(&input.column).map_err(|e| anyhow::anyhow!("{e}"))?;
1089    let strs: Vec<&str> = input.values.iter().map(|s| s.as_str()).collect();
1090    entry
1091        .data
1092        .worksheet(idx)
1093        .map_err(|e| anyhow::anyhow!("{e}"))?
1094        .filter_column(col, &strs);
1095    Ok(success_no_data(&format!(
1096        "Filter applied to column {}",
1097        input.column
1098    )))
1099}
1100
1101pub fn ignore_error(
1102    store: &mut WorkbookStore,
1103    input: IgnoreErrorInput,
1104) -> Result<String, anyhow::Error> {
1105    let entry = match store.get_mut(&input.workbook_id) {
1106        Some(e) => e,
1107        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1108    };
1109    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1110        Some(i) => i,
1111        None => return Ok(sheet_err(&input.sheet_name)),
1112    };
1113    entry
1114        .data
1115        .worksheet(idx)
1116        .map_err(|e| anyhow::anyhow!("{e}"))?
1117        .ignore_error(&input.error_type, &input.range);
1118    Ok(success_no_data("Error ignored"))
1119}
1120
1121pub fn set_page_breaks(
1122    store: &mut WorkbookStore,
1123    input: SetPageBreaksInput,
1124) -> Result<String, anyhow::Error> {
1125    let entry = match store.get_mut(&input.workbook_id) {
1126        Some(e) => e,
1127        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1128    };
1129    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1130        Some(i) => i,
1131        None => return Ok(sheet_err(&input.sheet_name)),
1132    };
1133    entry
1134        .data
1135        .worksheet(idx)
1136        .map_err(|e| anyhow::anyhow!("{e}"))?
1137        .set_page_breaks(&input.row_breaks, &input.col_breaks);
1138    Ok(success_no_data("Page breaks set"))
1139}
1140
1141pub fn unprotect_range(
1142    store: &mut WorkbookStore,
1143    input: UnprotectRangeInput,
1144) -> Result<String, anyhow::Error> {
1145    let entry = match store.get_mut(&input.workbook_id) {
1146        Some(e) => e,
1147        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1148    };
1149    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1150        Some(i) => i,
1151        None => return Ok(sheet_err(&input.sheet_name)),
1152    };
1153    let ws = entry
1154        .data
1155        .worksheet(idx)
1156        .map_err(|e| anyhow::anyhow!("{e}"))?;
1157    if let Some(ref pw) = input.password {
1158        ws.unprotect_range_with_password(&input.range, &input.title, pw);
1159    } else {
1160        ws.unprotect_range(&input.range, &input.title);
1161    }
1162    Ok(success_no_data(&format!(
1163        "Range {} unprotected",
1164        input.range
1165    )))
1166}
1167
1168pub fn write_formula(
1169    store: &mut WorkbookStore,
1170    input: WriteFormulaInput,
1171) -> Result<String, anyhow::Error> {
1172    let entry = match store.get_mut(&input.workbook_id) {
1173        Some(e) => e,
1174        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1175    };
1176    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1177        Some(i) => i,
1178        None => return Ok(sheet_err(&input.sheet_name)),
1179    };
1180    let (row, col) =
1181        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
1182    let ws = entry
1183        .data
1184        .worksheet(idx)
1185        .map_err(|e| anyhow::anyhow!("{e}"))?;
1186    if let Some(result) = input.cached_result {
1187        ws.write_formula_with_result(row, col, &input.formula, result)
1188            .map_err(|e| anyhow::anyhow!("{e}"))?;
1189    } else {
1190        ws.write_formula(row, col, &input.formula)
1191            .map_err(|e| anyhow::anyhow!("{e}"))?;
1192    }
1193    Ok(success_no_data(&format!(
1194        "Formula written to {}",
1195        input.cell
1196    )))
1197}
1198
1199pub fn write_array_formula(
1200    store: &mut WorkbookStore,
1201    input: WriteArrayFormulaInput,
1202) -> Result<String, anyhow::Error> {
1203    let entry = match store.get_mut(&input.workbook_id) {
1204        Some(e) => e,
1205        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1206    };
1207    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1208        Some(i) => i,
1209        None => return Ok(sheet_err(&input.sheet_name)),
1210    };
1211    let (r1, c1, r2, c2) =
1212        zavora_xlsx::utility::parse_range_ref(&input.range).map_err(|e| anyhow::anyhow!("{e}"))?;
1213    entry
1214        .data
1215        .worksheet(idx)
1216        .map_err(|e| anyhow::anyhow!("{e}"))?
1217        .write_array_formula(r1, c1, r2, c2, &input.formula)
1218        .map_err(|e| anyhow::anyhow!("{e}"))?;
1219    Ok(success_no_data(&format!(
1220        "Array formula written to {}",
1221        input.range
1222    )))
1223}
1224
1225pub fn write_dynamic_formula(
1226    store: &mut WorkbookStore,
1227    input: WriteDynamicFormulaInput,
1228) -> Result<String, anyhow::Error> {
1229    let entry = match store.get_mut(&input.workbook_id) {
1230        Some(e) => e,
1231        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1232    };
1233    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1234        Some(i) => i,
1235        None => return Ok(sheet_err(&input.sheet_name)),
1236    };
1237    let (row, col) =
1238        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
1239    entry
1240        .data
1241        .worksheet(idx)
1242        .map_err(|e| anyhow::anyhow!("{e}"))?
1243        .write_dynamic_formula(row, col, &input.formula)
1244        .map_err(|e| anyhow::anyhow!("{e}"))?;
1245    Ok(success_no_data(&format!(
1246        "Dynamic formula written to {}",
1247        input.cell
1248    )))
1249}
1250
1251pub fn write_blank(
1252    store: &mut WorkbookStore,
1253    input: WriteBlankInput,
1254) -> Result<String, anyhow::Error> {
1255    let entry = match store.get_mut(&input.workbook_id) {
1256        Some(e) => e,
1257        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1258    };
1259    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1260        Some(i) => i,
1261        None => return Ok(sheet_err(&input.sheet_name)),
1262    };
1263    let (row, col) =
1264        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
1265    let fmt = build_format(
1266        input.bold,
1267        None,
1268        None,
1269        None,
1270        input.background_color.as_deref(),
1271        input.number_format.as_deref(),
1272    );
1273    entry
1274        .data
1275        .worksheet(idx)
1276        .map_err(|e| anyhow::anyhow!("{e}"))?
1277        .write_blank(row, col, &fmt)
1278        .map_err(|e| anyhow::anyhow!("{e}"))?;
1279    Ok(success_no_data(&format!(
1280        "Blank cell written at {}",
1281        input.cell
1282    )))
1283}
1284
1285pub fn clear_cell(
1286    store: &mut WorkbookStore,
1287    input: ClearCellInput,
1288) -> Result<String, anyhow::Error> {
1289    let entry = match store.get_mut(&input.workbook_id) {
1290        Some(e) => e,
1291        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1292    };
1293    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1294        Some(i) => i,
1295        None => return Ok(sheet_err(&input.sheet_name)),
1296    };
1297    let (row, col) =
1298        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
1299    entry
1300        .data
1301        .worksheet(idx)
1302        .map_err(|e| anyhow::anyhow!("{e}"))?
1303        .clear_cell(row, col);
1304    Ok(success_no_data(&format!("Cell {} cleared", input.cell)))
1305}
1306
1307pub fn set_calc_mode(
1308    store: &mut WorkbookStore,
1309    input: SetCalcModeInput,
1310) -> Result<String, anyhow::Error> {
1311    let entry = match store.get_mut(&input.workbook_id) {
1312        Some(e) => e,
1313        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1314    };
1315    let mode = match input.mode.as_str() {
1316        "manual" => zavora_xlsx::CalcMode::Manual,
1317        "auto_no_table" => zavora_xlsx::CalcMode::AutoNoTable,
1318        _ => zavora_xlsx::CalcMode::Auto,
1319    };
1320    entry.data.set_calc_mode(mode);
1321    Ok(success_no_data(&format!("Calc mode set to {}", input.mode)))
1322}
1323
1324pub fn set_properties(
1325    store: &mut WorkbookStore,
1326    input: SetPropertiesInput,
1327) -> Result<String, anyhow::Error> {
1328    let entry = match store.get_mut(&input.workbook_id) {
1329        Some(e) => e,
1330        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1331    };
1332    let mut props = entry.data.properties().clone();
1333    if let Some(ref t) = input.title {
1334        props.title = Some(t.clone());
1335    }
1336    if let Some(ref a) = input.author {
1337        props.author = Some(a.clone());
1338    }
1339    if let Some(ref s) = input.subject {
1340        props.subject = Some(s.clone());
1341    }
1342    if let Some(ref c) = input.company {
1343        props.company = Some(c.clone());
1344    }
1345    if let Some(ref d) = input.description {
1346        props.description = Some(d.clone());
1347    }
1348    entry.data.set_properties(props);
1349    Ok(success_no_data("Document properties set"))
1350}
1351
1352pub fn move_worksheet(
1353    store: &mut WorkbookStore,
1354    input: MoveWorksheetInput,
1355) -> Result<String, anyhow::Error> {
1356    let entry = match store.get_mut(&input.workbook_id) {
1357        Some(e) => e,
1358        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1359    };
1360    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1361        Some(i) => i,
1362        None => return Ok(sheet_err(&input.sheet_name)),
1363    };
1364    entry
1365        .data
1366        .move_worksheet(idx, input.to_index)
1367        .map_err(|e| anyhow::anyhow!("{e}"))?;
1368    Ok(success_no_data(&format!(
1369        "Sheet '{}' moved to position {}",
1370        input.sheet_name, input.to_index
1371    )))
1372}
1373
1374pub fn write_internal_link(
1375    store: &mut WorkbookStore,
1376    input: WriteInternalLinkInput,
1377) -> Result<String, anyhow::Error> {
1378    let entry = match store.get_mut(&input.workbook_id) {
1379        Some(e) => e,
1380        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1381    };
1382    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1383        Some(i) => i,
1384        None => return Ok(sheet_err(&input.sheet_name)),
1385    };
1386    let (row, col) =
1387        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
1388    entry
1389        .data
1390        .worksheet(idx)
1391        .map_err(|e| anyhow::anyhow!("{e}"))?
1392        .write_internal_link(row, col, &input.location, &input.display_text)
1393        .map_err(|e| anyhow::anyhow!("{e}"))?;
1394    Ok(success_no_data(&format!(
1395        "Internal link written at {}",
1396        input.cell
1397    )))
1398}
1399
1400// ══════════════════════════════════════════════════════════════════
1401// Consolidated tools (replacing multiple separate tools)
1402// ══════════════════════════════════════════════════════════════════
1403
1404pub fn configure_workbook(
1405    store: &mut WorkbookStore,
1406    input: ConfigureWorkbookInput,
1407) -> Result<String, anyhow::Error> {
1408    let entry = match store.get_mut(&input.workbook_id) {
1409        Some(e) => e,
1410        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1411    };
1412    if let Some(ref m) = input.calc_mode {
1413        let mode = match m.as_str() {
1414            "manual" => zavora_xlsx::CalcMode::Manual,
1415            "auto_no_table" => zavora_xlsx::CalcMode::AutoNoTable,
1416            _ => zavora_xlsx::CalcMode::Auto,
1417        };
1418        entry.data.set_calc_mode(mode);
1419    }
1420    if let Some(i) = input.active_sheet {
1421        entry.data.set_active_sheet(i);
1422    }
1423    let mut props = entry.data.properties().clone();
1424    if let Some(ref v) = input.title {
1425        props.title = Some(v.clone());
1426    }
1427    if let Some(ref v) = input.author {
1428        props.author = Some(v.clone());
1429    }
1430    if let Some(ref v) = input.subject {
1431        props.subject = Some(v.clone());
1432    }
1433    if let Some(ref v) = input.company {
1434        props.company = Some(v.clone());
1435    }
1436    if let Some(ref v) = input.description {
1437        props.description = Some(v.clone());
1438    }
1439    entry.data.set_properties(props);
1440    Ok(success_no_data("Workbook configured"))
1441}
1442
1443pub fn modify_rows(
1444    store: &mut WorkbookStore,
1445    input: ModifyRowsInput,
1446) -> Result<String, anyhow::Error> {
1447    let entry = match store.get_mut(&input.workbook_id) {
1448        Some(e) => e,
1449        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1450    };
1451    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1452        Some(i) => i,
1453        None => return Ok(sheet_err(&input.sheet_name)),
1454    };
1455    let ws = entry
1456        .data
1457        .worksheet(idx)
1458        .map_err(|e| anyhow::anyhow!("{e}"))?;
1459    let row = input.at_row.saturating_sub(1);
1460    match input.action.as_str() {
1461        "delete" => {
1462            ws.remove_rows(row, input.count)
1463                .map_err(|e| anyhow::anyhow!("{e}"))?;
1464        }
1465        _ => {
1466            ws.insert_rows(row, input.count)
1467                .map_err(|e| anyhow::anyhow!("{e}"))?;
1468        }
1469    }
1470    Ok(success_no_data(&format!(
1471        "{} {} rows at row {}",
1472        input.action, input.count, input.at_row
1473    )))
1474}
1475
1476pub fn modify_columns(
1477    store: &mut WorkbookStore,
1478    input: ModifyColumnsInput,
1479) -> Result<String, anyhow::Error> {
1480    let entry = match store.get_mut(&input.workbook_id) {
1481        Some(e) => e,
1482        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1483    };
1484    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1485        Some(i) => i,
1486        None => return Ok(sheet_err(&input.sheet_name)),
1487    };
1488    let col = zavora_xlsx::utility::col_from_letter(&input.at_column)
1489        .map_err(|e| anyhow::anyhow!("{e}"))?;
1490    let ws = entry
1491        .data
1492        .worksheet(idx)
1493        .map_err(|e| anyhow::anyhow!("{e}"))?;
1494    match input.action.as_str() {
1495        "delete" => {
1496            ws.remove_columns(col, input.count)
1497                .map_err(|e| anyhow::anyhow!("{e}"))?;
1498        }
1499        _ => {
1500            ws.insert_columns(col, input.count)
1501                .map_err(|e| anyhow::anyhow!("{e}"))?;
1502        }
1503    }
1504    Ok(success_no_data(&format!(
1505        "{} {} columns at {}",
1506        input.action, input.count, input.at_column
1507    )))
1508}
1509
1510pub fn write_formula_consolidated(
1511    store: &mut WorkbookStore,
1512    input: WriteFormulaConsolidatedInput,
1513) -> Result<String, anyhow::Error> {
1514    let entry = match store.get_mut(&input.workbook_id) {
1515        Some(e) => e,
1516        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1517    };
1518    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1519        Some(i) => i,
1520        None => return Ok(sheet_err(&input.sheet_name)),
1521    };
1522    let ws = entry
1523        .data
1524        .worksheet(idx)
1525        .map_err(|e| anyhow::anyhow!("{e}"))?;
1526    match input.formula_type.as_deref().unwrap_or("regular") {
1527        "array" => {
1528            let (r1, c1, r2, c2) = zavora_xlsx::utility::parse_range_ref(&input.cell)
1529                .map_err(|e| anyhow::anyhow!("{e}"))?;
1530            ws.write_array_formula(r1, c1, r2, c2, &input.formula)
1531                .map_err(|e| anyhow::anyhow!("{e}"))?;
1532        }
1533        "dynamic" => {
1534            let (row, col) = zavora_xlsx::utility::parse_cell_ref(&input.cell)
1535                .map_err(|e| anyhow::anyhow!("{e}"))?;
1536            ws.write_dynamic_formula(row, col, &input.formula)
1537                .map_err(|e| anyhow::anyhow!("{e}"))?;
1538        }
1539        _ => {
1540            let (row, col) = zavora_xlsx::utility::parse_cell_ref(&input.cell)
1541                .map_err(|e| anyhow::anyhow!("{e}"))?;
1542            if let Some(r) = input.cached_result {
1543                ws.write_formula_with_result(row, col, &input.formula, r)
1544                    .map_err(|e| anyhow::anyhow!("{e}"))?;
1545            } else {
1546                ws.write_formula(row, col, &input.formula)
1547                    .map_err(|e| anyhow::anyhow!("{e}"))?;
1548            }
1549        }
1550    }
1551    Ok(success_no_data(&format!(
1552        "Formula written to {}",
1553        input.cell
1554    )))
1555}
1556
1557pub fn manage_cell(
1558    store: &mut WorkbookStore,
1559    input: ManageCellInput,
1560) -> Result<String, anyhow::Error> {
1561    let entry = match store.get_mut(&input.workbook_id) {
1562        Some(e) => e,
1563        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1564    };
1565    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1566        Some(i) => i,
1567        None => return Ok(sheet_err(&input.sheet_name)),
1568    };
1569    let (row, col) =
1570        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
1571    let ws = entry
1572        .data
1573        .worksheet(idx)
1574        .map_err(|e| anyhow::anyhow!("{e}"))?;
1575    match input.action.as_str() {
1576        "clear" => {
1577            ws.clear_cell(row, col);
1578        }
1579        _ => {
1580            let fmt = build_format(
1581                None,
1582                None,
1583                None,
1584                None,
1585                input.background_color.as_deref(),
1586                input.number_format.as_deref(),
1587            );
1588            ws.write_blank(row, col, &fmt)
1589                .map_err(|e| anyhow::anyhow!("{e}"))?;
1590        }
1591    }
1592    Ok(success_no_data(&format!(
1593        "Cell {} {}",
1594        input.cell, input.action
1595    )))
1596}
1597
1598pub fn manage_comments(
1599    store: &mut WorkbookStore,
1600    input: ManageCommentsInput,
1601) -> Result<String, anyhow::Error> {
1602    let entry = match store.get_mut(&input.workbook_id) {
1603        Some(e) => e,
1604        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1605    };
1606    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1607        Some(i) => i,
1608        None => return Ok(sheet_err(&input.sheet_name)),
1609    };
1610    match input.action.as_str() {
1611        "read" => {
1612            let ws = entry
1613                .data
1614                .worksheet(idx)
1615                .map_err(|e| anyhow::anyhow!("{e}"))?;
1616            let comments: Vec<serde_json::Value> = ws.comments().iter().map(|c|
1617                serde_json::json!({"cell": zavora_xlsx::utility::to_a1(c.row, c.col), "author": c.author, "text": c.text})
1618            ).collect();
1619            Ok(success("Comments read", comments))
1620        }
1621        _ => {
1622            let cell = input.cell.as_deref().unwrap_or("A1");
1623            let text = input.text.as_deref().unwrap_or("");
1624            let (row, col) =
1625                zavora_xlsx::utility::parse_cell_ref(cell).map_err(|e| anyhow::anyhow!("{e}"))?;
1626            let ws = entry
1627                .data
1628                .worksheet(idx)
1629                .map_err(|e| anyhow::anyhow!("{e}"))?;
1630            if let Some(ref a) = input.author {
1631                ws.add_comment_with_author(row, col, text, a);
1632            } else {
1633                ws.add_comment(row, col, text);
1634            }
1635            Ok(success_no_data(&format!("Comment added at {cell}")))
1636        }
1637    }
1638}
1639
1640pub fn manage_defined_names(
1641    store: &mut WorkbookStore,
1642    input: ManageDefinedNamesInput,
1643) -> Result<String, anyhow::Error> {
1644    let entry = match store.get_mut(&input.workbook_id) {
1645        Some(e) => e,
1646        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1647    };
1648    match input.action.as_str() {
1649        "list" => {
1650            let names: Vec<serde_json::Value> = entry
1651                .data
1652                .defined_names()
1653                .iter()
1654                .map(|(n, f)| serde_json::json!({"name": n, "formula": f}))
1655                .collect();
1656            Ok(success("Defined names", names))
1657        }
1658        _ => {
1659            let name = input.name.as_deref().unwrap_or("");
1660            let formula = input.formula.as_deref().unwrap_or("");
1661            entry.data.define_name(name, formula);
1662            Ok(success_no_data(&format!("Defined name '{name}' added")))
1663        }
1664    }
1665}
1666
1667pub fn add_link(store: &mut WorkbookStore, input: AddLinkInput) -> Result<String, anyhow::Error> {
1668    let entry = match store.get_mut(&input.workbook_id) {
1669        Some(e) => e,
1670        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1671    };
1672    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1673        Some(i) => i,
1674        None => return Ok(sheet_err(&input.sheet_name)),
1675    };
1676    let (row, col) =
1677        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
1678    let ws = entry
1679        .data
1680        .worksheet(idx)
1681        .map_err(|e| anyhow::anyhow!("{e}"))?;
1682    match input.link_type.as_str() {
1683        "internal" => {
1684            ws.write_internal_link(
1685                row,
1686                col,
1687                &input.target,
1688                input.display_text.as_deref().unwrap_or(&input.target),
1689            )
1690            .map_err(|e| anyhow::anyhow!("{e}"))?;
1691        }
1692        _ => {
1693            ws.write_url(
1694                row,
1695                col,
1696                &input.target,
1697                input.display_text.as_deref().unwrap_or(&input.target),
1698            )
1699            .map_err(|e| anyhow::anyhow!("{e}"))?;
1700        }
1701    }
1702    Ok(success_no_data(&format!("Link added at {}", input.cell)))
1703}
1704
1705pub fn protect_consolidated(
1706    store: &mut WorkbookStore,
1707    input: ProtectInput,
1708) -> Result<String, anyhow::Error> {
1709    let entry = match store.get_mut(&input.workbook_id) {
1710        Some(e) => e,
1711        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1712    };
1713    match input.target.as_str() {
1714        "workbook" => {
1715            if let Some(ref pw) = input.password {
1716                entry.data.protect_with_password(pw);
1717            } else {
1718                entry.data.protect();
1719            }
1720            Ok(success_no_data("Workbook protected"))
1721        }
1722        "unprotect_range" => {
1723            let sn = input.sheet_name.as_deref().unwrap_or("Sheet1");
1724            let idx = match find_sheet(&entry.data, sn) {
1725                Some(i) => i,
1726                None => return Ok(sheet_err(sn)),
1727            };
1728            let ws = entry
1729                .data
1730                .worksheet(idx)
1731                .map_err(|e| anyhow::anyhow!("{e}"))?;
1732            let range = input.range.as_deref().unwrap_or("A1:A1");
1733            let title = input.range_title.as_deref().unwrap_or("Range");
1734            if let Some(ref pw) = input.password {
1735                ws.unprotect_range_with_password(range, title, pw);
1736            } else {
1737                ws.unprotect_range(range, title);
1738            }
1739            Ok(success_no_data(&format!("Range {range} unprotected")))
1740        }
1741        _ => {
1742            // "sheet"
1743            let sn = input.sheet_name.as_deref().unwrap_or("Sheet1");
1744            let idx = match find_sheet(&entry.data, sn) {
1745                Some(i) => i,
1746                None => return Ok(sheet_err(sn)),
1747            };
1748            let ws = entry
1749                .data
1750                .worksheet(idx)
1751                .map_err(|e| anyhow::anyhow!("{e}"))?;
1752            if let Some(ref pw) = input.password {
1753                ws.protect_with_password(pw);
1754            } else {
1755                ws.protect();
1756            }
1757            Ok(success_no_data(&format!("Sheet '{sn}' protected")))
1758        }
1759    }
1760}
1761
1762pub fn set_dimensions(
1763    store: &mut WorkbookStore,
1764    input: SetDimensionsInput,
1765) -> Result<String, anyhow::Error> {
1766    let entry = match store.get_mut(&input.workbook_id) {
1767        Some(e) => e,
1768        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1769    };
1770    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1771        Some(i) => i,
1772        None => return Ok(sheet_err(&input.sheet_name)),
1773    };
1774    let ws = entry
1775        .data
1776        .worksheet(idx)
1777        .map_err(|e| anyhow::anyhow!("{e}"))?;
1778    match input.target.as_str() {
1779        "row_height" => {
1780            ws.set_row_height(input.row.unwrap_or(1).saturating_sub(1), input.value)
1781                .map_err(|e| anyhow::anyhow!("{e}"))?;
1782        }
1783        "column_range_width" => {
1784            let c1 =
1785                zavora_xlsx::utility::col_from_letter(input.first_column.as_deref().unwrap_or("A"))
1786                    .map_err(|e| anyhow::anyhow!("{e}"))?;
1787            let c2 =
1788                zavora_xlsx::utility::col_from_letter(input.last_column.as_deref().unwrap_or("A"))
1789                    .map_err(|e| anyhow::anyhow!("{e}"))?;
1790            ws.set_column_range_width(c1, c2, input.value);
1791        }
1792        "default_row_height" => {
1793            ws.set_default_row_height(input.value);
1794        }
1795        _ => {
1796            // "column_width"
1797            let col = zavora_xlsx::utility::col_from_letter(input.column.as_deref().unwrap_or("A"))
1798                .map_err(|e| anyhow::anyhow!("{e}"))?;
1799            ws.set_column_width(col, input.value)
1800                .map_err(|e| anyhow::anyhow!("{e}"))?;
1801        }
1802    }
1803    Ok(success_no_data(&format!(
1804        "{} set to {}",
1805        input.target, input.value
1806    )))
1807}
1808
1809pub fn set_visibility(
1810    store: &mut WorkbookStore,
1811    input: SetVisibilityInput,
1812) -> Result<String, anyhow::Error> {
1813    let entry = match store.get_mut(&input.workbook_id) {
1814        Some(e) => e,
1815        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1816    };
1817    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1818        Some(i) => i,
1819        None => return Ok(sheet_err(&input.sheet_name)),
1820    };
1821    let ws = entry
1822        .data
1823        .worksheet(idx)
1824        .map_err(|e| anyhow::anyhow!("{e}"))?;
1825    match input.target.as_str() {
1826        "row" => {
1827            let r: u32 = input.identifier.parse().unwrap_or(1);
1828            ws.set_row_hidden(r.saturating_sub(1), input.hidden);
1829        }
1830        _ => {
1831            let col = zavora_xlsx::utility::col_from_letter(&input.identifier)
1832                .map_err(|e| anyhow::anyhow!("{e}"))?;
1833            ws.set_column_hidden(col, input.hidden);
1834        }
1835    }
1836    Ok(success_no_data(&format!(
1837        "{} {} hidden={}",
1838        input.target, input.identifier, input.hidden
1839    )))
1840}
1841
1842pub fn set_row_column_format(
1843    store: &mut WorkbookStore,
1844    input: SetRowColumnFormatInput,
1845) -> Result<String, anyhow::Error> {
1846    let entry = match store.get_mut(&input.workbook_id) {
1847        Some(e) => e,
1848        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1849    };
1850    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1851        Some(i) => i,
1852        None => return Ok(sheet_err(&input.sheet_name)),
1853    };
1854    let fmt = build_format(
1855        input.bold,
1856        input.italic,
1857        input.font_size,
1858        input.font_color.as_deref(),
1859        input.background_color.as_deref(),
1860        input.number_format.as_deref(),
1861    );
1862    let ws = entry
1863        .data
1864        .worksheet(idx)
1865        .map_err(|e| anyhow::anyhow!("{e}"))?;
1866    match input.target.as_str() {
1867        "row" => {
1868            let r: u32 = input.identifier.parse().unwrap_or(1);
1869            ws.set_row_format(r.saturating_sub(1), &fmt);
1870        }
1871        _ => {
1872            let col = zavora_xlsx::utility::col_from_letter(&input.identifier)
1873                .map_err(|e| anyhow::anyhow!("{e}"))?;
1874            ws.set_column_format(col, &fmt);
1875        }
1876    }
1877    Ok(success_no_data(&format!(
1878        "{} {} format set",
1879        input.target, input.identifier
1880    )))
1881}
1882
1883pub fn group_consolidated(
1884    store: &mut WorkbookStore,
1885    input: GroupInput,
1886) -> Result<String, anyhow::Error> {
1887    let entry = match store.get_mut(&input.workbook_id) {
1888        Some(e) => e,
1889        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1890    };
1891    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1892        Some(i) => i,
1893        None => return Ok(sheet_err(&input.sheet_name)),
1894    };
1895    let ws = entry
1896        .data
1897        .worksheet(idx)
1898        .map_err(|e| anyhow::anyhow!("{e}"))?;
1899    match input.target.as_str() {
1900        "columns" => {
1901            let c1 = zavora_xlsx::utility::col_from_letter(&input.start)
1902                .map_err(|e| anyhow::anyhow!("{e}"))?;
1903            let c2 = zavora_xlsx::utility::col_from_letter(&input.end)
1904                .map_err(|e| anyhow::anyhow!("{e}"))?;
1905            ws.group_columns(c1, c2, input.level);
1906        }
1907        _ => {
1908            let s: u32 = input.start.parse().unwrap_or(1);
1909            let e: u32 = input.end.parse().unwrap_or(1);
1910            ws.group_rows(s.saturating_sub(1), e.saturating_sub(1), input.level);
1911        }
1912    }
1913    Ok(success_no_data(&format!(
1914        "{} {}-{} grouped at level {}",
1915        input.target, input.start, input.end, input.level
1916    )))
1917}
1918
1919pub fn manage_autofilter(
1920    store: &mut WorkbookStore,
1921    input: ManageAutofilterInput,
1922) -> Result<String, anyhow::Error> {
1923    let entry = match store.get_mut(&input.workbook_id) {
1924        Some(e) => e,
1925        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1926    };
1927    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1928        Some(i) => i,
1929        None => return Ok(sheet_err(&input.sheet_name)),
1930    };
1931    let (r1, c1, r2, c2) =
1932        zavora_xlsx::utility::parse_range_ref(&input.range).map_err(|e| anyhow::anyhow!("{e}"))?;
1933    let ws = entry
1934        .data
1935        .worksheet(idx)
1936        .map_err(|e| anyhow::anyhow!("{e}"))?;
1937    ws.set_autofilter(r1, c1, r2, c2);
1938    if let (Some(ref fc), Some(ref fv)) = (input.filter_column, input.filter_values) {
1939        let col = zavora_xlsx::utility::col_from_letter(fc).map_err(|e| anyhow::anyhow!("{e}"))?;
1940        let strs: Vec<&str> = fv.iter().map(|s| s.as_str()).collect();
1941        ws.filter_column(col, &strs);
1942    }
1943    Ok(success_no_data(&format!(
1944        "Autofilter set on {}",
1945        input.range
1946    )))
1947}
1948
1949// ══════════════════════════════════════════════════════════════════
1950// Batch 9: Feature-parity tools (waterfall, funnel, treemap, shape, doc properties)
1951// ══════════════════════════════════════════════════════════════════
1952
1953/// Parse a hex color string like "#FF0000" into [u8; 3].
1954fn parse_hex_color(hex: &str) -> [u8; 3] {
1955    let s = hex.trim_start_matches('#');
1956    if s.len() == 6 {
1957        let r = u8::from_str_radix(&s[0..2], 16).unwrap_or(0);
1958        let g = u8::from_str_radix(&s[2..4], 16).unwrap_or(0);
1959        let b = u8::from_str_radix(&s[4..6], 16).unwrap_or(0);
1960        [r, g, b]
1961    } else {
1962        [0, 0, 0]
1963    }
1964}
1965
1966pub fn add_waterfall_chart(
1967    store: &mut WorkbookStore,
1968    input: AddWaterfallChartInput,
1969) -> Result<String, anyhow::Error> {
1970    let entry = match store.get_mut(&input.workbook_id) {
1971        Some(e) => e,
1972        None => return Ok(workbook_not_found(store, &input.workbook_id)),
1973    };
1974    let idx = match find_sheet(&entry.data, &input.sheet_name) {
1975        Some(i) => i,
1976        None => return Ok(sheet_err(&input.sheet_name)),
1977    };
1978    let mut chart = zavora_xlsx::WaterfallChart::new();
1979    if let Some(ref t) = input.title {
1980        chart.set_title(t);
1981    }
1982    if let Some(ref n) = input.series_name {
1983        chart.set_series_name(n);
1984    }
1985    chart.set_width(input.width);
1986    chart.set_height(input.height);
1987    for pt in &input.points {
1988        let point_type = match pt.point_type {
1989            crate::types::enums::WaterfallPointKind::Increase => {
1990                zavora_xlsx::WaterfallPointType::Increase
1991            }
1992            crate::types::enums::WaterfallPointKind::Decrease => {
1993                zavora_xlsx::WaterfallPointType::Decrease
1994            }
1995            crate::types::enums::WaterfallPointKind::Total => {
1996                zavora_xlsx::WaterfallPointType::Total
1997            }
1998        };
1999        chart.add_point(&pt.category, pt.value, point_type);
2000    }
2001    let (row, col) = if let Some(ref c) = input.cell {
2002        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
2003    } else {
2004        (0, 0)
2005    };
2006    entry
2007        .data
2008        .worksheet(idx)
2009        .map_err(|e| anyhow::anyhow!("{e}"))?
2010        .insert_waterfall(row, col, &chart)?;
2011    Ok(success_no_data(&format!(
2012        "Waterfall chart added to '{}'",
2013        input.sheet_name
2014    )))
2015}
2016
2017pub fn add_funnel_chart(
2018    store: &mut WorkbookStore,
2019    input: AddFunnelChartInput,
2020) -> Result<String, anyhow::Error> {
2021    let entry = match store.get_mut(&input.workbook_id) {
2022        Some(e) => e,
2023        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2024    };
2025    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2026        Some(i) => i,
2027        None => return Ok(sheet_err(&input.sheet_name)),
2028    };
2029    let mut chart = zavora_xlsx::FunnelChart::new();
2030    if let Some(ref t) = input.title {
2031        chart.set_title(t);
2032    }
2033    if let Some(ref n) = input.series_name {
2034        chart.set_series_name(n);
2035    }
2036    chart.set_width(input.width);
2037    chart.set_height(input.height);
2038    for pt in &input.points {
2039        chart.add_point(&pt.category, pt.value);
2040    }
2041    let (row, col) = if let Some(ref c) = input.cell {
2042        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
2043    } else {
2044        (0, 0)
2045    };
2046    entry
2047        .data
2048        .worksheet(idx)
2049        .map_err(|e| anyhow::anyhow!("{e}"))?
2050        .insert_funnel(row, col, &chart)?;
2051    Ok(success_no_data(&format!(
2052        "Funnel chart added to '{}'",
2053        input.sheet_name
2054    )))
2055}
2056
2057pub fn add_treemap_chart(
2058    store: &mut WorkbookStore,
2059    input: AddTreemapChartInput,
2060) -> Result<String, anyhow::Error> {
2061    let entry = match store.get_mut(&input.workbook_id) {
2062        Some(e) => e,
2063        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2064    };
2065    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2066        Some(i) => i,
2067        None => return Ok(sheet_err(&input.sheet_name)),
2068    };
2069    let mut chart = zavora_xlsx::TreemapChart::new();
2070    if let Some(ref t) = input.title {
2071        chart.set_title(t);
2072    }
2073    if let Some(ref n) = input.series_name {
2074        chart.set_series_name(n);
2075    }
2076    chart.set_width(input.width);
2077    chart.set_height(input.height);
2078    for pt in &input.points {
2079        if let Some(ref c) = pt.color {
2080            chart.add_point_with_color(&pt.category, pt.value, c.as_str());
2081        } else {
2082            chart.add_point(&pt.category, pt.value);
2083        }
2084    }
2085    let (row, col) = if let Some(ref c) = input.cell {
2086        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
2087    } else {
2088        (0, 0)
2089    };
2090    entry
2091        .data
2092        .worksheet(idx)
2093        .map_err(|e| anyhow::anyhow!("{e}"))?
2094        .insert_treemap(row, col, &chart)?;
2095    Ok(success_no_data(&format!(
2096        "Treemap chart added to '{}'",
2097        input.sheet_name
2098    )))
2099}
2100
2101pub fn add_shape(store: &mut WorkbookStore, input: AddShapeInput) -> Result<String, anyhow::Error> {
2102    let entry = match store.get_mut(&input.workbook_id) {
2103        Some(e) => e,
2104        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2105    };
2106    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2107        Some(i) => i,
2108        None => return Ok(sheet_err(&input.sheet_name)),
2109    };
2110    let st = match input.shape_type {
2111        crate::types::enums::ShapeKind::Rectangle => zavora_xlsx::ShapeType::Rectangle,
2112        crate::types::enums::ShapeKind::RoundedRectangle => {
2113            zavora_xlsx::ShapeType::RoundedRectangle
2114        }
2115        crate::types::enums::ShapeKind::Ellipse => zavora_xlsx::ShapeType::Ellipse,
2116        crate::types::enums::ShapeKind::Triangle => zavora_xlsx::ShapeType::Triangle,
2117        crate::types::enums::ShapeKind::Diamond => zavora_xlsx::ShapeType::Diamond,
2118        crate::types::enums::ShapeKind::Arrow => zavora_xlsx::ShapeType::Arrow,
2119        crate::types::enums::ShapeKind::Callout => zavora_xlsx::ShapeType::Callout,
2120        crate::types::enums::ShapeKind::TextBox => zavora_xlsx::ShapeType::TextBox,
2121    };
2122    let mut shape = zavora_xlsx::Shape::new(st, input.width, input.height);
2123    if let Some(ref t) = input.text {
2124        shape = shape.text(t);
2125    }
2126    if let Some(ref c) = input.fill_color {
2127        shape = shape.fill_color(parse_hex_color(c));
2128    }
2129    if let Some(ref c) = input.outline_color {
2130        shape = shape.outline_color(parse_hex_color(c));
2131    }
2132    if let Some(w) = input.outline_width {
2133        shape = shape.outline_width(w);
2134    }
2135    if let Some(s) = input.font_size {
2136        shape = shape.font_size(s);
2137    }
2138    if input.bold == Some(true) {
2139        shape = shape.bold();
2140    }
2141    let (row, col) =
2142        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
2143    entry
2144        .data
2145        .worksheet(idx)
2146        .map_err(|e| anyhow::anyhow!("{e}"))?
2147        .add_shape(row, col, &shape)?;
2148    Ok(success_no_data(&format!("Shape added at {}", input.cell)))
2149}
2150
2151pub fn set_doc_properties(
2152    store: &mut WorkbookStore,
2153    input: SetDocPropertiesInput,
2154) -> Result<String, anyhow::Error> {
2155    let entry = match store.get_mut(&input.workbook_id) {
2156        Some(e) => e,
2157        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2158    };
2159    let mut props = zavora_xlsx::DocProperties::new();
2160    if let Some(ref v) = input.title {
2161        props = props.title(v);
2162    }
2163    if let Some(ref v) = input.author {
2164        props = props.author(v);
2165    }
2166    if let Some(ref v) = input.subject {
2167        props = props.subject(v);
2168    }
2169    if let Some(ref v) = input.description {
2170        props = props.description(v);
2171    }
2172    if let Some(ref v) = input.keywords {
2173        props = props.keywords(v);
2174    }
2175    if let Some(ref v) = input.category {
2176        props = props.category(v);
2177    }
2178    if let Some(ref v) = input.company {
2179        props = props.company(v);
2180    }
2181    entry.data.set_properties(props);
2182    Ok(success_no_data("Document properties set"))
2183}
2184
2185// ══════════════════════════════════════════════════════════════════
2186// v0.2.0: New tools — 4 chart types, slicers, timelines, form controls,
2187// advanced save, named ranges CRUD, sheet metadata, chart sheet,
2188// chart enhancements, protection options
2189// ══════════════════════════════════════════════════════════════════
2190
2191pub fn add_sunburst_chart(
2192    store: &mut WorkbookStore,
2193    input: AddSunburstChartInput,
2194) -> Result<String, anyhow::Error> {
2195    let entry = match store.get_mut(&input.workbook_id) {
2196        Some(e) => e,
2197        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2198    };
2199    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2200        Some(i) => i,
2201        None => return Ok(sheet_err(&input.sheet_name)),
2202    };
2203    let mut chart = zavora_xlsx::SunburstChart::new();
2204    if let Some(ref t) = input.title {
2205        chart.set_title(t);
2206    }
2207    if let Some(ref n) = input.series_name {
2208        chart.set_series_name(n);
2209    }
2210    chart.set_width(input.width);
2211    chart.set_height(input.height);
2212    // SunburstChart uses add_level for hierarchy labels and set_values for leaf sizes
2213    let labels: Vec<&str> = input.points.iter().map(|p| p.category.as_str()).collect();
2214    let values: Vec<f64> = input.points.iter().map(|p| p.value).collect();
2215    chart.add_level(&labels);
2216    chart.set_values(&values);
2217    let (row, col) = if let Some(ref c) = input.cell {
2218        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
2219    } else {
2220        (0, 0)
2221    };
2222    entry
2223        .data
2224        .worksheet(idx)
2225        .map_err(|e| anyhow::anyhow!("{e}"))?
2226        .insert_sunburst(row, col, &chart)?;
2227    Ok(success_no_data(&format!(
2228        "Sunburst chart added to '{}'",
2229        input.sheet_name
2230    )))
2231}
2232
2233pub fn add_histogram_chart(
2234    store: &mut WorkbookStore,
2235    input: AddHistogramChartInput,
2236) -> Result<String, anyhow::Error> {
2237    let entry = match store.get_mut(&input.workbook_id) {
2238        Some(e) => e,
2239        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2240    };
2241    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2242        Some(i) => i,
2243        None => return Ok(sheet_err(&input.sheet_name)),
2244    };
2245    let mut chart = if input.pareto == Some(true) {
2246        zavora_xlsx::HistogramChart::pareto()
2247    } else {
2248        zavora_xlsx::HistogramChart::new()
2249    };
2250    if let Some(ref t) = input.title {
2251        chart.set_title(t);
2252    }
2253    if let Some(ref n) = input.series_name {
2254        chart.set_series_name(n);
2255    }
2256    chart.set_width(input.width);
2257    chart.set_height(input.height);
2258    if let Some(bc) = input.bin_count {
2259        chart.set_bin_count(bc);
2260    }
2261    if let Some(bw) = input.bin_width {
2262        chart.set_bin_width(bw);
2263    }
2264    let values: Vec<f64> = input.points.iter().map(|p| p.value).collect();
2265    chart.set_values(&values);
2266    let (row, col) = if let Some(ref c) = input.cell {
2267        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
2268    } else {
2269        (0, 0)
2270    };
2271    entry
2272        .data
2273        .worksheet(idx)
2274        .map_err(|e| anyhow::anyhow!("{e}"))?
2275        .insert_histogram(row, col, &chart)?;
2276    Ok(success_no_data(&format!(
2277        "Histogram chart added to '{}'",
2278        input.sheet_name
2279    )))
2280}
2281
2282pub fn add_box_whisker_chart(
2283    store: &mut WorkbookStore,
2284    input: AddBoxWhiskerChartInput,
2285) -> Result<String, anyhow::Error> {
2286    let entry = match store.get_mut(&input.workbook_id) {
2287        Some(e) => e,
2288        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2289    };
2290    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2291        Some(i) => i,
2292        None => return Ok(sheet_err(&input.sheet_name)),
2293    };
2294    let mut chart = zavora_xlsx::BoxWhiskerChart::new();
2295    if let Some(ref t) = input.title {
2296        chart.set_title(t);
2297    }
2298    if let Some(ref n) = input.series_name {
2299        chart.set_series_name(n);
2300    }
2301    chart.set_width(input.width);
2302    chart.set_height(input.height);
2303    if let Some(v) = input.show_outliers {
2304        chart.set_show_outliers(v);
2305    }
2306    if let Some(v) = input.show_mean {
2307        chart.set_show_mean_markers(v);
2308    }
2309    if let Some(v) = input.show_inner_points {
2310        chart.set_show_inner_points(v);
2311    }
2312    // BoxWhiskerChart uses add_data_set(category, &[f64]) for each box
2313    for pt in &input.points {
2314        chart.add_data_set(&pt.category, &[pt.value]);
2315    }
2316    let (row, col) = if let Some(ref c) = input.cell {
2317        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
2318    } else {
2319        (0, 0)
2320    };
2321    entry
2322        .data
2323        .worksheet(idx)
2324        .map_err(|e| anyhow::anyhow!("{e}"))?
2325        .insert_box_whisker(row, col, &chart)?;
2326    Ok(success_no_data(&format!(
2327        "Box & whisker chart added to '{}'",
2328        input.sheet_name
2329    )))
2330}
2331
2332pub fn add_map_chart(
2333    store: &mut WorkbookStore,
2334    input: AddMapChartInput,
2335) -> Result<String, anyhow::Error> {
2336    let entry = match store.get_mut(&input.workbook_id) {
2337        Some(e) => e,
2338        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2339    };
2340    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2341        Some(i) => i,
2342        None => return Ok(sheet_err(&input.sheet_name)),
2343    };
2344    let mut chart = zavora_xlsx::MapChart::new();
2345    if let Some(ref t) = input.title {
2346        chart.set_title(t);
2347    }
2348    if let Some(ref n) = input.series_name {
2349        chart.set_series_name(n);
2350    }
2351    chart.set_width(input.width);
2352    chart.set_height(input.height);
2353    if let Some(ref ml) = input.map_level {
2354        let level = match ml.as_str() {
2355            "region" => zavora_xlsx::MapLevel::Region,
2356            _ => zavora_xlsx::MapLevel::Country,
2357        };
2358        chart.set_map_level(level);
2359    }
2360    for pt in &input.points {
2361        chart.add_point(&pt.category, pt.value);
2362    }
2363    let (row, col) = if let Some(ref c) = input.cell {
2364        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
2365    } else {
2366        (0, 0)
2367    };
2368    entry
2369        .data
2370        .worksheet(idx)
2371        .map_err(|e| anyhow::anyhow!("{e}"))?
2372        .insert_map(row, col, &chart)?;
2373    Ok(success_no_data(&format!(
2374        "Map chart added to '{}'",
2375        input.sheet_name
2376    )))
2377}
2378
2379pub fn add_slicer(
2380    store: &mut WorkbookStore,
2381    input: AddSlicerInput,
2382) -> Result<String, anyhow::Error> {
2383    let entry = match store.get_mut(&input.workbook_id) {
2384        Some(e) => e,
2385        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2386    };
2387    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2388        Some(i) => i,
2389        None => return Ok(sheet_err(&input.sheet_name)),
2390    };
2391    // Slicer uses builder pattern (takes ownership)
2392    let mut slicer = zavora_xlsx::Slicer::new(&input.pivot_table_name, &input.field_name);
2393    if let Some(w) = input.width {
2394        slicer = slicer.set_width(w);
2395    }
2396    if let Some(h) = input.height {
2397        slicer = slicer.set_height(h);
2398    }
2399    let (row, col) = if let Some(ref c) = input.cell {
2400        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
2401    } else {
2402        (0, 0)
2403    };
2404    entry
2405        .data
2406        .worksheet(idx)
2407        .map_err(|e| anyhow::anyhow!("{e}"))?
2408        .add_slicer(row, col, &slicer)?;
2409    Ok(success_no_data(&format!(
2410        "Slicer for '{}' added to '{}'",
2411        input.field_name, input.sheet_name
2412    )))
2413}
2414
2415pub fn add_timeline(
2416    store: &mut WorkbookStore,
2417    input: AddTimelineInput,
2418) -> Result<String, anyhow::Error> {
2419    let entry = match store.get_mut(&input.workbook_id) {
2420        Some(e) => e,
2421        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2422    };
2423    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2424        Some(i) => i,
2425        None => return Ok(sheet_err(&input.sheet_name)),
2426    };
2427    let timeline = zavora_xlsx::Timeline::new(&input.pivot_table_name, &input.field_name);
2428    let (row, col) = if let Some(ref c) = input.cell {
2429        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
2430    } else {
2431        (0, 0)
2432    };
2433    entry
2434        .data
2435        .worksheet(idx)
2436        .map_err(|e| anyhow::anyhow!("{e}"))?
2437        .add_timeline(row, col, &timeline)?;
2438    Ok(success_no_data(&format!(
2439        "Timeline for '{}' added to '{}'",
2440        input.field_name, input.sheet_name
2441    )))
2442}
2443
2444pub fn add_form_control(
2445    store: &mut WorkbookStore,
2446    input: AddFormControlInput,
2447) -> Result<String, anyhow::Error> {
2448    let entry = match store.get_mut(&input.workbook_id) {
2449        Some(e) => e,
2450        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2451    };
2452    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2453        Some(i) => i,
2454        None => return Ok(sheet_err(&input.sheet_name)),
2455    };
2456    let (row, col) =
2457        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
2458    let text = input.text.as_deref().unwrap_or("Control");
2459    let fc = match input.control_type.as_str() {
2460        "checkbox" => {
2461            if let Some(ref cl) = input.cell_link {
2462                zavora_xlsx::FormControl::checkbox_with_link(text, cl)
2463            } else {
2464                zavora_xlsx::FormControl::checkbox(text)
2465            }
2466        }
2467        "dropdown" => {
2468            let items = input
2469                .input_range
2470                .as_deref()
2471                .unwrap_or("")
2472                .split(',')
2473                .map(|s| s.trim().to_string())
2474                .collect();
2475            zavora_xlsx::FormControl::dropdown(items)
2476        }
2477        "spinner" => zavora_xlsx::FormControl::spinner(0, 100, 0),
2478        _ => zavora_xlsx::FormControl::button(text),
2479    };
2480    entry
2481        .data
2482        .worksheet(idx)
2483        .map_err(|e| anyhow::anyhow!("{e}"))?
2484        .add_form_control(row, col, fc);
2485    Ok(success_no_data(&format!(
2486        "Form control '{}' added at {}",
2487        input.control_type, input.cell
2488    )))
2489}
2490
2491pub fn save_workbook_advanced(
2492    store: &mut WorkbookStore,
2493    input: SaveWorkbookAdvancedInput,
2494) -> Result<String, anyhow::Error> {
2495    let entry = match store.get_mut(&input.workbook_id) {
2496        Some(e) => e,
2497        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2498    };
2499    if entry.read_only {
2500        return Ok(error(
2501            ErrorCategory::EngineUnsupported,
2502            "Read-only workbooks cannot be saved",
2503            "Reopen in edit mode.",
2504        ));
2505    }
2506    let _ = entry.data.recalculate();
2507    let path = std::path::Path::new(&input.file_path);
2508    match input.format.as_str() {
2509        "template" => entry
2510            .data
2511            .save_as_template(path)
2512            .map_err(|e| anyhow::anyhow!("{e}"))?,
2513        "encrypted" => {
2514            let pw = input.password.as_deref().unwrap_or("");
2515            entry
2516                .data
2517                .save_encrypted(path, pw)
2518                .map_err(|e| anyhow::anyhow!("{e}"))?;
2519        }
2520        "parallel" => entry
2521            .data
2522            .save_parallel(path)
2523            .map_err(|e| anyhow::anyhow!("{e}"))?,
2524        _ => entry
2525            .data
2526            .save(path)
2527            .map_err(|e| anyhow::anyhow!("{e}"))?,
2528    }
2529    Ok(success_no_data(&format!(
2530        "Workbook saved as {} to {}",
2531        input.format, input.file_path
2532    )))
2533}
2534
2535pub fn open_workbook_encrypted(
2536    store: &mut WorkbookStore,
2537    input: OpenWorkbookEncryptedInput,
2538) -> Result<String, anyhow::Error> {
2539    use crate::store::WorkbookEntry;
2540    use std::time::Instant;
2541
2542    if store.is_full() {
2543        return Ok(error(
2544            ErrorCategory::CapacityExceeded,
2545            "Workbook store is at maximum capacity",
2546            "Save and close an existing workbook first.",
2547        ));
2548    }
2549    let path = std::path::Path::new(&input.file_path);
2550    if !path.exists() {
2551        return Ok(error(
2552            ErrorCategory::NotFound,
2553            &format!("File not found: {}", input.file_path),
2554            "Check the file path.",
2555        ));
2556    }
2557    let wb = zavora_xlsx::Workbook::open_with_password(path, &input.password)
2558        .map_err(|e| anyhow::anyhow!("{e}"))?;
2559    let sheets = crate::engines::zavora::sheet_summaries(&wb);
2560    let entry = WorkbookEntry {
2561        id: String::new(),
2562        data: wb,
2563        read_only: false,
2564        last_access: Instant::now(),
2565    };
2566    let id = store.insert(entry).map_err(|e| anyhow::anyhow!("{}", e))?;
2567    Ok(success(
2568        "Encrypted workbook opened",
2569        crate::types::responses::WorkbookInfo {
2570            workbook_id: id,
2571            engine: "zavora-xlsx".to_string(),
2572            sheets,
2573        },
2574    ))
2575}
2576
2577pub fn manage_named_ranges(
2578    store: &mut WorkbookStore,
2579    input: ManageNamedRangesInput,
2580) -> Result<String, anyhow::Error> {
2581    let entry = match store.get_mut(&input.workbook_id) {
2582        Some(e) => e,
2583        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2584    };
2585    match input.action.as_str() {
2586        "add" => {
2587            let name = input.name.as_deref().unwrap_or("");
2588            let formula = input.formula.as_deref().unwrap_or("");
2589            entry.data.define_name(name, formula);
2590            Ok(success_no_data(&format!("Named range '{}' added", name)))
2591        }
2592        "add_scoped" => {
2593            let name = input.name.as_deref().unwrap_or("");
2594            let formula = input.formula.as_deref().unwrap_or("");
2595            let sheet_idx = input.sheet_index.unwrap_or(0);
2596            entry.data.define_name_scoped(name, formula, sheet_idx);
2597            Ok(success_no_data(&format!(
2598                "Scoped named range '{}' added for sheet {}",
2599                name, sheet_idx
2600            )))
2601        }
2602        "update" => {
2603            let name = input.name.as_deref().unwrap_or("");
2604            let formula = input.formula.as_deref().unwrap_or("");
2605            let _ = entry.data.update_named_range(name, formula);
2606            Ok(success_no_data(&format!("Named range '{}' updated", name)))
2607        }
2608        "remove" => {
2609            let name = input.name.as_deref().unwrap_or("");
2610            let scope = if let Some(idx) = input.sheet_index {
2611                zavora_xlsx::DefinedNameScope::Sheet(idx)
2612            } else {
2613                zavora_xlsx::DefinedNameScope::Workbook
2614            };
2615            let _ = entry.data.remove_named_range(name, &scope);
2616            Ok(success_no_data(&format!("Named range '{}' removed", name)))
2617        }
2618        _ => {
2619            // "list"
2620            let names: Vec<serde_json::Value> = entry
2621                .data
2622                .defined_names_with_scope()
2623                .iter()
2624                .map(|dn| {
2625                    serde_json::json!({
2626                        "name": dn.name,
2627                        "formula": dn.formula,
2628                        "scope": format!("{:?}", dn.scope),
2629                    })
2630                })
2631                .collect();
2632            Ok(success("Named ranges listed", names))
2633        }
2634    }
2635}
2636
2637pub fn read_sheet_metadata(
2638    store: &mut WorkbookStore,
2639    input: ReadSheetMetadataInput,
2640) -> Result<String, anyhow::Error> {
2641    let entry = match store.get_mut(&input.workbook_id) {
2642        Some(e) => e,
2643        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2644    };
2645    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2646        Some(i) => i,
2647        None => return Ok(sheet_err(&input.sheet_name)),
2648    };
2649    let ws = entry
2650        .data
2651        .worksheet(idx)
2652        .map_err(|e| anyhow::anyhow!("{e}"))?;
2653
2654    let mut result = serde_json::Map::new();
2655
2656    if input.info == "used_range" || input.info == "all" {
2657        if let Some((r1, c1, r2, c2)) = ws.used_range() {
2658            let range_str = format!(
2659                "{}:{}",
2660                zavora_xlsx::utility::to_a1(r1, c1),
2661                zavora_xlsx::utility::to_a1(r2, c2)
2662            );
2663            result.insert("used_range".into(), serde_json::json!(range_str));
2664        } else {
2665            result.insert("used_range".into(), serde_json::Value::Null);
2666        }
2667    }
2668
2669    if input.info == "hyperlinks" || input.info == "all" {
2670        let links: Vec<serde_json::Value> = ws
2671            .hyperlinks()
2672            .iter()
2673            .map(|h| {
2674                serde_json::json!({
2675                    "cell": zavora_xlsx::utility::to_a1(h.row, h.col),
2676                    "url": h.url,
2677                    "location": h.location,
2678                    "tooltip": h.tooltip,
2679                })
2680            })
2681            .collect();
2682        result.insert("hyperlinks".into(), serde_json::json!(links));
2683    }
2684
2685    if input.info == "merge_ranges" || input.info == "all" {
2686        let merges: Vec<String> = ws
2687            .merge_ranges()
2688            .iter()
2689            .map(|(r1, c1, r2, c2)| {
2690                format!(
2691                    "{}:{}",
2692                    zavora_xlsx::utility::to_a1(*r1, *c1),
2693                    zavora_xlsx::utility::to_a1(*r2, *c2)
2694                )
2695            })
2696            .collect();
2697        result.insert("merge_ranges".into(), serde_json::json!(merges));
2698    }
2699
2700    if input.info == "charts" || input.info == "all" {
2701        let charts: Vec<serde_json::Value> = ws
2702            .charts()
2703            .iter()
2704            .map(|c| {
2705                serde_json::json!({
2706                    "title": c.title(),
2707                    "type": format!("{:?}", c.chart_type()),
2708                    "series_count": c.series().len(),
2709                })
2710            })
2711            .collect();
2712        result.insert("charts".into(), serde_json::json!(charts));
2713    }
2714
2715    Ok(success(
2716        "Sheet metadata read",
2717        serde_json::Value::Object(result),
2718    ))
2719}
2720
2721pub fn add_chart_sheet(
2722    store: &mut WorkbookStore,
2723    input: AddChartSheetInput,
2724) -> Result<String, anyhow::Error> {
2725    let entry = match store.get_mut(&input.workbook_id) {
2726        Some(e) => e,
2727        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2728    };
2729    let ct = match input.chart_type {
2730        ChartType::Bar => zavora_xlsx::ChartType::Bar,
2731        ChartType::Column => zavora_xlsx::ChartType::Column,
2732        ChartType::Line => zavora_xlsx::ChartType::Line,
2733        ChartType::Pie => zavora_xlsx::ChartType::Pie,
2734        ChartType::Scatter => zavora_xlsx::ChartType::Scatter,
2735        ChartType::Area => zavora_xlsx::ChartType::Area,
2736        ChartType::Doughnut => zavora_xlsx::ChartType::Doughnut,
2737    };
2738    let mut chart = zavora_xlsx::Chart::new(ct);
2739    if let Some(ref t) = input.title {
2740        chart.set_title(t);
2741    }
2742    if let Some(ref x) = input.x_axis_label {
2743        chart.set_x_axis_name(x);
2744    }
2745    if let Some(ref y) = input.y_axis_label {
2746        chart.set_y_axis_name(y);
2747    }
2748    if let Some(ref lp) = input.legend_position {
2749        chart.set_legend_position(match lp {
2750            LegendPosition::Top => zavora_xlsx::LegendPosition::Top,
2751            LegendPosition::Bottom => zavora_xlsx::LegendPosition::Bottom,
2752            LegendPosition::Left => zavora_xlsx::LegendPosition::Left,
2753            LegendPosition::Right => zavora_xlsx::LegendPosition::Right,
2754            LegendPosition::None => zavora_xlsx::LegendPosition::None,
2755        });
2756    }
2757    if !input.series.is_empty() {
2758        for si in &input.series {
2759            let s = chart.add_series();
2760            s.set_values(&si.values);
2761            if let Some(ref c) = si.categories {
2762                s.set_categories(c);
2763            }
2764            if let Some(ref n) = si.name {
2765                s.set_name(n);
2766            }
2767        }
2768    } else if let Some(ref dr) = input.data_range {
2769        chart.add_series().set_values(dr);
2770    }
2771    entry
2772        .data
2773        .add_chart_sheet(&input.sheet_name, chart)
2774        .map_err(|e| anyhow::anyhow!("{e}"))?;
2775    Ok(success_no_data(&format!(
2776        "Chart sheet '{}' added",
2777        input.sheet_name
2778    )))
2779}
2780
2781
2782// ══════════════════════════════════════════════════════════════════
2783// v0.2.1: Threaded comments, granular protection, custom properties
2784// ══════════════════════════════════════════════════════════════════
2785
2786pub fn add_threaded_comment(
2787    store: &mut WorkbookStore,
2788    input: AddThreadedCommentInput,
2789) -> Result<String, anyhow::Error> {
2790    let entry = match store.get_mut(&input.workbook_id) {
2791        Some(e) => e,
2792        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2793    };
2794    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2795        Some(i) => i,
2796        None => return Ok(sheet_err(&input.sheet_name)),
2797    };
2798    let (row, col) =
2799        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
2800    let mut tc = zavora_xlsx::ThreadedComment::new(&input.author, &input.text);
2801    if let Some(ref ts) = input.timestamp {
2802        tc = tc.timestamp(ts);
2803    }
2804    for reply in &input.replies {
2805        if let Some(ref ts) = reply.timestamp {
2806            tc.add_reply_with_timestamp(&reply.author, &reply.text, ts);
2807        } else {
2808            tc.add_reply(&reply.author, &reply.text);
2809        }
2810    }
2811    entry
2812        .data
2813        .worksheet(idx)
2814        .map_err(|e| anyhow::anyhow!("{e}"))?
2815        .add_threaded_comment(row, col, tc);
2816    Ok(success_no_data(&format!(
2817        "Threaded comment added at {}",
2818        input.cell
2819    )))
2820}
2821
2822pub fn protect_sheet_advanced(
2823    store: &mut WorkbookStore,
2824    input: ProtectSheetAdvancedInput,
2825) -> Result<String, anyhow::Error> {
2826    let entry = match store.get_mut(&input.workbook_id) {
2827        Some(e) => e,
2828        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2829    };
2830    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2831        Some(i) => i,
2832        None => return Ok(sheet_err(&input.sheet_name)),
2833    };
2834    let ws = entry
2835        .data
2836        .worksheet(idx)
2837        .map_err(|e| anyhow::anyhow!("{e}"))?;
2838
2839    if let Some(ref pw) = input.password {
2840        ws.protect_with_password(pw);
2841    }
2842
2843    // Build SheetProtection with granular options
2844    // Default: everything locked (true = locked)
2845    let mut prot = zavora_xlsx::SheetProtection::default();
2846
2847    // "allow" means NOT locked, so we invert: allow=true -> field=false
2848    if let Some(v) = input.allow_insert_rows {
2849        prot.insert_rows = !v;
2850    }
2851    if let Some(v) = input.allow_delete_rows {
2852        prot.delete_rows = !v;
2853    }
2854    if let Some(v) = input.allow_insert_columns {
2855        prot.insert_columns = !v;
2856    }
2857    if let Some(v) = input.allow_delete_columns {
2858        prot.delete_columns = !v;
2859    }
2860    if let Some(v) = input.allow_format_cells {
2861        prot.format_cells = !v;
2862    }
2863    if let Some(v) = input.allow_format_columns {
2864        prot.format_columns = !v;
2865    }
2866    if let Some(v) = input.allow_format_rows {
2867        prot.format_rows = !v;
2868    }
2869    if let Some(v) = input.allow_sort {
2870        prot.sort = !v;
2871    }
2872    if let Some(v) = input.allow_insert_hyperlinks {
2873        prot.insert_hyperlinks = !v;
2874    }
2875    if let Some(v) = input.allow_select_locked_cells {
2876        prot.select_locked_cells = !v;
2877    }
2878    if let Some(v) = input.allow_select_unlocked_cells {
2879        prot.select_unlocked_cells = !v;
2880    }
2881    if let Some(v) = input.allow_pivot_tables {
2882        prot.pivot_tables = !v;
2883    }
2884
2885    ws.protect_with_options(prot);
2886
2887    Ok(success_no_data(&format!(
2888        "Sheet '{}' protected with custom options",
2889        input.sheet_name
2890    )))
2891}
2892
2893pub fn set_custom_property(
2894    store: &mut WorkbookStore,
2895    input: SetCustomPropertyInput,
2896) -> Result<String, anyhow::Error> {
2897    let entry = match store.get_mut(&input.workbook_id) {
2898        Some(e) => e,
2899        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2900    };
2901    let val = match input.value_type.as_str() {
2902        "number" => {
2903            let n: f64 = input.value.parse().unwrap_or(0.0);
2904            zavora_xlsx::CustomPropertyValue::Number(n)
2905        }
2906        "integer" => {
2907            let n: i32 = input.value.parse().unwrap_or(0);
2908            zavora_xlsx::CustomPropertyValue::Integer(n)
2909        }
2910        "bool" => {
2911            let b = input.value == "true" || input.value == "1";
2912            zavora_xlsx::CustomPropertyValue::Bool(b)
2913        }
2914        "datetime" => zavora_xlsx::CustomPropertyValue::DateTime(input.value.clone()),
2915        _ => zavora_xlsx::CustomPropertyValue::Text(input.value.clone()),
2916    };
2917    entry.data.set_custom_property(&input.name, val);
2918    Ok(success_no_data(&format!(
2919        "Custom property '{}' set",
2920        input.name
2921    )))
2922}
2923
2924
2925// ══════════════════════════════════════════════════════════════════
2926// v0.2.1 continued: remaining gap items
2927// ══════════════════════════════════════════════════════════════════
2928
2929pub fn read_cell_comment(
2930    store: &mut WorkbookStore,
2931    input: ReadCellCommentInput,
2932) -> Result<String, anyhow::Error> {
2933    let entry = match store.get_mut(&input.workbook_id) {
2934        Some(e) => e,
2935        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2936    };
2937    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2938        Some(i) => i,
2939        None => return Ok(sheet_err(&input.sheet_name)),
2940    };
2941    let (row, col) =
2942        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
2943    let ws = entry
2944        .data
2945        .worksheet(idx)
2946        .map_err(|e| anyhow::anyhow!("{e}"))?;
2947    match ws.get_comment(row, col) {
2948        Some((author, text)) => Ok(success(
2949            "Comment found",
2950            serde_json::json!({
2951                "cell": input.cell,
2952                "author": author,
2953                "text": text,
2954            }),
2955        )),
2956        None => Ok(success_no_data(&format!(
2957            "No comment at {}",
2958            input.cell
2959        ))),
2960    }
2961}
2962
2963pub fn read_cell_format(
2964    store: &mut WorkbookStore,
2965    input: ReadCellFormatInput,
2966) -> Result<String, anyhow::Error> {
2967    let entry = match store.get_mut(&input.workbook_id) {
2968        Some(e) => e,
2969        None => return Ok(workbook_not_found(store, &input.workbook_id)),
2970    };
2971    let idx = match find_sheet(&entry.data, &input.sheet_name) {
2972        Some(i) => i,
2973        None => return Ok(sheet_err(&input.sheet_name)),
2974    };
2975    let (row, col) =
2976        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
2977    let ws = entry
2978        .data
2979        .worksheet(idx)
2980        .map_err(|e| anyhow::anyhow!("{e}"))?;
2981    match ws.cell_format(row, col) {
2982        Some(fmt) => Ok(success(
2983            "Cell format read",
2984            serde_json::json!({
2985                "cell": input.cell,
2986                "format": format!("{:?}", fmt),
2987            }),
2988        )),
2989        None => Ok(success_no_data(&format!(
2990            "No format at {}",
2991            input.cell
2992        ))),
2993    }
2994}
2995
2996pub fn manage_custom_xml(
2997    store: &mut WorkbookStore,
2998    input: ManageCustomXmlInput,
2999) -> Result<String, anyhow::Error> {
3000    let entry = match store.get_mut(&input.workbook_id) {
3001        Some(e) => e,
3002        None => return Ok(workbook_not_found(store, &input.workbook_id)),
3003    };
3004    match input.action.as_str() {
3005        "add" => {
3006            let content = input.content.as_deref().unwrap_or("");
3007            entry
3008                .data
3009                .add_custom_xml(&input.namespace, content.as_bytes());
3010            Ok(success_no_data(&format!(
3011                "Custom XML added for namespace '{}'",
3012                input.namespace
3013            )))
3014        }
3015        _ => {
3016            // "read"
3017            match entry.data.read_custom_xml(&input.namespace) {
3018                Some(bytes) => {
3019                    let text = String::from_utf8_lossy(bytes).to_string();
3020                    Ok(success(
3021                        "Custom XML read",
3022                        serde_json::json!({
3023                            "namespace": input.namespace,
3024                            "content": text,
3025                        }),
3026                    ))
3027                }
3028                None => Ok(success_no_data(&format!(
3029                    "No custom XML for namespace '{}'",
3030                    input.namespace
3031                ))),
3032            }
3033        }
3034    }
3035}
3036
3037pub fn add_connection(
3038    store: &mut WorkbookStore,
3039    input: AddConnectionInput,
3040) -> Result<String, anyhow::Error> {
3041    let entry = match store.get_mut(&input.workbook_id) {
3042        Some(e) => e,
3043        None => return Ok(workbook_not_found(store, &input.workbook_id)),
3044    };
3045    entry
3046        .data
3047        .add_connection(&input.connection_string, &input.command);
3048    Ok(success_no_data("External data connection added"))
3049}
3050
3051pub fn set_sst_threshold(
3052    store: &mut WorkbookStore,
3053    input: SetSstThresholdInput,
3054) -> Result<String, anyhow::Error> {
3055    let entry = match store.get_mut(&input.workbook_id) {
3056        Some(e) => e,
3057        None => return Ok(workbook_not_found(store, &input.workbook_id)),
3058    };
3059    entry.data.set_sst_threshold(input.threshold);
3060    Ok(success_no_data(&format!(
3061        "SST threshold set to {}",
3062        input.threshold
3063    )))
3064}
3065
3066pub fn write_json_rows(
3067    store: &mut WorkbookStore,
3068    input: WriteJsonRowsInput,
3069) -> Result<String, anyhow::Error> {
3070    let entry = match store.get_mut(&input.workbook_id) {
3071        Some(e) => e,
3072        None => return Ok(workbook_not_found(store, &input.workbook_id)),
3073    };
3074    let idx = match find_sheet(&entry.data, &input.sheet_name) {
3075        Some(i) => i,
3076        None => return Ok(sheet_err(&input.sheet_name)),
3077    };
3078    let (start_row, start_col) = if let Some(ref c) = input.start_cell {
3079        zavora_xlsx::utility::parse_cell_ref(c).map_err(|e| anyhow::anyhow!("{e}"))?
3080    } else {
3081        (0, 0)
3082    };
3083    let ws = entry
3084        .data
3085        .worksheet(idx)
3086        .map_err(|e| anyhow::anyhow!("{e}"))?;
3087
3088    if input.rows.is_empty() {
3089        return Ok(success_no_data("No rows to write"));
3090    }
3091
3092    // Extract headers from first object's keys
3093    let headers: Vec<String> = if let Some(obj) = input.rows[0].as_object() {
3094        obj.keys().cloned().collect()
3095    } else {
3096        return Ok(error(
3097            ErrorCategory::EngineUnsupported,
3098            "Rows must be JSON objects",
3099            "Each row should be {\"key\": value, ...}",
3100        ));
3101    };
3102
3103    let mut current_row = start_row;
3104
3105    // Write headers
3106    if input.write_headers {
3107        for (ci, header) in headers.iter().enumerate() {
3108            ws.write(current_row, start_col + ci as u16, header.as_str())
3109                .map_err(|e| anyhow::anyhow!("{e}"))?;
3110        }
3111        current_row += 1;
3112    }
3113
3114    // Write data rows
3115    for row_val in &input.rows {
3116        if let Some(obj) = row_val.as_object() {
3117            for (ci, header) in headers.iter().enumerate() {
3118                if let Some(val) = obj.get(header) {
3119                    crate::engines::zavora::write_json_value(
3120                        ws,
3121                        current_row,
3122                        start_col + ci as u16,
3123                        val,
3124                    )
3125                    .map_err(|e| anyhow::anyhow!("{e}"))?;
3126                }
3127            }
3128        }
3129        current_row += 1;
3130    }
3131
3132    Ok(success_no_data(&format!(
3133        "{} rows written to '{}'",
3134        input.rows.len(),
3135        input.sheet_name
3136    )))
3137}