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>, 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(¶ms.fork_id)?;
105 let work_path = fork_ctx.work_path.clone();
106
107 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 ¶ms.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(¶ms.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(¶ms.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 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 view.set_top_left_cell("");
486 view.set_pane(pane);
487
488 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, (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 {
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 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 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 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}