Skip to main content

spreadsheet_kit/tools/
sheet_layout.rs

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