1use super::param_enums::{BatchMode, FillDirection, FormulaRelativeMode, ReplaceMatchMode};
2use crate::config::RecalcBackendKind;
3use crate::fork::{ChangeSummary, EditOp, StagedChange, StagedOp};
4use crate::formula::pattern::{RelativeMode, parse_base_formula, shift_formula_ast};
5use crate::model::{
6 AlignmentPatch, BordersPatch, CommandClass, FORMULA_PARSE_FAILED_PREFIX, FillPatch, FontPatch,
7 FormulaParseDiagnostics, FormulaParseDiagnosticsBuilder, FormulaParsePolicy, PatternFillPatch,
8 StylePatch, Warning, WorkbookId, validate_formula,
9};
10use crate::recalc::RecalcBackend;
11#[cfg(not(target_arch = "wasm32"))]
12use crate::security::sanitize_filename_component;
13use crate::state::AppState;
14use crate::tools::write_normalize::{EditBatchParamsInput, normalize_edit_batch};
15use crate::utils::make_short_random_id;
16use anyhow::{Result, anyhow, bail};
17use chrono::Utc;
18use formualizer_parse::tokenizer::Tokenizer;
19use regex::Regex;
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize, de};
22use std::collections::{BTreeMap, BTreeSet};
23use std::fs;
24use std::io::{Read, Write};
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28fn set_recalc_needed_flag(summary: &mut ChangeSummary, recalc_needed: bool) {
29 summary
30 .flags
31 .insert("recalc_needed".to_string(), recalc_needed);
32}
33
34#[derive(Debug, Deserialize, JsonSchema)]
35pub struct CreateForkParams {
36 #[serde(alias = "workbook_id")]
37 pub workbook_or_fork_id: WorkbookId,
38}
39
40#[derive(Debug, Serialize, JsonSchema)]
41pub struct CreateForkResponse {
42 pub fork_id: String,
43 pub base_workbook: String,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub client_base_workbook: Option<String>,
46 pub ttl_seconds: u64,
47}
48
49pub async fn create_fork(
50 state: Arc<AppState>,
51 params: CreateForkParams,
52) -> Result<CreateForkResponse> {
53 let registry = state
54 .fork_registry()
55 .ok_or_else(|| anyhow!("fork registry not available (recalc disabled?)"))?;
56
57 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
58 let base_path = &workbook.path;
59 let config = state.config();
60 let workspace_root = &config.workspace_root;
61
62 let fork_id = registry.create_fork(base_path, workspace_root)?;
63
64 Ok(CreateForkResponse {
65 fork_id,
66 base_workbook: base_path.display().to_string(),
67 client_base_workbook: config
68 .map_path_for_client(base_path)
69 .map(|p| p.display().to_string()),
70 ttl_seconds: registry.ttl().as_secs(),
71 })
72}
73
74#[derive(Debug, Deserialize, JsonSchema)]
75pub struct EditBatchParams {
76 pub fork_id: String,
77 pub sheet_name: String,
78 pub edits: Vec<CellEdit>,
79}
80
81#[derive(Debug, Clone, Deserialize, JsonSchema)]
82pub struct CellEdit {
83 pub address: String,
84 pub value: String,
85 #[serde(default)]
86 pub is_formula: bool,
87}
88
89#[derive(Debug, Serialize, JsonSchema)]
90pub struct EditBatchResponse {
91 pub fork_id: String,
92 pub edits_applied: usize,
93 pub total_edits: usize,
94 pub recalc_needed: bool,
95 #[serde(skip_serializing_if = "Vec::is_empty")]
96 pub warnings: Vec<Warning>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
99}
100
101pub async fn edit_batch(
102 state: Arc<AppState>,
103 params: EditBatchParamsInput,
104) -> Result<EditBatchResponse> {
105 let policy =
106 params
107 .formula_parse_policy
108 .unwrap_or(FormulaParsePolicy::default_for_command_class(
109 CommandClass::BatchWrite,
110 ));
111 let (params, warnings) = normalize_edit_batch(params)?;
112
113 let (edits_to_write, formula_parse_diagnostics) = if policy == FormulaParsePolicy::Off {
114 (params.edits.clone(), None)
115 } else {
116 let mut builder = FormulaParseDiagnosticsBuilder::new(policy);
117 let mut valid_edits = Vec::new();
118 for edit in ¶ms.edits {
119 if edit.is_formula {
120 match validate_formula(&edit.value) {
121 Ok(()) => valid_edits.push(edit.clone()),
122 Err(err_msg) => {
123 if policy == FormulaParsePolicy::Fail {
124 bail!(
125 "{}edit at {} failed: {}",
126 FORMULA_PARSE_FAILED_PREFIX,
127 edit.address,
128 err_msg
129 );
130 }
131 builder.record_error(
132 ¶ms.sheet_name,
133 &edit.address,
134 &edit.value,
135 &err_msg,
136 );
137 }
138 }
139 } else {
140 valid_edits.push(edit.clone());
141 }
142 }
143 let diagnostics = if builder.has_errors() {
144 Some(builder.build())
145 } else {
146 None
147 };
148 (valid_edits, diagnostics)
149 };
150
151 let registry = state
152 .fork_registry()
153 .ok_or_else(|| anyhow!("fork registry not available"))?;
154
155 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
156 let work_path = fork_ctx.work_path.clone();
157
158 let edits_to_apply: Vec<_> = edits_to_write
159 .iter()
160 .map(|e| EditOp {
161 timestamp: Utc::now(),
162 sheet: params.sheet_name.clone(),
163 address: e.address.clone(),
164 value: e.value.clone(),
165 is_formula: e.is_formula,
166 })
167 .collect();
168
169 let edit_count = edits_to_apply.len();
170
171 tokio::task::spawn_blocking({
172 let sheet_name = params.sheet_name.clone();
173 let edits = edits_to_write.clone();
174 move || {
175 let core_edits = edits
176 .into_iter()
177 .map(|edit| crate::core::types::CellEdit {
178 address: edit.address,
179 value: edit.value,
180 is_formula: edit.is_formula,
181 })
182 .collect::<Vec<_>>();
183 crate::core::write::apply_edits_to_file(&work_path, &sheet_name, &core_edits)
184 }
185 })
186 .await??;
187
188 let total = registry.with_fork_mut(¶ms.fork_id, |ctx| {
189 ctx.edits.extend(edits_to_apply);
190 ctx.recalc_needed = true;
191 Ok(ctx.edits.len())
192 })?;
193
194 let fork_workbook_id = WorkbookId(params.fork_id.clone());
195 let _ = state.close_workbook(&fork_workbook_id);
196
197 Ok(EditBatchResponse {
198 fork_id: params.fork_id,
199 edits_applied: edit_count,
200 total_edits: total,
201 recalc_needed: true,
202 warnings,
203 formula_parse_diagnostics,
204 })
205}
206
207fn default_clone_count() -> u32 {
208 1
209}
210
211fn default_clear_values() -> bool {
212 true
213}
214
215fn default_overwrite_formulas() -> bool {
216 false
217}
218
219fn default_replace_case_sensitive() -> bool {
220 true
221}
222
223#[derive(Debug, Deserialize, JsonSchema)]
224pub struct TransformBatchParams {
225 pub fork_id: String,
226 pub ops: Vec<TransformOp>,
227 #[serde(default)]
228 pub mode: Option<BatchMode>, pub label: Option<String>,
230 #[serde(default)]
231 pub formula_parse_policy: Option<FormulaParsePolicy>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
235pub enum MatrixCell {
236 #[serde(rename = "v")]
237 Value(serde_json::Value),
238 #[serde(rename = "f")]
239 Formula(String),
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
243#[serde(tag = "kind", rename_all = "snake_case")]
244pub enum TransformOp {
245 ClearRange {
246 sheet_name: String,
247 target: TransformTarget,
248 #[serde(default = "default_clear_values")]
249 clear_values: bool,
250 #[serde(default)]
251 clear_formulas: bool,
252 },
253 FillRange {
254 sheet_name: String,
255 target: TransformTarget,
256 value: String,
257 #[serde(default)]
258 is_formula: bool,
259 #[serde(default = "default_overwrite_formulas")]
260 overwrite_formulas: bool,
261 },
262 ReplaceInRange {
263 sheet_name: String,
264 target: TransformTarget,
265 find: String,
266 replace: String,
267 #[serde(default)]
268 match_mode: ReplaceMatchMode,
269 #[serde(default = "default_replace_case_sensitive")]
270 case_sensitive: bool,
271 #[serde(default)]
272 include_formulas: bool,
273 },
274 WriteMatrix {
275 sheet_name: String,
276 anchor: String,
277 rows: Vec<Vec<Option<MatrixCell>>>,
278 #[serde(default = "default_overwrite_formulas")]
279 overwrite_formulas: bool,
280 },
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
284#[serde(tag = "kind", rename_all = "snake_case")]
285pub enum TransformTarget {
286 Range { range: String },
287 Region { region_id: u32 },
288 Cells { cells: Vec<String> },
289}
290
291#[derive(Debug, Serialize, JsonSchema)]
292pub struct TransformBatchResponse {
293 pub fork_id: String,
294 pub mode: String,
295 pub change_id: Option<String>,
296 pub ops_applied: usize,
297 pub summary: ChangeSummary,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
300}
301
302#[derive(Debug, Serialize, Deserialize)]
303struct TransformBatchStagedPayload {
304 ops: Vec<TransformOp>,
305}
306
307pub(crate) fn resolve_transform_ops_for_workbook(
308 workbook: &crate::workbook::WorkbookContext,
309 ops: &[TransformOp],
310) -> Result<Vec<TransformOp>> {
311 let mut resolved_ops = Vec::with_capacity(ops.len());
312
313 for op in ops {
314 match op {
315 TransformOp::WriteMatrix { .. } => {
316 resolved_ops.push(op.clone());
317 }
318 TransformOp::ClearRange {
319 sheet_name, target, ..
320 }
321 | TransformOp::FillRange {
322 sheet_name, target, ..
323 }
324 | TransformOp::ReplaceInRange {
325 sheet_name, target, ..
326 } => {
327 let resolved_target = match target {
328 TransformTarget::Region { region_id } => {
329 let metrics = workbook.get_sheet_metrics(sheet_name)?;
330 let regions = metrics.detected_regions();
331 let region =
332 regions.iter().find(|r| r.id == *region_id).ok_or_else(|| {
333 anyhow!(
334 "region_id {} not found on sheet '{}'",
335 region_id,
336 sheet_name
337 )
338 })?;
339 TransformTarget::Range {
340 range: region.bounds.clone(),
341 }
342 }
343 other => other.clone(),
344 };
345
346 match op {
347 TransformOp::ClearRange {
348 sheet_name,
349 clear_values,
350 clear_formulas,
351 ..
352 } => {
353 resolved_ops.push(TransformOp::ClearRange {
354 sheet_name: sheet_name.clone(),
355 target: resolved_target,
356 clear_values: *clear_values,
357 clear_formulas: *clear_formulas,
358 });
359 }
360 TransformOp::FillRange {
361 sheet_name,
362 value,
363 is_formula,
364 overwrite_formulas,
365 ..
366 } => {
367 resolved_ops.push(TransformOp::FillRange {
368 sheet_name: sheet_name.clone(),
369 target: resolved_target,
370 value: value.clone(),
371 is_formula: *is_formula,
372 overwrite_formulas: *overwrite_formulas,
373 });
374 }
375 TransformOp::ReplaceInRange {
376 sheet_name,
377 find,
378 replace,
379 match_mode,
380 case_sensitive,
381 include_formulas,
382 ..
383 } => {
384 resolved_ops.push(TransformOp::ReplaceInRange {
385 sheet_name: sheet_name.clone(),
386 target: resolved_target,
387 find: find.clone(),
388 replace: replace.clone(),
389 match_mode: *match_mode,
390 case_sensitive: *case_sensitive,
391 include_formulas: *include_formulas,
392 });
393 }
394 TransformOp::WriteMatrix { .. } => unreachable!(),
395 }
396 }
397 }
398 }
399
400 Ok(resolved_ops)
401}
402
403pub async fn transform_batch(
404 state: Arc<AppState>,
405 params: TransformBatchParams,
406) -> Result<TransformBatchResponse> {
407 let registry = state
408 .fork_registry()
409 .ok_or_else(|| anyhow!("fork registry not available"))?;
410
411 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
412 let work_path = fork_ctx.work_path.clone();
413
414 let fork_workbook_id = WorkbookId(params.fork_id.clone());
415 let workbook = state.open_workbook(&fork_workbook_id).await?;
416
417 let resolved_ops = resolve_transform_ops_for_workbook(&workbook, ¶ms.ops)?;
418
419 let policy =
420 params
421 .formula_parse_policy
422 .unwrap_or(FormulaParsePolicy::default_for_command_class(
423 CommandClass::BatchWrite,
424 ));
425
426 let (ops_to_apply, formula_parse_diagnostics) = if policy == FormulaParsePolicy::Off {
427 (resolved_ops, None)
428 } else {
429 let mut builder = FormulaParseDiagnosticsBuilder::new(policy);
430 let mut valid_ops = Vec::new();
431 for op in resolved_ops {
432 match &op {
433 TransformOp::FillRange {
434 sheet_name,
435 value,
436 is_formula,
437 ..
438 } if *is_formula => match validate_formula(value) {
439 Ok(()) => valid_ops.push(op),
440 Err(err_msg) => {
441 if policy == FormulaParsePolicy::Fail {
442 bail!(
443 "{}FillRange formula failed: {}",
444 FORMULA_PARSE_FAILED_PREFIX,
445 err_msg
446 );
447 }
448 builder.record_error(sheet_name, "FillRange", value, &err_msg);
449 }
450 },
451 TransformOp::WriteMatrix {
452 sheet_name,
453 anchor,
454 rows,
455 overwrite_formulas,
456 } => {
457 let mut has_errors = false;
458 let mut valid_rows = Vec::new();
459
460 let (anchor_col, anchor_row) = parse_cell_ref(anchor)?;
461
462 for (r_idx, row) in rows.iter().enumerate() {
463 let mut valid_row = Vec::new();
464 let r = anchor_row + r_idx as u32;
465
466 for (c_idx, cell_opt) in row.iter().enumerate() {
467 let c = anchor_col + c_idx as u32;
468 if let Some(MatrixCell::Formula(f)) = cell_opt {
469 match validate_formula(f) {
470 Ok(()) => valid_row.push(cell_opt.clone()),
471 Err(err_msg) => {
472 if policy == FormulaParsePolicy::Fail {
473 bail!(
474 "{}WriteMatrix formula failed at {}: {}",
475 FORMULA_PARSE_FAILED_PREFIX,
476 crate::utils::cell_address(c, r),
477 err_msg
478 );
479 }
480 builder.record_error(
481 sheet_name,
482 &crate::utils::cell_address(c, r),
483 f,
484 &err_msg,
485 );
486 has_errors = true;
487 valid_row.push(None); }
489 }
490 } else {
491 valid_row.push(cell_opt.clone());
492 }
493 }
494 valid_rows.push(valid_row);
495 }
496
497 if has_errors && policy == FormulaParsePolicy::Warn {
498 valid_ops.push(TransformOp::WriteMatrix {
499 sheet_name: sheet_name.clone(),
500 anchor: anchor.clone(),
501 rows: valid_rows,
502 overwrite_formulas: *overwrite_formulas,
503 });
504 } else {
505 valid_ops.push(op);
506 }
507 }
508 _ => valid_ops.push(op),
509 }
510 }
511 let diagnostics = if builder.has_errors() {
512 Some(builder.build())
513 } else {
514 None
515 };
516 (valid_ops, diagnostics)
517 };
518
519 let mode = params.mode.unwrap_or_default();
520
521 if mode.is_preview() {
522 let change_id = make_short_random_id("chg", 12);
523 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
524 fs::create_dir_all(snapshot_path.parent().unwrap())?;
525 fs::copy(&work_path, &snapshot_path)?;
526
527 let snapshot_for_apply = snapshot_path.clone();
528 let apply_result = tokio::task::spawn_blocking({
529 let ops = ops_to_apply.clone();
530 move || apply_transform_ops_to_file(&snapshot_for_apply, &ops)
531 })
532 .await??;
533
534 let mut summary = apply_result.summary;
535 summary.op_kinds = vec!["transform_batch".to_string()];
536 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
537
538 let staged_op = StagedOp {
539 kind: "transform_batch".to_string(),
540 payload: serde_json::to_value(TransformBatchStagedPayload {
541 ops: ops_to_apply.clone(),
542 })?,
543 };
544
545 let staged = StagedChange {
546 change_id: change_id.clone(),
547 created_at: Utc::now(),
548 label: params.label.clone(),
549 ops: vec![staged_op],
550 summary: summary.clone(),
551 fork_path_snapshot: Some(snapshot_path),
552 };
553
554 registry.add_staged_change(¶ms.fork_id, staged)?;
555
556 Ok(TransformBatchResponse {
557 fork_id: params.fork_id,
558 mode: mode.as_str().to_string(),
559 change_id: Some(change_id),
560 ops_applied: apply_result.ops_applied,
561 summary,
562 formula_parse_diagnostics,
563 })
564 } else {
565 let apply_result = tokio::task::spawn_blocking({
566 let ops = ops_to_apply.clone();
567 let work_path = work_path.clone();
568 move || apply_transform_ops_to_file(&work_path, &ops)
569 })
570 .await??;
571
572 let mut summary = apply_result.summary;
573 summary.op_kinds = vec!["transform_batch".to_string()];
574
575 registry.with_fork_mut(¶ms.fork_id, |ctx| {
576 ctx.recalc_needed = true;
577 Ok(())
578 })?;
579 set_recalc_needed_flag(&mut summary, true);
580
581 let _ = state.close_workbook(&fork_workbook_id);
582
583 Ok(TransformBatchResponse {
584 fork_id: params.fork_id,
585 mode: mode.as_str().to_string(),
586 change_id: None,
587 ops_applied: apply_result.ops_applied,
588 summary,
589 formula_parse_diagnostics,
590 })
591 }
592}
593
594#[derive(Debug, Deserialize, JsonSchema)]
595pub struct StyleBatchParams {
596 pub fork_id: String,
597 pub ops: Vec<StyleOp>,
598 #[serde(default)]
599 pub mode: Option<BatchMode>, pub label: Option<String>,
601}
602
603#[derive(Debug, Deserialize, JsonSchema)]
604pub struct StyleBatchParamsInput {
605 pub fork_id: String,
606 pub ops: Vec<StyleOpInput>,
607 #[serde(default)]
608 pub mode: Option<BatchMode>,
609 pub label: Option<String>,
610}
611
612#[derive(Debug, Clone)]
613pub struct StyleOpInput {
614 op: StyleOp,
615 shorthand_used: bool,
616 fill_color_used: bool,
617 color_alpha_defaulted: bool,
618}
619
620impl From<StyleOp> for StyleOpInput {
621 fn from(op: StyleOp) -> Self {
622 Self {
623 op,
624 shorthand_used: false,
625 fill_color_used: false,
626 color_alpha_defaulted: false,
627 }
628 }
629}
630
631impl<'de> Deserialize<'de> for StyleOpInput {
632 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
633 where
634 D: serde::Deserializer<'de>,
635 {
636 let mut value = serde_json::Value::deserialize(deserializer)?;
637 let Some(obj) = value.as_object_mut() else {
638 return Err(de::Error::custom("style op must be an object"));
639 };
640
641 let mut shorthand_used = false;
642 let mut fill_color_used = false;
643 let mut color_alpha_defaulted = false;
644
645 if obj.get("target").is_none()
646 && let Some(range) = obj.remove("range")
647 {
648 shorthand_used = true;
649 obj.insert(
650 "target".to_string(),
651 serde_json::json!({ "kind": "range", "range": range }),
652 );
653 }
654
655 if obj.get("patch").is_none()
656 && let Some(style) = obj.remove("style")
657 {
658 shorthand_used = true;
659 obj.insert("patch".to_string(), style);
660 }
661
662 if let Some(patch_value) = obj.remove("patch") {
663 let patch_input: StylePatchInput =
664 serde_json::from_value(patch_value).map_err(de::Error::custom)?;
665 let (patch, used_fill_color, alpha_defaulted) =
666 normalize_style_patch_input(patch_input);
667 if used_fill_color {
668 fill_color_used = true;
669 }
670 if alpha_defaulted {
671 color_alpha_defaulted = true;
672 }
673 obj.insert(
674 "patch".to_string(),
675 serde_json::to_value(patch).map_err(de::Error::custom)?,
676 );
677 }
678
679 let op = serde_json::from_value(value).map_err(de::Error::custom)?;
680 Ok(StyleOpInput {
681 op,
682 shorthand_used,
683 fill_color_used,
684 color_alpha_defaulted,
685 })
686 }
687}
688
689impl schemars::JsonSchema for StyleOpInput {
690 fn schema_name() -> std::borrow::Cow<'static, str> {
691 "StyleOp".into()
692 }
693
694 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
695 StyleOp::json_schema(generator)
696 }
697}
698
699#[derive(Debug, Clone, Deserialize)]
700struct StylePatchInput {
701 #[serde(default)]
702 pub font: Option<Option<FontPatch>>,
703 #[serde(default)]
704 pub fill: Option<Option<FillPatchInput>>,
705 #[serde(default)]
706 pub borders: Option<Option<BordersPatch>>,
707 #[serde(default)]
708 pub alignment: Option<Option<AlignmentPatch>>,
709 #[serde(default)]
710 pub number_format: Option<Option<NumberFormatPatchInput>>,
711}
712
713#[derive(Debug, Clone, Deserialize)]
714#[serde(untagged)]
715enum NumberFormatPatchInput {
716 FormatCode(String),
717 Shorthand(NumberFormatShorthandInput),
718}
719
720#[derive(Debug, Clone, Deserialize)]
721struct NumberFormatShorthandInput {
722 pub kind: NumberFormatKind,
723 #[serde(default)]
724 pub format_code: Option<String>,
725}
726
727#[derive(Debug, Clone, Deserialize)]
728#[serde(rename_all = "snake_case")]
729enum NumberFormatKind {
730 Currency,
731 Percent,
732 DateIso,
733 Accounting,
734 Integer,
735}
736
737fn number_format_kind_to_format_code(kind: &NumberFormatKind) -> &'static str {
738 match kind {
739 NumberFormatKind::Currency => "$#,##0.00",
740 NumberFormatKind::Percent => "0.00%",
741 NumberFormatKind::DateIso => "yyyy-mm-dd",
742 NumberFormatKind::Accounting => "_($* #,##0.00_)",
743 NumberFormatKind::Integer => "0",
744 }
745}
746
747#[derive(Debug, Clone, Deserialize)]
748#[serde(untagged)]
749enum FillPatchInput {
750 Canonical(FillPatch),
751 Color(FillColorPatch),
752}
753
754#[derive(Debug, Clone, Deserialize)]
755struct FillColorPatch {
756 color: String,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
760pub struct StyleOp {
761 pub sheet_name: String,
762 pub target: StyleTarget,
763 pub patch: StylePatch,
764 #[serde(default)]
765 pub op_mode: Option<crate::styles::StylePatchMode>,
766}
767
768#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
769#[serde(tag = "kind", rename_all = "snake_case")]
770pub enum StyleTarget {
771 Range { range: String },
772 Region { region_id: u32 },
773 Cells { cells: Vec<String> },
774}
775
776#[derive(Debug, Serialize, JsonSchema)]
777pub struct StyleBatchResponse {
778 pub fork_id: String,
779 pub mode: String,
780 pub change_id: Option<String>,
781 pub ops_applied: usize,
782 pub summary: ChangeSummary,
783}
784
785#[derive(Debug, Deserialize, JsonSchema)]
786pub struct ColumnSizeBatchParamsInput {
787 pub fork_id: String,
788 pub sheet_name: String,
789 pub ops: Vec<ColumnSizeOpInput>,
790 pub mode: Option<BatchMode>, pub label: Option<String>,
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
795#[serde(tag = "kind", rename_all = "snake_case")]
796pub enum ColumnTarget {
797 Columns { range: String },
798}
799
800#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
801#[serde(tag = "kind", rename_all = "snake_case")]
802pub enum ColumnSizeSpec {
803 Auto {
804 #[serde(default)]
805 min_width_chars: Option<f64>,
806 #[serde(default)]
807 max_width_chars: Option<f64>,
808 },
809 Width {
810 width_chars: f64,
811 },
812}
813
814#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
815pub struct ColumnSizeOp {
816 pub target: ColumnTarget,
817 pub size: ColumnSizeSpec,
818}
819
820#[derive(Debug, Deserialize, JsonSchema)]
821#[serde(untagged)]
822pub enum ColumnSizeOpInput {
823 Canonical(ColumnSizeOp),
824 Shorthand { range: String, size: ColumnSizeSpec },
825}
826
827impl From<ColumnSizeOp> for ColumnSizeOpInput {
828 fn from(value: ColumnSizeOp) -> Self {
829 Self::Canonical(value)
830 }
831}
832
833#[derive(Debug, Clone)]
834struct ColumnSizeBatchParams {
835 fork_id: String,
836 sheet_name: String,
837 ops: Vec<ColumnSizeOp>,
838 mode: Option<BatchMode>,
839 label: Option<String>,
840}
841
842#[derive(Debug, Serialize, JsonSchema)]
843pub struct ColumnSizeBatchResponse {
844 pub fork_id: String,
845 pub sheet_name: String,
846 pub mode: String,
847 pub change_id: Option<String>,
848 pub ops_applied: usize,
849 pub summary: ChangeSummary,
850}
851
852#[derive(Debug, Serialize, Deserialize)]
853struct ColumnSizeBatchStagedPayload {
854 sheet_name: String,
855 ops: Vec<ColumnSizeOp>,
856}
857
858fn normalize_column_size_batch(
859 params: ColumnSizeBatchParamsInput,
860) -> Result<(ColumnSizeBatchParams, Vec<crate::model::Warning>)> {
861 let mut warnings = Vec::new();
862 let mut ops = Vec::with_capacity(params.ops.len());
863
864 for entry in params.ops {
865 match entry {
866 ColumnSizeOpInput::Canonical(op) => ops.push(op),
867 ColumnSizeOpInput::Shorthand { range, size } => {
868 warnings.push(crate::model::Warning {
869 code: "WARN_COLUMN_SHORTHAND_TARGET".to_string(),
870 message: "Used range shorthand; prefer target:{kind:'columns',range:'A:C'}"
871 .to_string(),
872 });
873 ops.push(ColumnSizeOp {
874 target: ColumnTarget::Columns { range },
875 size,
876 });
877 }
878 }
879 }
880
881 Ok((
882 ColumnSizeBatchParams {
883 fork_id: params.fork_id,
884 sheet_name: params.sheet_name,
885 ops,
886 mode: params.mode,
887 label: params.label,
888 },
889 warnings,
890 ))
891}
892
893pub(crate) fn normalize_column_size_payload(
894 sheet_name: String,
895 ops: Vec<ColumnSizeOpInput>,
896) -> Result<(Vec<ColumnSizeOp>, Vec<Warning>)> {
897 let (params, warnings) = normalize_column_size_batch(ColumnSizeBatchParamsInput {
898 fork_id: String::new(),
899 sheet_name,
900 ops,
901 mode: None,
902 label: None,
903 })?;
904 Ok((params.ops, warnings))
905}
906
907pub async fn column_size_batch(
908 state: Arc<AppState>,
909 params: ColumnSizeBatchParamsInput,
910) -> Result<ColumnSizeBatchResponse> {
911 let (params, warnings) = normalize_column_size_batch(params)?;
912 let warning_messages: Vec<String> = warnings
913 .into_iter()
914 .map(|warning| format!("{}: {}", warning.code, warning.message))
915 .collect();
916 let registry = state
917 .fork_registry()
918 .ok_or_else(|| anyhow!("fork registry not available"))?;
919
920 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
921 let work_path = fork_ctx.work_path.clone();
922
923 let fork_workbook_id = WorkbookId(params.fork_id.clone());
924 let workbook = state.open_workbook(&fork_workbook_id).await?;
925 let _ = workbook.with_sheet(¶ms.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
926
927 let mode = params.mode.unwrap_or_default();
928
929 if mode.is_preview() {
930 let change_id = make_short_random_id("chg", 12);
931 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
932 fs::create_dir_all(snapshot_path.parent().unwrap())?;
933 fs::copy(&work_path, &snapshot_path)?;
934
935 let snapshot_path_for_apply = snapshot_path.clone();
936 let apply_result = tokio::task::spawn_blocking({
937 let ops = params.ops.clone();
938 let sheet_name = params.sheet_name.clone();
939 move || apply_column_size_ops_to_file(&snapshot_path_for_apply, &sheet_name, &ops)
940 })
941 .await??;
942
943 let mut summary = apply_result.summary;
944 summary.op_kinds = vec!["column_size_batch".to_string()];
945 summary.warnings.extend(warning_messages.clone());
946 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
947
948 let staged_op = StagedOp {
949 kind: "column_size_batch".to_string(),
950 payload: serde_json::to_value(ColumnSizeBatchStagedPayload {
951 sheet_name: params.sheet_name.clone(),
952 ops: params.ops.clone(),
953 })?,
954 };
955
956 let staged = StagedChange {
957 change_id: change_id.clone(),
958 created_at: Utc::now(),
959 label: params.label.clone(),
960 ops: vec![staged_op],
961 summary: summary.clone(),
962 fork_path_snapshot: Some(snapshot_path),
963 };
964
965 registry.add_staged_change(¶ms.fork_id, staged)?;
966
967 Ok(ColumnSizeBatchResponse {
968 fork_id: params.fork_id,
969 sheet_name: params.sheet_name,
970 mode: mode.as_str().to_string(),
971 change_id: Some(change_id),
972 ops_applied: apply_result.ops_applied,
973 summary,
974 })
975 } else {
976 let apply_result = tokio::task::spawn_blocking({
977 let ops = params.ops.clone();
978 let sheet_name = params.sheet_name.clone();
979 let work_path = work_path.clone();
980 move || apply_column_size_ops_to_file(&work_path, &sheet_name, &ops)
981 })
982 .await??;
983
984 let mut summary = apply_result.summary;
985 summary.op_kinds = vec!["column_size_batch".to_string()];
986 summary.warnings.extend(warning_messages);
987 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
988
989 let _ = state.close_workbook(&fork_workbook_id);
990
991 Ok(ColumnSizeBatchResponse {
992 fork_id: params.fork_id,
993 sheet_name: params.sheet_name,
994 mode: mode.as_str().to_string(),
995 change_id: None,
996 ops_applied: apply_result.ops_applied,
997 summary,
998 })
999 }
1000}
1001
1002#[derive(Debug, Serialize, Deserialize)]
1003struct StyleBatchStagedPayload {
1004 ops: Vec<StyleOp>,
1005}
1006
1007pub fn normalize_style_batch(
1008 input: StyleBatchParamsInput,
1009) -> Result<(StyleBatchParams, Vec<Warning>)> {
1010 let mut warnings = Vec::new();
1011 let mut ops = Vec::with_capacity(input.ops.len());
1012
1013 for op_input in input.ops {
1014 if op_input.shorthand_used {
1015 warnings.push(Warning {
1016 code: "WARN_STYLE_SHORTHAND".to_string(),
1017 message: "Normalized style op shorthand to canonical form".to_string(),
1018 });
1019 }
1020 if op_input.fill_color_used {
1021 warnings.push(Warning {
1022 code: "WARN_FILL_COLOR".to_string(),
1023 message: "Normalized fill color shorthand to pattern fill".to_string(),
1024 });
1025 }
1026 if op_input.color_alpha_defaulted {
1027 warnings.push(Warning {
1028 code: "WARN_COLOR_ALPHA_DEFAULT".to_string(),
1029 message: "Normalized RGB hex to ARGB with default alpha".to_string(),
1030 });
1031 }
1032 ops.push(op_input.op);
1033 }
1034
1035 Ok((
1036 StyleBatchParams {
1037 fork_id: input.fork_id,
1038 ops,
1039 mode: input.mode,
1040 label: input.label,
1041 },
1042 warnings,
1043 ))
1044}
1045
1046fn normalize_style_patch_input(input: StylePatchInput) -> (StylePatch, bool, bool) {
1047 let mut fill_color_used = false;
1048 let mut color_alpha_defaulted = false;
1049 let fill = match input.fill {
1050 None => None,
1051 Some(None) => Some(None),
1052 Some(Some(fill_input)) => {
1053 let normalized = match fill_input {
1054 FillPatchInput::Canonical(fill) => fill,
1055 FillPatchInput::Color(color) => {
1056 fill_color_used = true;
1057 FillPatch::Pattern(PatternFillPatch {
1058 pattern_type: Some(Some("solid".to_string())),
1059 foreground_color: Some(Some(color.color)),
1060 background_color: None,
1061 })
1062 }
1063 };
1064 Some(Some(normalized))
1065 }
1066 };
1067
1068 let number_format: Option<Option<String>> = match input.number_format {
1069 None => None,
1070 Some(None) => Some(None),
1071 Some(Some(nf)) => match nf {
1072 NumberFormatPatchInput::FormatCode(code) => Some(Some(code)),
1073 NumberFormatPatchInput::Shorthand(sh) => {
1074 if let Some(code) = sh.format_code {
1075 Some(Some(code))
1076 } else {
1077 Some(Some(
1078 number_format_kind_to_format_code(&sh.kind).to_string(),
1079 ))
1080 }
1081 }
1082 },
1083 };
1084
1085 let mut patch = StylePatch {
1086 font: input.font,
1087 fill,
1088 borders: input.borders,
1089 alignment: input.alignment,
1090 number_format,
1091 };
1092 normalize_style_patch_colors(&mut patch, &mut color_alpha_defaulted);
1093
1094 (patch, fill_color_used, color_alpha_defaulted)
1095}
1096
1097fn normalize_style_patch_colors(patch: &mut StylePatch, alpha_defaulted: &mut bool) {
1098 if let Some(Some(font)) = patch.font.as_mut() {
1099 normalize_color_option(&mut font.color, alpha_defaulted);
1100 }
1101
1102 if let Some(Some(fill)) = patch.fill.as_mut() {
1103 normalize_fill_colors(fill, alpha_defaulted);
1104 }
1105
1106 if let Some(Some(borders)) = patch.borders.as_mut() {
1107 normalize_border_side_color(&mut borders.left, alpha_defaulted);
1108 normalize_border_side_color(&mut borders.right, alpha_defaulted);
1109 normalize_border_side_color(&mut borders.top, alpha_defaulted);
1110 normalize_border_side_color(&mut borders.bottom, alpha_defaulted);
1111 normalize_border_side_color(&mut borders.diagonal, alpha_defaulted);
1112 normalize_border_side_color(&mut borders.vertical, alpha_defaulted);
1113 normalize_border_side_color(&mut borders.horizontal, alpha_defaulted);
1114 }
1115}
1116
1117fn normalize_fill_colors(fill: &mut FillPatch, alpha_defaulted: &mut bool) {
1118 match fill {
1119 FillPatch::Pattern(pattern) => {
1120 normalize_color_option(&mut pattern.foreground_color, alpha_defaulted);
1121 normalize_color_option(&mut pattern.background_color, alpha_defaulted);
1122 }
1123 FillPatch::Gradient(gradient) => {
1124 if let Some(stops) = gradient.stops.as_mut() {
1125 for stop in stops {
1126 if let Some((normalized, defaulted)) =
1127 crate::styles::normalize_color_hex(&stop.color)
1128 {
1129 stop.color = normalized;
1130 if defaulted {
1131 *alpha_defaulted = true;
1132 }
1133 }
1134 }
1135 }
1136 }
1137 }
1138}
1139
1140fn normalize_border_side_color(
1141 side: &mut Option<Option<crate::model::BorderSidePatch>>,
1142 alpha_defaulted: &mut bool,
1143) {
1144 if let Some(Some(side_patch)) = side.as_mut() {
1145 normalize_color_option(&mut side_patch.color, alpha_defaulted);
1146 }
1147}
1148
1149fn normalize_color_option(value: &mut Option<Option<String>>, alpha_defaulted: &mut bool) {
1150 let Some(Some(color)) = value.as_mut() else {
1151 return;
1152 };
1153 if let Some((normalized, defaulted)) = crate::styles::normalize_color_hex(color) {
1154 *color = normalized;
1155 if defaulted {
1156 *alpha_defaulted = true;
1157 }
1158 }
1159}
1160
1161pub(crate) fn resolve_style_ops_for_workbook(
1162 workbook: &crate::workbook::WorkbookContext,
1163 ops: &[StyleOp],
1164) -> Result<Vec<StyleOp>> {
1165 let mut resolved_ops = Vec::with_capacity(ops.len());
1166 for op in ops {
1167 let mut resolved = op.clone();
1168 if let StyleTarget::Region { region_id } = &op.target {
1169 let metrics = workbook.get_sheet_metrics(&op.sheet_name)?;
1170 let regions = metrics.detected_regions();
1171 let region = regions.iter().find(|r| r.id == *region_id).ok_or_else(|| {
1172 anyhow!(
1173 "region_id {} not found on sheet '{}'",
1174 region_id,
1175 op.sheet_name
1176 )
1177 })?;
1178 resolved.target = StyleTarget::Range {
1179 range: region.bounds.clone(),
1180 };
1181 }
1182 resolved_ops.push(resolved);
1183 }
1184 Ok(resolved_ops)
1185}
1186
1187pub async fn style_batch(
1188 state: Arc<AppState>,
1189 params: StyleBatchParamsInput,
1190) -> Result<StyleBatchResponse> {
1191 let (params, warnings) = normalize_style_batch(params)?;
1192 let warning_messages: Vec<String> = warnings
1193 .into_iter()
1194 .map(|warning| format!("{}: {}", warning.code, warning.message))
1195 .collect();
1196 let registry = state
1197 .fork_registry()
1198 .ok_or_else(|| anyhow!("fork registry not available"))?;
1199
1200 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
1201 let work_path = fork_ctx.work_path.clone();
1202
1203 let fork_workbook_id = WorkbookId(params.fork_id.clone());
1205 let workbook = state.open_workbook(&fork_workbook_id).await?;
1206 let resolved_ops = resolve_style_ops_for_workbook(&workbook, ¶ms.ops)?;
1207
1208 let mode = params.mode.unwrap_or_default();
1209
1210 if mode.is_preview() {
1211 let change_id = make_short_random_id("chg", 12);
1212 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
1213 fs::create_dir_all(snapshot_path.parent().unwrap())?;
1214 fs::copy(&work_path, &snapshot_path)?;
1215
1216 let snapshot_path_for_apply = snapshot_path.clone();
1217 let apply_result = tokio::task::spawn_blocking({
1218 let ops = resolved_ops.clone();
1219 move || apply_style_ops_to_file(&snapshot_path_for_apply, &ops)
1220 })
1221 .await??;
1222
1223 let mut summary = apply_result.summary;
1224 summary.op_kinds = vec!["style_batch".to_string()];
1225 summary.warnings.extend(warning_messages.clone());
1226 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1227
1228 let staged_op = StagedOp {
1229 kind: "style_batch".to_string(),
1230 payload: serde_json::to_value(StyleBatchStagedPayload {
1231 ops: resolved_ops.clone(),
1232 })?,
1233 };
1234
1235 let staged = StagedChange {
1236 change_id: change_id.clone(),
1237 created_at: Utc::now(),
1238 label: params.label.clone(),
1239 ops: vec![staged_op],
1240 summary: summary.clone(),
1241 fork_path_snapshot: Some(snapshot_path),
1242 };
1243
1244 registry.add_staged_change(¶ms.fork_id, staged)?;
1245
1246 Ok(StyleBatchResponse {
1247 fork_id: params.fork_id,
1248 mode: mode.as_str().to_string(),
1249 change_id: Some(change_id),
1250 ops_applied: resolved_ops.len(),
1251 summary,
1252 })
1253 } else {
1254 let apply_result = tokio::task::spawn_blocking({
1255 let ops = resolved_ops.clone();
1256 let work_path = work_path.clone();
1257 move || apply_style_ops_to_file(&work_path, &ops)
1258 })
1259 .await??;
1260
1261 let mut summary = apply_result.summary;
1262 summary.op_kinds = vec!["style_batch".to_string()];
1263 summary.warnings.extend(warning_messages);
1264 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1265
1266 let _ = state.close_workbook(&fork_workbook_id);
1267
1268 Ok(StyleBatchResponse {
1269 fork_id: params.fork_id,
1270 mode: mode.as_str().to_string(),
1271 change_id: None,
1272 ops_applied: apply_result.ops_applied,
1273 summary,
1274 })
1275 }
1276}
1277
1278#[derive(Debug, Deserialize, JsonSchema)]
1279pub struct ApplyFormulaPatternParams {
1280 pub fork_id: String,
1281 pub sheet_name: String,
1282 pub target_range: String,
1283 pub anchor_cell: String,
1284 pub base_formula: String,
1285 #[serde(default)]
1286 pub fill_direction: Option<FillDirection>, #[serde(default)]
1288 pub relative_mode: Option<FormulaRelativeMode>, #[serde(default)]
1290 pub mode: Option<BatchMode>, pub label: Option<String>,
1292}
1293
1294#[derive(Debug, Serialize, JsonSchema)]
1295pub struct ApplyFormulaPatternResponse {
1296 pub fork_id: String,
1297 pub sheet_name: String,
1298 pub target_range: String,
1299 pub mode: String,
1300 pub change_id: Option<String>,
1301 pub cells_filled: u64,
1302 pub summary: ChangeSummary,
1303}
1304
1305#[derive(Debug, Serialize, Deserialize)]
1306struct ApplyFormulaPatternStagedPayload {
1307 sheet_name: String,
1308 target_range: String,
1309 anchor_cell: String,
1310 base_formula: String,
1311 fill_direction: Option<FillDirection>,
1312 relative_mode: Option<FormulaRelativeMode>,
1313}
1314
1315#[derive(Debug, Clone, Deserialize, JsonSchema)]
1316pub struct ApplyFormulaPatternOpInput {
1317 pub sheet_name: String,
1318 pub target_range: String,
1319 pub anchor_cell: String,
1320 pub base_formula: String,
1321 #[serde(default)]
1322 pub fill_direction: Option<FillDirection>,
1323 #[serde(default)]
1324 pub relative_mode: Option<FormulaRelativeMode>,
1325}
1326
1327pub async fn apply_formula_pattern(
1328 state: Arc<AppState>,
1329 params: ApplyFormulaPatternParams,
1330) -> Result<ApplyFormulaPatternResponse> {
1331 let registry = state
1332 .fork_registry()
1333 .ok_or_else(|| anyhow!("fork registry not available"))?;
1334
1335 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
1336 let work_path = fork_ctx.work_path.clone();
1337
1338 let bounds = parse_range_bounds(¶ms.target_range)?;
1339 let (anchor_col, anchor_row) = parse_cell_ref(¶ms.anchor_cell)?;
1340 let fill_direction = params.fill_direction.unwrap_or_default();
1341 validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
1342
1343 let fork_workbook_id = WorkbookId(params.fork_id.clone());
1344 let workbook = state.open_workbook(&fork_workbook_id).await?;
1345 let _ = workbook.with_sheet(¶ms.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
1346
1347 let relative_mode_param = params.relative_mode.unwrap_or_default();
1348 let relative_mode: RelativeMode = relative_mode_param.into();
1349 let mode = params.mode.unwrap_or_default();
1350
1351 if mode.is_preview() {
1352 let change_id = make_short_random_id("chg", 12);
1353 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
1354 fs::create_dir_all(snapshot_path.parent().unwrap())?;
1355 fs::copy(&work_path, &snapshot_path)?;
1356
1357 let sheet_name = params.sheet_name.clone();
1358 let target_range = params.target_range.clone();
1359 let anchor_cell = params.anchor_cell.clone();
1360 let base_formula = params.base_formula.clone();
1361 let fill_direction = Some(fill_direction);
1362 let relative_mode_param = Some(relative_mode_param);
1363 let snapshot_for_apply = snapshot_path.clone();
1364 let sheet_name_for_apply = sheet_name.clone();
1365 let target_range_for_apply = target_range.clone();
1366 let base_formula_for_apply = base_formula.clone();
1367
1368 let apply_result = tokio::task::spawn_blocking(move || {
1369 apply_formula_pattern_to_file(
1370 &snapshot_for_apply,
1371 &sheet_name_for_apply,
1372 &target_range_for_apply,
1373 anchor_col,
1374 anchor_row,
1375 &base_formula_for_apply,
1376 relative_mode,
1377 )
1378 })
1379 .await??;
1380
1381 let mut summary = apply_result.summary;
1382 summary.op_kinds = vec!["apply_formula_pattern".to_string()];
1383 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1384
1385 let staged_op = StagedOp {
1386 kind: "apply_formula_pattern".to_string(),
1387 payload: serde_json::to_value(ApplyFormulaPatternStagedPayload {
1388 sheet_name: sheet_name.clone(),
1389 target_range: target_range.clone(),
1390 anchor_cell: anchor_cell.clone(),
1391 base_formula: base_formula.clone(),
1392 fill_direction,
1393 relative_mode: relative_mode_param,
1394 })?,
1395 };
1396
1397 let staged = StagedChange {
1398 change_id: change_id.clone(),
1399 created_at: Utc::now(),
1400 label: params.label.clone(),
1401 ops: vec![staged_op],
1402 summary: summary.clone(),
1403 fork_path_snapshot: Some(snapshot_path),
1404 };
1405
1406 registry.add_staged_change(¶ms.fork_id, staged)?;
1407
1408 Ok(ApplyFormulaPatternResponse {
1409 fork_id: params.fork_id,
1410 sheet_name,
1411 target_range,
1412 mode: mode.as_str().to_string(),
1413 change_id: Some(change_id),
1414 cells_filled: apply_result.cells_filled,
1415 summary,
1416 })
1417 } else {
1418 let sheet_name = params.sheet_name.clone();
1419 let target_range = params.target_range.clone();
1420 let base_formula = params.base_formula.clone();
1421 let sheet_name_for_apply = sheet_name.clone();
1422 let target_range_for_apply = target_range.clone();
1423 let base_formula_for_apply = base_formula.clone();
1424 let apply_result = tokio::task::spawn_blocking(move || {
1425 apply_formula_pattern_to_file(
1426 &work_path,
1427 &sheet_name_for_apply,
1428 &target_range_for_apply,
1429 anchor_col,
1430 anchor_row,
1431 &base_formula_for_apply,
1432 relative_mode,
1433 )
1434 })
1435 .await??;
1436
1437 let mut summary = apply_result.summary;
1438 summary.op_kinds = vec!["apply_formula_pattern".to_string()];
1439
1440 registry.with_fork_mut(¶ms.fork_id, |ctx| {
1441 ctx.recalc_needed = true;
1442 Ok(())
1443 })?;
1444 set_recalc_needed_flag(&mut summary, true);
1445
1446 let _ = state.close_workbook(&fork_workbook_id);
1447
1448 Ok(ApplyFormulaPatternResponse {
1449 fork_id: params.fork_id,
1450 sheet_name,
1451 target_range,
1452 mode: mode.as_str().to_string(),
1453 change_id: None,
1454 cells_filled: apply_result.cells_filled,
1455 summary,
1456 })
1457 }
1458}
1459
1460struct FormulaPatternApplyResult {
1461 cells_filled: u64,
1462 summary: ChangeSummary,
1463}
1464
1465fn apply_formula_pattern_to_file(
1466 path: &Path,
1467 sheet_name: &str,
1468 target_range: &str,
1469 anchor_col: u32,
1470 anchor_row: u32,
1471 base_formula: &str,
1472 relative_mode: RelativeMode,
1473) -> Result<FormulaPatternApplyResult> {
1474 let ast = parse_base_formula(base_formula)?;
1475 let bounds = parse_range_bounds(target_range)?;
1476
1477 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
1478 let sheet = book
1479 .get_sheet_by_name_mut(sheet_name)
1480 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1481
1482 let mut cells_filled: u64 = 0;
1483 for row in bounds.min_row..=bounds.max_row {
1484 for col in bounds.min_col..=bounds.max_col {
1485 let delta_col = col as i32 - anchor_col as i32;
1486 let delta_row = row as i32 - anchor_row as i32;
1487 let shifted = shift_formula_ast(&ast, delta_col, delta_row, relative_mode)?;
1488 let shifted_for_umya = shifted.strip_prefix('=').unwrap_or(&shifted);
1489 let addr = crate::utils::cell_address(col, row);
1490 let cell = sheet.get_cell_mut(addr.as_str());
1491 cell.set_formula(shifted_for_umya.to_string());
1492 cell.set_formula_result_default("");
1493 cells_filled += 1;
1494 }
1495 }
1496
1497 umya_spreadsheet::writer::xlsx::write(&book, path)?;
1498
1499 let mut counts = BTreeMap::new();
1500 counts.insert("cells_filled".to_string(), cells_filled);
1501
1502 let summary = ChangeSummary {
1503 op_kinds: vec!["apply_formula_pattern".to_string()],
1504 affected_sheets: vec![sheet_name.to_string()],
1505 affected_bounds: vec![target_range.to_string()],
1506 counts,
1507 warnings: Vec::new(),
1508 ..Default::default()
1509 };
1510
1511 Ok(FormulaPatternApplyResult {
1512 cells_filled,
1513 summary,
1514 })
1515}
1516
1517pub(crate) struct FormulaPatternBatchApplyResult {
1518 pub(crate) ops_applied: usize,
1519 pub(crate) summary: ChangeSummary,
1520}
1521
1522pub(crate) fn apply_formula_pattern_ops_to_file(
1523 path: &Path,
1524 ops: &[ApplyFormulaPatternOpInput],
1525) -> Result<FormulaPatternBatchApplyResult> {
1526 struct PreparedFormulaPatternOp {
1527 sheet_name: String,
1528 target_range: String,
1529 anchor_col: u32,
1530 anchor_row: u32,
1531 base_formula: String,
1532 relative_mode: RelativeMode,
1533 }
1534
1535 let mut prepared_ops = Vec::with_capacity(ops.len());
1536 let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
1537 let mut affected_bounds: Vec<String> = Vec::with_capacity(ops.len());
1538
1539 for op in ops {
1540 let bounds = parse_range_bounds(&op.target_range)?;
1541 let (anchor_col, anchor_row) = parse_cell_ref(&op.anchor_cell)?;
1542 let fill_direction = op.fill_direction.unwrap_or_default();
1543 validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
1544 parse_base_formula(&op.base_formula)?;
1545
1546 let relative_mode: RelativeMode = op.relative_mode.unwrap_or_default().into();
1547
1548 affected_sheets.insert(op.sheet_name.clone());
1549 affected_bounds.push(op.target_range.clone());
1550
1551 prepared_ops.push(PreparedFormulaPatternOp {
1552 sheet_name: op.sheet_name.clone(),
1553 target_range: op.target_range.clone(),
1554 anchor_col,
1555 anchor_row,
1556 base_formula: op.base_formula.clone(),
1557 relative_mode,
1558 });
1559 }
1560
1561 let mut cells_filled = 0u64;
1562 for op in prepared_ops {
1563 let result = apply_formula_pattern_to_file(
1564 path,
1565 &op.sheet_name,
1566 &op.target_range,
1567 op.anchor_col,
1568 op.anchor_row,
1569 &op.base_formula,
1570 op.relative_mode,
1571 )?;
1572 cells_filled += result.cells_filled;
1573 }
1574
1575 let mut counts = BTreeMap::new();
1576 counts.insert("cells_filled".to_string(), cells_filled);
1577
1578 Ok(FormulaPatternBatchApplyResult {
1579 ops_applied: ops.len(),
1580 summary: ChangeSummary {
1581 op_kinds: vec!["apply_formula_pattern".to_string()],
1582 affected_sheets: affected_sheets.into_iter().collect(),
1583 affected_bounds,
1584 counts,
1585 warnings: Vec::new(),
1586 ..Default::default()
1587 },
1588 })
1589}
1590
1591fn validate_formula_pattern_bounds(
1592 bounds: &ScreenshotBounds,
1593 anchor_col: u32,
1594 anchor_row: u32,
1595 fill_direction: FillDirection,
1596) -> Result<()> {
1597 if anchor_col < bounds.min_col
1598 || anchor_col > bounds.max_col
1599 || anchor_row < bounds.min_row
1600 || anchor_row > bounds.max_row
1601 {
1602 let bounds_range = format!(
1603 "{}:{}",
1604 crate::utils::cell_address(bounds.min_col, bounds.min_row),
1605 crate::utils::cell_address(bounds.max_col, bounds.max_row)
1606 );
1607 bail!(
1608 "anchor_cell must be inside target_range (anchor {} not within {})",
1609 crate::utils::cell_address(anchor_col, anchor_row),
1610 bounds_range
1611 );
1612 }
1613
1614 if bounds.min_col != anchor_col || bounds.min_row != anchor_row {
1615 bail!("target_range must start at anchor_cell (anchor should be top-left of fill range)");
1616 }
1617
1618 match fill_direction {
1619 FillDirection::Down => {
1620 if bounds.min_col != bounds.max_col {
1621 bail!("fill_direction=down requires a single-column target_range");
1622 }
1623 }
1624 FillDirection::Right => {
1625 if bounds.min_row != bounds.max_row {
1626 bail!("fill_direction=right requires a single-row target_range");
1627 }
1628 }
1629 FillDirection::Both => {}
1630 }
1631 Ok(())
1632}
1633
1634#[derive(Debug, Deserialize, JsonSchema)]
1635pub struct StructureBatchParams {
1636 pub fork_id: String,
1637 pub ops: Vec<StructureOp>,
1638 #[serde(default)]
1639 pub mode: Option<BatchMode>, pub label: Option<String>,
1641 #[serde(default)]
1642 pub formula_parse_policy: Option<FormulaParsePolicy>,
1643 #[serde(default)]
1644 pub impact_report: Option<bool>,
1645 #[serde(default)]
1646 pub show_formula_delta: Option<bool>,
1647}
1648
1649#[derive(Debug, Deserialize, JsonSchema)]
1650pub struct StructureBatchParamsInput {
1651 pub fork_id: String,
1652 pub ops: Vec<StructureOpInput>,
1653 #[serde(default)]
1654 pub mode: Option<BatchMode>,
1655 pub label: Option<String>,
1656 #[serde(default)]
1657 pub formula_parse_policy: Option<FormulaParsePolicy>,
1658 #[serde(default)]
1661 pub impact_report: Option<bool>,
1662 #[serde(default)]
1665 pub show_formula_delta: Option<bool>,
1666}
1667
1668#[derive(Debug, Clone)]
1669pub struct StructureOpInput {
1670 op: StructureOp,
1671 alias_used: bool,
1672}
1673
1674impl From<StructureOp> for StructureOpInput {
1675 fn from(op: StructureOp) -> Self {
1676 Self {
1677 op,
1678 alias_used: false,
1679 }
1680 }
1681}
1682
1683impl<'de> Deserialize<'de> for StructureOpInput {
1684 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1685 where
1686 D: serde::Deserializer<'de>,
1687 {
1688 let mut value = serde_json::Value::deserialize(deserializer)?;
1689 let Some(obj) = value.as_object_mut() else {
1690 return Err(de::Error::custom("structure op must be an object"));
1691 };
1692
1693 let mut alias_used = false;
1694 let kind_value = if let Some(kind) = obj.get("kind") {
1695 kind.clone()
1696 } else if let Some(op) = obj.remove("op") {
1697 alias_used = true;
1698 op
1699 } else {
1700 return Err(de::Error::custom("structure op requires 'kind' or 'op'"));
1701 };
1702
1703 let Some(kind_str) = kind_value.as_str() else {
1704 return Err(de::Error::custom("structure op kind must be a string"));
1705 };
1706
1707 let normalized_kind = if kind_str == "add_sheet" {
1708 alias_used = true;
1709 "create_sheet"
1710 } else {
1711 kind_str
1712 };
1713
1714 obj.insert(
1715 "kind".to_string(),
1716 serde_json::Value::String(normalized_kind.to_string()),
1717 );
1718
1719 let op = serde_json::from_value(value).map_err(de::Error::custom)?;
1720 Ok(StructureOpInput { op, alias_used })
1721 }
1722}
1723
1724impl schemars::JsonSchema for StructureOpInput {
1725 fn schema_name() -> std::borrow::Cow<'static, str> {
1726 "StructureOp".into()
1727 }
1728
1729 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1730 StructureOp::json_schema(generator)
1731 }
1732}
1733
1734pub fn normalize_structure_batch(
1735 input: StructureBatchParamsInput,
1736) -> Result<(StructureBatchParams, Vec<Warning>)> {
1737 let mut warnings = Vec::new();
1738 let mut ops = Vec::with_capacity(input.ops.len());
1739
1740 for op_input in input.ops {
1741 if op_input.alias_used {
1742 warnings.push(Warning {
1743 code: "WARN_ALIAS_KIND".to_string(),
1744 message: "Normalized structure op alias to canonical kind".to_string(),
1745 });
1746 }
1747 ops.push(op_input.op);
1748 }
1749
1750 Ok((
1751 StructureBatchParams {
1752 fork_id: input.fork_id,
1753 ops,
1754 mode: input.mode,
1755 label: input.label,
1756 formula_parse_policy: input.formula_parse_policy,
1757 impact_report: input.impact_report,
1758 show_formula_delta: input.show_formula_delta,
1759 },
1760 warnings,
1761 ))
1762}
1763
1764#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1765#[serde(tag = "kind", rename_all = "snake_case")]
1766pub enum StructureOp {
1767 MergeCells {
1768 sheet_name: String,
1769 target_range: String,
1770 },
1771 UnmergeCells {
1772 sheet_name: String,
1773 target_range: String,
1774 },
1775 InsertRows {
1776 sheet_name: String,
1777 at_row: u32,
1778 count: u32,
1779 #[serde(default)]
1784 expand_adjacent_sums: bool,
1785 },
1786 CloneRow {
1787 sheet_name: String,
1788 source_row: u32,
1790 insert_at: u32,
1792 #[serde(default = "default_clone_count")]
1794 count: u32,
1795 #[serde(default)]
1797 expand_adjacent_sums: bool,
1798 },
1799 DeleteRows {
1800 sheet_name: String,
1801 start_row: u32,
1802 count: u32,
1803 },
1804 InsertCols {
1805 sheet_name: String,
1806 at_col: String,
1807 count: u32,
1808 },
1809 DeleteCols {
1810 sheet_name: String,
1811 start_col: String,
1812 count: u32,
1813 },
1814 RenameSheet {
1815 old_name: String,
1816 new_name: String,
1817 },
1818 CreateSheet {
1819 name: String,
1820 #[serde(default)]
1821 position: Option<u32>,
1822 },
1823 DeleteSheet {
1824 name: String,
1825 },
1826 CopyRange {
1827 sheet_name: String,
1828 #[serde(default)]
1829 dest_sheet_name: Option<String>,
1830 src_range: String,
1831 dest_anchor: String,
1832 include_styles: bool,
1833 include_formulas: bool,
1834 },
1835 MoveRange {
1836 sheet_name: String,
1837 #[serde(default)]
1838 dest_sheet_name: Option<String>,
1839 src_range: String,
1840 dest_anchor: String,
1841 include_styles: bool,
1842 include_formulas: bool,
1843 },
1844}
1845
1846fn structure_ops_require_recalc(ops: &[StructureOp]) -> bool {
1847 ops.iter().any(|op| {
1848 matches!(
1849 op,
1850 StructureOp::InsertRows { .. }
1851 | StructureOp::DeleteRows { .. }
1852 | StructureOp::InsertCols { .. }
1853 | StructureOp::DeleteCols { .. }
1854 | StructureOp::RenameSheet { .. }
1855 | StructureOp::CloneRow { .. }
1856 | StructureOp::CopyRange {
1857 include_formulas: true,
1858 ..
1859 }
1860 | StructureOp::MoveRange {
1861 include_formulas: true,
1862 ..
1863 }
1864 )
1865 })
1866}
1867
1868#[derive(Debug, Serialize, JsonSchema)]
1869pub struct StructureBatchResponse {
1870 pub fork_id: String,
1871 pub mode: String,
1872 pub change_id: Option<String>,
1873 pub ops_applied: usize,
1874 pub summary: ChangeSummary,
1875 #[serde(skip_serializing_if = "Option::is_none")]
1876 pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
1877 #[serde(skip_serializing_if = "Option::is_none")]
1879 pub impact_report: Option<crate::tools::structure_impact::StructureImpactReport>,
1880 #[serde(skip_serializing_if = "Option::is_none")]
1882 pub formula_delta_preview: Option<Vec<crate::tools::structure_impact::FormulaDeltaItem>>,
1883}
1884
1885#[derive(Debug, Serialize, Deserialize)]
1886struct StructureBatchStagedPayload {
1887 ops: Vec<StructureOp>,
1888 #[serde(default)]
1889 formula_parse_policy: Option<FormulaParsePolicy>,
1890}
1891
1892pub async fn structure_batch(
1893 state: Arc<AppState>,
1894 params: StructureBatchParamsInput,
1895) -> Result<StructureBatchResponse> {
1896 let want_impact = params.impact_report.unwrap_or(false);
1897 let want_delta = params.show_formula_delta.unwrap_or(false);
1898 let (params, warnings) = normalize_structure_batch(params)?;
1899 let policy =
1900 params
1901 .formula_parse_policy
1902 .unwrap_or(FormulaParsePolicy::default_for_command_class(
1903 CommandClass::BatchWrite,
1904 ));
1905 let alias_warnings: Vec<String> = warnings
1906 .into_iter()
1907 .map(|warning| format!("{}: {}", warning.code, warning.message))
1908 .collect();
1909 let registry = state
1910 .fork_registry()
1911 .ok_or_else(|| anyhow!("fork registry not available"))?;
1912
1913 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
1914 let work_path = fork_ctx.work_path.clone();
1915
1916 let will_need_recalc = fork_ctx.recalc_needed || structure_ops_require_recalc(¶ms.ops);
1917
1918 let mode = params.mode.unwrap_or_default();
1919
1920 if mode.is_preview() {
1921 let change_id = make_short_random_id("chg", 12);
1922 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
1923 fs::create_dir_all(snapshot_path.parent().unwrap())?;
1924 fs::copy(&work_path, &snapshot_path)?;
1925
1926 let snapshot_for_apply = snapshot_path.clone();
1927 let ops_for_apply = params.ops.clone();
1928
1929 let apply_result = tokio::task::spawn_blocking(move || {
1930 apply_structure_ops_to_file(&snapshot_for_apply, &ops_for_apply, policy)
1931 })
1932 .await??;
1933
1934 let mut summary = apply_result.summary;
1935 summary.op_kinds = vec!["structure_batch".to_string()];
1936 summary.warnings.extend(alias_warnings);
1937 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1938 if let Ok(change_count) = tokio::task::spawn_blocking({
1941 let base_path = work_path.clone();
1942 let preview_path = snapshot_path.clone();
1943 move || {
1944 crate::core::diff::calculate_changeset(&base_path, &preview_path, None)
1945 .map(|changes| changes.len() as u64)
1946 }
1947 })
1948 .await?
1949 {
1950 summary
1951 .counts
1952 .insert("preview_change_items".to_string(), change_count);
1953 } else {
1954 summary.warnings.push(
1955 "Preview diff computation failed; run get_changeset after applying to inspect changes."
1956 .to_string(),
1957 );
1958 }
1959
1960 let staged_op = StagedOp {
1961 kind: "structure_batch".to_string(),
1962 payload: serde_json::to_value(StructureBatchStagedPayload {
1963 ops: params.ops.clone(),
1964 formula_parse_policy: Some(policy),
1965 })?,
1966 };
1967
1968 let staged = StagedChange {
1969 change_id: change_id.clone(),
1970 created_at: Utc::now(),
1971 label: params.label.clone(),
1972 ops: vec![staged_op],
1973 summary: summary.clone(),
1974 fork_path_snapshot: Some(snapshot_path),
1975 };
1976
1977 registry.add_staged_change(¶ms.fork_id, staged)?;
1978
1979 let (ir, fdp) = if want_impact || want_delta {
1981 let ops_for_impact = params.ops.clone();
1982 let base_path = work_path.clone();
1983 let (report, delta) = tokio::task::spawn_blocking(move || {
1984 crate::tools::structure_impact::compute_structure_impact(
1985 &base_path,
1986 &ops_for_impact,
1987 want_delta,
1988 )
1989 })
1990 .await??;
1991 (if want_impact { Some(report) } else { None }, delta)
1992 } else {
1993 (None, None)
1994 };
1995
1996 Ok(StructureBatchResponse {
1997 fork_id: params.fork_id,
1998 mode: mode.as_str().to_string(),
1999 change_id: Some(change_id),
2000 ops_applied: apply_result.ops_applied,
2001 summary,
2002 formula_parse_diagnostics: apply_result.formula_parse_diagnostics,
2003 impact_report: ir,
2004 formula_delta_preview: fdp,
2005 })
2006 } else {
2007 let ops_for_apply = params.ops.clone();
2008 let apply_result = tokio::task::spawn_blocking(move || {
2009 apply_structure_ops_to_file(&work_path, &ops_for_apply, policy)
2010 })
2011 .await??;
2012
2013 let mut summary = apply_result.summary;
2014 summary.op_kinds = vec!["structure_batch".to_string()];
2015 summary.warnings.extend(alias_warnings);
2016
2017 if will_need_recalc {
2018 registry.with_fork_mut(¶ms.fork_id, |ctx| {
2019 ctx.recalc_needed = true;
2020 Ok(())
2021 })?;
2022 }
2023 set_recalc_needed_flag(&mut summary, will_need_recalc);
2024
2025 let fork_workbook_id = WorkbookId(params.fork_id.clone());
2026 let _ = state.close_workbook(&fork_workbook_id);
2027
2028 Ok(StructureBatchResponse {
2029 fork_id: params.fork_id,
2030 mode: mode.as_str().to_string(),
2031 change_id: None,
2032 ops_applied: apply_result.ops_applied,
2033 summary,
2034 formula_parse_diagnostics: apply_result.formula_parse_diagnostics,
2035 impact_report: None,
2036 formula_delta_preview: None,
2037 })
2038 }
2039}
2040
2041pub(crate) struct StructureApplyResult {
2042 pub(crate) ops_applied: usize,
2043 pub(crate) summary: ChangeSummary,
2044 pub(crate) formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
2045}
2046
2047pub(crate) fn apply_structure_ops_to_file(
2048 path: &Path,
2049 ops: &[StructureOp],
2050 policy: FormulaParsePolicy,
2051) -> Result<StructureApplyResult> {
2052 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
2053 let mut formula_parse_diagnostics_builder = FormulaParseDiagnosticsBuilder::new(policy);
2054
2055 let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
2056 let affected_bounds: Vec<String> = Vec::new();
2057 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
2058 let mut warnings: Vec<String> = vec![
2059 "Structural edits may not fully rewrite formulas/named ranges like Excel. After apply, run recalculate and review get_changeset.".to_string(),
2060 ];
2061
2062 for op in ops {
2063 match op {
2064 StructureOp::MergeCells {
2065 sheet_name,
2066 target_range,
2067 } => {
2068 let sheet = book
2069 .get_sheet_by_name_mut(sheet_name)
2070 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2071 sheet.add_merge_cells(target_range.clone());
2072 affected_sheets.insert(sheet_name.clone());
2073 *counts.entry("cells_merged".to_string()).or_insert(0) += 1;
2074 }
2075 StructureOp::UnmergeCells {
2076 sheet_name,
2077 target_range,
2078 } => {
2079 let sheet = book
2080 .get_sheet_by_name_mut(sheet_name)
2081 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2082
2083 let original_len = sheet.get_merge_cells().len();
2084 if let Ok(target_bounds) = parse_range_bounds(target_range) {
2085 let merges = sheet.get_merge_cells_mut();
2086 merges.retain(|m| {
2087 let m_range = m.get_range();
2088 if let Ok(m_bounds) = parse_range_bounds(&m_range) {
2089 !(m_bounds.min_col <= target_bounds.max_col
2090 && m_bounds.max_col >= target_bounds.min_col
2091 && m_bounds.min_row <= target_bounds.max_row
2092 && m_bounds.max_row >= target_bounds.min_row)
2093 } else {
2094 true
2095 }
2096 });
2097 }
2098 *counts.entry("cells_unmerged".to_string()).or_insert(0) +=
2099 (original_len - sheet.get_merge_cells().len()) as u64;
2100
2101 affected_sheets.insert(sheet_name.clone());
2102 }
2103 StructureOp::InsertRows {
2104 sheet_name,
2105 at_row,
2106 count,
2107 expand_adjacent_sums,
2108 } => {
2109 if *at_row == 0 || *count == 0 {
2110 bail!("insert_rows requires at_row>=1 and count>=1");
2111 }
2112 {
2113 let sheet = book
2114 .get_sheet_by_name_mut(sheet_name)
2115 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2116 sheet.insert_new_row(at_row, count);
2117 }
2118 rewrite_formulas_for_sheet_row_insert(
2119 &mut book,
2120 sheet_name,
2121 *at_row,
2122 *count,
2123 policy,
2124 &mut formula_parse_diagnostics_builder,
2125 )?;
2126 rewrite_defined_name_formulas_for_sheet_row_insert(
2127 &mut book,
2128 sheet_name,
2129 *at_row,
2130 *count,
2131 policy,
2132 &mut formula_parse_diagnostics_builder,
2133 )?;
2134 if *expand_adjacent_sums {
2135 let (expansion_warnings, expanded_count) =
2136 expand_adjacent_sum_formulas(&mut book, sheet_name, *at_row, *count)?;
2137 warnings.extend(expansion_warnings);
2138 if expanded_count > 0 {
2139 counts
2140 .entry("sums_expanded".to_string())
2141 .and_modify(|v| *v += expanded_count)
2142 .or_insert(expanded_count);
2143 }
2144 }
2145 affected_sheets.insert(sheet_name.clone());
2146 counts
2147 .entry("rows_inserted".to_string())
2148 .and_modify(|v| *v += *count as u64)
2149 .or_insert(*count as u64);
2150 }
2151 StructureOp::CloneRow {
2152 sheet_name,
2153 source_row,
2154 insert_at,
2155 count,
2156 expand_adjacent_sums,
2157 } => {
2158 if *source_row == 0 || *insert_at == 0 || *count == 0 {
2159 bail!("clone_row requires source_row>=1, insert_at>=1, and count>=1");
2160 }
2161 let template_cells = {
2163 let sheet = book
2164 .get_sheet_by_name_mut(sheet_name)
2165 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2166 capture_row_template(sheet, *source_row)?
2167 };
2168 if template_cells.is_empty() {
2169 warnings.push(format!(
2170 "WARN_CLONE_TEMPLATE_EMPTY: {} row {} has no template cells to clone.",
2171 sheet_name, source_row
2172 ));
2173 }
2174
2175 {
2177 let sheet = book
2178 .get_sheet_by_name_mut(sheet_name)
2179 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2180 sheet.insert_new_row(insert_at, count);
2181 }
2182 rewrite_formulas_for_sheet_row_insert(
2183 &mut book,
2184 sheet_name,
2185 *insert_at,
2186 *count,
2187 policy,
2188 &mut formula_parse_diagnostics_builder,
2189 )?;
2190 rewrite_defined_name_formulas_for_sheet_row_insert(
2191 &mut book,
2192 sheet_name,
2193 *insert_at,
2194 *count,
2195 policy,
2196 &mut formula_parse_diagnostics_builder,
2197 )?;
2198
2199 {
2201 let sheet = book
2202 .get_sheet_by_name_mut(sheet_name)
2203 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2204 let clone_warnings = stamp_template_rows(
2205 sheet,
2206 &template_cells,
2207 *source_row,
2208 *insert_at,
2209 *count,
2210 )?;
2211 warnings.extend(clone_warnings);
2212 }
2213
2214 if *expand_adjacent_sums {
2216 let (expansion_warnings, expanded_count) =
2217 expand_adjacent_sum_formulas(&mut book, sheet_name, *insert_at, *count)?;
2218 warnings.extend(expansion_warnings);
2219 if expanded_count > 0 {
2220 counts
2221 .entry("sums_expanded".to_string())
2222 .and_modify(|v| *v += expanded_count)
2223 .or_insert(expanded_count);
2224 }
2225 }
2226
2227 affected_sheets.insert(sheet_name.clone());
2228 counts
2229 .entry("rows_inserted".to_string())
2230 .and_modify(|v| *v += *count as u64)
2231 .or_insert(*count as u64);
2232 counts
2233 .entry("rows_cloned".to_string())
2234 .and_modify(|v| *v += *count as u64)
2235 .or_insert(*count as u64);
2236 }
2237 StructureOp::DeleteRows {
2238 sheet_name,
2239 start_row,
2240 count,
2241 } => {
2242 if *start_row == 0 || *count == 0 {
2243 bail!("delete_rows requires start_row>=1 and count>=1");
2244 }
2245 {
2246 let sheet = book
2247 .get_sheet_by_name_mut(sheet_name)
2248 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2249 sheet.remove_row(start_row, count);
2250 }
2251 rewrite_formulas_for_sheet_row_delete(
2252 &mut book,
2253 sheet_name,
2254 *start_row,
2255 *count,
2256 policy,
2257 &mut formula_parse_diagnostics_builder,
2258 )?;
2259 rewrite_defined_name_formulas_for_sheet_row_delete(
2260 &mut book,
2261 sheet_name,
2262 *start_row,
2263 *count,
2264 policy,
2265 &mut formula_parse_diagnostics_builder,
2266 )?;
2267 affected_sheets.insert(sheet_name.clone());
2268 counts
2269 .entry("rows_deleted".to_string())
2270 .and_modify(|v| *v += *count as u64)
2271 .or_insert(*count as u64);
2272 }
2273 StructureOp::InsertCols {
2274 sheet_name,
2275 at_col,
2276 count,
2277 } => {
2278 if at_col.trim().is_empty() || *count == 0 {
2279 bail!("insert_cols requires at_col and count>=1");
2280 }
2281 let col_letters = normalize_col_letters(at_col)?;
2282 let root_col =
2283 umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
2284 {
2285 let sheet = book
2286 .get_sheet_by_name_mut(sheet_name)
2287 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2288 sheet.insert_new_column(&col_letters, count);
2289 }
2290 rewrite_formulas_for_sheet_col_insert(
2291 &mut book,
2292 sheet_name,
2293 root_col,
2294 *count,
2295 policy,
2296 &mut formula_parse_diagnostics_builder,
2297 )?;
2298 rewrite_defined_name_formulas_for_sheet_col_insert(
2299 &mut book,
2300 sheet_name,
2301 root_col,
2302 *count,
2303 policy,
2304 &mut formula_parse_diagnostics_builder,
2305 )?;
2306 affected_sheets.insert(sheet_name.clone());
2307 counts
2308 .entry("cols_inserted".to_string())
2309 .and_modify(|v| *v += *count as u64)
2310 .or_insert(*count as u64);
2311 }
2312 StructureOp::DeleteCols {
2313 sheet_name,
2314 start_col,
2315 count,
2316 } => {
2317 if start_col.trim().is_empty() || *count == 0 {
2318 bail!("delete_cols requires start_col and count>=1");
2319 }
2320 let col_letters = normalize_col_letters(start_col)?;
2321 let root_col =
2322 umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
2323 {
2324 let sheet = book
2325 .get_sheet_by_name_mut(sheet_name)
2326 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2327 sheet.remove_column(&col_letters, count);
2328 }
2329 rewrite_formulas_for_sheet_col_delete(
2330 &mut book,
2331 sheet_name,
2332 root_col,
2333 *count,
2334 policy,
2335 &mut formula_parse_diagnostics_builder,
2336 )?;
2337 rewrite_defined_name_formulas_for_sheet_col_delete(
2338 &mut book,
2339 sheet_name,
2340 root_col,
2341 *count,
2342 policy,
2343 &mut formula_parse_diagnostics_builder,
2344 )?;
2345 affected_sheets.insert(sheet_name.clone());
2346 counts
2347 .entry("cols_deleted".to_string())
2348 .and_modify(|v| *v += *count as u64)
2349 .or_insert(*count as u64);
2350 }
2351 StructureOp::RenameSheet { old_name, new_name } => {
2352 let old_name = old_name.trim();
2353 let new_name = new_name.trim();
2354 if old_name.is_empty() || new_name.is_empty() {
2355 bail!("rename_sheet requires non-empty old_name and new_name");
2356 }
2357
2358 let sheet_index = book
2359 .get_sheet_collection_no_check()
2360 .iter()
2361 .position(|s| s.get_name() == old_name)
2362 .ok_or_else(|| anyhow!("sheet '{}' not found", old_name))?;
2363 book.set_sheet_name(sheet_index, new_name.to_string())
2364 .map_err(|e| anyhow!("failed to rename sheet '{}': {}", old_name, e))?;
2365
2366 rewrite_formulas_for_sheet_rename(
2367 &mut book,
2368 old_name,
2369 new_name,
2370 policy,
2371 &mut formula_parse_diagnostics_builder,
2372 )?;
2373 rewrite_defined_name_formulas_for_sheet_rename(
2374 &mut book,
2375 old_name,
2376 new_name,
2377 policy,
2378 &mut formula_parse_diagnostics_builder,
2379 )?;
2380
2381 affected_sheets.insert(old_name.to_string());
2382 affected_sheets.insert(new_name.to_string());
2383 counts
2384 .entry("sheets_renamed".to_string())
2385 .and_modify(|v| *v += 1)
2386 .or_insert(1);
2387 }
2388 StructureOp::CreateSheet { name, position } => {
2389 let name_trimmed = name.trim();
2390 if name_trimmed.is_empty() {
2391 bail!("create_sheet requires non-empty name");
2392 }
2393 let requested_position = *position;
2394 book.new_sheet(name_trimmed.to_string())
2395 .map_err(|e| anyhow!("failed to create sheet '{}': {}", name_trimmed, e))?;
2396
2397 if let Some(pos) = requested_position {
2398 let desired = pos as usize;
2399 let len = book.get_sheet_collection_no_check().len();
2400 if desired >= len {
2401 warnings.push(format!(
2402 "create_sheet position {} is out of range (sheet_count {}). Appended at end.",
2403 desired, len
2404 ));
2405 } else if desired != len - 1 {
2406 let sheets = book.get_sheet_collection_mut();
2407 let created = sheets.remove(len - 1);
2408 sheets.insert(desired, created);
2409 }
2410 }
2411
2412 affected_sheets.insert(name_trimmed.to_string());
2413 counts
2414 .entry("sheets_created".to_string())
2415 .and_modify(|v| *v += 1)
2416 .or_insert(1);
2417 }
2418 StructureOp::DeleteSheet { name } => {
2419 let name_trimmed = name.trim();
2420 if name_trimmed.is_empty() {
2421 bail!("delete_sheet requires non-empty name");
2422 }
2423 if book.get_sheet_collection_no_check().len() <= 1 {
2424 bail!("cannot delete the last remaining sheet");
2425 }
2426 book.remove_sheet_by_name(name_trimmed)
2427 .map_err(|e| anyhow!("failed to delete sheet '{}': {}", name_trimmed, e))?;
2428 affected_sheets.insert(name_trimmed.to_string());
2429 counts
2430 .entry("sheets_deleted".to_string())
2431 .and_modify(|v| *v += 1)
2432 .or_insert(1);
2433 }
2434 StructureOp::CopyRange {
2435 sheet_name,
2436 dest_sheet_name,
2437 src_range,
2438 dest_anchor,
2439 include_styles,
2440 include_formulas,
2441 } => {
2442 let dest_sheet_name = dest_sheet_name.as_deref().unwrap_or(sheet_name);
2443 let result = copy_or_move_range(
2444 &mut book,
2445 sheet_name,
2446 dest_sheet_name,
2447 src_range,
2448 dest_anchor,
2449 *include_styles,
2450 *include_formulas,
2451 false,
2452 policy,
2453 &mut formula_parse_diagnostics_builder,
2454 )?;
2455 affected_sheets.insert(sheet_name.clone());
2456 affected_sheets.insert(dest_sheet_name.to_string());
2457 counts
2458 .entry("cells_copied".to_string())
2459 .and_modify(|v| *v += result.cells_written)
2460 .or_insert(result.cells_written);
2461 counts
2462 .entry("ranges_copied".to_string())
2463 .and_modify(|v| *v += 1)
2464 .or_insert(1);
2465 warnings.extend(result.warnings);
2466 }
2467 StructureOp::MoveRange {
2468 sheet_name,
2469 dest_sheet_name,
2470 src_range,
2471 dest_anchor,
2472 include_styles,
2473 include_formulas,
2474 } => {
2475 let dest_sheet_name = dest_sheet_name.as_deref().unwrap_or(sheet_name);
2476 let result = copy_or_move_range(
2477 &mut book,
2478 sheet_name,
2479 dest_sheet_name,
2480 src_range,
2481 dest_anchor,
2482 *include_styles,
2483 *include_formulas,
2484 true,
2485 policy,
2486 &mut formula_parse_diagnostics_builder,
2487 )?;
2488 affected_sheets.insert(sheet_name.clone());
2489 affected_sheets.insert(dest_sheet_name.to_string());
2490 counts
2491 .entry("cells_moved".to_string())
2492 .and_modify(|v| *v += result.cells_written)
2493 .or_insert(result.cells_written);
2494 counts
2495 .entry("ranges_moved".to_string())
2496 .and_modify(|v| *v += 1)
2497 .or_insert(1);
2498 warnings.extend(result.warnings);
2499 }
2500 }
2501 }
2502
2503 let clamped_defined_names = clamp_out_of_bounds_defined_name_rows(&mut book);
2504 if clamped_defined_names > 0 {
2505 warnings.push(format!(
2506 "Clamped {} defined name reference(s) to Excel max row 1048576 after structural edits.",
2507 clamped_defined_names
2508 ));
2509 }
2510
2511 umya_spreadsheet::writer::xlsx::write(&book, path)?;
2512
2513 let clamped_workbook_xml_defined_names = sanitize_workbook_xml_defined_name_rows(path)?;
2517 if clamped_workbook_xml_defined_names > 0 {
2518 warnings.push(format!(
2519 "Clamped {} workbook.xml defined name reference(s) to Excel max row 1048576 after structural edits.",
2520 clamped_workbook_xml_defined_names
2521 ));
2522 }
2523
2524 let summary = ChangeSummary {
2525 op_kinds: vec!["structure_batch".to_string()],
2526 affected_sheets: affected_sheets.into_iter().collect(),
2527 affected_bounds,
2528 counts,
2529 warnings,
2530 ..Default::default()
2531 };
2532
2533 let formula_parse_diagnostics = if formula_parse_diagnostics_builder.has_errors() {
2534 Some(formula_parse_diagnostics_builder.build())
2535 } else {
2536 None
2537 };
2538
2539 Ok(StructureApplyResult {
2540 ops_applied: ops.len(),
2541 summary,
2542 formula_parse_diagnostics,
2543 })
2544}
2545
2546fn normalize_col_letters(col: &str) -> Result<String> {
2547 let letters = col.trim().to_ascii_uppercase();
2548 if letters.is_empty() || !letters.chars().all(|c| c.is_ascii_alphabetic()) {
2549 bail!("invalid column reference: {}", col);
2550 }
2551 Ok(letters)
2552}
2553
2554struct CopyMoveApplyResult {
2555 cells_written: u64,
2556 warnings: Vec<String>,
2557}
2558
2559#[allow(clippy::too_many_arguments)]
2560fn ranges_intersect(
2561 a_min_col: u32,
2562 a_min_row: u32,
2563 a_max_col: u32,
2564 a_max_row: u32,
2565 b_min_col: u32,
2566 b_min_row: u32,
2567 b_max_col: u32,
2568 b_max_row: u32,
2569) -> bool {
2570 !(a_max_col < b_min_col
2571 || b_max_col < a_min_col
2572 || a_max_row < b_min_row
2573 || b_max_row < a_min_row)
2574}
2575
2576#[allow(clippy::too_many_arguments)]
2577fn copy_or_move_range(
2578 book: &mut umya_spreadsheet::Spreadsheet,
2579 src_sheet_name: &str,
2580 dest_sheet_name: &str,
2581 src_range: &str,
2582 dest_anchor: &str,
2583 include_styles: bool,
2584 include_formulas: bool,
2585 clear_source: bool,
2586 policy: FormulaParsePolicy,
2587 builder: &mut FormulaParseDiagnosticsBuilder,
2588) -> Result<CopyMoveApplyResult> {
2589 let src_bounds = parse_range_bounds(src_range)?;
2590 let (dest_start_col, dest_start_row) = parse_cell_ref(dest_anchor)?;
2591
2592 let width = src_bounds.cols;
2593 let height = src_bounds.rows;
2594
2595 let dest_end_col = dest_start_col
2596 .checked_add(width.saturating_sub(1))
2597 .ok_or_else(|| anyhow!("destination range overflows column bounds"))?;
2598 let dest_end_row = dest_start_row
2599 .checked_add(height.saturating_sub(1))
2600 .ok_or_else(|| anyhow!("destination range overflows row bounds"))?;
2601
2602 let same_sheet = src_sheet_name == dest_sheet_name;
2603
2604 if same_sheet
2605 && ranges_intersect(
2606 src_bounds.min_col,
2607 src_bounds.min_row,
2608 src_bounds.max_col,
2609 src_bounds.max_row,
2610 dest_start_col,
2611 dest_start_row,
2612 dest_end_col,
2613 dest_end_row,
2614 )
2615 {
2616 let dest_range = if width == 1 && height == 1 {
2617 crate::utils::cell_address(dest_start_col, dest_start_row)
2618 } else {
2619 format!(
2620 "{}:{}",
2621 crate::utils::cell_address(dest_start_col, dest_start_row),
2622 crate::utils::cell_address(dest_end_col, dest_end_row)
2623 )
2624 };
2625 bail!(
2626 "copy/move destination overlaps source (src {}, dest {})",
2627 src_range,
2628 dest_range
2629 );
2630 }
2631
2632 let delta_col = dest_start_col as i32 - src_bounds.min_col as i32;
2633 let delta_row = dest_start_row as i32 - src_bounds.min_row as i32;
2634
2635 let mut warnings: Vec<String> = Vec::new();
2636 let mut formula_value_copies: u64 = 0;
2637
2638 let (src_sheet_index, dest_sheet_index) = {
2639 let sheets = book.get_sheet_collection_no_check();
2640 let src = sheets
2641 .iter()
2642 .position(|s| s.get_name() == src_sheet_name)
2643 .ok_or_else(|| anyhow!("sheet '{}' not found", src_sheet_name))?;
2644 let dest = sheets
2645 .iter()
2646 .position(|s| s.get_name() == dest_sheet_name)
2647 .ok_or_else(|| anyhow!("sheet '{}' not found", dest_sheet_name))?;
2648 (src, dest)
2649 };
2650
2651 let sheets = book.get_sheet_collection_mut();
2652
2653 if src_sheet_index == dest_sheet_index {
2654 let sheet = &mut sheets[src_sheet_index];
2655
2656 for row in 0..height {
2657 for col in 0..width {
2658 let src_col = src_bounds.min_col + col;
2659 let src_row = src_bounds.min_row + row;
2660 let dest_col = dest_start_col + col;
2661 let dest_row = dest_start_row + row;
2662
2663 let Some(src_cell) = sheet.get_cell((src_col, src_row)) else {
2664 sheet.remove_cell((dest_col, dest_row));
2665 continue;
2666 };
2667
2668 let mut set_value = true;
2669 let mut dest_formula: Option<String> = None;
2670
2671 if include_formulas && src_cell.is_formula() {
2672 let src_formula = src_cell.get_formula().to_string();
2673 if policy == FormulaParsePolicy::Off {
2674 dest_formula = Some(src_formula);
2675 set_value = false;
2676 } else {
2677 match parse_base_formula(&src_formula).and_then(|ast| {
2678 shift_formula_ast(&ast, delta_col, delta_row, RelativeMode::Excel)
2679 }) {
2680 Ok(shifted) => {
2681 let shifted =
2682 shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
2683 dest_formula = Some(shifted);
2684 set_value = false;
2685 }
2686 Err(err) => {
2687 let src_address = crate::utils::cell_address(src_col, src_row);
2688 let dest_address = crate::utils::cell_address(dest_col, dest_row);
2689 if policy == FormulaParsePolicy::Fail {
2690 bail!(
2691 "{}formula shift failed in {}!{} -> {}!{}: {}",
2692 FORMULA_PARSE_FAILED_PREFIX,
2693 src_sheet_name,
2694 src_address,
2695 dest_sheet_name,
2696 dest_address,
2697 err
2698 );
2699 }
2700 builder.record_error(
2701 dest_sheet_name,
2702 &dest_address,
2703 &src_formula,
2704 &err.to_string(),
2705 );
2706 dest_formula = Some(src_formula);
2707 set_value = false;
2708 }
2709 }
2710 }
2711 } else if !include_formulas && src_cell.is_formula() {
2712 formula_value_copies += 1;
2713 }
2714
2715 let src_value = src_cell.get_value().to_string();
2716 let src_style = src_cell.get_style().clone();
2717
2718 let dest_cell = sheet.get_cell_mut((dest_col, dest_row));
2719 if include_styles {
2720 dest_cell.set_style(src_style);
2721 }
2722
2723 dest_cell.get_cell_value_mut().remove_formula();
2724 if let Some(formula) = dest_formula {
2725 dest_cell.set_formula(formula);
2726 dest_cell.set_formula_result_default("");
2727 }
2728 if set_value {
2729 dest_cell.set_value(src_value);
2730 }
2731 }
2732 }
2733
2734 if clear_source {
2735 for row in 0..height {
2736 for col in 0..width {
2737 let src_col = src_bounds.min_col + col;
2738 let src_row = src_bounds.min_row + row;
2739 sheet.remove_cell((src_col, src_row));
2740 }
2741 }
2742 }
2743 } else {
2744 let (src_sheet, dest_sheet) = if src_sheet_index < dest_sheet_index {
2745 let (left, right) = sheets.split_at_mut(dest_sheet_index);
2746 (&mut left[src_sheet_index], &mut right[0])
2747 } else {
2748 let (left, right) = sheets.split_at_mut(src_sheet_index);
2749 (&mut right[0], &mut left[dest_sheet_index])
2750 };
2751
2752 for row in 0..height {
2753 for col in 0..width {
2754 let src_col = src_bounds.min_col + col;
2755 let src_row = src_bounds.min_row + row;
2756 let dest_col = dest_start_col + col;
2757 let dest_row = dest_start_row + row;
2758
2759 let Some(src_cell) = src_sheet.get_cell((src_col, src_row)) else {
2760 dest_sheet.remove_cell((dest_col, dest_row));
2761 continue;
2762 };
2763
2764 let mut set_value = true;
2765 let mut dest_formula: Option<String> = None;
2766
2767 if include_formulas && src_cell.is_formula() {
2768 let src_formula = src_cell.get_formula().to_string();
2769 if policy == FormulaParsePolicy::Off {
2770 dest_formula = Some(src_formula);
2771 set_value = false;
2772 } else {
2773 match parse_base_formula(&src_formula).and_then(|ast| {
2774 shift_formula_ast(&ast, delta_col, delta_row, RelativeMode::Excel)
2775 }) {
2776 Ok(shifted) => {
2777 let shifted =
2778 shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
2779 dest_formula = Some(shifted);
2780 set_value = false;
2781 }
2782 Err(err) => {
2783 let src_address = crate::utils::cell_address(src_col, src_row);
2784 let dest_address = crate::utils::cell_address(dest_col, dest_row);
2785 if policy == FormulaParsePolicy::Fail {
2786 bail!(
2787 "{}formula shift failed in {}!{} -> {}!{}: {}",
2788 FORMULA_PARSE_FAILED_PREFIX,
2789 src_sheet_name,
2790 src_address,
2791 dest_sheet_name,
2792 dest_address,
2793 err
2794 );
2795 }
2796 builder.record_error(
2797 dest_sheet_name,
2798 &dest_address,
2799 &src_formula,
2800 &err.to_string(),
2801 );
2802 dest_formula = Some(src_formula);
2803 set_value = false;
2804 }
2805 }
2806 }
2807 } else if !include_formulas && src_cell.is_formula() {
2808 formula_value_copies += 1;
2809 }
2810
2811 let src_value = src_cell.get_value().to_string();
2812 let src_style = src_cell.get_style().clone();
2813
2814 let dest_cell = dest_sheet.get_cell_mut((dest_col, dest_row));
2815 if include_styles {
2816 dest_cell.set_style(src_style);
2817 }
2818
2819 dest_cell.get_cell_value_mut().remove_formula();
2820 if let Some(formula) = dest_formula {
2821 dest_cell.set_formula(formula);
2822 dest_cell.set_formula_result_default("");
2823 }
2824 if set_value {
2825 dest_cell.set_value(src_value);
2826 }
2827 }
2828 }
2829
2830 if clear_source {
2831 for row in 0..height {
2832 for col in 0..width {
2833 let src_col = src_bounds.min_col + col;
2834 let src_row = src_bounds.min_row + row;
2835 src_sheet.remove_cell((src_col, src_row));
2836 }
2837 }
2838 }
2839 }
2840
2841 if !include_formulas && formula_value_copies > 0 {
2842 warnings.push(format!(
2843 "Copied cached values for {} formula cell(s) (include_formulas=false); run recalculate for fresh results.",
2844 formula_value_copies
2845 ));
2846 }
2847
2848 Ok(CopyMoveApplyResult {
2849 cells_written: width as u64 * height as u64,
2850 warnings,
2851 })
2852}
2853
2854fn rewrite_formulas_for_sheet_rename(
2855 book: &mut umya_spreadsheet::Spreadsheet,
2856 old_name: &str,
2857 new_name: &str,
2858 policy: FormulaParsePolicy,
2859 builder: &mut FormulaParseDiagnosticsBuilder,
2860) -> Result<()> {
2861 let new_prefix = format_sheet_prefix_for_formula(new_name);
2862
2863 for sheet in book.get_sheet_collection_mut().iter_mut() {
2864 let sheet_name = sheet.get_name().to_string();
2865 for cell in sheet.get_cell_collection_mut() {
2866 if !cell.is_formula() {
2867 continue;
2868 }
2869 let formula_text = cell.get_formula();
2870 if formula_text.is_empty() {
2871 continue;
2872 }
2873 let formula_with_equals = if formula_text.starts_with('=') {
2874 formula_text.to_string()
2875 } else {
2876 format!("={}", formula_text)
2877 };
2878
2879 if policy == FormulaParsePolicy::Off {
2880 continue;
2881 }
2882
2883 let cell_address = cell.get_coordinate().get_coordinate().to_string();
2884 let context_description = format!("{}!{}", sheet_name, cell_address);
2885
2886 let tokens = match Tokenizer::new(&formula_with_equals) {
2887 Ok(tokenizer) => tokenizer.items,
2888 Err(e) => {
2889 if policy == FormulaParsePolicy::Fail {
2890 bail!(
2891 "{}tokenizer error in {}: {}",
2892 FORMULA_PARSE_FAILED_PREFIX,
2893 context_description,
2894 e.message
2895 );
2896 }
2897 builder.record_error(&sheet_name, &cell_address, formula_text, &e.message);
2898 continue;
2899 }
2900 };
2901 let mut out = String::with_capacity(formula_with_equals.len());
2902 let mut cursor = 0usize;
2903
2904 for token in &tokens {
2905 if token.start > cursor {
2906 out.push_str(&formula_with_equals[cursor..token.start]);
2907 }
2908
2909 let mut value = token.value.clone();
2910 if token.subtype == formualizer_parse::TokenSubType::Range
2911 && value.contains('!')
2912 && let Some((sheet_part, tail)) = value.split_once('!')
2913 && sheet_part_matches(sheet_part, old_name)
2914 {
2915 value = format!("{}{}", new_prefix, tail);
2916 }
2917
2918 out.push_str(&value);
2919 cursor = token.end;
2920 }
2921
2922 if cursor < formula_with_equals.len() {
2923 out.push_str(&formula_with_equals[cursor..]);
2924 }
2925
2926 let new_formula = out.strip_prefix('=').unwrap_or(&out);
2927 cell.set_formula(new_formula.to_string());
2928 cell.set_formula_result_default("");
2929 }
2930 }
2931
2932 Ok(())
2933}
2934
2935fn rewrite_defined_name_formulas_for_sheet_rename(
2936 book: &mut umya_spreadsheet::Spreadsheet,
2937 old_name: &str,
2938 new_name: &str,
2939 policy: FormulaParsePolicy,
2940 builder: &mut FormulaParseDiagnosticsBuilder,
2941) -> Result<()> {
2942 let new_prefix = format_sheet_prefix_for_formula(new_name);
2943
2944 for defined in book.get_defined_names_mut() {
2945 let refers_to = defined.get_address();
2946 let trimmed = refers_to.trim();
2947 let had_equals = trimmed.starts_with('=');
2948 let looks_like_formula = had_equals || trimmed.contains('(');
2949 if !looks_like_formula {
2950 continue;
2951 }
2952
2953 let formula_in = if had_equals {
2954 trimmed.to_string()
2955 } else {
2956 format!("={}", trimmed)
2957 };
2958
2959 if policy == FormulaParsePolicy::Off {
2960 continue;
2961 }
2962
2963 let defined_name = defined.get_name().to_string();
2964 let context_description = format!("defined name '{}'", defined_name);
2965
2966 let tokens = match Tokenizer::new(&formula_in) {
2967 Ok(tokenizer) => tokenizer.items,
2968 Err(e) => {
2969 if policy == FormulaParsePolicy::Fail {
2970 bail!(
2971 "{}tokenizer error in {}: {}",
2972 FORMULA_PARSE_FAILED_PREFIX,
2973 context_description,
2974 e.message
2975 );
2976 }
2977 builder.record_error("[DefinedName]", &defined_name, trimmed, &e.message);
2978 continue;
2979 }
2980 };
2981
2982 let mut out = String::with_capacity(formula_in.len());
2983 let mut cursor = 0usize;
2984 let mut changed = false;
2985
2986 for token in &tokens {
2987 if token.start > cursor {
2988 out.push_str(&formula_in[cursor..token.start]);
2989 }
2990
2991 let mut value = token.value.clone();
2992 if token.subtype == formualizer_parse::TokenSubType::Range
2993 && value.contains('!')
2994 && let Some((sheet_part, tail)) = value.split_once('!')
2995 && sheet_part_matches(sheet_part, old_name)
2996 {
2997 value = format!("{}{}", new_prefix, tail);
2998 changed = true;
2999 }
3000
3001 out.push_str(&value);
3002 cursor = token.end;
3003 }
3004
3005 if cursor < formula_in.len() {
3006 out.push_str(&formula_in[cursor..]);
3007 }
3008
3009 if changed {
3010 let out_final = if had_equals {
3011 out
3012 } else {
3013 out.strip_prefix('=').unwrap_or(&out).to_string()
3014 };
3015 defined.set_address(out_final);
3016 }
3017 }
3018
3019 Ok(())
3020}
3021
3022fn rewrite_defined_name_formulas_for_sheet_col_insert(
3023 book: &mut umya_spreadsheet::Spreadsheet,
3024 sheet_name: &str,
3025 at_col: u32,
3026 count: u32,
3027 policy: FormulaParsePolicy,
3028 builder: &mut FormulaParseDiagnosticsBuilder,
3029) -> Result<()> {
3030 rewrite_defined_name_formulas_for_sheet_structure_change(
3031 book,
3032 sheet_name,
3033 StructureAxis::Col,
3034 StructureEdit::Insert { at: at_col, count },
3035 policy,
3036 builder,
3037 )
3038}
3039
3040fn rewrite_defined_name_formulas_for_sheet_col_delete(
3041 book: &mut umya_spreadsheet::Spreadsheet,
3042 sheet_name: &str,
3043 start_col: u32,
3044 count: u32,
3045 policy: FormulaParsePolicy,
3046 builder: &mut FormulaParseDiagnosticsBuilder,
3047) -> Result<()> {
3048 rewrite_defined_name_formulas_for_sheet_structure_change(
3049 book,
3050 sheet_name,
3051 StructureAxis::Col,
3052 StructureEdit::Delete {
3053 start: start_col,
3054 count,
3055 },
3056 policy,
3057 builder,
3058 )
3059}
3060
3061fn rewrite_defined_name_formulas_for_sheet_row_insert(
3062 book: &mut umya_spreadsheet::Spreadsheet,
3063 sheet_name: &str,
3064 at_row: u32,
3065 count: u32,
3066 policy: FormulaParsePolicy,
3067 builder: &mut FormulaParseDiagnosticsBuilder,
3068) -> Result<()> {
3069 rewrite_defined_name_formulas_for_sheet_structure_change(
3070 book,
3071 sheet_name,
3072 StructureAxis::Row,
3073 StructureEdit::Insert { at: at_row, count },
3074 policy,
3075 builder,
3076 )
3077}
3078
3079fn rewrite_defined_name_formulas_for_sheet_row_delete(
3080 book: &mut umya_spreadsheet::Spreadsheet,
3081 sheet_name: &str,
3082 start_row: u32,
3083 count: u32,
3084 policy: FormulaParsePolicy,
3085 builder: &mut FormulaParseDiagnosticsBuilder,
3086) -> Result<()> {
3087 rewrite_defined_name_formulas_for_sheet_structure_change(
3088 book,
3089 sheet_name,
3090 StructureAxis::Row,
3091 StructureEdit::Delete {
3092 start: start_row,
3093 count,
3094 },
3095 policy,
3096 builder,
3097 )
3098}
3099
3100fn rewrite_defined_name_formulas_for_sheet_structure_change(
3101 book: &mut umya_spreadsheet::Spreadsheet,
3102 sheet_name: &str,
3103 axis: StructureAxis,
3104 edit: StructureEdit,
3105 policy: FormulaParsePolicy,
3106 builder: &mut FormulaParseDiagnosticsBuilder,
3107) -> Result<()> {
3108 for defined in book.get_defined_names_mut() {
3109 let refers_to = defined.get_address();
3110 let trimmed = refers_to.trim();
3111 let had_equals = trimmed.starts_with('=');
3112 let looks_like_formula = had_equals || trimmed.contains('(');
3113 if !looks_like_formula {
3114 continue;
3115 }
3116
3117 let formula_in = if had_equals {
3118 trimmed.to_string()
3119 } else {
3120 format!("={}", trimmed)
3121 };
3122
3123 if policy == FormulaParsePolicy::Off {
3124 continue;
3125 }
3126
3127 let defined_name = defined.get_name().to_string();
3128 let context_description = format!("defined name '{}'", defined_name);
3129
3130 let tokens = match Tokenizer::new(&formula_in) {
3131 Ok(tokenizer) => tokenizer.items,
3132 Err(e) => {
3133 if policy == FormulaParsePolicy::Fail {
3134 bail!(
3135 "{}tokenizer error in {}: {}",
3136 FORMULA_PARSE_FAILED_PREFIX,
3137 context_description,
3138 e.message
3139 );
3140 }
3141 builder.record_error("[DefinedName]", &defined_name, trimmed, &e.message);
3142 continue;
3143 }
3144 };
3145
3146 let mut out = String::with_capacity(formula_in.len());
3147 let mut cursor = 0usize;
3148 let mut changed = false;
3149
3150 for token in &tokens {
3151 if token.start > cursor {
3152 out.push_str(&formula_in[cursor..token.start]);
3153 }
3154
3155 let mut value = token.value.clone();
3156 if token.subtype == formualizer_parse::TokenSubType::Range
3157 && value.contains('!')
3158 && let Some((sheet_part, coord_part)) = value.split_once('!')
3159 && sheet_part_matches(sheet_part, sheet_name)
3160 {
3161 let adjusted = adjust_ref_coord_part(coord_part, axis, edit)?;
3162 value = format!("{sheet_part}!{adjusted}");
3163 changed = true;
3164 }
3165
3166 out.push_str(&value);
3167 cursor = token.end;
3168 }
3169
3170 if cursor < formula_in.len() {
3171 out.push_str(&formula_in[cursor..]);
3172 }
3173
3174 if changed {
3175 let out_final = if had_equals {
3176 out
3177 } else {
3178 out.strip_prefix('=').unwrap_or(&out).to_string()
3179 };
3180 defined.set_address(out_final);
3181 }
3182 }
3183
3184 Ok(())
3185}
3186
3187fn clamp_defined_name_refers_to_max_row(refers_to: &str) -> Option<String> {
3188 const EXCEL_MAX_ROW: u32 = 1_048_576;
3189
3190 let trimmed = refers_to.trim();
3191 if trimmed.is_empty() {
3192 return None;
3193 }
3194
3195 let had_equals = trimmed.starts_with('=');
3196 let formula_in = if had_equals {
3197 trimmed.to_string()
3198 } else {
3199 format!("={trimmed}")
3200 };
3201
3202 let tokens = Tokenizer::new(&formula_in).ok()?.items;
3203
3204 let mut out = String::with_capacity(formula_in.len());
3205 let mut cursor = 0usize;
3206 let mut changed = false;
3207
3208 for token in &tokens {
3209 if token.start > cursor {
3210 out.push_str(&formula_in[cursor..token.start]);
3211 }
3212
3213 let mut value = token.value.clone();
3214 if token.subtype == formualizer_parse::TokenSubType::Range {
3215 let clamped = clamp_range_token_max_row(&value, EXCEL_MAX_ROW);
3216 if clamped != value {
3217 value = clamped;
3218 changed = true;
3219 }
3220 }
3221
3222 out.push_str(&value);
3223 cursor = token.end;
3224 }
3225
3226 if cursor < formula_in.len() {
3227 out.push_str(&formula_in[cursor..]);
3228 }
3229
3230 if !changed {
3231 return None;
3232 }
3233
3234 Some(if had_equals {
3235 out
3236 } else {
3237 out.strip_prefix('=').unwrap_or(&out).to_string()
3238 })
3239}
3240
3241fn clamp_range_token_max_row(token_value: &str, max_row: u32) -> String {
3242 if token_value == "#REF!" {
3243 return token_value.to_string();
3244 }
3245
3246 if let Some((sheet_part, coord_part)) = token_value.split_once('!') {
3247 let clamped = clamp_coord_part_max_row(coord_part, max_row);
3248 if clamped == coord_part {
3249 token_value.to_string()
3250 } else {
3251 format!("{sheet_part}!{clamped}")
3252 }
3253 } else {
3254 clamp_coord_part_max_row(token_value, max_row)
3255 }
3256}
3257
3258fn clamp_coord_part_max_row(coord_part: &str, max_row: u32) -> String {
3259 let mut changed = false;
3260 let mut union_out = Vec::new();
3261
3262 for union_piece in coord_part.split(',') {
3263 let mut range_out = Vec::new();
3264 let mut local_changed = false;
3265
3266 for segment in union_piece.split(':') {
3267 let clamped = clamp_ref_segment_max_row(segment, max_row);
3268 if clamped != segment {
3269 local_changed = true;
3270 }
3271 range_out.push(clamped);
3272 }
3273
3274 if local_changed {
3275 changed = true;
3276 }
3277 union_out.push(range_out.join(":"));
3278 }
3279
3280 if changed {
3281 union_out.join(",")
3282 } else {
3283 coord_part.to_string()
3284 }
3285}
3286
3287fn clamp_ref_segment_max_row(segment: &str, max_row: u32) -> String {
3288 use umya_spreadsheet::helper::coordinate::{
3289 coordinate_from_index_with_lock, index_from_coordinate, string_from_column_index,
3290 };
3291
3292 let (col, row, col_lock, row_lock) = index_from_coordinate(segment);
3293
3294 if col.is_none() && row.is_none() {
3296 return segment.to_string();
3297 }
3298
3299 let row = row.map(|value| value.min(max_row));
3300
3301 match (col, row) {
3302 (Some(c), Some(r)) => coordinate_from_index_with_lock(
3303 &c,
3304 &r,
3305 &col_lock.unwrap_or(false),
3306 &row_lock.unwrap_or(false),
3307 ),
3308 (Some(c), None) => {
3309 let col_str = string_from_column_index(&c);
3310 format!(
3311 "{}{}",
3312 if col_lock.unwrap_or(false) { "$" } else { "" },
3313 col_str
3314 )
3315 }
3316 (None, Some(r)) => format!("{}{}", if row_lock.unwrap_or(false) { "$" } else { "" }, r),
3317 (None, None) => segment.to_string(),
3318 }
3319}
3320
3321fn clamp_out_of_bounds_defined_name_rows(book: &mut umya_spreadsheet::Spreadsheet) -> usize {
3322 #[derive(Debug)]
3323 struct Patch {
3324 idx: usize,
3325 name: String,
3326 clamped_address: String,
3327 hidden: bool,
3328 local_sheet_id: Option<u32>,
3329 sheet_hint: Option<String>,
3330 }
3331
3332 fn extract_first_sheet_name(refers_to: &str) -> Option<String> {
3333 let first = refers_to.split(',').next()?.trim();
3334 let (sheet_part, _) = first.split_once('!')?;
3335 let sheet_part = sheet_part.trim();
3336 if let Some(inner) = sheet_part
3337 .strip_prefix('\'')
3338 .and_then(|value| value.strip_suffix('\''))
3339 {
3340 Some(inner.replace("''", "'"))
3341 } else {
3342 Some(sheet_part.to_string())
3343 }
3344 }
3345
3346 let mut patches = Vec::new();
3347 for (idx, defined) in book.get_defined_names().iter().enumerate() {
3348 let original = defined.get_address();
3349 let trimmed = original.trim();
3350
3351 if trimmed.starts_with('=') || trimmed.contains('(') || trimmed.is_empty() {
3353 continue;
3354 }
3355
3356 if let Some(clamped_address) = clamp_defined_name_refers_to_max_row(trimmed) {
3357 patches.push(Patch {
3358 idx,
3359 name: defined.get_name().to_string(),
3360 clamped_address,
3361 hidden: *defined.get_hidden(),
3362 local_sheet_id: if defined.has_local_sheet_id() {
3363 Some(*defined.get_local_sheet_id())
3364 } else {
3365 None
3366 },
3367 sheet_hint: extract_first_sheet_name(trimmed),
3368 });
3369 }
3370 }
3371
3372 let mut applied = 0usize;
3373 for patch in patches {
3374 let target_sheet = patch
3375 .sheet_hint
3376 .as_ref()
3377 .filter(|name| book.get_sheet_by_name(name).is_some())
3378 .cloned()
3379 .or_else(|| {
3380 book.get_sheet_collection_no_check()
3381 .first()
3382 .map(|sheet| sheet.get_name().to_string())
3383 });
3384
3385 let Some(target_sheet) = target_sheet else {
3386 continue;
3387 };
3388
3389 let mut replacement = {
3390 let Some(sheet) = book.get_sheet_by_name_mut(&target_sheet) else {
3391 continue;
3392 };
3393 let before = sheet.get_defined_names().len();
3394 if sheet
3395 .add_defined_name(patch.name.clone(), patch.clamped_address.clone())
3396 .is_err()
3397 {
3398 continue;
3399 }
3400 let Some(candidate) = sheet.get_defined_names().last().cloned() else {
3401 sheet.get_defined_names_mut().truncate(before);
3402 continue;
3403 };
3404 sheet.get_defined_names_mut().truncate(before);
3405 candidate
3406 };
3407
3408 replacement.set_hidden(patch.hidden);
3409 if let Some(local_sheet_id) = patch.local_sheet_id {
3410 replacement.set_local_sheet_id(local_sheet_id);
3411 }
3412
3413 if let Some(slot) = book.get_defined_names_mut().get_mut(patch.idx) {
3414 *slot = replacement;
3415 applied += 1;
3416 }
3417 }
3418
3419 applied
3420}
3421
3422fn clamp_defined_name_rows_in_workbook_xml(xml: &str) -> (String, usize) {
3423 let defined_name_re =
3424 Regex::new(r"(?s)(<definedName[^>]*>)([^<]*)(</definedName>)").expect("valid xml regex");
3425
3426 let mut clamped_entries = 0usize;
3427 let rewritten = defined_name_re
3428 .replace_all(xml, |caps: ®ex::Captures<'_>| {
3429 let full = caps.get(0).map(|m| m.as_str()).unwrap_or_default();
3430 let prefix = caps.get(1).map(|m| m.as_str()).unwrap_or_default();
3431 let body = caps.get(2).map(|m| m.as_str()).unwrap_or_default();
3432 let suffix = caps.get(3).map(|m| m.as_str()).unwrap_or_default();
3433
3434 if body.trim_start().starts_with('=') || body.contains('(') {
3435 return full.to_string();
3436 }
3437
3438 if let Some(clamped_body) = clamp_defined_name_refers_to_max_row(body) {
3439 clamped_entries += 1;
3440 format!("{prefix}{clamped_body}{suffix}")
3441 } else {
3442 full.to_string()
3443 }
3444 })
3445 .to_string();
3446
3447 (rewritten, clamped_entries)
3448}
3449
3450fn sanitize_workbook_xml_defined_name_rows(path: &Path) -> Result<usize> {
3451 use zip::{ZipArchive, ZipWriter, write::FileOptions};
3452
3453 let input_file = fs::File::open(path)?;
3454 let mut archive = ZipArchive::new(input_file)?;
3455
3456 #[derive(Debug)]
3457 struct ZipEntry {
3458 name: String,
3459 is_dir: bool,
3460 data: Vec<u8>,
3461 compression: zip::CompressionMethod,
3462 unix_mode: Option<u32>,
3463 modified: zip::DateTime,
3464 }
3465
3466 let mut entries: Vec<ZipEntry> = Vec::with_capacity(archive.len());
3467 let mut clamped_defined_names = 0usize;
3468
3469 for idx in 0..archive.len() {
3470 let mut file = archive.by_index(idx)?;
3471 let name = file.name().to_string();
3472 let is_dir = file.is_dir();
3473 let compression = file.compression();
3474 let unix_mode = file.unix_mode();
3475 let modified = file.last_modified();
3476
3477 let mut data = Vec::new();
3478 if !is_dir {
3479 file.read_to_end(&mut data)?;
3480 if name == "xl/workbook.xml" {
3481 let xml = String::from_utf8_lossy(&data);
3482 let (rewritten, changed) = clamp_defined_name_rows_in_workbook_xml(&xml);
3483 clamped_defined_names = changed;
3484 if changed > 0 {
3485 data = rewritten.into_bytes();
3486 }
3487 }
3488 }
3489
3490 entries.push(ZipEntry {
3491 name,
3492 is_dir,
3493 data,
3494 compression,
3495 unix_mode,
3496 modified,
3497 });
3498 }
3499
3500 if clamped_defined_names == 0 {
3501 return Ok(0);
3502 }
3503
3504 let temp_path = path.with_extension("xlsx.tmp");
3505 let output_file = fs::File::create(&temp_path)?;
3506 let mut writer = ZipWriter::new(output_file);
3507
3508 for entry in entries {
3509 let mut options = FileOptions::default()
3510 .compression_method(entry.compression)
3511 .last_modified_time(entry.modified);
3512 if let Some(mode) = entry.unix_mode {
3513 options = options.unix_permissions(mode);
3514 }
3515
3516 if entry.is_dir {
3517 writer.add_directory(entry.name, options)?;
3518 } else {
3519 writer.start_file(entry.name, options)?;
3520 writer.write_all(&entry.data)?;
3521 }
3522 }
3523
3524 writer.finish()?;
3525 fs::rename(temp_path, path)?;
3526 Ok(clamped_defined_names)
3527}
3528
3529fn rewrite_formulas_for_sheet_col_insert(
3530 book: &mut umya_spreadsheet::Spreadsheet,
3531 sheet_name: &str,
3532 at_col: u32,
3533 count: u32,
3534 policy: FormulaParsePolicy,
3535 builder: &mut FormulaParseDiagnosticsBuilder,
3536) -> Result<()> {
3537 rewrite_formulas_for_sheet_structure_change(
3538 book,
3539 sheet_name,
3540 StructureAxis::Col,
3541 StructureEdit::Insert { at: at_col, count },
3542 policy,
3543 builder,
3544 )
3545}
3546
3547fn rewrite_formulas_for_sheet_col_delete(
3548 book: &mut umya_spreadsheet::Spreadsheet,
3549 sheet_name: &str,
3550 start_col: u32,
3551 count: u32,
3552 policy: FormulaParsePolicy,
3553 builder: &mut FormulaParseDiagnosticsBuilder,
3554) -> Result<()> {
3555 rewrite_formulas_for_sheet_structure_change(
3556 book,
3557 sheet_name,
3558 StructureAxis::Col,
3559 StructureEdit::Delete {
3560 start: start_col,
3561 count,
3562 },
3563 policy,
3564 builder,
3565 )
3566}
3567
3568fn rewrite_formulas_for_sheet_row_insert(
3569 book: &mut umya_spreadsheet::Spreadsheet,
3570 sheet_name: &str,
3571 at_row: u32,
3572 count: u32,
3573 policy: FormulaParsePolicy,
3574 builder: &mut FormulaParseDiagnosticsBuilder,
3575) -> Result<()> {
3576 rewrite_formulas_for_sheet_structure_change(
3577 book,
3578 sheet_name,
3579 StructureAxis::Row,
3580 StructureEdit::Insert { at: at_row, count },
3581 policy,
3582 builder,
3583 )
3584}
3585
3586fn rewrite_formulas_for_sheet_row_delete(
3587 book: &mut umya_spreadsheet::Spreadsheet,
3588 sheet_name: &str,
3589 start_row: u32,
3590 count: u32,
3591 policy: FormulaParsePolicy,
3592 builder: &mut FormulaParseDiagnosticsBuilder,
3593) -> Result<()> {
3594 rewrite_formulas_for_sheet_structure_change(
3595 book,
3596 sheet_name,
3597 StructureAxis::Row,
3598 StructureEdit::Delete {
3599 start: start_row,
3600 count,
3601 },
3602 policy,
3603 builder,
3604 )
3605}
3606
3607#[derive(Debug, Clone, Copy)]
3608enum StructureAxis {
3609 Row,
3610 Col,
3611}
3612
3613#[derive(Debug, Clone, Copy)]
3614enum StructureEdit {
3615 Insert { at: u32, count: u32 },
3616 Delete { start: u32, count: u32 },
3617}
3618
3619fn rewrite_formulas_for_sheet_structure_change(
3620 book: &mut umya_spreadsheet::Spreadsheet,
3621 sheet_name: &str,
3622 axis: StructureAxis,
3623 edit: StructureEdit,
3624 policy: FormulaParsePolicy,
3625 builder: &mut FormulaParseDiagnosticsBuilder,
3626) -> Result<()> {
3627 for sheet in book.get_sheet_collection_mut().iter_mut() {
3628 if sheet.get_name() == sheet_name {
3629 continue;
3630 }
3631 let current_sheet_name = sheet.get_name().to_string();
3632 for cell in sheet.get_cell_collection_mut() {
3633 if !cell.is_formula() {
3634 continue;
3635 }
3636 let formula_text = cell.get_formula();
3637 if formula_text.is_empty() {
3638 continue;
3639 }
3640 let formula_with_equals = if formula_text.starts_with('=') {
3641 formula_text.to_string()
3642 } else {
3643 format!("={}", formula_text)
3644 };
3645 if policy == FormulaParsePolicy::Off {
3646 continue;
3647 }
3648
3649 let cell_address = cell.get_coordinate().get_coordinate().to_string();
3650 let context_description = format!("{}!{}", current_sheet_name, cell_address);
3651
3652 let tokens = match Tokenizer::new(&formula_with_equals) {
3653 Ok(tokenizer) => tokenizer.items,
3654 Err(e) => {
3655 if policy == FormulaParsePolicy::Fail {
3656 bail!(
3657 "{}tokenizer error in {}: {}",
3658 FORMULA_PARSE_FAILED_PREFIX,
3659 context_description,
3660 e.message
3661 );
3662 }
3663 builder.record_error(
3664 ¤t_sheet_name,
3665 &cell_address,
3666 formula_text,
3667 &e.message,
3668 );
3669 continue;
3670 }
3671 };
3672
3673 let mut out = String::with_capacity(formula_with_equals.len());
3674 let mut cursor = 0usize;
3675 let mut changed = false;
3676
3677 for token in &tokens {
3678 if token.start > cursor {
3679 out.push_str(&formula_with_equals[cursor..token.start]);
3680 }
3681
3682 let mut value = token.value.clone();
3683 if token.subtype == formualizer_parse::TokenSubType::Range
3684 && value.contains('!')
3685 && let Some((sheet_part, coord_part)) = value.split_once('!')
3686 && sheet_part_matches(sheet_part, sheet_name)
3687 {
3688 let adjusted = adjust_ref_coord_part(coord_part, axis, edit)?;
3689 value = format!("{sheet_part}!{adjusted}");
3690 changed = true;
3691 }
3692
3693 out.push_str(&value);
3694 cursor = token.end;
3695 }
3696
3697 if cursor < formula_with_equals.len() {
3698 out.push_str(&formula_with_equals[cursor..]);
3699 }
3700
3701 if changed {
3702 let new_formula = out.strip_prefix('=').unwrap_or(&out);
3703 cell.set_formula(new_formula.to_string());
3704 cell.set_formula_result_default("");
3705 }
3706 }
3707 }
3708 Ok(())
3709}
3710
3711fn adjust_ref_coord_part(
3712 coord_part: &str,
3713 axis: StructureAxis,
3714 edit: StructureEdit,
3715) -> Result<String> {
3716 if coord_part == "#REF!" {
3717 return Ok(coord_part.to_string());
3718 }
3719 if let Some((start, end)) = coord_part.split_once(':') {
3720 let start_adj = adjust_ref_segment(start, axis, edit)?;
3721 let end_adj = adjust_ref_segment(end, axis, edit)?;
3722 if start_adj == "#REF!" || end_adj == "#REF!" {
3723 return Ok("#REF!".to_string());
3724 }
3725 Ok(format!("{start_adj}:{end_adj}"))
3726 } else {
3727 Ok(adjust_ref_segment(coord_part, axis, edit)?)
3728 }
3729}
3730
3731fn adjust_ref_segment(segment: &str, axis: StructureAxis, edit: StructureEdit) -> Result<String> {
3732 use umya_spreadsheet::helper::coordinate::{
3733 coordinate_from_index_with_lock, index_from_coordinate, string_from_column_index,
3734 };
3735
3736 const EXCEL_MAX_ROW: u32 = 1_048_576;
3737
3738 let (col, row, col_lock, row_lock) = index_from_coordinate(segment);
3739 let mut col = col;
3740 let mut row = row;
3741
3742 match axis {
3743 StructureAxis::Col => {
3744 if let Some(c) = col {
3745 col = match edit {
3746 StructureEdit::Insert { at, count } => Some(adjust_insert(c, at, count)),
3747 StructureEdit::Delete { start, count } => adjust_delete(c, start, count),
3748 };
3749 }
3750 }
3751 StructureAxis::Row => {
3752 if let Some(r) = row {
3753 row = match edit {
3754 StructureEdit::Insert { at, count } => {
3755 Some(adjust_insert_bounded(r, at, count, EXCEL_MAX_ROW))
3756 }
3757 StructureEdit::Delete { start, count } => adjust_delete(r, start, count),
3758 };
3759 }
3760 }
3761 }
3762
3763 if col.is_none() && row.is_none() {
3764 return Ok("#REF!".to_string());
3765 }
3766
3767 match (col, row) {
3768 (Some(c), Some(r)) => Ok(coordinate_from_index_with_lock(
3769 &c,
3770 &r,
3771 &col_lock.unwrap_or(false),
3772 &row_lock.unwrap_or(false),
3773 )),
3774 (Some(c), None) => {
3775 let col_str = string_from_column_index(&c);
3776 Ok(format!(
3777 "{}{}",
3778 if col_lock.unwrap_or(false) { "$" } else { "" },
3779 col_str
3780 ))
3781 }
3782 (None, Some(r)) => Ok(format!(
3783 "{}{}",
3784 if row_lock.unwrap_or(false) { "$" } else { "" },
3785 r
3786 )),
3787 (None, None) => Ok("#REF!".to_string()),
3788 }
3789}
3790
3791fn adjust_insert(value: u32, at: u32, count: u32) -> u32 {
3792 if value >= at {
3793 value.saturating_add(count)
3794 } else {
3795 value
3796 }
3797}
3798
3799fn adjust_insert_bounded(value: u32, at: u32, count: u32, max_value: u32) -> u32 {
3800 adjust_insert(value, at, count).min(max_value)
3801}
3802
3803fn adjust_delete(value: u32, start: u32, count: u32) -> Option<u32> {
3804 let end = start.saturating_add(count.saturating_sub(1));
3805 if value >= start && value <= end {
3806 None
3807 } else if value > end {
3808 Some(value - count)
3809 } else {
3810 Some(value)
3811 }
3812}
3813
3814fn simple_sum_range_regex() -> Regex {
3821 Regex::new(r"(?i)^SUM\(([A-Z]{1,3})(\d+):([A-Z]{1,3})(\d+)\)$").expect("valid regex")
3822}
3823
3824fn expand_adjacent_sum_formulas(
3838 book: &mut umya_spreadsheet::Spreadsheet,
3839 sheet_name: &str,
3840 at_row: u32,
3841 count: u32,
3842) -> Result<(Vec<String>, u64)> {
3843 let mut warnings: Vec<String> = Vec::new();
3844 let mut expanded_count: u64 = 0;
3845 let subtotal_row = at_row + count;
3847 let sum_re = simple_sum_range_regex();
3848
3849 let sheet = book
3850 .get_sheet_by_name_mut(sheet_name)
3851 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
3852
3853 let max_col = sheet.get_highest_column();
3854 if max_col == 0 {
3855 return Ok((warnings, expanded_count));
3856 }
3857
3858 for col in 1..=max_col {
3859 let Some(cell) = sheet.get_cell((col, subtotal_row)) else {
3860 continue;
3861 };
3862 if !cell.is_formula() {
3863 continue;
3864 }
3865 let formula_text = cell.get_formula().to_string();
3866 if formula_text.is_empty() {
3867 continue;
3868 }
3869 let formula_bare = formula_text.strip_prefix('=').unwrap_or(&formula_text);
3871
3872 let Some(caps) = sum_re.captures(formula_bare) else {
3873 if formula_bare.to_ascii_uppercase().contains("SUM") {
3875 warnings.push(format!(
3876 "WARN_SUM_EXPANSION_SKIPPED: {}!{} has complex SUM formula '{}'; skipped.",
3877 sheet_name,
3878 crate::utils::cell_address(col, subtotal_row),
3879 formula_bare
3880 ));
3881 }
3882 continue;
3883 };
3884
3885 let col1_str = caps.get(1).unwrap().as_str().to_ascii_uppercase();
3886 let row1: u32 = caps.get(2).unwrap().as_str().parse().unwrap_or(0);
3887 let col2_str = caps.get(3).unwrap().as_str().to_ascii_uppercase();
3888 let row2: u32 = caps.get(4).unwrap().as_str().parse().unwrap_or(0);
3889
3890 if col1_str != col2_str {
3892 warnings.push(format!(
3893 "WARN_SUM_EXPANSION_SKIPPED: {}!{} SUM spans columns {}:{} — not a single-column range; skipped.",
3894 sheet_name,
3895 crate::utils::cell_address(col, subtotal_row),
3896 col1_str,
3897 col2_str,
3898 ));
3899 continue;
3900 }
3901
3902 if at_row == 0 || row2 + 1 != at_row {
3906 continue;
3908 }
3909 if row1 > row2 || row1 == 0 {
3910 continue;
3911 }
3912
3913 let desired_end = subtotal_row - 1;
3916
3917 let new_formula = format!("SUM({}{}:{}{})", col1_str, row1, col2_str, desired_end);
3919
3920 let cell = sheet.get_cell_mut((col, subtotal_row));
3921 cell.set_formula(new_formula);
3922 cell.set_formula_result_default("");
3923 expanded_count += 1;
3924 }
3925
3926 Ok((warnings, expanded_count))
3927}
3928
3929#[derive(Debug, Clone)]
3935struct TemplateCellData {
3936 col: u32,
3937 value: String,
3938 formula: Option<String>,
3939 style: umya_spreadsheet::Style,
3940}
3941
3942fn capture_row_template(
3944 sheet: &umya_spreadsheet::Worksheet,
3945 source_row: u32,
3946) -> Result<Vec<TemplateCellData>> {
3947 let max_col = sheet.get_highest_column();
3948 let mut cells = Vec::new();
3949 for col in 1..=max_col {
3950 let Some(cell) = sheet.get_cell((col, source_row)) else {
3951 continue;
3952 };
3953 let value = cell.get_value().to_string();
3954 let formula = if cell.is_formula() {
3955 Some(cell.get_formula().to_string())
3956 } else {
3957 None
3958 };
3959 let style = cell.get_style().clone();
3960 cells.push(TemplateCellData {
3961 col,
3962 value,
3963 formula,
3964 style,
3965 });
3966 }
3967 Ok(cells)
3968}
3969
3970fn stamp_template_rows(
3972 sheet: &mut umya_spreadsheet::Worksheet,
3973 template: &[TemplateCellData],
3974 source_row: u32,
3975 insert_at: u32,
3976 count: u32,
3977) -> Result<Vec<String>> {
3978 let mut warnings = Vec::new();
3979 for copy_idx in 0..count {
3980 let dest_row = insert_at + copy_idx;
3981 let delta_row = dest_row as i32 - source_row as i32;
3982 for tpl in template {
3983 let dest_cell = sheet.get_cell_mut((tpl.col, dest_row));
3984 dest_cell.set_style(tpl.style.clone());
3985
3986 if let Some(formula) = &tpl.formula {
3987 match parse_base_formula(formula)
3989 .and_then(|ast| shift_formula_ast(&ast, 0, delta_row, RelativeMode::Excel))
3990 {
3991 Ok(shifted) => {
3992 let shifted = shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
3993 dest_cell.set_formula(shifted);
3994 dest_cell.set_formula_result_default("");
3995 }
3996 Err(err) => {
3997 warnings.push(format!(
3998 "WARN_CLONE_FORMULA_SHIFT: could not shift formula '{}' for row {}: {}; copied verbatim.",
3999 formula, dest_row, err
4000 ));
4001 dest_cell.set_formula(formula.clone());
4002 dest_cell.set_formula_result_default("");
4003 }
4004 }
4005 } else {
4006 dest_cell.set_value(tpl.value.clone());
4007 }
4008 }
4009 }
4010 Ok(warnings)
4011}
4012
4013fn sheet_part_matches(sheet_part: &str, old_name: &str) -> bool {
4014 let trimmed = sheet_part.trim();
4015 if let Some(stripped) = trimmed.strip_prefix('\'')
4016 && let Some(inner) = stripped.strip_suffix('\'')
4017 {
4018 return inner.replace("''", "'") == old_name;
4019 }
4020 trimmed == old_name
4021}
4022
4023fn format_sheet_prefix_for_formula(sheet_name: &str) -> String {
4024 if sheet_name_needs_quoting_for_formula(sheet_name) {
4025 let escaped = sheet_name.replace('\'', "''");
4026 format!("'{escaped}'!")
4027 } else {
4028 format!("{sheet_name}!")
4029 }
4030}
4031
4032fn sheet_name_needs_quoting_for_formula(name: &str) -> bool {
4033 if name.is_empty() {
4034 return false;
4035 }
4036 let bytes = name.as_bytes();
4037 if bytes[0].is_ascii_digit() {
4038 return true;
4039 }
4040 for &byte in bytes {
4041 match byte {
4042 b' ' | b'!' | b'"' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+'
4043 | b',' | b'-' | b'.' | b'/' | b':' | b';' | b'<' | b'=' | b'>' | b'?' | b'@' | b'['
4044 | b'\\' | b']' | b'^' | b'`' | b'{' | b'|' | b'}' | b'~' => return true,
4045 _ => {}
4046 }
4047 }
4048 let upper = name.to_uppercase();
4049 matches!(
4050 upper.as_str(),
4051 "TRUE" | "FALSE" | "NULL" | "REF" | "DIV" | "NAME" | "NUM" | "VALUE" | "N/A"
4052 )
4053}
4054
4055pub(crate) struct StyleApplyResult {
4056 pub(crate) ops_applied: usize,
4057 pub(crate) summary: ChangeSummary,
4058}
4059
4060pub(crate) fn stage_snapshot_path(fork_id: &str, change_id: &str) -> PathBuf {
4061 PathBuf::from("/tmp/mcp-staged").join(format!("{fork_id}_{change_id}.xlsx"))
4062}
4063
4064pub(crate) struct ColumnSizeApplyResult {
4065 pub(crate) ops_applied: usize,
4066 pub(crate) summary: ChangeSummary,
4067}
4068
4069fn parse_column_span(spec: &str) -> Result<(u32, u32)> {
4070 let raw = spec.trim();
4071 if raw.is_empty() {
4072 return Err(anyhow!("column range is empty"));
4073 }
4074
4075 let raw = raw.replace(' ', "");
4076 let (start, end) = if let Some((a, b)) = raw.split_once(':') {
4077 (a, b)
4078 } else if let Some((a, b)) = raw.split_once('-') {
4079 (a, b)
4080 } else {
4081 (raw.as_str(), raw.as_str())
4082 };
4083
4084 let start_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(start);
4085 let end_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(end);
4086 if start_idx == 0 || end_idx == 0 {
4087 return Err(anyhow!("invalid column span '{spec}'"));
4088 }
4089 let (min, max) = if start_idx <= end_idx {
4090 (start_idx, end_idx)
4091 } else {
4092 (end_idx, start_idx)
4093 };
4094 Ok((min, max))
4095}
4096
4097pub(crate) fn apply_column_size_ops_to_file(
4098 path: &Path,
4099 sheet_name: &str,
4100 ops: &[ColumnSizeOp],
4101) -> Result<ColumnSizeApplyResult> {
4102 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
4103 let sheet = book
4104 .get_sheet_by_name_mut(sheet_name)
4105 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4106
4107 let mut affected_bounds: Vec<String> = Vec::new();
4108 let mut warnings: Vec<String> = Vec::new();
4109
4110 let mut columns_sized: u64 = 0;
4111 let mut auto_ops: u64 = 0;
4112 let mut width_ops: u64 = 0;
4113
4114 for op in ops {
4115 let ColumnTarget::Columns { range } = &op.target;
4116 let (start_col, end_col) = parse_column_span(range)?;
4117 affected_bounds.push(range.clone());
4118
4119 match &op.size {
4120 ColumnSizeSpec::Width { width_chars } => {
4121 width_ops += 1;
4122 for col in start_col..=end_col {
4123 let col_dim = sheet.get_column_dimension_by_number_mut(&col);
4124 col_dim.set_width(*width_chars);
4125 col_dim.set_best_fit(false);
4126 col_dim.set_auto_width(false);
4127 columns_sized += 1;
4128 }
4129 }
4130 ColumnSizeSpec::Auto {
4131 min_width_chars,
4132 max_width_chars,
4133 } => {
4134 auto_ops += 1;
4135
4136 let mut saw_formula_without_cached = false;
4137 for cell in sheet.get_cell_collection() {
4138 let col_num = *cell.get_coordinate().get_col_num();
4139 if col_num < start_col || col_num > end_col {
4140 continue;
4141 }
4142 if cell.is_formula() && cell.get_value().is_empty() {
4143 saw_formula_without_cached = true;
4144 break;
4145 }
4146 }
4147 if saw_formula_without_cached {
4148 warnings.push(
4149 "WARN_AUTOWIDTH_FORMULA_NO_CACHED: Autosize measured empty values for some formula cells; results may be too narrow. Recalc the sheet before autosize for best results."
4150 .to_string(),
4151 );
4152 }
4153
4154 for col in start_col..=end_col {
4155 sheet
4156 .get_column_dimension_by_number_mut(&col)
4157 .set_auto_width(true);
4158 }
4159 sheet.calculation_auto_width();
4160
4161 for col in start_col..=end_col {
4162 let col_dim = sheet.get_column_dimension_by_number_mut(&col);
4163 col_dim.set_auto_width(false);
4164 col_dim.set_best_fit(true);
4165
4166 let mut width = *col_dim.get_width();
4167 if let Some(min_width) = min_width_chars
4168 && width < *min_width
4169 {
4170 width = *min_width;
4171 }
4172 if let Some(max_width) = max_width_chars
4173 && width > *max_width
4174 {
4175 width = *max_width;
4176 }
4177 col_dim.set_width(width);
4178 columns_sized += 1;
4179 }
4180 }
4181 }
4182 }
4183
4184 umya_spreadsheet::writer::xlsx::write(&book, path)?;
4185
4186 let mut counts = BTreeMap::new();
4187 counts.insert("columns_sized".to_string(), columns_sized);
4188 counts.insert("auto_ops".to_string(), auto_ops);
4189 counts.insert("width_ops".to_string(), width_ops);
4190
4191 Ok(ColumnSizeApplyResult {
4192 ops_applied: ops.len(),
4193 summary: ChangeSummary {
4194 op_kinds: vec!["column_size_batch".to_string()],
4195 affected_sheets: vec![sheet_name.to_string()],
4196 affected_bounds,
4197 counts,
4198 warnings,
4199 ..Default::default()
4200 },
4201 })
4202}
4203
4204pub(crate) struct TransformApplyResult {
4205 pub(crate) ops_applied: usize,
4206 pub(crate) summary: ChangeSummary,
4207}
4208
4209pub(crate) fn apply_transform_ops_to_file(
4210 path: &Path,
4211 ops: &[TransformOp],
4212) -> Result<TransformApplyResult> {
4213 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
4214
4215 let mut sheets: BTreeSet<String> = BTreeSet::new();
4216 let mut affected_bounds: Vec<String> = Vec::new();
4217
4218 let mut cells_touched: u64 = 0;
4219 let mut cells_value_cleared: u64 = 0;
4220 let mut cells_formula_cleared: u64 = 0;
4221 let mut cells_skipped_keep_formulas: u64 = 0;
4222
4223 let mut cells_value_set: u64 = 0;
4224 let mut cells_formula_set: u64 = 0;
4225 let mut cells_value_replaced: u64 = 0;
4226 let mut cells_formula_replaced: u64 = 0;
4227
4228 for op in ops {
4229 match op {
4230 TransformOp::ClearRange {
4231 sheet_name,
4232 target,
4233 clear_values,
4234 clear_formulas,
4235 } => {
4236 let sheet = book
4237 .get_sheet_by_name_mut(sheet_name)
4238 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4239 sheets.insert(sheet_name.clone());
4240
4241 match target {
4242 TransformTarget::Range { range } => {
4243 let bounds = parse_range_bounds(range)?;
4244 affected_bounds.push(range.clone());
4245
4246 for row in bounds.min_row..=bounds.max_row {
4247 for col in bounds.min_col..=bounds.max_col {
4248 let exists = sheet.get_cell((col, row)).is_some();
4249 if !exists {
4250 continue;
4251 }
4252
4253 let cell = sheet.get_cell_mut((col, row));
4254 let was_formula = cell.is_formula();
4255 cells_touched += 1;
4256
4257 if *clear_formulas && was_formula {
4258 cell.set_formula(String::new());
4259 cells_formula_cleared += 1;
4260 }
4261
4262 if *clear_values {
4263 if was_formula && !*clear_formulas {
4264 cells_skipped_keep_formulas += 1;
4265 } else {
4266 if !cell.get_value().is_empty() {
4267 cells_value_cleared += 1;
4268 }
4269 cell.set_value(String::new());
4270 }
4271 }
4272 }
4273 }
4274 }
4275 TransformTarget::Cells { cells } => {
4276 affected_bounds.extend(cells.iter().cloned());
4277 for addr in cells {
4278 let exists = sheet.get_cell(addr.as_str()).is_some();
4279 if !exists {
4280 continue;
4281 }
4282
4283 let cell = sheet.get_cell_mut(addr.as_str());
4284 let was_formula = cell.is_formula();
4285 cells_touched += 1;
4286
4287 if *clear_formulas && was_formula {
4288 cell.set_formula(String::new());
4289 cells_formula_cleared += 1;
4290 }
4291
4292 if *clear_values {
4293 if was_formula && !*clear_formulas {
4294 cells_skipped_keep_formulas += 1;
4295 } else {
4296 if !cell.get_value().is_empty() {
4297 cells_value_cleared += 1;
4298 }
4299 cell.set_value(String::new());
4300 }
4301 }
4302 }
4303 }
4304 TransformTarget::Region { .. } => {
4305 return Err(anyhow!(
4306 "region_id targets must be resolved before apply_transform_ops_to_file"
4307 ));
4308 }
4309 }
4310 }
4311 TransformOp::FillRange {
4312 sheet_name,
4313 target,
4314 value,
4315 is_formula,
4316 overwrite_formulas,
4317 } => {
4318 let sheet = book
4319 .get_sheet_by_name_mut(sheet_name)
4320 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4321 sheets.insert(sheet_name.clone());
4322
4323 match target {
4324 TransformTarget::Range { range } => {
4325 let bounds = parse_range_bounds(range)?;
4326 affected_bounds.push(range.clone());
4327
4328 for row in bounds.min_row..=bounds.max_row {
4329 for col in bounds.min_col..=bounds.max_col {
4330 let cell = sheet.get_cell_mut((col, row));
4331 cells_touched += 1;
4332
4333 if !*is_formula && cell.is_formula() {
4334 if !*overwrite_formulas {
4335 cells_skipped_keep_formulas += 1;
4336 continue;
4337 }
4338 cell.set_formula(String::new());
4339 cells_formula_cleared += 1;
4340 }
4341
4342 if *is_formula {
4343 cell.set_formula(value.clone());
4344 cell.set_formula_result_default("");
4345 cells_formula_set += 1;
4346 } else {
4347 cell.set_value(value.clone());
4348 cells_value_set += 1;
4349 }
4350 }
4351 }
4352 }
4353 TransformTarget::Cells { cells } => {
4354 affected_bounds.extend(cells.iter().cloned());
4355 for addr in cells {
4356 let cell = sheet.get_cell_mut(addr.as_str());
4357 cells_touched += 1;
4358
4359 if !*is_formula && cell.is_formula() {
4360 if !*overwrite_formulas {
4361 cells_skipped_keep_formulas += 1;
4362 continue;
4363 }
4364 cell.set_formula(String::new());
4365 cells_formula_cleared += 1;
4366 }
4367
4368 if *is_formula {
4369 cell.set_formula(value.clone());
4370 cell.set_formula_result_default("");
4371 cells_formula_set += 1;
4372 } else {
4373 cell.set_value(value.clone());
4374 cells_value_set += 1;
4375 }
4376 }
4377 }
4378 TransformTarget::Region { .. } => {
4379 return Err(anyhow!(
4380 "region_id targets must be resolved before apply_transform_ops_to_file"
4381 ));
4382 }
4383 }
4384 }
4385 TransformOp::ReplaceInRange {
4386 sheet_name,
4387 target,
4388 find,
4389 replace,
4390 match_mode,
4391 case_sensitive,
4392 include_formulas,
4393 } => {
4394 let sheet = book
4395 .get_sheet_by_name_mut(sheet_name)
4396 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4397 sheets.insert(sheet_name.clone());
4398
4399 if *match_mode == ReplaceMatchMode::Contains && !*case_sensitive {
4400 return Err(anyhow!(
4401 "match_mode 'contains' requires case_sensitive=true"
4402 ));
4403 }
4404
4405 let replace_value = |input: &str| -> Option<String> {
4406 if *match_mode == ReplaceMatchMode::Exact {
4407 if *case_sensitive {
4408 (input == find).then(|| replace.clone())
4409 } else {
4410 input.eq_ignore_ascii_case(find).then(|| replace.clone())
4411 }
4412 } else if input.contains(find) {
4413 Some(input.replace(find, replace))
4414 } else {
4415 None
4416 }
4417 };
4418
4419 match target {
4420 TransformTarget::Range { range } => {
4421 let bounds = parse_range_bounds(range)?;
4422 affected_bounds.push(range.clone());
4423
4424 for row in bounds.min_row..=bounds.max_row {
4425 for col in bounds.min_col..=bounds.max_col {
4426 let exists = sheet.get_cell((col, row)).is_some();
4427 if !exists {
4428 continue;
4429 }
4430
4431 let cell = sheet.get_cell_mut((col, row));
4432 cells_touched += 1;
4433
4434 if cell.is_formula() {
4435 if !*include_formulas {
4436 cells_skipped_keep_formulas += 1;
4437 continue;
4438 }
4439
4440 let formula = cell.get_formula().to_string();
4441 if formula.is_empty() {
4442 continue;
4443 }
4444 if let Some(next) = replace_value(&formula) {
4445 cell.set_formula(next);
4446 cell.set_formula_result_default("");
4447 cells_formula_replaced += 1;
4448 }
4449 continue;
4450 }
4451
4452 let value = cell.get_value().to_string();
4453 if value.is_empty() {
4454 continue;
4455 }
4456 if let Some(next) = replace_value(&value) {
4457 cell.set_value(next);
4458 cells_value_replaced += 1;
4459 }
4460 }
4461 }
4462 }
4463 TransformTarget::Cells { cells } => {
4464 affected_bounds.extend(cells.iter().cloned());
4465 for addr in cells {
4466 let exists = sheet.get_cell(addr.as_str()).is_some();
4467 if !exists {
4468 continue;
4469 }
4470
4471 let cell = sheet.get_cell_mut(addr.as_str());
4472 cells_touched += 1;
4473
4474 if cell.is_formula() {
4475 if !*include_formulas {
4476 cells_skipped_keep_formulas += 1;
4477 continue;
4478 }
4479
4480 let formula = cell.get_formula().to_string();
4481 if formula.is_empty() {
4482 continue;
4483 }
4484 if let Some(next) = replace_value(&formula) {
4485 cell.set_formula(next);
4486 cell.set_formula_result_default("");
4487 cells_formula_replaced += 1;
4488 }
4489 continue;
4490 }
4491
4492 let value = cell.get_value().to_string();
4493 if value.is_empty() {
4494 continue;
4495 }
4496 if let Some(next) = replace_value(&value) {
4497 cell.set_value(next);
4498 cells_value_replaced += 1;
4499 }
4500 }
4501 }
4502 TransformTarget::Region { .. } => {
4503 return Err(anyhow!(
4504 "region_id targets must be resolved before apply_transform_ops_to_file"
4505 ));
4506 }
4507 }
4508 }
4509 TransformOp::WriteMatrix {
4510 sheet_name,
4511 anchor,
4512 rows,
4513 overwrite_formulas,
4514 } => {
4515 let sheet = book
4516 .get_sheet_by_name_mut(sheet_name)
4517 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4518 sheets.insert(sheet_name.clone());
4519
4520 let (anchor_col, anchor_row) = parse_cell_ref(anchor)?;
4521
4522 let mut max_row = anchor_row;
4523 let mut max_col = anchor_col;
4524
4525 for (r_idx, row) in rows.iter().enumerate() {
4526 let r = anchor_row + r_idx as u32;
4527 if r > max_row {
4528 max_row = r;
4529 }
4530 for (c_idx, cell_opt) in row.iter().enumerate() {
4531 let c = anchor_col + c_idx as u32;
4532 if c > max_col {
4533 max_col = c;
4534 }
4535
4536 let Some(cell_data) = cell_opt else {
4537 continue;
4538 };
4539
4540 let cell = sheet.get_cell_mut((c, r));
4541 cells_touched += 1;
4542
4543 if cell.is_formula() {
4544 if !*overwrite_formulas {
4545 cells_skipped_keep_formulas += 1;
4546 continue;
4547 }
4548 cell.set_formula(String::new());
4549 cells_formula_cleared += 1;
4550 }
4551
4552 match cell_data {
4553 MatrixCell::Value(v) => {
4554 let val_str = match v {
4555 serde_json::Value::Null => String::new(),
4556 serde_json::Value::Bool(b) => b.to_string(),
4557 serde_json::Value::Number(n) => n.to_string(),
4558 serde_json::Value::String(s) => s.clone(),
4559 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
4560 v.to_string()
4561 }
4562 };
4563 cell.set_value(val_str);
4564 cells_value_set += 1;
4565 }
4566 MatrixCell::Formula(f) => {
4567 let f_str = f.strip_prefix('=').unwrap_or(f);
4568 cell.set_formula(f_str);
4569 cell.set_formula_result_default("");
4570 cells_formula_set += 1;
4571 }
4572 }
4573 }
4574 }
4575
4576 affected_bounds.push(format!(
4577 "{}:{}",
4578 crate::utils::cell_address(anchor_col, anchor_row),
4579 crate::utils::cell_address(max_col, max_row)
4580 ));
4581 }
4582 }
4583 }
4584
4585 umya_spreadsheet::writer::xlsx::write(&book, path)?;
4586
4587 let mut counts = BTreeMap::new();
4588 counts.insert("cells_touched".to_string(), cells_touched);
4589 counts.insert("cells_value_cleared".to_string(), cells_value_cleared);
4590 counts.insert("cells_formula_cleared".to_string(), cells_formula_cleared);
4591 counts.insert(
4592 "cells_skipped_keep_formulas".to_string(),
4593 cells_skipped_keep_formulas,
4594 );
4595
4596 counts.insert("cells_value_set".to_string(), cells_value_set);
4597 counts.insert("cells_formula_set".to_string(), cells_formula_set);
4598 counts.insert("cells_value_replaced".to_string(), cells_value_replaced);
4599 counts.insert("cells_formula_replaced".to_string(), cells_formula_replaced);
4600
4601 let summary = ChangeSummary {
4602 op_kinds: vec!["transform_batch".to_string()],
4603 affected_sheets: sheets.into_iter().collect(),
4604 affected_bounds,
4605 counts,
4606 warnings: Vec::new(),
4607 ..Default::default()
4608 };
4609
4610 Ok(TransformApplyResult {
4611 ops_applied: ops.len(),
4612 summary,
4613 })
4614}
4615
4616#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
4619pub struct ReplaceInFormulasOp {
4620 pub sheet_name: String,
4621 pub find: String,
4622 pub replace: String,
4623 #[serde(default)]
4625 pub range: Option<String>,
4626 #[serde(default)]
4628 pub regex: bool,
4629 #[serde(default = "default_replace_case_sensitive")]
4631 pub case_sensitive: bool,
4632}
4633
4634#[derive(Debug, Clone, Serialize, JsonSchema)]
4635pub struct FormulaReplaceSample {
4636 pub address: String,
4637 pub before: String,
4638 pub after: String,
4639}
4640
4641#[derive(Debug)]
4642pub struct ReplaceInFormulasApplyResult {
4643 pub formulas_checked: u64,
4644 pub formulas_changed: u64,
4645 pub samples: Vec<FormulaReplaceSample>,
4646 pub warnings: Vec<String>,
4647 pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
4648}
4649
4650const REPLACE_SAMPLE_LIMIT: usize = 20;
4651
4652pub fn apply_replace_in_formulas_to_file(
4653 path: &Path,
4654 op: &ReplaceInFormulasOp,
4655 policy: FormulaParsePolicy,
4656) -> Result<ReplaceInFormulasApplyResult> {
4657 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
4658
4659 let sheet = book
4660 .get_sheet_by_name_mut(&op.sheet_name)
4661 .ok_or_else(|| anyhow!("sheet '{}' not found", op.sheet_name))?;
4662
4663 let (min_col, min_row, max_col, max_row) = if let Some(range) = &op.range {
4665 let bounds = parse_range_bounds(range)?;
4666 (
4667 bounds.min_col,
4668 bounds.min_row,
4669 bounds.max_col,
4670 bounds.max_row,
4671 )
4672 } else {
4673 let (hc, hr) = sheet.get_highest_column_and_row();
4674 (1, 1, hc.max(1), hr.max(1))
4675 };
4676
4677 let compiled_regex: Option<Regex> = if op.regex {
4679 let pattern = if op.case_sensitive {
4680 op.find.clone()
4681 } else {
4682 format!("(?i){}", op.find)
4683 };
4684 Some(Regex::new(&pattern).map_err(|e| anyhow!("invalid regex pattern: {}", e))?)
4685 } else {
4686 None
4687 };
4688
4689 let replace_formula = |formula: &str| -> Option<String> {
4690 if let Some(re) = &compiled_regex {
4691 let result = re.replace_all(formula, op.replace.as_str());
4692 if result != formula {
4693 Some(result.into_owned())
4694 } else {
4695 None
4696 }
4697 } else if op.case_sensitive {
4698 if formula.contains(&op.find) {
4699 Some(formula.replace(&op.find, &op.replace))
4700 } else {
4701 None
4702 }
4703 } else {
4704 let find_lower = op.find.to_ascii_lowercase();
4706 let formula_lower = formula.to_ascii_lowercase();
4707 if !formula_lower.contains(&find_lower) {
4708 return None;
4709 }
4710 let mut result = String::with_capacity(formula.len());
4712 let mut cursor = 0usize;
4713 while let Some(pos) = formula_lower[cursor..].find(&find_lower) {
4714 result.push_str(&formula[cursor..cursor + pos]);
4715 result.push_str(&op.replace);
4716 cursor += pos + op.find.len();
4717 }
4718 result.push_str(&formula[cursor..]);
4719 Some(result)
4720 }
4721 };
4722
4723 let mut formulas_checked: u64 = 0;
4724 let mut formulas_changed: u64 = 0;
4725 let mut samples: Vec<FormulaReplaceSample> = Vec::new();
4726 let mut warnings: Vec<String> = Vec::new();
4727 let mut formula_parse_diagnostics_builder = FormulaParseDiagnosticsBuilder::new(policy);
4728
4729 for row in min_row..=max_row {
4730 for col in min_col..=max_col {
4731 let exists = sheet.get_cell((col, row)).is_some();
4732 if !exists {
4733 continue;
4734 }
4735 let cell = sheet.get_cell_mut((col, row));
4736 if !cell.is_formula() {
4737 continue;
4738 }
4739 let formula = cell.get_formula().to_string();
4740 if formula.is_empty() {
4741 continue;
4742 }
4743 formulas_checked += 1;
4744
4745 if let Some(next) = replace_formula(&formula) {
4746 let address = crate::utils::cell_address(col, row);
4747
4748 if policy != FormulaParsePolicy::Off
4749 && let Err(err_msg) = validate_formula(&next)
4750 {
4751 if policy == FormulaParsePolicy::Fail {
4752 bail!(
4753 "{}replaced formula at {} failed parse: {}",
4754 FORMULA_PARSE_FAILED_PREFIX,
4755 address,
4756 err_msg
4757 );
4758 }
4759 formula_parse_diagnostics_builder.record_error(
4760 &op.sheet_name,
4761 &address,
4762 &next,
4763 &err_msg,
4764 );
4765 continue;
4767 }
4768
4769 if samples.len() < REPLACE_SAMPLE_LIMIT {
4770 samples.push(FormulaReplaceSample {
4771 address,
4772 before: formula.clone(),
4773 after: next.clone(),
4774 });
4775 }
4776 cell.set_formula(next);
4777 cell.set_formula_result_default("");
4778 formulas_changed += 1;
4779 }
4780 }
4781 }
4782
4783 if formulas_changed == 0 {
4784 warnings.push("WARN_NO_MATCH: no formula text matched the find pattern".to_string());
4785 }
4786
4787 umya_spreadsheet::writer::xlsx::write(&book, path)?;
4788
4789 let formula_parse_diagnostics = if formula_parse_diagnostics_builder.has_errors() {
4790 Some(formula_parse_diagnostics_builder.build())
4791 } else {
4792 None
4793 };
4794
4795 Ok(ReplaceInFormulasApplyResult {
4796 formulas_checked,
4797 formulas_changed,
4798 samples,
4799 warnings,
4800 formula_parse_diagnostics,
4801 })
4802}
4803
4804#[derive(Debug, Deserialize, JsonSchema)]
4807pub struct ReplaceInFormulasParams {
4808 pub fork_id: String,
4809 pub sheet_name: String,
4810 pub find: String,
4811 pub replace: String,
4812 #[serde(default)]
4814 pub range: Option<String>,
4815 #[serde(default)]
4817 pub regex: bool,
4818 #[serde(default = "default_replace_case_sensitive")]
4820 pub case_sensitive: bool,
4821 #[serde(default)]
4822 pub mode: Option<BatchMode>,
4823 #[serde(default)]
4824 pub label: Option<String>,
4825 #[serde(default)]
4827 pub formula_parse_policy: Option<FormulaParsePolicy>,
4828}
4829
4830#[derive(Debug, Serialize, JsonSchema)]
4831pub struct ReplaceInFormulasResponse {
4832 pub fork_id: String,
4833 pub mode: String,
4834 #[serde(skip_serializing_if = "Option::is_none")]
4835 pub change_id: Option<String>,
4836 pub formulas_checked: u64,
4837 pub formulas_changed: u64,
4838 pub recalc_needed: bool,
4839 pub samples: Vec<FormulaReplaceSample>,
4840 pub warnings: Vec<String>,
4841 #[serde(skip_serializing_if = "Option::is_none")]
4842 pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
4843}
4844
4845#[derive(Debug, Serialize, Deserialize)]
4846struct ReplaceInFormulasStagedPayload {
4847 op: ReplaceInFormulasOp,
4848}
4849
4850pub async fn replace_in_formulas(
4851 state: Arc<AppState>,
4852 params: ReplaceInFormulasParams,
4853) -> Result<ReplaceInFormulasResponse> {
4854 let registry = state
4855 .fork_registry()
4856 .ok_or_else(|| anyhow!("fork registry not available"))?;
4857
4858 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
4859 let work_path = fork_ctx.work_path.clone();
4860
4861 let op = ReplaceInFormulasOp {
4862 sheet_name: params.sheet_name.clone(),
4863 find: params.find.clone(),
4864 replace: params.replace.clone(),
4865 range: params.range.clone(),
4866 regex: params.regex,
4867 case_sensitive: params.case_sensitive,
4868 };
4869
4870 let policy =
4871 params
4872 .formula_parse_policy
4873 .unwrap_or(FormulaParsePolicy::default_for_command_class(
4874 CommandClass::BatchWrite,
4875 ));
4876
4877 let mode = params.mode.unwrap_or_default();
4878
4879 if mode.is_preview() {
4880 let change_id = make_short_random_id("chg", 12);
4881 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
4882 std::fs::create_dir_all(snapshot_path.parent().unwrap())?;
4883 std::fs::copy(&work_path, &snapshot_path)?;
4884
4885 let snapshot_for_apply = snapshot_path.clone();
4886 let op_clone = op.clone();
4887 let result = tokio::task::spawn_blocking(move || {
4888 apply_replace_in_formulas_to_file(&snapshot_for_apply, &op_clone, policy)
4889 })
4890 .await??;
4891
4892 let staged_op = StagedOp {
4893 kind: "replace_in_formulas".to_string(),
4894 payload: serde_json::to_value(ReplaceInFormulasStagedPayload { op: op.clone() })?,
4895 };
4896
4897 let summary = ChangeSummary {
4898 op_kinds: vec!["replace_in_formulas".to_string()],
4899 affected_sheets: vec![op.sheet_name.clone()],
4900 affected_bounds: op.range.clone().into_iter().collect(),
4901 counts: {
4902 let mut c = BTreeMap::new();
4903 c.insert("formulas_checked".to_string(), result.formulas_checked);
4904 c.insert("formulas_changed".to_string(), result.formulas_changed);
4905 c
4906 },
4907 warnings: result.warnings.clone(),
4908 ..Default::default()
4909 };
4910
4911 let staged = StagedChange {
4912 change_id: change_id.clone(),
4913 created_at: Utc::now(),
4914 label: params.label.clone(),
4915 ops: vec![staged_op],
4916 summary,
4917 fork_path_snapshot: Some(snapshot_path),
4918 };
4919
4920 registry.add_staged_change(¶ms.fork_id, staged)?;
4921
4922 Ok(ReplaceInFormulasResponse {
4923 fork_id: params.fork_id,
4924 mode: mode.as_str().to_string(),
4925 change_id: Some(change_id),
4926 formulas_checked: result.formulas_checked,
4927 formulas_changed: result.formulas_changed,
4928 recalc_needed: fork_ctx.recalc_needed || result.formulas_changed > 0,
4929 samples: result.samples,
4930 warnings: result.warnings,
4931 formula_parse_diagnostics: result.formula_parse_diagnostics,
4932 })
4933 } else {
4934 let prior_recalc_needed = fork_ctx.recalc_needed;
4935 let op_clone = op.clone();
4936 let work_path_for_apply = work_path.clone();
4937 let result = tokio::task::spawn_blocking(move || {
4938 apply_replace_in_formulas_to_file(&work_path_for_apply, &op_clone, policy)
4939 })
4940 .await??;
4941
4942 if result.formulas_changed > 0 {
4943 registry.with_fork_mut(¶ms.fork_id, |ctx| {
4944 ctx.recalc_needed = true;
4945 Ok(())
4946 })?;
4947 let fork_workbook_id = WorkbookId(params.fork_id.clone());
4948 let _ = state.close_workbook(&fork_workbook_id);
4949 }
4950
4951 Ok(ReplaceInFormulasResponse {
4952 fork_id: params.fork_id,
4953 mode: mode.as_str().to_string(),
4954 change_id: None,
4955 formulas_checked: result.formulas_checked,
4956 formulas_changed: result.formulas_changed,
4957 recalc_needed: prior_recalc_needed || result.formulas_changed > 0,
4958 samples: result.samples,
4959 warnings: result.warnings,
4960 formula_parse_diagnostics: result.formula_parse_diagnostics,
4961 })
4962 }
4963}
4964
4965pub(crate) fn apply_style_ops_to_file(path: &Path, ops: &[StyleOp]) -> Result<StyleApplyResult> {
4966 use crate::styles::{
4967 StylePatchMode, apply_style_patch, descriptor_from_style, stable_style_id,
4968 };
4969
4970 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
4971
4972 let mut sheets: BTreeSet<String> = BTreeSet::new();
4973 let mut affected_bounds: Vec<String> = Vec::new();
4974 let mut cells_touched: u64 = 0;
4975 let mut cells_style_changed: u64 = 0;
4976
4977 for op in ops {
4978 let sheet = book
4979 .get_sheet_by_name_mut(&op.sheet_name)
4980 .ok_or_else(|| anyhow!("sheet '{}' not found", op.sheet_name))?;
4981 sheets.insert(op.sheet_name.clone());
4982
4983 let op_mode = op.op_mode.unwrap_or(StylePatchMode::Merge);
4984
4985 match &op.target {
4986 StyleTarget::Range { range } => {
4987 let bounds = parse_range_bounds(range)?;
4988 affected_bounds.push(range.clone());
4989 for row in bounds.min_row..=bounds.max_row {
4990 for col in bounds.min_col..=bounds.max_col {
4991 let addr = crate::utils::cell_address(col, row);
4992 let cell = sheet.get_cell_mut(addr.as_str());
4993 let before = stable_style_id(&descriptor_from_style(cell.get_style()));
4994 let next_style = apply_style_patch(cell.get_style(), &op.patch, op_mode);
4995 cell.set_style(next_style);
4996 let after = stable_style_id(&descriptor_from_style(cell.get_style()));
4997 cells_touched += 1;
4998 if before != after {
4999 cells_style_changed += 1;
5000 }
5001 }
5002 }
5003 }
5004 StyleTarget::Cells { cells } => {
5005 affected_bounds.extend(cells.iter().cloned());
5006 for addr in cells {
5007 let cell = sheet.get_cell_mut(addr.as_str());
5008 let before = stable_style_id(&descriptor_from_style(cell.get_style()));
5009 let next_style = apply_style_patch(cell.get_style(), &op.patch, op_mode);
5010 cell.set_style(next_style);
5011 let after = stable_style_id(&descriptor_from_style(cell.get_style()));
5012 cells_touched += 1;
5013 if before != after {
5014 cells_style_changed += 1;
5015 }
5016 }
5017 }
5018 StyleTarget::Region { .. } => {
5019 return Err(anyhow!(
5020 "region_id targets must be resolved before apply_style_ops_to_file"
5021 ));
5022 }
5023 }
5024 }
5025
5026 umya_spreadsheet::writer::xlsx::write(&book, path)?;
5027
5028 let mut counts = BTreeMap::new();
5029 counts.insert("cells_touched".to_string(), cells_touched);
5030 counts.insert("cells_style_changed".to_string(), cells_style_changed);
5031
5032 let summary = ChangeSummary {
5033 op_kinds: vec!["style_batch".to_string()],
5034 affected_sheets: sheets.into_iter().collect(),
5035 affected_bounds,
5036 counts,
5037 warnings: Vec::new(),
5038 ..Default::default()
5039 };
5040
5041 Ok(StyleApplyResult {
5042 ops_applied: ops.len(),
5043 summary,
5044 })
5045}
5046
5047#[derive(Debug, Deserialize, JsonSchema)]
5048pub struct GetEditsParams {
5049 pub fork_id: String,
5050}
5051
5052#[derive(Debug, Serialize, JsonSchema)]
5053pub struct GetEditsResponse {
5054 pub fork_id: String,
5055 pub edits: Vec<EditRecord>,
5056}
5057
5058#[derive(Debug, Serialize, JsonSchema)]
5059pub struct EditRecord {
5060 pub timestamp: String,
5061 pub sheet: String,
5062 pub address: String,
5063 pub value: String,
5064 pub is_formula: bool,
5065}
5066
5067pub async fn get_edits(state: Arc<AppState>, params: GetEditsParams) -> Result<GetEditsResponse> {
5068 let registry = state
5069 .fork_registry()
5070 .ok_or_else(|| anyhow!("fork registry not available"))?;
5071
5072 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
5073
5074 let edits: Vec<EditRecord> = fork_ctx
5075 .edits
5076 .iter()
5077 .map(|e| EditRecord {
5078 timestamp: e.timestamp.to_rfc3339(),
5079 sheet: e.sheet.clone(),
5080 address: e.address.clone(),
5081 value: e.value.clone(),
5082 is_formula: e.is_formula,
5083 })
5084 .collect();
5085
5086 Ok(GetEditsResponse {
5087 fork_id: params.fork_id,
5088 edits,
5089 })
5090}
5091
5092fn default_get_changeset_limit() -> u32 {
5093 200
5094}
5095
5096#[derive(Debug, Deserialize, JsonSchema)]
5097pub struct GetChangesetParams {
5098 pub fork_id: String,
5099 pub sheet_name: Option<String>,
5100 #[serde(default = "default_get_changeset_limit")]
5101 pub limit: u32,
5102 #[serde(default)]
5103 pub offset: u32,
5104 #[serde(default)]
5105 pub summary_only: bool,
5106 #[serde(default)]
5107 pub include_types: Option<Vec<String>>,
5108 #[serde(default)]
5109 pub exclude_types: Option<Vec<String>>,
5110 #[serde(default)]
5111 pub include_subtypes: Option<Vec<String>>,
5112 #[serde(default)]
5113 pub exclude_subtypes: Option<Vec<String>>,
5114}
5115
5116impl Default for GetChangesetParams {
5117 fn default() -> Self {
5118 Self {
5119 fork_id: String::new(),
5120 sheet_name: None,
5121 limit: default_get_changeset_limit(),
5122 offset: 0,
5123 summary_only: false,
5124 include_types: None,
5125 exclude_types: None,
5126 include_subtypes: None,
5127 exclude_subtypes: None,
5128 }
5129 }
5130}
5131
5132#[derive(Debug, Serialize, JsonSchema)]
5133pub struct ChangesetSummary {
5134 pub total_changes: u32,
5135 pub returned_changes: u32,
5136 pub truncated: bool,
5137 pub next_offset: Option<u32>,
5138 pub counts_by_kind: BTreeMap<String, u32>,
5139 pub counts_by_type: BTreeMap<String, u32>,
5140 pub counts_by_subtype: BTreeMap<String, u32>,
5141 pub affected_sheets: Vec<String>,
5142}
5143
5144#[derive(Debug, Serialize, JsonSchema)]
5145pub struct GetChangesetResponse {
5146 pub fork_id: String,
5147 pub base_workbook: String,
5148 #[serde(skip_serializing_if = "Option::is_none")]
5149 pub client_base_workbook: Option<String>,
5150 pub changes: Vec<crate::diff::Change>,
5151 pub summary: ChangesetSummary,
5152}
5153
5154fn normalize_filter(values: &Option<Vec<String>>) -> Option<BTreeSet<String>> {
5155 values.as_ref().map(|items| {
5156 items
5157 .iter()
5158 .map(|s| s.to_ascii_lowercase())
5159 .collect::<BTreeSet<_>>()
5160 })
5161}
5162
5163fn change_kind_key(change: &crate::diff::Change) -> &'static str {
5164 match change {
5165 crate::diff::Change::Cell(_) => "cell",
5166 crate::diff::Change::Table(_) => "table",
5167 crate::diff::Change::Name(_) => "name",
5168 }
5169}
5170
5171fn change_type_key(change: &crate::diff::Change) -> &'static str {
5172 use crate::diff::merge::CellDiff;
5173 match change {
5174 crate::diff::Change::Cell(cell) => match &cell.diff {
5175 CellDiff::Added { .. } => "added",
5176 CellDiff::Deleted { .. } => "deleted",
5177 CellDiff::Modified { .. } => "modified",
5178 },
5179 crate::diff::Change::Table(table) => match table {
5180 crate::diff::tables::TableDiff::TableAdded { .. } => "table_added",
5181 crate::diff::tables::TableDiff::TableDeleted { .. } => "table_deleted",
5182 crate::diff::tables::TableDiff::TableModified { .. } => "table_modified",
5183 },
5184 crate::diff::Change::Name(name) => match name {
5185 crate::diff::names::NameDiff::NameAdded { .. } => "name_added",
5186 crate::diff::names::NameDiff::NameDeleted { .. } => "name_deleted",
5187 crate::diff::names::NameDiff::NameModified { .. } => "name_modified",
5188 },
5189 }
5190}
5191
5192fn change_subtype_key(change: &crate::diff::Change) -> Option<&'static str> {
5193 use crate::diff::merge::{CellDiff, ModificationType};
5194 match change {
5195 crate::diff::Change::Cell(cell) => match &cell.diff {
5196 CellDiff::Modified { subtype, .. } => Some(match subtype {
5197 ModificationType::FormulaEdit => "formula_edit",
5198 ModificationType::RecalcResult => "recalc_result",
5199 ModificationType::ValueEdit => "value_edit",
5200 ModificationType::StyleEdit => "style_edit",
5201 }),
5202 _ => None,
5203 },
5204 _ => None,
5205 }
5206}
5207
5208fn change_sheet_name(change: &crate::diff::Change) -> Option<&str> {
5209 match change {
5210 crate::diff::Change::Cell(cell) => Some(cell.sheet.as_str()),
5211 crate::diff::Change::Table(table) => match table {
5212 crate::diff::tables::TableDiff::TableAdded { sheet, .. }
5213 | crate::diff::tables::TableDiff::TableDeleted { sheet, .. }
5214 | crate::diff::tables::TableDiff::TableModified { sheet, .. } => Some(sheet.as_str()),
5215 },
5216 crate::diff::Change::Name(name) => match name {
5217 crate::diff::names::NameDiff::NameAdded { scope_sheet, .. }
5218 | crate::diff::names::NameDiff::NameDeleted { scope_sheet, .. }
5219 | crate::diff::names::NameDiff::NameModified { scope_sheet, .. } => {
5220 scope_sheet.as_deref()
5221 }
5222 },
5223 }
5224}
5225
5226fn change_passes_filters(
5227 change: &crate::diff::Change,
5228 include_types: &Option<BTreeSet<String>>,
5229 exclude_types: &Option<BTreeSet<String>>,
5230 include_subtypes: &Option<BTreeSet<String>>,
5231 exclude_subtypes: &Option<BTreeSet<String>>,
5232) -> bool {
5233 let type_key = change_type_key(change);
5234 let subtype_key = change_subtype_key(change);
5235
5236 if let Some(include) = include_types
5237 && !include.contains(type_key)
5238 {
5239 return false;
5240 }
5241 if let Some(exclude) = exclude_types
5242 && exclude.contains(type_key)
5243 {
5244 return false;
5245 }
5246
5247 if let Some(include) = include_subtypes
5248 && subtype_key.is_none_or(|subtype| !include.contains(subtype))
5249 {
5250 return false;
5251 }
5252 if let Some(exclude) = exclude_subtypes
5253 && subtype_key.is_some_and(|subtype| exclude.contains(subtype))
5254 {
5255 return false;
5256 }
5257
5258 true
5259}
5260
5261pub async fn get_changeset(
5262 state: Arc<AppState>,
5263 params: GetChangesetParams,
5264) -> Result<GetChangesetResponse> {
5265 let registry = state
5266 .fork_registry()
5267 .ok_or_else(|| anyhow!("fork registry not available"))?;
5268
5269 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
5270
5271 let raw_changes = tokio::task::spawn_blocking({
5272 let base_path = fork_ctx.base_path.clone();
5273 let work_path = fork_ctx.work_path.clone();
5274 let sheet_filter = params.sheet_name.clone();
5275 move || {
5276 crate::core::diff::calculate_changeset(&base_path, &work_path, sheet_filter.as_deref())
5277 }
5278 })
5279 .await??;
5280
5281 let include_types = normalize_filter(¶ms.include_types);
5282 let exclude_types = normalize_filter(¶ms.exclude_types);
5283 let include_subtypes = normalize_filter(¶ms.include_subtypes);
5284 let exclude_subtypes = normalize_filter(¶ms.exclude_subtypes);
5285
5286 let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
5287 let mut counts_by_kind: BTreeMap<String, u32> = BTreeMap::new();
5288 let mut counts_by_type: BTreeMap<String, u32> = BTreeMap::new();
5289 let mut counts_by_subtype: BTreeMap<String, u32> = BTreeMap::new();
5290
5291 let mut filtered: Vec<crate::diff::Change> = Vec::new();
5292 for change in raw_changes {
5293 if !change_passes_filters(
5294 &change,
5295 &include_types,
5296 &exclude_types,
5297 &include_subtypes,
5298 &exclude_subtypes,
5299 ) {
5300 continue;
5301 }
5302
5303 *counts_by_kind
5304 .entry(change_kind_key(&change).to_string())
5305 .or_default() += 1;
5306 *counts_by_type
5307 .entry(change_type_key(&change).to_string())
5308 .or_default() += 1;
5309 if let Some(subtype) = change_subtype_key(&change) {
5310 *counts_by_subtype.entry(subtype.to_string()).or_default() += 1;
5311 }
5312 if let Some(sheet) = change_sheet_name(&change) {
5313 affected_sheets.insert(sheet.to_string());
5314 }
5315
5316 filtered.push(change);
5317 }
5318
5319 let limit = params.limit.clamp(1, 2000) as usize;
5320 let offset = params.offset as usize;
5321 let total = filtered.len();
5322
5323 let (returned_changes, changes, truncated, next_offset) = if params.summary_only {
5324 (0u32, Vec::new(), false, None)
5325 } else {
5326 let end = offset.saturating_add(limit);
5327 let truncated = end < total;
5328 let next_offset = truncated.then_some(end as u32);
5329 let changes: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
5330 (changes.len() as u32, changes, truncated, next_offset)
5331 };
5332
5333 let summary = ChangesetSummary {
5334 total_changes: total as u32,
5335 returned_changes,
5336 truncated,
5337 next_offset,
5338 counts_by_kind,
5339 counts_by_type,
5340 counts_by_subtype,
5341 affected_sheets: affected_sheets.into_iter().collect(),
5342 };
5343
5344 Ok(GetChangesetResponse {
5345 fork_id: params.fork_id,
5346 base_workbook: fork_ctx.base_path.display().to_string(),
5347 client_base_workbook: state
5348 .config()
5349 .map_path_for_client(&fork_ctx.base_path)
5350 .map(|p| p.display().to_string()),
5351 changes,
5352 summary,
5353 })
5354}
5355
5356#[derive(Debug, Deserialize, JsonSchema)]
5357pub struct RecalculateParams {
5358 pub fork_id: String,
5359 #[serde(default = "default_timeout")]
5360 pub timeout_ms: u64,
5361 #[serde(default)]
5362 pub backend: Option<RecalcBackendKind>,
5363}
5364
5365fn default_timeout() -> u64 {
5366 30_000
5367}
5368
5369#[derive(Debug, Serialize, JsonSchema)]
5370pub struct RecalculateResponse {
5371 pub fork_id: String,
5372 pub duration_ms: u64,
5373 pub backend: String,
5374 #[serde(skip_serializing_if = "Option::is_none")]
5375 pub cells_evaluated: Option<u64>,
5376 #[serde(skip_serializing_if = "Option::is_none")]
5377 pub eval_errors: Option<Vec<String>>,
5378}
5379
5380pub async fn recalculate(
5381 state: Arc<AppState>,
5382 params: RecalculateParams,
5383) -> Result<RecalculateResponse> {
5384 let backend = state
5385 .recalc_backend(params.backend)
5386 .ok_or_else(|| anyhow!("requested recalc backend not available"))?;
5387
5388 recalculate_with_backend(state, params, backend).await
5389}
5390
5391pub async fn recalculate_with_backend(
5392 state: Arc<AppState>,
5393 params: RecalculateParams,
5394 backend: Arc<dyn RecalcBackend>,
5395) -> Result<RecalculateResponse> {
5396 let registry = state
5397 .fork_registry()
5398 .ok_or_else(|| anyhow!("fork registry not available"))?;
5399
5400 let semaphore = state
5401 .recalc_semaphore()
5402 .ok_or_else(|| anyhow!("recalc semaphore not available"))?;
5403
5404 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
5405
5406 let _permit = semaphore
5407 .0
5408 .acquire()
5409 .await
5410 .map_err(|e| anyhow!("failed to acquire recalc permit: {}", e))?;
5411
5412 let timeout_ms = if params.timeout_ms == 0 {
5413 None
5414 } else {
5415 Some(params.timeout_ms)
5416 };
5417 let result =
5418 crate::core::recalc::execute_with_backend(&fork_ctx.work_path, timeout_ms, backend).await?;
5419
5420 registry.with_fork_mut(¶ms.fork_id, |ctx| {
5421 ctx.recalc_needed = false;
5422 Ok(())
5423 })?;
5424
5425 let fork_workbook_id = WorkbookId(params.fork_id.clone());
5426 let _ = state.close_workbook(&fork_workbook_id);
5427
5428 Ok(RecalculateResponse {
5429 fork_id: params.fork_id,
5430 duration_ms: result.duration_ms,
5431 backend: result.backend,
5432 cells_evaluated: result.cells_evaluated,
5433 eval_errors: result.eval_errors,
5434 })
5435}
5436
5437#[derive(Debug, Deserialize, JsonSchema)]
5438pub struct ListForksParams {}
5439
5440#[derive(Debug, Serialize, JsonSchema)]
5441pub struct ListForksResponse {
5442 pub forks: Vec<ForkSummary>,
5443}
5444
5445#[derive(Debug, Serialize, JsonSchema)]
5446pub struct ForkSummary {
5447 pub fork_id: String,
5448 pub base_path: String,
5449 #[serde(skip_serializing_if = "Option::is_none")]
5450 pub client_base_path: Option<String>,
5451 pub age_seconds: u64,
5452 pub edit_count: usize,
5453 pub recalc_needed: bool,
5454}
5455
5456pub async fn list_forks(
5457 state: Arc<AppState>,
5458 _params: ListForksParams,
5459) -> Result<ListForksResponse> {
5460 let registry = state
5461 .fork_registry()
5462 .ok_or_else(|| anyhow!("fork registry not available"))?;
5463
5464 let config = state.config();
5465 let forks: Vec<ForkSummary> = registry
5466 .list_forks()
5467 .into_iter()
5468 .map(|f| {
5469 let base_path = f.base_path;
5470 let client_base_path = if config.path_mappings.is_empty() {
5471 None
5472 } else {
5473 config
5474 .map_path_for_client(PathBuf::from(&base_path))
5475 .map(|p| p.display().to_string())
5476 };
5477 ForkSummary {
5478 fork_id: f.fork_id,
5479 base_path,
5480 client_base_path,
5481 age_seconds: f.created_at.elapsed().as_secs(),
5482 edit_count: f.edit_count,
5483 recalc_needed: f.recalc_needed,
5484 }
5485 })
5486 .collect();
5487
5488 Ok(ListForksResponse { forks })
5489}
5490
5491#[derive(Debug, Deserialize, JsonSchema)]
5492pub struct DiscardForkParams {
5493 pub fork_id: String,
5494}
5495
5496#[derive(Debug, Serialize, JsonSchema)]
5497pub struct DiscardForkResponse {
5498 pub fork_id: String,
5499 pub discarded: bool,
5500}
5501
5502pub async fn discard_fork(
5503 state: Arc<AppState>,
5504 params: DiscardForkParams,
5505) -> Result<DiscardForkResponse> {
5506 let registry = state
5507 .fork_registry()
5508 .ok_or_else(|| anyhow!("fork registry not available"))?;
5509
5510 registry.discard_fork(¶ms.fork_id)?;
5511
5512 Ok(DiscardForkResponse {
5513 fork_id: params.fork_id,
5514 discarded: true,
5515 })
5516}
5517
5518#[derive(Debug, Deserialize, JsonSchema)]
5519pub struct SaveForkParams {
5520 pub fork_id: String,
5521 pub target_path: Option<String>,
5523 #[serde(default = "default_drop_fork")]
5525 pub drop_fork: bool,
5526}
5527
5528fn default_drop_fork() -> bool {
5529 true
5530}
5531
5532#[derive(Debug, Serialize, JsonSchema)]
5533pub struct SaveForkResponse {
5534 pub fork_id: String,
5535 pub saved_to: String,
5536 #[serde(skip_serializing_if = "Option::is_none")]
5537 pub client_saved_to: Option<String>,
5538 pub fork_dropped: bool,
5539}
5540
5541pub async fn save_fork(state: Arc<AppState>, params: SaveForkParams) -> Result<SaveForkResponse> {
5542 let registry = state
5543 .fork_registry()
5544 .ok_or_else(|| anyhow!("fork registry not available"))?;
5545
5546 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
5547 let config = state.config();
5548 let workspace_root = &config.workspace_root;
5549
5550 let (target, is_overwrite) = match params.target_path {
5551 Some(p) => {
5552 let resolved = config.resolve_user_path(&p);
5553 let is_overwrite = if resolved.exists() {
5554 let base_canon = fork_ctx.base_path.canonicalize().map_err(|e| {
5555 anyhow!("failed to canonicalize base_path for overwrite check: {e}")
5556 })?;
5557 let target_canon = resolved.canonicalize().map_err(|e| {
5558 anyhow!("failed to canonicalize target_path for overwrite check: {e}")
5559 })?;
5560 target_canon == base_canon
5561 } else {
5562 false
5563 };
5564 (resolved, is_overwrite)
5565 }
5566 None => (fork_ctx.base_path.clone(), true),
5567 };
5568
5569 if is_overwrite && !config.allow_overwrite {
5570 return Err(anyhow!(
5571 "overwriting original file is disabled. Use --allow-overwrite flag or specify a different target_path"
5572 ));
5573 }
5574
5575 let base_path = fork_ctx.base_path.clone();
5576 registry.save_fork(¶ms.fork_id, &target, workspace_root, params.drop_fork)?;
5577
5578 if is_overwrite {
5579 state.evict_by_path(&base_path);
5580 }
5581
5582 Ok(SaveForkResponse {
5583 fork_id: params.fork_id,
5584 saved_to: target.display().to_string(),
5585 client_saved_to: config
5586 .map_path_for_client(&target)
5587 .map(|p| p.display().to_string()),
5588 fork_dropped: params.drop_fork,
5589 })
5590}
5591
5592#[derive(Debug, Deserialize, JsonSchema)]
5593pub struct CheckpointForkParams {
5594 pub fork_id: String,
5595 pub label: Option<String>,
5596}
5597
5598#[derive(Debug, Serialize, JsonSchema)]
5599pub struct CheckpointInfo {
5600 pub checkpoint_id: String,
5601 pub created_at: String,
5602 pub label: Option<String>,
5603}
5604
5605#[derive(Debug, Serialize, JsonSchema)]
5606pub struct CheckpointForkResponse {
5607 pub fork_id: String,
5608 pub checkpoint: CheckpointInfo,
5609 pub total_checkpoints: usize,
5610}
5611
5612pub async fn checkpoint_fork(
5613 state: Arc<AppState>,
5614 params: CheckpointForkParams,
5615) -> Result<CheckpointForkResponse> {
5616 let registry = state
5617 .fork_registry()
5618 .ok_or_else(|| anyhow!("fork registry not available"))?;
5619
5620 registry.get_fork(¶ms.fork_id)?;
5621 let checkpoint = registry.create_checkpoint(¶ms.fork_id, params.label.clone())?;
5622 let total = registry.list_checkpoints(¶ms.fork_id)?.len();
5623
5624 Ok(CheckpointForkResponse {
5625 fork_id: params.fork_id,
5626 checkpoint: CheckpointInfo {
5627 checkpoint_id: checkpoint.checkpoint_id,
5628 created_at: checkpoint.created_at.to_rfc3339(),
5629 label: checkpoint.label,
5630 },
5631 total_checkpoints: total,
5632 })
5633}
5634
5635#[derive(Debug, Deserialize, JsonSchema)]
5636pub struct ListCheckpointsParams {
5637 pub fork_id: String,
5638}
5639
5640#[derive(Debug, Serialize, JsonSchema)]
5641pub struct ListCheckpointsResponse {
5642 pub fork_id: String,
5643 pub checkpoints: Vec<CheckpointInfo>,
5644}
5645
5646pub async fn list_checkpoints(
5647 state: Arc<AppState>,
5648 params: ListCheckpointsParams,
5649) -> Result<ListCheckpointsResponse> {
5650 let registry = state
5651 .fork_registry()
5652 .ok_or_else(|| anyhow!("fork registry not available"))?;
5653
5654 let checkpoints = registry.list_checkpoints(¶ms.fork_id)?;
5655 let checkpoints = checkpoints
5656 .into_iter()
5657 .map(|cp| CheckpointInfo {
5658 checkpoint_id: cp.checkpoint_id,
5659 created_at: cp.created_at.to_rfc3339(),
5660 label: cp.label,
5661 })
5662 .collect();
5663
5664 Ok(ListCheckpointsResponse {
5665 fork_id: params.fork_id,
5666 checkpoints,
5667 })
5668}
5669
5670#[derive(Debug, Deserialize, JsonSchema)]
5671pub struct RestoreCheckpointParams {
5672 pub fork_id: String,
5673 pub checkpoint_id: String,
5674}
5675
5676#[derive(Debug, Serialize, JsonSchema)]
5677pub struct RestoreCheckpointResponse {
5678 pub fork_id: String,
5679 pub restored_checkpoint: CheckpointInfo,
5680}
5681
5682pub async fn restore_checkpoint(
5683 state: Arc<AppState>,
5684 params: RestoreCheckpointParams,
5685) -> Result<RestoreCheckpointResponse> {
5686 let registry = state
5687 .fork_registry()
5688 .ok_or_else(|| anyhow!("fork registry not available"))?;
5689
5690 let checkpoint = registry.restore_checkpoint(¶ms.fork_id, ¶ms.checkpoint_id)?;
5691 let fork_workbook_id = WorkbookId(params.fork_id.clone());
5692 let _ = state.close_workbook(&fork_workbook_id);
5693
5694 Ok(RestoreCheckpointResponse {
5695 fork_id: params.fork_id,
5696 restored_checkpoint: CheckpointInfo {
5697 checkpoint_id: checkpoint.checkpoint_id,
5698 created_at: checkpoint.created_at.to_rfc3339(),
5699 label: checkpoint.label,
5700 },
5701 })
5702}
5703
5704#[derive(Debug, Deserialize, JsonSchema)]
5705pub struct DeleteCheckpointParams {
5706 pub fork_id: String,
5707 pub checkpoint_id: String,
5708}
5709
5710#[derive(Debug, Serialize, JsonSchema)]
5711pub struct DeleteCheckpointResponse {
5712 pub fork_id: String,
5713 pub checkpoint_id: String,
5714 pub deleted: bool,
5715}
5716
5717pub async fn delete_checkpoint(
5718 state: Arc<AppState>,
5719 params: DeleteCheckpointParams,
5720) -> Result<DeleteCheckpointResponse> {
5721 let registry = state
5722 .fork_registry()
5723 .ok_or_else(|| anyhow!("fork registry not available"))?;
5724
5725 registry.delete_checkpoint(¶ms.fork_id, ¶ms.checkpoint_id)?;
5726
5727 Ok(DeleteCheckpointResponse {
5728 fork_id: params.fork_id,
5729 checkpoint_id: params.checkpoint_id,
5730 deleted: true,
5731 })
5732}
5733
5734#[derive(Debug, Deserialize, JsonSchema)]
5735pub struct ListStagedChangesParams {
5736 pub fork_id: String,
5737}
5738
5739#[derive(Debug, Serialize, JsonSchema)]
5740pub struct StagedChangeInfo {
5741 pub change_id: String,
5742 pub created_at: String,
5743 pub label: Option<String>,
5744 pub summary: ChangeSummary,
5745}
5746
5747#[derive(Debug, Serialize, JsonSchema)]
5748pub struct ListStagedChangesResponse {
5749 pub fork_id: String,
5750 pub staged_changes: Vec<StagedChangeInfo>,
5751}
5752
5753pub async fn list_staged_changes(
5754 state: Arc<AppState>,
5755 params: ListStagedChangesParams,
5756) -> Result<ListStagedChangesResponse> {
5757 let registry = state
5758 .fork_registry()
5759 .ok_or_else(|| anyhow!("fork registry not available"))?;
5760
5761 let staged = registry.list_staged_changes(¶ms.fork_id)?;
5762 let staged_changes = staged
5763 .into_iter()
5764 .map(|c| StagedChangeInfo {
5765 change_id: c.change_id,
5766 created_at: c.created_at.to_rfc3339(),
5767 label: c.label,
5768 summary: c.summary,
5769 })
5770 .collect();
5771
5772 Ok(ListStagedChangesResponse {
5773 fork_id: params.fork_id,
5774 staged_changes,
5775 })
5776}
5777
5778#[derive(Debug, Deserialize, JsonSchema)]
5779pub struct ApplyStagedChangeParams {
5780 pub fork_id: String,
5781 pub change_id: String,
5782}
5783
5784#[derive(Debug, Serialize, JsonSchema)]
5785pub struct ApplyStagedChangeResponse {
5786 pub fork_id: String,
5787 pub change_id: String,
5788 pub ops_applied: usize,
5789 pub summary: ChangeSummary,
5790}
5791
5792#[derive(Debug, Deserialize)]
5793struct EditBatchStagedPayload {
5794 sheet_name: String,
5795 edits: Vec<CellEdit>,
5796}
5797
5798pub async fn apply_staged_change(
5799 state: Arc<AppState>,
5800 params: ApplyStagedChangeParams,
5801) -> Result<ApplyStagedChangeResponse> {
5802 let registry = state
5803 .fork_registry()
5804 .ok_or_else(|| anyhow!("fork registry not available"))?;
5805
5806 let staged_list = registry.list_staged_changes(¶ms.fork_id)?;
5807 let staged = staged_list
5808 .iter()
5809 .find(|c| c.change_id == params.change_id)
5810 .cloned()
5811 .ok_or_else(|| anyhow!("staged change not found: {}", params.change_id))?;
5812
5813 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
5814 let work_path = fork_ctx.work_path.clone();
5815
5816 let initial_recalc_needed = fork_ctx.recalc_needed;
5817 let mut recalc_triggered = false;
5818
5819 let mut ops_applied = 0usize;
5820
5821 for op in &staged.ops {
5822 match op.kind.as_str() {
5823 "edit_batch" => {
5824 recalc_triggered = true;
5825 let payload: EditBatchStagedPayload = serde_json::from_value(op.payload.clone())
5826 .map_err(|e| anyhow!("invalid edit_batch payload: {}", e))?;
5827
5828 let edits_to_apply: Vec<_> = payload
5829 .edits
5830 .iter()
5831 .map(|e| EditOp {
5832 timestamp: Utc::now(),
5833 sheet: payload.sheet_name.clone(),
5834 address: e.address.clone(),
5835 value: e.value.clone(),
5836 is_formula: e.is_formula,
5837 })
5838 .collect();
5839
5840 tokio::task::spawn_blocking({
5841 let sheet_name = payload.sheet_name.clone();
5842 let edits = payload.edits.clone();
5843 let work_path = work_path.clone();
5844 move || {
5845 let core_edits = edits
5846 .into_iter()
5847 .map(|edit| crate::core::types::CellEdit {
5848 address: edit.address,
5849 value: edit.value,
5850 is_formula: edit.is_formula,
5851 })
5852 .collect::<Vec<_>>();
5853 crate::core::write::apply_edits_to_file(
5854 &work_path,
5855 &sheet_name,
5856 &core_edits,
5857 )
5858 }
5859 })
5860 .await??;
5861
5862 registry.with_fork_mut(¶ms.fork_id, |ctx| {
5863 ctx.edits.extend(edits_to_apply);
5864 ctx.recalc_needed = true;
5865 Ok(())
5866 })?;
5867
5868 ops_applied += 1;
5869 }
5870 "style_batch" => {
5871 let payload: StyleBatchStagedPayload =
5872 serde_json::from_value(op.payload.clone())
5873 .map_err(|e| anyhow!("invalid style_batch payload: {}", e))?;
5874
5875 tokio::task::spawn_blocking({
5876 let ops = payload.ops.clone();
5877 let work_path = work_path.clone();
5878 move || apply_style_ops_to_file(&work_path, &ops)
5879 })
5880 .await??;
5881
5882 ops_applied += 1;
5883 }
5884 "column_size_batch" => {
5885 let payload: ColumnSizeBatchStagedPayload =
5886 serde_json::from_value(op.payload.clone())
5887 .map_err(|e| anyhow!("invalid column_size_batch payload: {}", e))?;
5888
5889 tokio::task::spawn_blocking({
5890 let sheet_name = payload.sheet_name.clone();
5891 let ops = payload.ops.clone();
5892 let work_path = work_path.clone();
5893 move || apply_column_size_ops_to_file(&work_path, &sheet_name, &ops)
5894 })
5895 .await??;
5896
5897 ops_applied += 1;
5898 }
5899 "transform_batch" => {
5900 recalc_triggered = true;
5901 let payload: TransformBatchStagedPayload =
5902 serde_json::from_value(op.payload.clone())
5903 .map_err(|e| anyhow!("invalid transform_batch payload: {}", e))?;
5904
5905 tokio::task::spawn_blocking({
5906 let ops = payload.ops.clone();
5907 let work_path = work_path.clone();
5908 move || apply_transform_ops_to_file(&work_path, &ops)
5909 })
5910 .await??;
5911
5912 ops_applied += 1;
5913 }
5914 "grid_import" => {
5915 recalc_triggered = true;
5916 let payload: GridImportStagedPayload =
5917 serde_json::from_value(op.payload.clone())
5918 .map_err(|e| anyhow!("invalid grid_import payload: {}", e))?;
5919
5920 grid_import(
5921 state.clone(),
5922 GridImportParams {
5923 fork_id: params.fork_id.clone(),
5924 sheet_name: payload.sheet_name,
5925 anchor: payload.anchor,
5926 grid: payload.grid,
5927 clear_target: payload.clear_target,
5928 mode: Some(BatchMode::Apply),
5929 label: None,
5930 formula_parse_policy: None,
5931 },
5932 )
5933 .await?;
5934
5935 ops_applied += 1;
5936 }
5937 "apply_formula_pattern" => {
5938 recalc_triggered = true;
5939 let payload: ApplyFormulaPatternStagedPayload =
5940 serde_json::from_value(op.payload.clone())
5941 .map_err(|e| anyhow!("invalid apply_formula_pattern payload: {}", e))?;
5942
5943 let bounds = parse_range_bounds(&payload.target_range)?;
5944 let (anchor_col, anchor_row) = parse_cell_ref(&payload.anchor_cell)?;
5945 let fill_direction = payload.fill_direction.unwrap_or_default();
5946 validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
5947 let relative_mode: RelativeMode = payload.relative_mode.unwrap_or_default().into();
5948
5949 tokio::task::spawn_blocking({
5950 let sheet_name = payload.sheet_name.clone();
5951 let target_range = payload.target_range.clone();
5952 let base_formula = payload.base_formula.clone();
5953 let work_path = work_path.clone();
5954 move || {
5955 apply_formula_pattern_to_file(
5956 &work_path,
5957 &sheet_name,
5958 &target_range,
5959 anchor_col,
5960 anchor_row,
5961 &base_formula,
5962 relative_mode,
5963 )
5964 }
5965 })
5966 .await??;
5967
5968 ops_applied += 1;
5969 }
5970 "structure_batch" => {
5971 let payload: StructureBatchStagedPayload =
5972 serde_json::from_value(op.payload.clone())
5973 .map_err(|e| anyhow!("invalid structure_batch payload: {}", e))?;
5974
5975 if structure_ops_require_recalc(&payload.ops) {
5976 recalc_triggered = true;
5977 }
5978
5979 tokio::task::spawn_blocking({
5980 let ops = payload.ops.clone();
5981 let work_path = work_path.clone();
5982 let policy = payload.formula_parse_policy.unwrap_or(
5983 FormulaParsePolicy::default_for_command_class(CommandClass::BatchWrite),
5984 );
5985 move || apply_structure_ops_to_file(&work_path, &ops, policy)
5986 })
5987 .await??;
5988
5989 ops_applied += 1;
5990 }
5991 "sheet_layout_batch" => {
5992 let payload: crate::tools::sheet_layout::SheetLayoutBatchStagedPayload =
5993 serde_json::from_value(op.payload.clone())
5994 .map_err(|e| anyhow!("invalid sheet_layout_batch payload: {}", e))?;
5995
5996 tokio::task::spawn_blocking({
5997 let ops = payload.ops.clone();
5998 let work_path = work_path.clone();
5999 move || {
6000 crate::tools::sheet_layout::apply_sheet_layout_ops_to_file(&work_path, &ops)
6001 }
6002 })
6003 .await??;
6004
6005 ops_applied += 1;
6006 }
6007 "rules_batch" => {
6008 let payload: crate::tools::rules_batch::RulesBatchStagedPayload =
6009 serde_json::from_value(op.payload.clone())
6010 .map_err(|e| anyhow!("invalid rules_batch payload: {}", e))?;
6011
6012 tokio::task::spawn_blocking({
6013 let ops = payload.ops.clone();
6014 let work_path = work_path.clone();
6015 let policy = payload.formula_parse_policy.unwrap_or(
6016 FormulaParsePolicy::default_for_command_class(CommandClass::BatchWrite),
6017 );
6018 move || {
6019 crate::tools::rules_batch::apply_rules_ops_to_file(&work_path, &ops, policy)
6020 }
6021 })
6022 .await??;
6023
6024 ops_applied += 1;
6025 }
6026 other => {
6027 return Err(anyhow!("unsupported staged op kind: {}", other));
6028 }
6029 }
6030 }
6031
6032 let recalc_needed_now = initial_recalc_needed || recalc_triggered;
6033 if recalc_needed_now {
6034 registry.with_fork_mut(¶ms.fork_id, |ctx| {
6035 ctx.recalc_needed = true;
6036 Ok(())
6037 })?;
6038 }
6039
6040 registry.discard_staged_change(¶ms.fork_id, ¶ms.change_id)?;
6041 let fork_workbook_id = WorkbookId(params.fork_id.clone());
6042 let _ = state.close_workbook(&fork_workbook_id);
6043
6044 let mut summary = staged.summary;
6045 set_recalc_needed_flag(&mut summary, recalc_needed_now);
6046
6047 Ok(ApplyStagedChangeResponse {
6048 fork_id: params.fork_id,
6049 change_id: params.change_id,
6050 ops_applied,
6051 summary,
6052 })
6053}
6054
6055#[derive(Debug, Deserialize, JsonSchema)]
6056pub struct DiscardStagedChangeParams {
6057 pub fork_id: String,
6058 pub change_id: String,
6059}
6060
6061#[derive(Debug, Serialize, JsonSchema)]
6062pub struct DiscardStagedChangeResponse {
6063 pub fork_id: String,
6064 pub change_id: String,
6065 pub discarded: bool,
6066}
6067
6068pub async fn discard_staged_change(
6069 state: Arc<AppState>,
6070 params: DiscardStagedChangeParams,
6071) -> Result<DiscardStagedChangeResponse> {
6072 let registry = state
6073 .fork_registry()
6074 .ok_or_else(|| anyhow!("fork registry not available"))?;
6075
6076 registry.discard_staged_change(¶ms.fork_id, ¶ms.change_id)?;
6077
6078 Ok(DiscardStagedChangeResponse {
6079 fork_id: params.fork_id,
6080 change_id: params.change_id,
6081 discarded: true,
6082 })
6083}
6084
6085#[cfg(not(target_arch = "wasm32"))]
6086const MAX_SCREENSHOT_ROWS: u32 = 100;
6087#[cfg(not(target_arch = "wasm32"))]
6088const MAX_SCREENSHOT_COLS: u32 = 30;
6089#[cfg(not(target_arch = "wasm32"))]
6090const DEFAULT_SCREENSHOT_RANGE: &str = "A1:M40";
6091#[cfg(feature = "recalc-libreoffice")]
6092const DEFAULT_MAX_PNG_DIM_PX: u32 = 4096;
6093#[cfg(feature = "recalc-libreoffice")]
6094const DEFAULT_MAX_PNG_AREA_PX: u64 = 12_000_000;
6095
6096#[derive(Debug, Deserialize, JsonSchema)]
6097pub struct ScreenshotSheetParams {
6098 #[serde(alias = "workbook_id")]
6099 pub workbook_or_fork_id: WorkbookId,
6100 pub sheet_name: String,
6101 #[serde(default)]
6102 pub range: Option<String>,
6103}
6104
6105#[derive(Debug, Serialize, JsonSchema)]
6106pub struct ScreenshotSheetResponse {
6107 pub workbook_id: String,
6108 pub sheet_name: String,
6109 pub range: String,
6110 pub output_path: String,
6111 #[serde(skip_serializing_if = "Option::is_none")]
6112 pub client_output_path: Option<String>,
6113 pub size_bytes: u64,
6114 pub duration_ms: u64,
6115}
6116
6117#[cfg(not(target_arch = "wasm32"))]
6118pub async fn screenshot_sheet(
6119 state: Arc<AppState>,
6120 params: ScreenshotSheetParams,
6121) -> Result<ScreenshotSheetResponse> {
6122 let range = params.range.as_deref().unwrap_or(DEFAULT_SCREENSHOT_RANGE);
6123 let bounds = validate_screenshot_range(range)?;
6124
6125 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
6126 let workbook_path = workbook.path.clone();
6127
6128 let _ = workbook.with_sheet(¶ms.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
6129
6130 let safe_range = sanitize_filename_component(&range.replace(':', "-"));
6131 let safe_sheet = sanitize_filename_component(¶ms.sheet_name).replace(' ', "_");
6132 let safe_slug = sanitize_filename_component(&workbook.slug);
6133 let filename = format!("{}_{}_{}.png", safe_slug, safe_sheet, safe_range);
6134
6135 let config = state.config();
6136 let screenshot_dir = config.screenshot_dir.clone();
6137 tokio::fs::create_dir_all(&screenshot_dir).await?;
6138 let output_path = screenshot_dir.join(&filename);
6139
6140 let semaphore = state
6141 .screenshot_semaphore()
6142 .ok_or_else(|| anyhow!("screenshot semaphore not available"))?;
6143
6144 let _permit = semaphore
6146 .0
6147 .acquire()
6148 .await
6149 .map_err(|e| anyhow!("failed to acquire screenshot permit: {}", e))?;
6150
6151 #[cfg(not(feature = "recalc-libreoffice"))]
6152 {
6153 let _ = workbook_path;
6154 let _ = output_path;
6155 let _ = bounds;
6156 Err(anyhow!(
6157 "screenshot backend unavailable (build without recalc-libreoffice feature)"
6158 ))
6159 }
6160
6161 #[cfg(feature = "recalc-libreoffice")]
6162 {
6163 let executor =
6164 crate::recalc::ScreenshotExecutor::new(&crate::recalc::RecalcConfig::default());
6165 let result = executor
6166 .screenshot(
6167 &workbook_path,
6168 &output_path,
6169 ¶ms.sheet_name,
6170 Some(range),
6171 )
6172 .await?;
6173
6174 enforce_png_pixel_limits(&result.output_path, range, &bounds).await?;
6175
6176 Ok(ScreenshotSheetResponse {
6177 workbook_id: params.workbook_or_fork_id.0,
6178 sheet_name: params.sheet_name,
6179 range: range.to_string(),
6180 output_path: format!("file://{}", result.output_path.display()),
6181 client_output_path: config
6182 .map_path_for_client(&result.output_path)
6183 .map(|p| format!("file://{}", p.display())),
6184 size_bytes: result.size_bytes,
6185 duration_ms: result.duration_ms,
6186 })
6187 }
6188}
6189
6190#[derive(Debug, Clone, Copy)]
6191struct ScreenshotBounds {
6192 min_col: u32,
6193 max_col: u32,
6194 min_row: u32,
6195 max_row: u32,
6196 rows: u32,
6197 cols: u32,
6198}
6199
6200#[cfg(not(target_arch = "wasm32"))]
6201fn validate_screenshot_range(range: &str) -> Result<ScreenshotBounds> {
6202 let bounds = parse_range_bounds(range)?;
6203
6204 if bounds.rows > MAX_SCREENSHOT_ROWS || bounds.cols > MAX_SCREENSHOT_COLS {
6205 let row_tiles = div_ceil(bounds.rows, MAX_SCREENSHOT_ROWS);
6206 let col_tiles = div_ceil(bounds.cols, MAX_SCREENSHOT_COLS);
6207 let total_tiles = row_tiles * col_tiles;
6208
6209 let display_limit = 50usize;
6210 let display_ranges = suggest_tiled_ranges(
6211 &bounds,
6212 MAX_SCREENSHOT_ROWS,
6213 MAX_SCREENSHOT_COLS,
6214 Some(display_limit),
6215 );
6216
6217 let mut msg = format!(
6218 "Requested range {range} is too large for a single screenshot ({} rows x {} cols; max {} x {}). \
6219Split into {} tile(s) ({} row tiles x {} col tiles). Suggested ranges: {}",
6220 bounds.rows,
6221 bounds.cols,
6222 MAX_SCREENSHOT_ROWS,
6223 MAX_SCREENSHOT_COLS,
6224 total_tiles,
6225 row_tiles,
6226 col_tiles,
6227 display_ranges.join(", ")
6228 );
6229 if total_tiles as usize > display_limit {
6230 msg.push_str(&format!(
6231 " ... and {} more.",
6232 total_tiles as usize - display_limit
6233 ));
6234 }
6235 return Err(anyhow!(msg));
6236 }
6237
6238 Ok(bounds)
6239}
6240
6241fn parse_cell_ref(cell: &str) -> Result<(u32, u32)> {
6242 use umya_spreadsheet::helper::coordinate::index_from_coordinate;
6243 let (col, row, _, _) = index_from_coordinate(cell);
6244 match (col, row) {
6245 (Some(c), Some(r)) => Ok((c, r)),
6246 _ => Err(anyhow!("Invalid cell reference: {}", cell)),
6247 }
6248}
6249
6250fn parse_range_bounds(range: &str) -> Result<ScreenshotBounds> {
6251 let parts: Vec<&str> = range.split(':').collect();
6252 if parts.is_empty() || parts.len() > 2 {
6253 return Err(anyhow!("Invalid range format. Expected 'A1' or 'A1:Z99'"));
6254 }
6255
6256 let start = parse_cell_ref(parts[0])?;
6257 let end = if parts.len() == 2 {
6258 parse_cell_ref(parts[1])?
6259 } else {
6260 start
6261 };
6262
6263 let min_col = start.0.min(end.0);
6264 let max_col = start.0.max(end.0);
6265 let min_row = start.1.min(end.1);
6266 let max_row = start.1.max(end.1);
6267
6268 let rows = max_row - min_row + 1;
6269 let cols = max_col - min_col + 1;
6270
6271 Ok(ScreenshotBounds {
6272 min_col,
6273 max_col,
6274 min_row,
6275 max_row,
6276 rows,
6277 cols,
6278 })
6279}
6280
6281#[cfg(not(target_arch = "wasm32"))]
6282fn div_ceil(n: u32, d: u32) -> u32 {
6283 n.div_ceil(d)
6284}
6285
6286#[cfg(not(target_arch = "wasm32"))]
6287fn suggest_tiled_ranges(
6288 bounds: &ScreenshotBounds,
6289 max_rows: u32,
6290 max_cols: u32,
6291 limit: Option<usize>,
6292) -> Vec<String> {
6293 use umya_spreadsheet::helper::coordinate::coordinate_from_index;
6294
6295 let mut out = Vec::new();
6296 let mut row_start = bounds.min_row;
6297 while row_start <= bounds.max_row {
6298 let row_end = (row_start + max_rows - 1).min(bounds.max_row);
6299 let mut col_start = bounds.min_col;
6300 while col_start <= bounds.max_col {
6301 let col_end = (col_start + max_cols - 1).min(bounds.max_col);
6302 let start_cell = coordinate_from_index(&col_start, &row_start);
6303 let end_cell = coordinate_from_index(&col_end, &row_end);
6304 out.push(format!("{start_cell}:{end_cell}"));
6305 if let Some(lim) = limit
6306 && out.len() >= lim
6307 {
6308 return out;
6309 }
6310 col_start = col_end + 1;
6311 }
6312 row_start = row_end + 1;
6313 if let Some(lim) = limit
6314 && out.len() >= lim
6315 {
6316 return out;
6317 }
6318 }
6319 out
6320}
6321
6322#[cfg(feature = "recalc-libreoffice")]
6323fn suggest_split_single_tile(bounds: &ScreenshotBounds) -> Vec<String> {
6324 use umya_spreadsheet::helper::coordinate::coordinate_from_index;
6325
6326 if bounds.rows >= bounds.cols && bounds.rows > 1 {
6327 let mid_row = bounds.min_row + (bounds.rows / 2) - 1;
6328 let start1 = coordinate_from_index(&bounds.min_col, &bounds.min_row);
6329 let end1 = coordinate_from_index(&bounds.max_col, &mid_row);
6330 let start2 = coordinate_from_index(&bounds.min_col, &(mid_row + 1));
6331 let end2 = coordinate_from_index(&bounds.max_col, &bounds.max_row);
6332 vec![format!("{start1}:{end1}"), format!("{start2}:{end2}")]
6333 } else if bounds.cols > 1 {
6334 let mid_col = bounds.min_col + (bounds.cols / 2) - 1;
6335 let start1 = coordinate_from_index(&bounds.min_col, &bounds.min_row);
6336 let end1 = coordinate_from_index(&mid_col, &bounds.max_row);
6337 let start2 = coordinate_from_index(&(mid_col + 1), &bounds.min_row);
6338 let end2 = coordinate_from_index(&bounds.max_col, &bounds.max_row);
6339 vec![format!("{start1}:{end1}"), format!("{start2}:{end2}")]
6340 } else {
6341 vec![range_from_bounds(bounds)]
6342 }
6343}
6344
6345#[cfg(feature = "recalc-libreoffice")]
6346fn range_from_bounds(bounds: &ScreenshotBounds) -> String {
6347 use umya_spreadsheet::helper::coordinate::coordinate_from_index;
6348 let start = coordinate_from_index(&bounds.min_col, &bounds.min_row);
6349 let end = coordinate_from_index(&bounds.max_col, &bounds.max_row);
6350 format!("{start}:{end}")
6351}
6352
6353#[cfg(feature = "recalc-libreoffice")]
6354async fn enforce_png_pixel_limits(
6355 path: &std::path::Path,
6356 range: &str,
6357 bounds: &ScreenshotBounds,
6358) -> Result<()> {
6359 use image::GenericImageView;
6360 use image::ImageReader;
6361
6362 let max_dim_px = std::env::var("SPREADSHEET_MCP_MAX_PNG_DIM_PX")
6363 .ok()
6364 .and_then(|v| v.parse::<u32>().ok())
6365 .unwrap_or(DEFAULT_MAX_PNG_DIM_PX);
6366 let max_area_px = std::env::var("SPREADSHEET_MCP_MAX_PNG_AREA_PX")
6367 .ok()
6368 .and_then(|v| v.parse::<u64>().ok())
6369 .unwrap_or(DEFAULT_MAX_PNG_AREA_PX);
6370
6371 let reader = ImageReader::open(path)
6372 .map_err(|e| anyhow!("failed to read png {}: {}", path.display(), e))?;
6373 let reader = reader
6374 .with_guessed_format()
6375 .map_err(|e| anyhow!("failed to sniff png {}: {}", path.display(), e))?;
6376 let img = reader
6377 .decode()
6378 .map_err(|e| anyhow!("failed to decode png {}: {}", path.display(), e))?;
6379 let (w, h) = img.dimensions();
6380 let area = (w as u64) * (h as u64);
6381
6382 if w > max_dim_px || h > max_dim_px || area > max_area_px {
6383 let _ = tokio::fs::remove_file(path).await;
6384
6385 let mut suggestions =
6386 suggest_tiled_ranges(bounds, MAX_SCREENSHOT_ROWS, MAX_SCREENSHOT_COLS, Some(50));
6387 let row_tiles = div_ceil(bounds.rows, MAX_SCREENSHOT_ROWS);
6388 let col_tiles = div_ceil(bounds.cols, MAX_SCREENSHOT_COLS);
6389 let total_tiles = row_tiles * col_tiles;
6390 if total_tiles == 1 {
6391 suggestions = suggest_split_single_tile(bounds);
6392 }
6393
6394 return Err(anyhow!(
6395 "Rendered PNG for range {range} is {w}x{h}px (area {area}px), exceeding limits (max_dim={max_dim_px}px, max_area={max_area_px}px). \
6396Try smaller ranges. Suggested ranges: {}",
6397 suggestions.join(", ")
6398 ));
6399 }
6400
6401 Ok(())
6402}
6403
6404#[derive(Debug, Deserialize, JsonSchema)]
6405pub struct GridImportParams {
6406 #[serde(alias = "workbook_or_fork_id")]
6407 pub fork_id: String,
6408 pub sheet_name: String,
6409 pub anchor: String,
6410 pub grid: crate::model::GridPayload,
6411 #[serde(default)]
6412 pub clear_target: bool,
6413 #[serde(default)]
6414 pub mode: Option<BatchMode>,
6415 pub label: Option<String>,
6416 #[serde(default)]
6417 pub formula_parse_policy: Option<FormulaParsePolicy>,
6418}
6419
6420#[derive(Debug, Serialize, JsonSchema)]
6421pub struct GridImportResponse {
6422 pub fork_id: String,
6423 pub mode: String,
6424 pub change_id: Option<String>,
6425 pub summary: ChangeSummary,
6426 #[serde(skip_serializing_if = "Option::is_none")]
6427 pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
6428}
6429
6430#[derive(Debug, Serialize, Deserialize)]
6431struct GridImportStagedPayload {
6432 sheet_name: String,
6433 anchor: String,
6434 clear_target: bool,
6435 grid: crate::model::GridPayload,
6436}
6437
6438pub async fn grid_import(
6439 state: Arc<AppState>,
6440 params: GridImportParams,
6441) -> Result<GridImportResponse> {
6442 let registry = state
6443 .fork_registry()
6444 .ok_or_else(|| anyhow!("fork registry not available"))?;
6445
6446 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
6447 let work_path = fork_ctx.work_path.clone();
6448
6449 let (anchor_col, anchor_row) = parse_cell_ref(¶ms.anchor)?;
6450 let mut max_col = anchor_col;
6451 let mut max_row = anchor_row;
6452
6453 let mut write_rows: Vec<Vec<Option<MatrixCell>>> = Vec::new();
6454 let mut style_ops_map: BTreeMap<String, (StyleOp, Vec<String>)> = BTreeMap::new(); for grid_row in ¶ms.grid.rows {
6457 for cell in &grid_row.cells {
6458 let r = anchor_row + cell.offset[0];
6459 let c = anchor_col + cell.offset[1];
6460 if r > max_row {
6461 max_row = r;
6462 }
6463 if c > max_col {
6464 max_col = c;
6465 }
6466 }
6467 }
6468
6469 let footprint_range = format!(
6470 "{}:{}",
6471 crate::utils::cell_address(anchor_col, anchor_row),
6472 crate::utils::cell_address(max_col, max_row)
6473 );
6474
6475 for grid_row in ¶ms.grid.rows {
6476 for cell in &grid_row.cells {
6477 let row_idx = cell.offset[0] as usize;
6478 let col_idx = cell.offset[1] as usize;
6479 while write_rows.len() <= row_idx {
6480 write_rows.push(Vec::new());
6481 }
6482 while write_rows[row_idx].len() <= col_idx {
6483 write_rows[row_idx].push(None);
6484 }
6485
6486 let mut mc = None;
6487 if let Some(f) = &cell.f {
6488 mc = Some(MatrixCell::Formula(f.clone()));
6489 } else if let Some(v) = &cell.v {
6490 mc = Some(MatrixCell::Value(v.clone()));
6491 }
6492 if mc.is_some() {
6493 write_rows[row_idx][col_idx] = mc;
6494 }
6495
6496 let mut has_style = false;
6497 let mut style_patch = crate::model::StylePatch::default();
6498 if let Some(st) = &cell.style {
6499 style_patch = st.clone();
6500 has_style = true;
6501 }
6502 if let Some(fmt) = &cell.fmt {
6503 style_patch.number_format = Some(Some(fmt.clone()));
6504 has_style = true;
6505 }
6506
6507 if has_style {
6508 let key = serde_json::to_string(&style_patch).unwrap();
6509 let addr = crate::utils::cell_address(
6510 anchor_col + cell.offset[1],
6511 anchor_row + cell.offset[0],
6512 );
6513
6514 let entry = style_ops_map.entry(key).or_insert_with(|| {
6515 (
6516 StyleOp {
6517 sheet_name: params.sheet_name.clone(),
6518 target: StyleTarget::Cells { cells: Vec::new() }, patch: style_patch.clone(),
6520 op_mode: None,
6521 },
6522 Vec::new(),
6523 )
6524 });
6525 entry.1.push(addr);
6526 }
6527 }
6528 }
6529
6530 let mut style_ops = Vec::new();
6531 if params.clear_target {
6532 style_ops.push(StyleOp {
6533 sheet_name: params.sheet_name.clone(),
6534 target: StyleTarget::Range {
6535 range: footprint_range.clone(),
6536 },
6537 patch: crate::model::StylePatch {
6538 font: Some(None),
6539 fill: Some(None),
6540 borders: Some(None),
6541 alignment: Some(None),
6542 number_format: Some(None),
6543 },
6544 op_mode: None,
6545 });
6546 }
6547 for mut group in style_ops_map.into_values() {
6548 group.0.target = StyleTarget::Cells { cells: group.1 };
6549 style_ops.push(group.0);
6550 }
6551
6552 let mut transform_ops = Vec::new();
6553 if params.clear_target {
6554 transform_ops.push(TransformOp::ClearRange {
6555 sheet_name: params.sheet_name.clone(),
6556 target: TransformTarget::Range {
6557 range: footprint_range.clone(),
6558 },
6559 clear_values: true,
6560 clear_formulas: true,
6561 });
6562 }
6563 transform_ops.push(TransformOp::WriteMatrix {
6564 sheet_name: params.sheet_name.clone(),
6565 anchor: params.anchor.clone(),
6566 rows: write_rows,
6567 overwrite_formulas: true,
6568 });
6569
6570 let mut column_ops = Vec::new();
6571 for col in ¶ms.grid.columns {
6572 let col_name = crate::utils::column_number_to_name(anchor_col + col.offset);
6573 column_ops.push(ColumnSizeOp {
6574 target: ColumnTarget::Columns {
6575 range: format!("{}:{}", col_name, col_name),
6576 },
6577 size: ColumnSizeSpec::Width {
6578 width_chars: col.width_chars,
6579 },
6580 });
6581 }
6582
6583 let mut structure_ops = Vec::new();
6584 if !params.grid.merges.is_empty() {
6585 if params.clear_target {
6586 structure_ops.push(StructureOp::UnmergeCells {
6587 sheet_name: params.sheet_name.clone(),
6588 target_range: footprint_range.clone(),
6589 });
6590 }
6591 for merge in ¶ms.grid.merges {
6592 structure_ops.push(StructureOp::MergeCells {
6593 sheet_name: params.sheet_name.clone(),
6594 target_range: merge.clone(),
6595 });
6596 }
6597 }
6598
6599 let fork_workbook_id = WorkbookId(params.fork_id.clone());
6601 let workbook = state.open_workbook(&fork_workbook_id).await?;
6602 let resolved_transform_ops = resolve_transform_ops_for_workbook(&workbook, &transform_ops)?;
6603 let resolved_style_ops = resolve_style_ops_for_workbook(&workbook, &style_ops)?;
6604
6605 let policy =
6606 params
6607 .formula_parse_policy
6608 .unwrap_or(FormulaParsePolicy::default_for_command_class(
6609 CommandClass::BatchWrite,
6610 ));
6611 let (ops_to_apply, formula_parse_diagnostics) = if policy == FormulaParsePolicy::Off {
6612 (resolved_transform_ops, None)
6613 } else {
6614 let mut builder = FormulaParseDiagnosticsBuilder::new(policy);
6615 let mut valid_ops = Vec::new();
6616 for op in resolved_transform_ops {
6617 match &op {
6618 TransformOp::WriteMatrix {
6619 sheet_name,
6620 anchor,
6621 rows,
6622 overwrite_formulas,
6623 } => {
6624 let mut has_errors = false;
6625 let mut valid_rows = Vec::new();
6626 let (a_col, a_row) = parse_cell_ref(anchor)?;
6627 for (r_idx, row) in rows.iter().enumerate() {
6628 let mut valid_row = Vec::new();
6629 let r = a_row + r_idx as u32;
6630 for (c_idx, cell_opt) in row.iter().enumerate() {
6631 let c = a_col + c_idx as u32;
6632 if let Some(MatrixCell::Formula(f)) = cell_opt {
6633 match validate_formula(f) {
6634 Ok(()) => valid_row.push(cell_opt.clone()),
6635 Err(err_msg) => {
6636 if policy == FormulaParsePolicy::Fail {
6637 bail!(
6638 "{}grid_import formula failed at {}: {}",
6639 FORMULA_PARSE_FAILED_PREFIX,
6640 crate::utils::cell_address(c, r),
6641 err_msg
6642 );
6643 }
6644 builder.record_error(
6645 sheet_name,
6646 &crate::utils::cell_address(c, r),
6647 f,
6648 &err_msg,
6649 );
6650 has_errors = true;
6651 valid_row.push(None);
6652 }
6653 }
6654 } else {
6655 valid_row.push(cell_opt.clone());
6656 }
6657 }
6658 valid_rows.push(valid_row);
6659 }
6660 if has_errors && policy == FormulaParsePolicy::Warn {
6661 valid_ops.push(TransformOp::WriteMatrix {
6662 sheet_name: sheet_name.clone(),
6663 anchor: anchor.clone(),
6664 rows: valid_rows,
6665 overwrite_formulas: *overwrite_formulas,
6666 });
6667 } else {
6668 valid_ops.push(op);
6669 }
6670 }
6671 _ => valid_ops.push(op),
6672 }
6673 }
6674 let diagnostics = if builder.has_errors() {
6675 Some(builder.build())
6676 } else {
6677 None
6678 };
6679 (valid_ops, diagnostics)
6680 };
6681
6682 let mode = params.mode.unwrap_or_default();
6683
6684 if mode.is_preview() {
6685 let change_id = make_short_random_id("chg", 12);
6686 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
6687 fs::create_dir_all(snapshot_path.parent().unwrap())?;
6688 fs::copy(&work_path, &snapshot_path)?;
6689
6690 let snapshot_for_apply = snapshot_path.clone();
6691 let sheet_name_for_col = params.sheet_name.clone();
6692 let apply_result = tokio::task::spawn_blocking(move || {
6693 let mut summary = ChangeSummary::default();
6694 summary.op_kinds.push("grid_import".to_string());
6695
6696 if !structure_ops.is_empty() {
6697 let res = apply_structure_ops_to_file(
6698 &snapshot_for_apply,
6699 &structure_ops,
6700 FormulaParsePolicy::Off,
6701 )?;
6702 merge_summary_counts(&mut summary, &res.summary);
6703 }
6704 if !column_ops.is_empty() {
6705 let res = apply_column_size_ops_to_file(
6706 &snapshot_for_apply,
6707 &sheet_name_for_col,
6708 &column_ops,
6709 )?;
6710 merge_summary_counts(&mut summary, &res.summary);
6711 }
6712 if !ops_to_apply.is_empty() {
6713 let res = apply_transform_ops_to_file(&snapshot_for_apply, &ops_to_apply)?;
6714 merge_summary_counts(&mut summary, &res.summary);
6715 }
6716 if !resolved_style_ops.is_empty() {
6717 let res = apply_style_ops_to_file(&snapshot_for_apply, &resolved_style_ops)?;
6718 merge_summary_counts(&mut summary, &res.summary);
6719 }
6720
6721 Ok::<_, anyhow::Error>(summary)
6722 })
6723 .await??;
6724
6725 let mut summary = apply_result;
6726 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
6727
6728 let staged_op = StagedOp {
6729 kind: "grid_import".to_string(),
6730 payload: serde_json::to_value(GridImportStagedPayload {
6731 sheet_name: params.sheet_name.clone(),
6732 anchor: params.anchor.clone(),
6733 clear_target: params.clear_target,
6734 grid: params.grid.clone(),
6735 })?,
6736 };
6737
6738 let staged = StagedChange {
6739 change_id: change_id.clone(),
6740 created_at: Utc::now(),
6741 label: params.label.clone(),
6742 ops: vec![staged_op],
6743 summary: summary.clone(),
6744 fork_path_snapshot: Some(snapshot_path),
6745 };
6746
6747 registry.add_staged_change(¶ms.fork_id, staged)?;
6748
6749 Ok(GridImportResponse {
6750 fork_id: params.fork_id,
6751 mode: mode.as_str().to_string(),
6752 change_id: Some(change_id),
6753 summary,
6754 formula_parse_diagnostics,
6755 })
6756 } else {
6757 let sheet_name_for_col = params.sheet_name.clone();
6758 let work_path_for_apply = work_path.clone();
6759 let apply_result = tokio::task::spawn_blocking(move || {
6760 let mut summary = ChangeSummary::default();
6761 summary.op_kinds.push("grid_import".to_string());
6762
6763 if !structure_ops.is_empty() {
6764 let res = apply_structure_ops_to_file(
6765 &work_path_for_apply,
6766 &structure_ops,
6767 FormulaParsePolicy::Off,
6768 )?;
6769 merge_summary_counts(&mut summary, &res.summary);
6770 }
6771 if !column_ops.is_empty() {
6772 let res = apply_column_size_ops_to_file(
6773 &work_path_for_apply,
6774 &sheet_name_for_col,
6775 &column_ops,
6776 )?;
6777 merge_summary_counts(&mut summary, &res.summary);
6778 }
6779 if !ops_to_apply.is_empty() {
6780 let res = apply_transform_ops_to_file(&work_path_for_apply, &ops_to_apply)?;
6781 merge_summary_counts(&mut summary, &res.summary);
6782 }
6783 if !resolved_style_ops.is_empty() {
6784 let res = apply_style_ops_to_file(&work_path_for_apply, &resolved_style_ops)?;
6785 merge_summary_counts(&mut summary, &res.summary);
6786 }
6787
6788 Ok::<_, anyhow::Error>(summary)
6789 })
6790 .await??;
6791
6792 let mut summary = apply_result;
6793 registry.with_fork_mut(¶ms.fork_id, |ctx| {
6794 ctx.recalc_needed = true;
6795 Ok(())
6796 })?;
6797 set_recalc_needed_flag(&mut summary, true);
6798
6799 let _ = state.close_workbook(&fork_workbook_id);
6800
6801 Ok(GridImportResponse {
6802 fork_id: params.fork_id,
6803 mode: mode.as_str().to_string(),
6804 change_id: None,
6805 summary,
6806 formula_parse_diagnostics,
6807 })
6808 }
6809}
6810
6811fn merge_summary_counts(dest: &mut ChangeSummary, src: &ChangeSummary) {
6812 for sheet in &src.affected_sheets {
6813 if !dest.affected_sheets.contains(sheet) {
6814 dest.affected_sheets.push(sheet.clone());
6815 }
6816 }
6817 for bounds in &src.affected_bounds {
6818 if !dest.affected_bounds.contains(bounds) {
6819 dest.affected_bounds.push(bounds.clone());
6820 }
6821 }
6822 for (k, v) in &src.counts {
6823 *dest.counts.entry(k.clone()).or_insert(0) += v;
6824 }
6825}
6826
6827#[cfg(test)]
6828mod tests {
6829 use super::*;
6830
6831 #[test]
6832 fn adjust_ref_coord_part_row_insert_clamps_excel_max_row() {
6833 let adjusted = adjust_ref_coord_part(
6834 "$C$2:$P$1048576",
6835 StructureAxis::Row,
6836 StructureEdit::Insert { at: 1791, count: 7 },
6837 )
6838 .expect("adjust range");
6839
6840 assert_eq!(adjusted, "$C$2:$P$1048576");
6841 }
6842
6843 #[test]
6844 fn clamp_out_of_bounds_defined_name_rows_clamps_plain_ranges() {
6845 let mut workbook = umya_spreadsheet::new_file();
6846 workbook
6847 .set_sheet_name(0, "GL Data".to_string())
6848 .expect("rename default sheet");
6849
6850 let workbook_scoped = {
6851 let sheet = workbook
6852 .get_sheet_by_name_mut("GL Data")
6853 .expect("GL Data sheet");
6854 sheet
6855 .add_defined_name("GLDATA", "'GL Data'!$C$2:$P$1048583")
6856 .expect("add defined name");
6857 let cloned = sheet
6858 .get_defined_names()
6859 .first()
6860 .expect("sheet defined name")
6861 .clone();
6862 sheet.get_defined_names_mut().clear();
6863 cloned
6864 };
6865 {
6866 let defs = workbook.get_defined_names_mut();
6867 defs.clear();
6868 defs.push(workbook_scoped);
6869 }
6870
6871 let changed = clamp_out_of_bounds_defined_name_rows(&mut workbook);
6872 assert!(changed >= 1, "expected at least one clamped defined name");
6873
6874 let global_refers_to = workbook
6875 .get_defined_names()
6876 .iter()
6877 .find(|item| item.get_name() == "GLDATA")
6878 .map(|item| item.get_address().to_string())
6879 .expect("global GLDATA defined name");
6880 assert!(
6881 global_refers_to.contains("$P$1048576"),
6882 "expected clamped max-row bound, got: {global_refers_to}"
6883 );
6884 assert!(
6885 !global_refers_to.contains("104858"),
6886 "defined name should not overflow Excel max rows: {global_refers_to}"
6887 );
6888 }
6889
6890 #[test]
6891 fn clamp_defined_name_rows_in_workbook_xml_clamps_overflow_rows() {
6892 let xml = r#"<workbook><definedNames><definedName name="GLDATA" hidden="0">'GL Data'!$C$2:$P$1048583</definedName><definedName name="Calc">=SUM(Sheet1!A1:A10)</definedName></definedNames></workbook>"#;
6893 let (rewritten, changed) = clamp_defined_name_rows_in_workbook_xml(xml);
6894
6895 assert_eq!(changed, 1);
6896 assert!(rewritten.contains("'GL Data'!$C$2:$P$1048576"));
6897 assert!(!rewritten.contains("1048583"));
6898 assert!(
6899 rewritten.contains("<definedName name=\"Calc\">=SUM(Sheet1!A1:A10)</definedName>"),
6900 "formula-like defined names should not be rewritten"
6901 );
6902 }
6903}