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>, 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(¶ms.fork_id)?;
106 let work_path = fork_ctx.work_path.clone();
107
108 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 ¶ms.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(¶ms.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(¶ms.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 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 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, (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 {
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 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 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 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}