Skip to main content

spreadsheet_mcp/tools/
sheet_layout.rs

1#![cfg(feature = "recalc")]
2
3use crate::fork::{ChangeSummary, StagedChange, StagedOp};
4use crate::model::WorkbookId;
5use crate::state::AppState;
6use crate::tools::param_enums::{BatchMode, PageOrientation};
7use crate::utils::make_short_random_id;
8use anyhow::{Result, anyhow, bail};
9use chrono::Utc;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use umya_spreadsheet::{
17    Break, Coordinate, Pane, PaneStateValues, PaneValues, SheetView, SheetViews, Worksheet,
18};
19
20#[derive(Debug, Deserialize, JsonSchema)]
21pub struct SheetLayoutBatchParams {
22    pub fork_id: String,
23    pub ops: Vec<SheetLayoutOp>,
24    #[serde(default)]
25    pub mode: Option<BatchMode>, // preview|apply (default apply)
26    pub label: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum SheetLayoutOp {
32    FreezePanes {
33        sheet_name: String,
34        #[serde(default)]
35        freeze_rows: u32,
36        #[serde(default)]
37        freeze_cols: u32,
38        #[serde(default)]
39        top_left_cell: Option<String>,
40    },
41    SetZoom {
42        sheet_name: String,
43        zoom_percent: u32,
44    },
45    SetGridlines {
46        sheet_name: String,
47        show: bool,
48    },
49    SetPageMargins {
50        sheet_name: String,
51        left: f64,
52        right: f64,
53        top: f64,
54        bottom: f64,
55        #[serde(default)]
56        header: Option<f64>,
57        #[serde(default)]
58        footer: Option<f64>,
59    },
60    SetPageSetup {
61        sheet_name: String,
62        orientation: PageOrientation,
63        #[serde(default)]
64        fit_to_width: Option<u32>,
65        #[serde(default)]
66        fit_to_height: Option<u32>,
67        #[serde(default)]
68        scale_percent: Option<u32>,
69    },
70    SetPrintArea {
71        sheet_name: String,
72        range: String,
73    },
74    SetPageBreaks {
75        sheet_name: String,
76        #[serde(default)]
77        row_breaks: Vec<u32>,
78        #[serde(default)]
79        col_breaks: Vec<u32>,
80    },
81}
82
83#[derive(Debug, Serialize, JsonSchema)]
84pub struct SheetLayoutBatchResponse {
85    pub fork_id: String,
86    pub mode: String,
87    pub change_id: Option<String>,
88    pub ops_applied: usize,
89    pub summary: ChangeSummary,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93pub(crate) struct SheetLayoutBatchStagedPayload {
94    pub(crate) ops: Vec<SheetLayoutOp>,
95}
96
97pub async fn sheet_layout_batch(
98    state: Arc<AppState>,
99    params: SheetLayoutBatchParams,
100) -> Result<SheetLayoutBatchResponse> {
101    let registry = state
102        .fork_registry()
103        .ok_or_else(|| anyhow!("fork registry not available"))?;
104
105    let fork_ctx = registry.get_fork(&params.fork_id)?;
106    let work_path = fork_ctx.work_path.clone();
107
108    // Validate sheet existence up-front.
109    let fork_workbook_id = WorkbookId(params.fork_id.clone());
110    let workbook = state.open_workbook(&fork_workbook_id).await?;
111    {
112        let mut seen = BTreeSet::new();
113        for op in &params.ops {
114            let sheet_name = op_sheet_name(op);
115            if seen.insert(sheet_name.to_string()) {
116                let _ = workbook.with_sheet(sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
117            }
118        }
119    }
120
121    let mode = params.mode.unwrap_or_default();
122
123    if mode.is_preview() {
124        let change_id = make_short_random_id("chg", 12);
125        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
126        fs::create_dir_all(snapshot_path.parent().unwrap())?;
127        fs::copy(&work_path, &snapshot_path)?;
128
129        let snapshot_for_apply = snapshot_path.clone();
130        let ops_for_apply = params.ops.clone();
131        let apply_result = tokio::task::spawn_blocking(move || {
132            apply_sheet_layout_ops_to_file(&snapshot_for_apply, &ops_for_apply)
133        })
134        .await??;
135
136        let mut summary = apply_result.summary;
137        summary.op_kinds = vec!["sheet_layout_batch".to_string()];
138        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
139
140        let staged_op = StagedOp {
141            kind: "sheet_layout_batch".to_string(),
142            payload: serde_json::to_value(SheetLayoutBatchStagedPayload {
143                ops: params.ops.clone(),
144            })?,
145        };
146
147        let staged = StagedChange {
148            change_id: change_id.clone(),
149            created_at: Utc::now(),
150            label: params.label.clone(),
151            ops: vec![staged_op],
152            summary: summary.clone(),
153            fork_path_snapshot: Some(snapshot_path),
154        };
155
156        registry.add_staged_change(&params.fork_id, staged)?;
157
158        Ok(SheetLayoutBatchResponse {
159            fork_id: params.fork_id,
160            mode: mode.as_str().to_string(),
161            change_id: Some(change_id),
162            ops_applied: apply_result.ops_applied,
163            summary,
164        })
165    } else {
166        let work_path_for_apply = work_path.clone();
167        let ops_for_apply = params.ops.clone();
168        let apply_result = tokio::task::spawn_blocking(move || {
169            apply_sheet_layout_ops_to_file(&work_path_for_apply, &ops_for_apply)
170        })
171        .await??;
172
173        let mut summary = apply_result.summary;
174        summary.op_kinds = vec!["sheet_layout_batch".to_string()];
175        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
176
177        let _ = state.close_workbook(&fork_workbook_id);
178
179        Ok(SheetLayoutBatchResponse {
180            fork_id: params.fork_id,
181            mode: mode.as_str().to_string(),
182            change_id: None,
183            ops_applied: apply_result.ops_applied,
184            summary,
185        })
186    }
187}
188
189fn op_sheet_name(op: &SheetLayoutOp) -> &str {
190    match op {
191        SheetLayoutOp::FreezePanes { sheet_name, .. }
192        | SheetLayoutOp::SetZoom { sheet_name, .. }
193        | SheetLayoutOp::SetGridlines { sheet_name, .. }
194        | SheetLayoutOp::SetPageMargins { sheet_name, .. }
195        | SheetLayoutOp::SetPageSetup { sheet_name, .. }
196        | SheetLayoutOp::SetPrintArea { sheet_name, .. }
197        | SheetLayoutOp::SetPageBreaks { sheet_name, .. } => sheet_name,
198    }
199}
200
201fn stage_snapshot_path(fork_id: &str, change_id: &str) -> PathBuf {
202    PathBuf::from("/tmp/mcp-staged").join(format!("{fork_id}_{change_id}.xlsx"))
203}
204
205fn set_recalc_needed_flag(summary: &mut ChangeSummary, recalc_needed: bool) {
206    summary
207        .flags
208        .insert("recalc_needed".to_string(), recalc_needed);
209}
210
211pub(crate) struct SheetLayoutApplyResult {
212    pub(crate) ops_applied: usize,
213    pub(crate) summary: ChangeSummary,
214}
215
216pub(crate) fn apply_sheet_layout_ops_to_file(
217    path: &Path,
218    ops: &[SheetLayoutOp],
219) -> Result<SheetLayoutApplyResult> {
220    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
221
222    let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
223    let mut affected_bounds: Vec<String> = Vec::new();
224    let mut warnings: Vec<String> = Vec::new();
225    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
226
227    let mut freeze_ops: u64 = 0;
228    let mut zoom_ops: u64 = 0;
229    let mut grid_ops: u64 = 0;
230    let mut margin_ops: u64 = 0;
231    let mut setup_ops: u64 = 0;
232    let mut print_area_ops: u64 = 0;
233    let mut page_break_ops: u64 = 0;
234
235    for op in ops {
236        match op {
237            SheetLayoutOp::FreezePanes {
238                sheet_name,
239                freeze_rows,
240                freeze_cols,
241                top_left_cell,
242            } => {
243                freeze_ops += 1;
244                affected_sheets.insert(sheet_name.clone());
245                let sheet = book
246                    .get_sheet_by_name_mut(sheet_name)
247                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
248
249                apply_freeze_panes(
250                    sheet,
251                    *freeze_rows,
252                    *freeze_cols,
253                    top_left_cell.as_deref(),
254                    &mut warnings,
255                )?;
256            }
257            SheetLayoutOp::SetZoom {
258                sheet_name,
259                zoom_percent,
260            } => {
261                zoom_ops += 1;
262                affected_sheets.insert(sheet_name.clone());
263                if *zoom_percent < 10 || *zoom_percent > 400 {
264                    bail!("zoom_percent must be between 10 and 400");
265                }
266                let sheet = book
267                    .get_sheet_by_name_mut(sheet_name)
268                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
269                let view = primary_sheet_view_mut(sheet);
270                view.set_zoom_scale(*zoom_percent);
271                view.set_zoom_scale_normal(*zoom_percent);
272            }
273            SheetLayoutOp::SetGridlines { sheet_name, show } => {
274                grid_ops += 1;
275                affected_sheets.insert(sheet_name.clone());
276                let sheet = book
277                    .get_sheet_by_name_mut(sheet_name)
278                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
279                let view = primary_sheet_view_mut(sheet);
280                view.set_show_grid_lines(*show);
281            }
282            SheetLayoutOp::SetPageMargins {
283                sheet_name,
284                left,
285                right,
286                top,
287                bottom,
288                header,
289                footer,
290            } => {
291                margin_ops += 1;
292                affected_sheets.insert(sheet_name.clone());
293                validate_margin_value("left", *left)?;
294                validate_margin_value("right", *right)?;
295                validate_margin_value("top", *top)?;
296                validate_margin_value("bottom", *bottom)?;
297                if let Some(h) = header {
298                    validate_margin_value("header", *h)?;
299                }
300                if let Some(f) = footer {
301                    validate_margin_value("footer", *f)?;
302                }
303                let sheet = book
304                    .get_sheet_by_name_mut(sheet_name)
305                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
306                let margins = sheet.get_page_margins_mut();
307                margins.set_left(*left);
308                margins.set_right(*right);
309                margins.set_top(*top);
310                margins.set_bottom(*bottom);
311                if let Some(h) = header {
312                    margins.set_header(*h);
313                }
314                if let Some(f) = footer {
315                    margins.set_footer(*f);
316                }
317            }
318            SheetLayoutOp::SetPageSetup {
319                sheet_name,
320                orientation,
321                fit_to_width,
322                fit_to_height,
323                scale_percent,
324            } => {
325                setup_ops += 1;
326                affected_sheets.insert(sheet_name.clone());
327                let orientation_value = orientation.to_umya();
328                if let Some(v) = fit_to_width {
329                    if *v < 1 {
330                        bail!("fit_to_width must be >= 1");
331                    }
332                }
333                if let Some(v) = fit_to_height {
334                    if *v < 1 {
335                        bail!("fit_to_height must be >= 1");
336                    }
337                }
338                if let Some(v) = scale_percent {
339                    if *v < 10 || *v > 400 {
340                        bail!("scale_percent must be between 10 and 400");
341                    }
342                }
343
344                let sheet = book
345                    .get_sheet_by_name_mut(sheet_name)
346                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
347                let setup = sheet.get_page_setup_mut();
348                setup.set_orientation(orientation_value);
349                if let Some(v) = fit_to_width {
350                    setup.set_fit_to_width(*v);
351                }
352                if let Some(v) = fit_to_height {
353                    setup.set_fit_to_height(*v);
354                }
355                if let Some(v) = scale_percent {
356                    setup.set_scale(*v);
357                }
358            }
359            SheetLayoutOp::SetPrintArea { sheet_name, range } => {
360                print_area_ops += 1;
361                affected_sheets.insert(sheet_name.clone());
362                affected_bounds.push(range.clone());
363                set_print_area_defined_name(&mut book, sheet_name, range)?;
364            }
365            SheetLayoutOp::SetPageBreaks {
366                sheet_name,
367                row_breaks,
368                col_breaks,
369            } => {
370                page_break_ops += 1;
371                affected_sheets.insert(sheet_name.clone());
372                for b in row_breaks {
373                    if *b < 1 {
374                        bail!("row_breaks entries must be >= 1");
375                    }
376                }
377                for b in col_breaks {
378                    if *b < 1 {
379                        bail!("col_breaks entries must be >= 1");
380                    }
381                }
382                let sheet = book
383                    .get_sheet_by_name_mut(sheet_name)
384                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
385                apply_page_breaks(sheet, row_breaks, col_breaks);
386            }
387        }
388    }
389
390    umya_spreadsheet::writer::xlsx::write(&book, path)?;
391
392    counts.insert("ops".to_string(), ops.len() as u64);
393    if freeze_ops > 0 {
394        counts.insert("freeze_panes_ops".to_string(), freeze_ops);
395    }
396    if zoom_ops > 0 {
397        counts.insert("set_zoom_ops".to_string(), zoom_ops);
398    }
399    if grid_ops > 0 {
400        counts.insert("set_gridlines_ops".to_string(), grid_ops);
401    }
402    if margin_ops > 0 {
403        counts.insert("set_page_margins_ops".to_string(), margin_ops);
404    }
405    if setup_ops > 0 {
406        counts.insert("set_page_setup_ops".to_string(), setup_ops);
407    }
408    if print_area_ops > 0 {
409        counts.insert("set_print_area_ops".to_string(), print_area_ops);
410    }
411    if page_break_ops > 0 {
412        counts.insert("set_page_breaks_ops".to_string(), page_break_ops);
413    }
414
415    let summary = ChangeSummary {
416        op_kinds: vec!["sheet_layout_batch".to_string()],
417        affected_sheets: affected_sheets.into_iter().collect(),
418        affected_bounds,
419        counts,
420        warnings,
421        ..Default::default()
422    };
423
424    Ok(SheetLayoutApplyResult {
425        ops_applied: ops.len(),
426        summary,
427    })
428}
429
430fn primary_sheet_view_mut(sheet: &mut Worksheet) -> &mut SheetView {
431    let views = sheet.get_sheet_views_mut().get_sheet_view_list_mut();
432    if views.is_empty() {
433        let mut view = SheetView::default();
434        view.set_workbook_view_id(0);
435        let mut sheet_views = SheetViews::default();
436        sheet_views.add_sheet_view_list_mut(view);
437        sheet.set_sheets_views(sheet_views);
438    }
439    &mut sheet.get_sheet_views_mut().get_sheet_view_list_mut()[0]
440}
441
442fn apply_freeze_panes(
443    sheet: &mut Worksheet,
444    freeze_rows: u32,
445    freeze_cols: u32,
446    top_left_cell: Option<&str>,
447    warnings: &mut Vec<String>,
448) -> Result<()> {
449    if freeze_rows == 0 && freeze_cols == 0 {
450        bail!("freeze_rows and freeze_cols cannot both be 0");
451    }
452
453    let view = primary_sheet_view_mut(sheet);
454
455    let inferred = if let Some(tlc) = top_left_cell {
456        tlc.trim().to_string()
457    } else {
458        warnings.push(
459            "WARN_FREEZE_PANES_TOPLEFT_DEFAULTED: top_left_cell inferred from freeze_rows/freeze_cols"
460                .to_string(),
461        );
462        let col = freeze_cols.saturating_add(1).max(1);
463        let row = freeze_rows.saturating_add(1).max(1);
464        umya_spreadsheet::helper::coordinate::coordinate_from_index(&col, &row)
465    };
466
467    // Pane.topLeftCell is stored as a Coordinate (no $ locks).
468    let mut coord = Coordinate::default();
469    coord.set_coordinate(&inferred);
470
471    let mut pane = Pane::default();
472    if freeze_cols > 0 {
473        pane.set_horizontal_split(freeze_cols as f64);
474    }
475    if freeze_rows > 0 {
476        pane.set_vertical_split(freeze_rows as f64);
477    }
478    pane.set_top_left_cell(coord);
479    pane.set_state(PaneStateValues::Frozen);
480    pane.set_active_pane(active_pane_for_freeze(freeze_rows, freeze_cols));
481    view.set_pane(pane);
482
483    // Also set the sheetView attribute for consistency with Excel.
484    view.set_top_left_cell(inferred);
485
486    Ok(())
487}
488
489fn active_pane_for_freeze(freeze_rows: u32, freeze_cols: u32) -> PaneValues {
490    match (freeze_rows > 0, freeze_cols > 0) {
491        (true, true) => PaneValues::BottomRight,
492        (true, false) => PaneValues::BottomLeft,
493        (false, true) => PaneValues::BottomRight, // avoid umya "TopRight" string quirk
494        (false, false) => PaneValues::BottomRight,
495    }
496}
497
498fn validate_margin_value(field: &str, value: f64) -> Result<()> {
499    if !value.is_finite() {
500        bail!("{field} margin must be finite");
501    }
502    if value < 0.0 {
503        bail!("{field} margin must be >= 0");
504    }
505    Ok(())
506}
507
508fn set_print_area_defined_name(
509    book: &mut umya_spreadsheet::Spreadsheet,
510    sheet_name: &str,
511    range: &str,
512) -> Result<()> {
513    let sheet_index = resolve_sheet_index(book, sheet_name)?;
514    let (start, end) = parse_a1_range(range)?;
515
516    let start_abs = umya_spreadsheet::helper::coordinate::coordinate_from_index_with_lock(
517        &start.0, &start.1, &true, &true,
518    );
519    let end_abs = umya_spreadsheet::helper::coordinate::coordinate_from_index_with_lock(
520        &end.0, &end.1, &true, &true,
521    );
522    let sheet_prefix = format_sheet_prefix(sheet_name);
523    let refers_to = format!("{sheet_prefix}{start_abs}:{end_abs}");
524
525    // Remove any workbook-scoped print area entries for this sheet to avoid duplicates.
526    {
527        let defined = book.get_defined_names_mut();
528        defined.retain(|d| {
529            if d.get_name() != "_xlnm.Print_Area" {
530                return true;
531            }
532            if d.has_local_sheet_id() {
533                return *d.get_local_sheet_id() != sheet_index;
534            }
535            true
536        });
537    }
538
539    let sheet = book
540        .get_sheet_by_name_mut(sheet_name)
541        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
542
543    // If present on the sheet, update in place; otherwise create.
544    let mut found = false;
545    {
546        let names = sheet.get_defined_names_mut();
547        for defined in names.iter_mut() {
548            if defined.get_name() == "_xlnm.Print_Area" {
549                defined.set_address(refers_to.clone());
550                defined.set_local_sheet_id(sheet_index);
551                found = true;
552            }
553        }
554        // Deduplicate within the sheet scope.
555        if found {
556            let mut kept = false;
557            names.retain(|d| {
558                if d.get_name() != "_xlnm.Print_Area" {
559                    return true;
560                }
561                if !kept {
562                    kept = true;
563                    true
564                } else {
565                    false
566                }
567            });
568        }
569    }
570
571    if !found {
572        sheet
573            .add_defined_name("_xlnm.Print_Area".to_string(), refers_to)
574            .map_err(|e| anyhow!("failed to add defined name: {e}"))?;
575        // Set local sheet id on the just-added entry.
576        if let Some(last) = sheet.get_defined_names_mut().last_mut() {
577            if last.get_name() == "_xlnm.Print_Area" {
578                last.set_local_sheet_id(sheet_index);
579            }
580        }
581    }
582
583    Ok(())
584}
585
586fn resolve_sheet_index(book: &umya_spreadsheet::Spreadsheet, sheet_name: &str) -> Result<u32> {
587    for (idx, sheet) in book.get_sheet_collection().iter().enumerate() {
588        if sheet.get_name() == sheet_name {
589            return Ok(idx as u32);
590        }
591    }
592    bail!("sheet '{}' not found", sheet_name)
593}
594
595fn parse_a1_range(range: &str) -> Result<((u32, u32), (u32, u32))> {
596    let trimmed = range.trim();
597    if trimmed.is_empty() {
598        bail!("range is empty");
599    }
600    let range_part = if let Some((_, tail)) = trimmed.rsplit_once('!') {
601        tail
602    } else {
603        trimmed
604    };
605    let mut parts = range_part.split(':');
606    let a = parts.next().unwrap_or("").trim();
607    let b = parts.next().unwrap_or(a).trim();
608    if a.is_empty() {
609        bail!("range is empty");
610    }
611    let (ac, ar, _, _) = umya_spreadsheet::helper::coordinate::index_from_coordinate(a);
612    let (bc, br, _, _) = umya_spreadsheet::helper::coordinate::index_from_coordinate(b);
613    let (Some(ac), Some(ar), Some(bc), Some(br)) = (ac, ar, bc, br) else {
614        bail!("invalid range: {range}");
615    };
616    Ok(((ac.min(bc), ar.min(br)), (ac.max(bc), ar.max(br))))
617}
618
619fn format_sheet_prefix(sheet_name: &str) -> String {
620    if sheet_name_needs_quoting(sheet_name) {
621        let escaped = sheet_name.replace('\'', "''");
622        format!("'{escaped}'!")
623    } else {
624        format!("{sheet_name}!")
625    }
626}
627
628fn sheet_name_needs_quoting(name: &str) -> bool {
629    if name.is_empty() {
630        return false;
631    }
632    let bytes = name.as_bytes();
633    if bytes[0].is_ascii_digit() {
634        return true;
635    }
636    for &byte in bytes {
637        match byte {
638            b' ' | b'!' | b'"' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+'
639            | b',' | b'-' | b'.' | b'/' | b':' | b';' | b'<' | b'=' | b'>' | b'?' | b'@' | b'['
640            | b'\\' | b']' | b'^' | b'`' | b'{' | b'|' | b'}' | b'~' => return true,
641            _ => {}
642        }
643    }
644    let upper = name.to_uppercase();
645    matches!(
646        upper.as_str(),
647        "TRUE" | "FALSE" | "NULL" | "REF" | "DIV" | "NAME" | "NUM" | "VALUE" | "N/A"
648    )
649}
650
651fn apply_page_breaks(sheet: &mut Worksheet, row_breaks: &[u32], col_breaks: &[u32]) {
652    let rb = sheet.get_row_breaks_mut().get_break_list_mut();
653    rb.clear();
654    for &id in row_breaks {
655        let mut brk = Break::default();
656        brk.set_id(id).set_manual_page_break(true);
657        rb.push(brk);
658    }
659
660    let cb = sheet.get_column_breaks_mut().get_break_list_mut();
661    cb.clear();
662    for &id in col_breaks {
663        let mut brk = Break::default();
664        brk.set_id(id).set_manual_page_break(true);
665        cb.push(brk);
666    }
667}