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, FillPatch, FontPatch, PatternFillPatch, StylePatch, Warning,
7 WorkbookId,
8};
9use crate::recalc::RecalcBackend;
10use crate::security::sanitize_filename_component;
11use crate::state::AppState;
12use crate::tools::write_normalize::{EditBatchParamsInput, normalize_edit_batch};
13use crate::utils::make_short_random_id;
14use anyhow::{Result, anyhow, bail};
15use chrono::Utc;
16use formualizer_parse::tokenizer::Tokenizer;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize, de};
19use std::collections::{BTreeMap, BTreeSet};
20use std::fs;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24fn set_recalc_needed_flag(summary: &mut ChangeSummary, recalc_needed: bool) {
25 summary
26 .flags
27 .insert("recalc_needed".to_string(), recalc_needed);
28}
29
30#[derive(Debug, Deserialize, JsonSchema)]
31pub struct CreateForkParams {
32 #[serde(alias = "workbook_id")]
33 pub workbook_or_fork_id: WorkbookId,
34}
35
36#[derive(Debug, Serialize, JsonSchema)]
37pub struct CreateForkResponse {
38 pub fork_id: String,
39 pub base_workbook: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub client_base_workbook: Option<String>,
42 pub ttl_seconds: u64,
43}
44
45pub async fn create_fork(
46 state: Arc<AppState>,
47 params: CreateForkParams,
48) -> Result<CreateForkResponse> {
49 let registry = state
50 .fork_registry()
51 .ok_or_else(|| anyhow!("fork registry not available (recalc disabled?)"))?;
52
53 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
54 let base_path = &workbook.path;
55 let config = state.config();
56 let workspace_root = &config.workspace_root;
57
58 let fork_id = registry.create_fork(base_path, workspace_root)?;
59
60 Ok(CreateForkResponse {
61 fork_id,
62 base_workbook: base_path.display().to_string(),
63 client_base_workbook: config
64 .map_path_for_client(base_path)
65 .map(|p| p.display().to_string()),
66 ttl_seconds: registry.ttl().as_secs(),
67 })
68}
69
70#[derive(Debug, Deserialize, JsonSchema)]
71pub struct EditBatchParams {
72 pub fork_id: String,
73 pub sheet_name: String,
74 pub edits: Vec<CellEdit>,
75}
76
77#[derive(Debug, Clone, Deserialize, JsonSchema)]
78pub struct CellEdit {
79 pub address: String,
80 pub value: String,
81 #[serde(default)]
82 pub is_formula: bool,
83}
84
85#[derive(Debug, Serialize, JsonSchema)]
86pub struct EditBatchResponse {
87 pub fork_id: String,
88 pub edits_applied: usize,
89 pub total_edits: usize,
90 pub recalc_needed: bool,
91 #[serde(skip_serializing_if = "Vec::is_empty")]
92 pub warnings: Vec<Warning>,
93}
94
95pub async fn edit_batch(
96 state: Arc<AppState>,
97 params: EditBatchParamsInput,
98) -> Result<EditBatchResponse> {
99 let (params, warnings) = normalize_edit_batch(params)?;
100 let registry = state
101 .fork_registry()
102 .ok_or_else(|| anyhow!("fork registry not available"))?;
103
104 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
105 let work_path = fork_ctx.work_path.clone();
106
107 let edits_to_apply: Vec<_> = params
108 .edits
109 .iter()
110 .map(|e| EditOp {
111 timestamp: Utc::now(),
112 sheet: params.sheet_name.clone(),
113 address: e.address.clone(),
114 value: e.value.clone(),
115 is_formula: e.is_formula,
116 })
117 .collect();
118
119 let edit_count = edits_to_apply.len();
120
121 tokio::task::spawn_blocking({
122 let sheet_name = params.sheet_name.clone();
123 let edits = params.edits.clone();
124 move || {
125 let core_edits = edits
126 .into_iter()
127 .map(|edit| crate::core::types::CellEdit {
128 address: edit.address,
129 value: edit.value,
130 is_formula: edit.is_formula,
131 })
132 .collect::<Vec<_>>();
133 crate::core::write::apply_edits_to_file(&work_path, &sheet_name, &core_edits)
134 }
135 })
136 .await??;
137
138 let total = registry.with_fork_mut(¶ms.fork_id, |ctx| {
139 ctx.edits.extend(edits_to_apply);
140 ctx.recalc_needed = true;
141 Ok(ctx.edits.len())
142 })?;
143
144 let fork_workbook_id = WorkbookId(params.fork_id.clone());
145 let _ = state.close_workbook(&fork_workbook_id);
146
147 Ok(EditBatchResponse {
148 fork_id: params.fork_id,
149 edits_applied: edit_count,
150 total_edits: total,
151 recalc_needed: true,
152 warnings,
153 })
154}
155
156fn default_clear_values() -> bool {
157 true
158}
159
160fn default_overwrite_formulas() -> bool {
161 false
162}
163
164fn default_replace_case_sensitive() -> bool {
165 true
166}
167
168#[derive(Debug, Deserialize, JsonSchema)]
169pub struct TransformBatchParams {
170 pub fork_id: String,
171 pub ops: Vec<TransformOp>,
172 #[serde(default)]
173 pub mode: Option<BatchMode>, pub label: Option<String>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
178#[serde(tag = "kind", rename_all = "snake_case")]
179pub enum TransformOp {
180 ClearRange {
181 sheet_name: String,
182 target: TransformTarget,
183 #[serde(default = "default_clear_values")]
184 clear_values: bool,
185 #[serde(default)]
186 clear_formulas: bool,
187 },
188 FillRange {
189 sheet_name: String,
190 target: TransformTarget,
191 value: String,
192 #[serde(default)]
193 is_formula: bool,
194 #[serde(default = "default_overwrite_formulas")]
195 overwrite_formulas: bool,
196 },
197 ReplaceInRange {
198 sheet_name: String,
199 target: TransformTarget,
200 find: String,
201 replace: String,
202 #[serde(default)]
203 match_mode: ReplaceMatchMode,
204 #[serde(default = "default_replace_case_sensitive")]
205 case_sensitive: bool,
206 #[serde(default)]
207 include_formulas: bool,
208 },
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
212#[serde(tag = "kind", rename_all = "snake_case")]
213pub enum TransformTarget {
214 Range { range: String },
215 Region { region_id: u32 },
216 Cells { cells: Vec<String> },
217}
218
219#[derive(Debug, Serialize, JsonSchema)]
220pub struct TransformBatchResponse {
221 pub fork_id: String,
222 pub mode: String,
223 pub change_id: Option<String>,
224 pub ops_applied: usize,
225 pub summary: ChangeSummary,
226}
227
228#[derive(Debug, Serialize, Deserialize)]
229struct TransformBatchStagedPayload {
230 ops: Vec<TransformOp>,
231}
232
233pub async fn transform_batch(
234 state: Arc<AppState>,
235 params: TransformBatchParams,
236) -> Result<TransformBatchResponse> {
237 let registry = state
238 .fork_registry()
239 .ok_or_else(|| anyhow!("fork registry not available"))?;
240
241 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
242 let work_path = fork_ctx.work_path.clone();
243
244 let fork_workbook_id = WorkbookId(params.fork_id.clone());
245 let workbook = state.open_workbook(&fork_workbook_id).await?;
246
247 let mut resolved_ops = Vec::with_capacity(params.ops.len());
248 for op in ¶ms.ops {
249 let (sheet_name, target) = match op {
250 TransformOp::ClearRange {
251 sheet_name, target, ..
252 }
253 | TransformOp::FillRange {
254 sheet_name, target, ..
255 }
256 | TransformOp::ReplaceInRange {
257 sheet_name, target, ..
258 } => (sheet_name, target),
259 };
260
261 let resolved_target = match target {
262 TransformTarget::Region { region_id } => {
263 let metrics = workbook.get_sheet_metrics(sheet_name)?;
264 let regions = metrics.detected_regions();
265 let region = regions.iter().find(|r| r.id == *region_id).ok_or_else(|| {
266 anyhow!(
267 "region_id {} not found on sheet '{}'",
268 region_id,
269 sheet_name
270 )
271 })?;
272 TransformTarget::Range {
273 range: region.bounds.clone(),
274 }
275 }
276 other => other.clone(),
277 };
278
279 match op {
280 TransformOp::ClearRange {
281 sheet_name,
282 clear_values,
283 clear_formulas,
284 ..
285 } => {
286 resolved_ops.push(TransformOp::ClearRange {
287 sheet_name: sheet_name.clone(),
288 target: resolved_target,
289 clear_values: *clear_values,
290 clear_formulas: *clear_formulas,
291 });
292 }
293 TransformOp::FillRange {
294 sheet_name,
295 value,
296 is_formula,
297 overwrite_formulas,
298 ..
299 } => {
300 resolved_ops.push(TransformOp::FillRange {
301 sheet_name: sheet_name.clone(),
302 target: resolved_target,
303 value: value.clone(),
304 is_formula: *is_formula,
305 overwrite_formulas: *overwrite_formulas,
306 });
307 }
308 TransformOp::ReplaceInRange {
309 sheet_name,
310 find,
311 replace,
312 match_mode,
313 case_sensitive,
314 include_formulas,
315 ..
316 } => {
317 resolved_ops.push(TransformOp::ReplaceInRange {
318 sheet_name: sheet_name.clone(),
319 target: resolved_target,
320 find: find.clone(),
321 replace: replace.clone(),
322 match_mode: match_mode.clone(),
323 case_sensitive: *case_sensitive,
324 include_formulas: *include_formulas,
325 });
326 }
327 }
328 }
329
330 let mode = params.mode.unwrap_or_default();
331
332 if mode.is_preview() {
333 let change_id = make_short_random_id("chg", 12);
334 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
335 fs::create_dir_all(snapshot_path.parent().unwrap())?;
336 fs::copy(&work_path, &snapshot_path)?;
337
338 let snapshot_for_apply = snapshot_path.clone();
339 let apply_result = tokio::task::spawn_blocking({
340 let ops = resolved_ops.clone();
341 move || apply_transform_ops_to_file(&snapshot_for_apply, &ops)
342 })
343 .await??;
344
345 let mut summary = apply_result.summary;
346 summary.op_kinds = vec!["transform_batch".to_string()];
347 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
348
349 let staged_op = StagedOp {
350 kind: "transform_batch".to_string(),
351 payload: serde_json::to_value(TransformBatchStagedPayload {
352 ops: resolved_ops.clone(),
353 })?,
354 };
355
356 let staged = StagedChange {
357 change_id: change_id.clone(),
358 created_at: Utc::now(),
359 label: params.label.clone(),
360 ops: vec![staged_op],
361 summary: summary.clone(),
362 fork_path_snapshot: Some(snapshot_path),
363 };
364
365 registry.add_staged_change(¶ms.fork_id, staged)?;
366
367 Ok(TransformBatchResponse {
368 fork_id: params.fork_id,
369 mode: mode.as_str().to_string(),
370 change_id: Some(change_id),
371 ops_applied: apply_result.ops_applied,
372 summary,
373 })
374 } else {
375 let apply_result = tokio::task::spawn_blocking({
376 let ops = resolved_ops.clone();
377 let work_path = work_path.clone();
378 move || apply_transform_ops_to_file(&work_path, &ops)
379 })
380 .await??;
381
382 let mut summary = apply_result.summary;
383 summary.op_kinds = vec!["transform_batch".to_string()];
384
385 registry.with_fork_mut(¶ms.fork_id, |ctx| {
386 ctx.recalc_needed = true;
387 Ok(())
388 })?;
389 set_recalc_needed_flag(&mut summary, true);
390
391 let _ = state.close_workbook(&fork_workbook_id);
392
393 Ok(TransformBatchResponse {
394 fork_id: params.fork_id,
395 mode: mode.as_str().to_string(),
396 change_id: None,
397 ops_applied: apply_result.ops_applied,
398 summary,
399 })
400 }
401}
402
403#[derive(Debug, Deserialize, JsonSchema)]
404pub struct StyleBatchParams {
405 pub fork_id: String,
406 pub ops: Vec<StyleOp>,
407 #[serde(default)]
408 pub mode: Option<BatchMode>, pub label: Option<String>,
410}
411
412#[derive(Debug, Deserialize, JsonSchema)]
413pub struct StyleBatchParamsInput {
414 pub fork_id: String,
415 pub ops: Vec<StyleOpInput>,
416 #[serde(default)]
417 pub mode: Option<BatchMode>,
418 pub label: Option<String>,
419}
420
421#[derive(Debug, Clone)]
422pub struct StyleOpInput {
423 op: StyleOp,
424 shorthand_used: bool,
425 fill_color_used: bool,
426 color_alpha_defaulted: bool,
427}
428
429impl From<StyleOp> for StyleOpInput {
430 fn from(op: StyleOp) -> Self {
431 Self {
432 op,
433 shorthand_used: false,
434 fill_color_used: false,
435 color_alpha_defaulted: false,
436 }
437 }
438}
439
440impl<'de> Deserialize<'de> for StyleOpInput {
441 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
442 where
443 D: serde::Deserializer<'de>,
444 {
445 let mut value = serde_json::Value::deserialize(deserializer)?;
446 let Some(obj) = value.as_object_mut() else {
447 return Err(de::Error::custom("style op must be an object"));
448 };
449
450 let mut shorthand_used = false;
451 let mut fill_color_used = false;
452 let mut color_alpha_defaulted = false;
453
454 if obj.get("target").is_none()
455 && let Some(range) = obj.remove("range")
456 {
457 shorthand_used = true;
458 obj.insert(
459 "target".to_string(),
460 serde_json::json!({ "kind": "range", "range": range }),
461 );
462 }
463
464 if obj.get("patch").is_none()
465 && let Some(style) = obj.remove("style")
466 {
467 shorthand_used = true;
468 obj.insert("patch".to_string(), style);
469 }
470
471 if let Some(patch_value) = obj.remove("patch") {
472 let patch_input: StylePatchInput =
473 serde_json::from_value(patch_value).map_err(de::Error::custom)?;
474 let (patch, used_fill_color, alpha_defaulted) =
475 normalize_style_patch_input(patch_input);
476 if used_fill_color {
477 fill_color_used = true;
478 }
479 if alpha_defaulted {
480 color_alpha_defaulted = true;
481 }
482 obj.insert(
483 "patch".to_string(),
484 serde_json::to_value(patch).map_err(de::Error::custom)?,
485 );
486 }
487
488 let op = serde_json::from_value(value).map_err(de::Error::custom)?;
489 Ok(StyleOpInput {
490 op,
491 shorthand_used,
492 fill_color_used,
493 color_alpha_defaulted,
494 })
495 }
496}
497
498impl schemars::JsonSchema for StyleOpInput {
499 fn schema_name() -> std::borrow::Cow<'static, str> {
500 "StyleOp".into()
501 }
502
503 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
504 StyleOp::json_schema(generator)
505 }
506}
507
508#[derive(Debug, Clone, Deserialize)]
509struct StylePatchInput {
510 #[serde(default)]
511 pub font: Option<Option<FontPatch>>,
512 #[serde(default)]
513 pub fill: Option<Option<FillPatchInput>>,
514 #[serde(default)]
515 pub borders: Option<Option<BordersPatch>>,
516 #[serde(default)]
517 pub alignment: Option<Option<AlignmentPatch>>,
518 #[serde(default)]
519 pub number_format: Option<Option<NumberFormatPatchInput>>,
520}
521
522#[derive(Debug, Clone, Deserialize)]
523#[serde(untagged)]
524enum NumberFormatPatchInput {
525 FormatCode(String),
526 Shorthand(NumberFormatShorthandInput),
527}
528
529#[derive(Debug, Clone, Deserialize)]
530struct NumberFormatShorthandInput {
531 pub kind: NumberFormatKind,
532 #[serde(default)]
533 pub format_code: Option<String>,
534}
535
536#[derive(Debug, Clone, Deserialize)]
537#[serde(rename_all = "snake_case")]
538enum NumberFormatKind {
539 Currency,
540 Percent,
541 DateIso,
542 Accounting,
543 Integer,
544}
545
546fn number_format_kind_to_format_code(kind: &NumberFormatKind) -> &'static str {
547 match kind {
548 NumberFormatKind::Currency => "$#,##0.00",
549 NumberFormatKind::Percent => "0.00%",
550 NumberFormatKind::DateIso => "yyyy-mm-dd",
551 NumberFormatKind::Accounting => "_($* #,##0.00_)",
552 NumberFormatKind::Integer => "0",
553 }
554}
555
556#[derive(Debug, Clone, Deserialize)]
557#[serde(untagged)]
558enum FillPatchInput {
559 Canonical(FillPatch),
560 Color(FillColorPatch),
561}
562
563#[derive(Debug, Clone, Deserialize)]
564struct FillColorPatch {
565 color: String,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
569pub struct StyleOp {
570 pub sheet_name: String,
571 pub target: StyleTarget,
572 pub patch: StylePatch,
573 #[serde(default)]
574 pub op_mode: Option<crate::styles::StylePatchMode>,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
578#[serde(tag = "kind", rename_all = "snake_case")]
579pub enum StyleTarget {
580 Range { range: String },
581 Region { region_id: u32 },
582 Cells { cells: Vec<String> },
583}
584
585#[derive(Debug, Serialize, JsonSchema)]
586pub struct StyleBatchResponse {
587 pub fork_id: String,
588 pub mode: String,
589 pub change_id: Option<String>,
590 pub ops_applied: usize,
591 pub summary: ChangeSummary,
592}
593
594#[derive(Debug, Deserialize, JsonSchema)]
595pub struct ColumnSizeBatchParamsInput {
596 pub fork_id: String,
597 pub sheet_name: String,
598 pub ops: Vec<ColumnSizeOpInput>,
599 pub mode: Option<BatchMode>, pub label: Option<String>,
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
604#[serde(tag = "kind", rename_all = "snake_case")]
605pub enum ColumnTarget {
606 Columns { range: String },
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
610#[serde(tag = "kind", rename_all = "snake_case")]
611pub enum ColumnSizeSpec {
612 Auto {
613 #[serde(default)]
614 min_width_chars: Option<f64>,
615 #[serde(default)]
616 max_width_chars: Option<f64>,
617 },
618 Width {
619 width_chars: f64,
620 },
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
624pub struct ColumnSizeOp {
625 pub target: ColumnTarget,
626 pub size: ColumnSizeSpec,
627}
628
629#[derive(Debug, Deserialize, JsonSchema)]
630#[serde(untagged)]
631pub enum ColumnSizeOpInput {
632 Canonical(ColumnSizeOp),
633 Shorthand { range: String, size: ColumnSizeSpec },
634}
635
636impl From<ColumnSizeOp> for ColumnSizeOpInput {
637 fn from(value: ColumnSizeOp) -> Self {
638 Self::Canonical(value)
639 }
640}
641
642#[derive(Debug, Clone)]
643struct ColumnSizeBatchParams {
644 fork_id: String,
645 sheet_name: String,
646 ops: Vec<ColumnSizeOp>,
647 mode: Option<BatchMode>,
648 label: Option<String>,
649}
650
651#[derive(Debug, Serialize, JsonSchema)]
652pub struct ColumnSizeBatchResponse {
653 pub fork_id: String,
654 pub sheet_name: String,
655 pub mode: String,
656 pub change_id: Option<String>,
657 pub ops_applied: usize,
658 pub summary: ChangeSummary,
659}
660
661#[derive(Debug, Serialize, Deserialize)]
662struct ColumnSizeBatchStagedPayload {
663 sheet_name: String,
664 ops: Vec<ColumnSizeOp>,
665}
666
667fn normalize_column_size_batch(
668 params: ColumnSizeBatchParamsInput,
669) -> Result<(ColumnSizeBatchParams, Vec<crate::model::Warning>)> {
670 let mut warnings = Vec::new();
671 let mut ops = Vec::with_capacity(params.ops.len());
672
673 for entry in params.ops {
674 match entry {
675 ColumnSizeOpInput::Canonical(op) => ops.push(op),
676 ColumnSizeOpInput::Shorthand { range, size } => {
677 warnings.push(crate::model::Warning {
678 code: "WARN_COLUMN_SHORTHAND_TARGET".to_string(),
679 message: "Used range shorthand; prefer target:{kind:'columns',range:'A:C'}"
680 .to_string(),
681 });
682 ops.push(ColumnSizeOp {
683 target: ColumnTarget::Columns { range },
684 size,
685 });
686 }
687 }
688 }
689
690 Ok((
691 ColumnSizeBatchParams {
692 fork_id: params.fork_id,
693 sheet_name: params.sheet_name,
694 ops,
695 mode: params.mode,
696 label: params.label,
697 },
698 warnings,
699 ))
700}
701
702pub async fn column_size_batch(
703 state: Arc<AppState>,
704 params: ColumnSizeBatchParamsInput,
705) -> Result<ColumnSizeBatchResponse> {
706 let (params, warnings) = normalize_column_size_batch(params)?;
707 let warning_messages: Vec<String> = warnings
708 .into_iter()
709 .map(|warning| format!("{}: {}", warning.code, warning.message))
710 .collect();
711 let registry = state
712 .fork_registry()
713 .ok_or_else(|| anyhow!("fork registry not available"))?;
714
715 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
716 let work_path = fork_ctx.work_path.clone();
717
718 let fork_workbook_id = WorkbookId(params.fork_id.clone());
719 let workbook = state.open_workbook(&fork_workbook_id).await?;
720 let _ = workbook.with_sheet(¶ms.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
721
722 let mode = params.mode.unwrap_or_default();
723
724 if mode.is_preview() {
725 let change_id = make_short_random_id("chg", 12);
726 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
727 fs::create_dir_all(snapshot_path.parent().unwrap())?;
728 fs::copy(&work_path, &snapshot_path)?;
729
730 let snapshot_path_for_apply = snapshot_path.clone();
731 let apply_result = tokio::task::spawn_blocking({
732 let ops = params.ops.clone();
733 let sheet_name = params.sheet_name.clone();
734 move || apply_column_size_ops_to_file(&snapshot_path_for_apply, &sheet_name, &ops)
735 })
736 .await??;
737
738 let mut summary = apply_result.summary;
739 summary.op_kinds = vec!["column_size_batch".to_string()];
740 summary.warnings.extend(warning_messages.clone());
741 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
742
743 let staged_op = StagedOp {
744 kind: "column_size_batch".to_string(),
745 payload: serde_json::to_value(ColumnSizeBatchStagedPayload {
746 sheet_name: params.sheet_name.clone(),
747 ops: params.ops.clone(),
748 })?,
749 };
750
751 let staged = StagedChange {
752 change_id: change_id.clone(),
753 created_at: Utc::now(),
754 label: params.label.clone(),
755 ops: vec![staged_op],
756 summary: summary.clone(),
757 fork_path_snapshot: Some(snapshot_path),
758 };
759
760 registry.add_staged_change(¶ms.fork_id, staged)?;
761
762 Ok(ColumnSizeBatchResponse {
763 fork_id: params.fork_id,
764 sheet_name: params.sheet_name,
765 mode: mode.as_str().to_string(),
766 change_id: Some(change_id),
767 ops_applied: apply_result.ops_applied,
768 summary,
769 })
770 } else {
771 let apply_result = tokio::task::spawn_blocking({
772 let ops = params.ops.clone();
773 let sheet_name = params.sheet_name.clone();
774 let work_path = work_path.clone();
775 move || apply_column_size_ops_to_file(&work_path, &sheet_name, &ops)
776 })
777 .await??;
778
779 let mut summary = apply_result.summary;
780 summary.op_kinds = vec!["column_size_batch".to_string()];
781 summary.warnings.extend(warning_messages);
782 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
783
784 let _ = state.close_workbook(&fork_workbook_id);
785
786 Ok(ColumnSizeBatchResponse {
787 fork_id: params.fork_id,
788 sheet_name: params.sheet_name,
789 mode: mode.as_str().to_string(),
790 change_id: None,
791 ops_applied: apply_result.ops_applied,
792 summary,
793 })
794 }
795}
796
797#[derive(Debug, Serialize, Deserialize)]
798struct StyleBatchStagedPayload {
799 ops: Vec<StyleOp>,
800}
801
802pub fn normalize_style_batch(
803 input: StyleBatchParamsInput,
804) -> Result<(StyleBatchParams, Vec<Warning>)> {
805 let mut warnings = Vec::new();
806 let mut ops = Vec::with_capacity(input.ops.len());
807
808 for op_input in input.ops {
809 if op_input.shorthand_used {
810 warnings.push(Warning {
811 code: "WARN_STYLE_SHORTHAND".to_string(),
812 message: "Normalized style op shorthand to canonical form".to_string(),
813 });
814 }
815 if op_input.fill_color_used {
816 warnings.push(Warning {
817 code: "WARN_FILL_COLOR".to_string(),
818 message: "Normalized fill color shorthand to pattern fill".to_string(),
819 });
820 }
821 if op_input.color_alpha_defaulted {
822 warnings.push(Warning {
823 code: "WARN_COLOR_ALPHA_DEFAULT".to_string(),
824 message: "Normalized RGB hex to ARGB with default alpha".to_string(),
825 });
826 }
827 ops.push(op_input.op);
828 }
829
830 Ok((
831 StyleBatchParams {
832 fork_id: input.fork_id,
833 ops,
834 mode: input.mode,
835 label: input.label,
836 },
837 warnings,
838 ))
839}
840
841fn normalize_style_patch_input(input: StylePatchInput) -> (StylePatch, bool, bool) {
842 let mut fill_color_used = false;
843 let mut color_alpha_defaulted = false;
844 let fill = match input.fill {
845 None => None,
846 Some(None) => Some(None),
847 Some(Some(fill_input)) => {
848 let normalized = match fill_input {
849 FillPatchInput::Canonical(fill) => fill,
850 FillPatchInput::Color(color) => {
851 fill_color_used = true;
852 FillPatch::Pattern(PatternFillPatch {
853 pattern_type: Some(Some("solid".to_string())),
854 foreground_color: Some(Some(color.color)),
855 background_color: None,
856 })
857 }
858 };
859 Some(Some(normalized))
860 }
861 };
862
863 let number_format: Option<Option<String>> = match input.number_format {
864 None => None,
865 Some(None) => Some(None),
866 Some(Some(nf)) => match nf {
867 NumberFormatPatchInput::FormatCode(code) => Some(Some(code)),
868 NumberFormatPatchInput::Shorthand(sh) => {
869 if let Some(code) = sh.format_code {
870 Some(Some(code))
871 } else {
872 Some(Some(
873 number_format_kind_to_format_code(&sh.kind).to_string(),
874 ))
875 }
876 }
877 },
878 };
879
880 let mut patch = StylePatch {
881 font: input.font,
882 fill,
883 borders: input.borders,
884 alignment: input.alignment,
885 number_format,
886 };
887 normalize_style_patch_colors(&mut patch, &mut color_alpha_defaulted);
888
889 (patch, fill_color_used, color_alpha_defaulted)
890}
891
892fn normalize_style_patch_colors(patch: &mut StylePatch, alpha_defaulted: &mut bool) {
893 if let Some(Some(font)) = patch.font.as_mut() {
894 normalize_color_option(&mut font.color, alpha_defaulted);
895 }
896
897 if let Some(Some(fill)) = patch.fill.as_mut() {
898 normalize_fill_colors(fill, alpha_defaulted);
899 }
900
901 if let Some(Some(borders)) = patch.borders.as_mut() {
902 normalize_border_side_color(&mut borders.left, alpha_defaulted);
903 normalize_border_side_color(&mut borders.right, alpha_defaulted);
904 normalize_border_side_color(&mut borders.top, alpha_defaulted);
905 normalize_border_side_color(&mut borders.bottom, alpha_defaulted);
906 normalize_border_side_color(&mut borders.diagonal, alpha_defaulted);
907 normalize_border_side_color(&mut borders.vertical, alpha_defaulted);
908 normalize_border_side_color(&mut borders.horizontal, alpha_defaulted);
909 }
910}
911
912fn normalize_fill_colors(fill: &mut FillPatch, alpha_defaulted: &mut bool) {
913 match fill {
914 FillPatch::Pattern(pattern) => {
915 normalize_color_option(&mut pattern.foreground_color, alpha_defaulted);
916 normalize_color_option(&mut pattern.background_color, alpha_defaulted);
917 }
918 FillPatch::Gradient(gradient) => {
919 if let Some(stops) = gradient.stops.as_mut() {
920 for stop in stops {
921 if let Some((normalized, defaulted)) =
922 crate::styles::normalize_color_hex(&stop.color)
923 {
924 stop.color = normalized;
925 if defaulted {
926 *alpha_defaulted = true;
927 }
928 }
929 }
930 }
931 }
932 }
933}
934
935fn normalize_border_side_color(
936 side: &mut Option<Option<crate::model::BorderSidePatch>>,
937 alpha_defaulted: &mut bool,
938) {
939 if let Some(Some(side_patch)) = side.as_mut() {
940 normalize_color_option(&mut side_patch.color, alpha_defaulted);
941 }
942}
943
944fn normalize_color_option(value: &mut Option<Option<String>>, alpha_defaulted: &mut bool) {
945 let Some(Some(color)) = value.as_mut() else {
946 return;
947 };
948 if let Some((normalized, defaulted)) = crate::styles::normalize_color_hex(color) {
949 *color = normalized;
950 if defaulted {
951 *alpha_defaulted = true;
952 }
953 }
954}
955
956pub async fn style_batch(
957 state: Arc<AppState>,
958 params: StyleBatchParamsInput,
959) -> Result<StyleBatchResponse> {
960 let (params, warnings) = normalize_style_batch(params)?;
961 let warning_messages: Vec<String> = warnings
962 .into_iter()
963 .map(|warning| format!("{}: {}", warning.code, warning.message))
964 .collect();
965 let registry = state
966 .fork_registry()
967 .ok_or_else(|| anyhow!("fork registry not available"))?;
968
969 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
970 let work_path = fork_ctx.work_path.clone();
971
972 let fork_workbook_id = WorkbookId(params.fork_id.clone());
974 let workbook = state.open_workbook(&fork_workbook_id).await?;
975 let mut resolved_ops = Vec::with_capacity(params.ops.len());
976 for op in ¶ms.ops {
977 let mut resolved = op.clone();
978 if let StyleTarget::Region { region_id } = &op.target {
979 let metrics = workbook.get_sheet_metrics(&op.sheet_name)?;
980 let regions = metrics.detected_regions();
981 let region = regions.iter().find(|r| r.id == *region_id).ok_or_else(|| {
982 anyhow!(
983 "region_id {} not found on sheet '{}'",
984 region_id,
985 op.sheet_name
986 )
987 })?;
988 resolved.target = StyleTarget::Range {
989 range: region.bounds.clone(),
990 };
991 }
992 resolved_ops.push(resolved);
993 }
994
995 let mode = params.mode.unwrap_or_default();
996
997 if mode.is_preview() {
998 let change_id = make_short_random_id("chg", 12);
999 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
1000 fs::create_dir_all(snapshot_path.parent().unwrap())?;
1001 fs::copy(&work_path, &snapshot_path)?;
1002
1003 let snapshot_path_for_apply = snapshot_path.clone();
1004 let apply_result = tokio::task::spawn_blocking({
1005 let ops = resolved_ops.clone();
1006 move || apply_style_ops_to_file(&snapshot_path_for_apply, &ops)
1007 })
1008 .await??;
1009
1010 let mut summary = apply_result.summary;
1011 summary.op_kinds = vec!["style_batch".to_string()];
1012 summary.warnings.extend(warning_messages.clone());
1013 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1014
1015 let staged_op = StagedOp {
1016 kind: "style_batch".to_string(),
1017 payload: serde_json::to_value(StyleBatchStagedPayload {
1018 ops: resolved_ops.clone(),
1019 })?,
1020 };
1021
1022 let staged = StagedChange {
1023 change_id: change_id.clone(),
1024 created_at: Utc::now(),
1025 label: params.label.clone(),
1026 ops: vec![staged_op],
1027 summary: summary.clone(),
1028 fork_path_snapshot: Some(snapshot_path),
1029 };
1030
1031 registry.add_staged_change(¶ms.fork_id, staged)?;
1032
1033 Ok(StyleBatchResponse {
1034 fork_id: params.fork_id,
1035 mode: mode.as_str().to_string(),
1036 change_id: Some(change_id),
1037 ops_applied: resolved_ops.len(),
1038 summary,
1039 })
1040 } else {
1041 let apply_result = tokio::task::spawn_blocking({
1042 let ops = resolved_ops.clone();
1043 let work_path = work_path.clone();
1044 move || apply_style_ops_to_file(&work_path, &ops)
1045 })
1046 .await??;
1047
1048 let mut summary = apply_result.summary;
1049 summary.op_kinds = vec!["style_batch".to_string()];
1050 summary.warnings.extend(warning_messages);
1051 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1052
1053 let _ = state.close_workbook(&fork_workbook_id);
1054
1055 Ok(StyleBatchResponse {
1056 fork_id: params.fork_id,
1057 mode: mode.as_str().to_string(),
1058 change_id: None,
1059 ops_applied: apply_result.ops_applied,
1060 summary,
1061 })
1062 }
1063}
1064
1065#[derive(Debug, Deserialize, JsonSchema)]
1066pub struct ApplyFormulaPatternParams {
1067 pub fork_id: String,
1068 pub sheet_name: String,
1069 pub target_range: String,
1070 pub anchor_cell: String,
1071 pub base_formula: String,
1072 #[serde(default)]
1073 pub fill_direction: Option<FillDirection>, #[serde(default)]
1075 pub relative_mode: Option<FormulaRelativeMode>, #[serde(default)]
1077 pub mode: Option<BatchMode>, pub label: Option<String>,
1079}
1080
1081#[derive(Debug, Serialize, JsonSchema)]
1082pub struct ApplyFormulaPatternResponse {
1083 pub fork_id: String,
1084 pub sheet_name: String,
1085 pub target_range: String,
1086 pub mode: String,
1087 pub change_id: Option<String>,
1088 pub cells_filled: u64,
1089 pub summary: ChangeSummary,
1090}
1091
1092#[derive(Debug, Serialize, Deserialize)]
1093struct ApplyFormulaPatternStagedPayload {
1094 sheet_name: String,
1095 target_range: String,
1096 anchor_cell: String,
1097 base_formula: String,
1098 fill_direction: Option<FillDirection>,
1099 relative_mode: Option<FormulaRelativeMode>,
1100}
1101
1102pub async fn apply_formula_pattern(
1103 state: Arc<AppState>,
1104 params: ApplyFormulaPatternParams,
1105) -> Result<ApplyFormulaPatternResponse> {
1106 let registry = state
1107 .fork_registry()
1108 .ok_or_else(|| anyhow!("fork registry not available"))?;
1109
1110 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
1111 let work_path = fork_ctx.work_path.clone();
1112
1113 let bounds = parse_range_bounds(¶ms.target_range)?;
1114 let (anchor_col, anchor_row) = parse_cell_ref(¶ms.anchor_cell)?;
1115 let fill_direction = params.fill_direction.unwrap_or_default();
1116 validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
1117
1118 let fork_workbook_id = WorkbookId(params.fork_id.clone());
1119 let workbook = state.open_workbook(&fork_workbook_id).await?;
1120 let _ = workbook.with_sheet(¶ms.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
1121
1122 let relative_mode_param = params.relative_mode.unwrap_or_default();
1123 let relative_mode: RelativeMode = relative_mode_param.into();
1124 let mode = params.mode.unwrap_or_default();
1125
1126 if mode.is_preview() {
1127 let change_id = make_short_random_id("chg", 12);
1128 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
1129 fs::create_dir_all(snapshot_path.parent().unwrap())?;
1130 fs::copy(&work_path, &snapshot_path)?;
1131
1132 let sheet_name = params.sheet_name.clone();
1133 let target_range = params.target_range.clone();
1134 let anchor_cell = params.anchor_cell.clone();
1135 let base_formula = params.base_formula.clone();
1136 let fill_direction = Some(fill_direction);
1137 let relative_mode_param = Some(relative_mode_param);
1138 let snapshot_for_apply = snapshot_path.clone();
1139 let sheet_name_for_apply = sheet_name.clone();
1140 let target_range_for_apply = target_range.clone();
1141 let base_formula_for_apply = base_formula.clone();
1142
1143 let apply_result = tokio::task::spawn_blocking(move || {
1144 apply_formula_pattern_to_file(
1145 &snapshot_for_apply,
1146 &sheet_name_for_apply,
1147 &target_range_for_apply,
1148 anchor_col,
1149 anchor_row,
1150 &base_formula_for_apply,
1151 relative_mode,
1152 )
1153 })
1154 .await??;
1155
1156 let mut summary = apply_result.summary;
1157 summary.op_kinds = vec!["apply_formula_pattern".to_string()];
1158 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1159
1160 let staged_op = StagedOp {
1161 kind: "apply_formula_pattern".to_string(),
1162 payload: serde_json::to_value(ApplyFormulaPatternStagedPayload {
1163 sheet_name: sheet_name.clone(),
1164 target_range: target_range.clone(),
1165 anchor_cell: anchor_cell.clone(),
1166 base_formula: base_formula.clone(),
1167 fill_direction,
1168 relative_mode: relative_mode_param,
1169 })?,
1170 };
1171
1172 let staged = StagedChange {
1173 change_id: change_id.clone(),
1174 created_at: Utc::now(),
1175 label: params.label.clone(),
1176 ops: vec![staged_op],
1177 summary: summary.clone(),
1178 fork_path_snapshot: Some(snapshot_path),
1179 };
1180
1181 registry.add_staged_change(¶ms.fork_id, staged)?;
1182
1183 Ok(ApplyFormulaPatternResponse {
1184 fork_id: params.fork_id,
1185 sheet_name,
1186 target_range,
1187 mode: mode.as_str().to_string(),
1188 change_id: Some(change_id),
1189 cells_filled: apply_result.cells_filled,
1190 summary,
1191 })
1192 } else {
1193 let sheet_name = params.sheet_name.clone();
1194 let target_range = params.target_range.clone();
1195 let base_formula = params.base_formula.clone();
1196 let sheet_name_for_apply = sheet_name.clone();
1197 let target_range_for_apply = target_range.clone();
1198 let base_formula_for_apply = base_formula.clone();
1199 let apply_result = tokio::task::spawn_blocking(move || {
1200 apply_formula_pattern_to_file(
1201 &work_path,
1202 &sheet_name_for_apply,
1203 &target_range_for_apply,
1204 anchor_col,
1205 anchor_row,
1206 &base_formula_for_apply,
1207 relative_mode,
1208 )
1209 })
1210 .await??;
1211
1212 let mut summary = apply_result.summary;
1213 summary.op_kinds = vec!["apply_formula_pattern".to_string()];
1214
1215 registry.with_fork_mut(¶ms.fork_id, |ctx| {
1216 ctx.recalc_needed = true;
1217 Ok(())
1218 })?;
1219 set_recalc_needed_flag(&mut summary, true);
1220
1221 let _ = state.close_workbook(&fork_workbook_id);
1222
1223 Ok(ApplyFormulaPatternResponse {
1224 fork_id: params.fork_id,
1225 sheet_name,
1226 target_range,
1227 mode: mode.as_str().to_string(),
1228 change_id: None,
1229 cells_filled: apply_result.cells_filled,
1230 summary,
1231 })
1232 }
1233}
1234
1235struct FormulaPatternApplyResult {
1236 cells_filled: u64,
1237 summary: ChangeSummary,
1238}
1239
1240fn apply_formula_pattern_to_file(
1241 path: &Path,
1242 sheet_name: &str,
1243 target_range: &str,
1244 anchor_col: u32,
1245 anchor_row: u32,
1246 base_formula: &str,
1247 relative_mode: RelativeMode,
1248) -> Result<FormulaPatternApplyResult> {
1249 let ast = parse_base_formula(base_formula)?;
1250 let bounds = parse_range_bounds(target_range)?;
1251
1252 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
1253 let sheet = book
1254 .get_sheet_by_name_mut(sheet_name)
1255 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1256
1257 let mut cells_filled: u64 = 0;
1258 for row in bounds.min_row..=bounds.max_row {
1259 for col in bounds.min_col..=bounds.max_col {
1260 let delta_col = col as i32 - anchor_col as i32;
1261 let delta_row = row as i32 - anchor_row as i32;
1262 let shifted = shift_formula_ast(&ast, delta_col, delta_row, relative_mode)?;
1263 let shifted_for_umya = shifted.strip_prefix('=').unwrap_or(&shifted);
1264 let addr = crate::utils::cell_address(col, row);
1265 sheet
1266 .get_cell_mut(addr.as_str())
1267 .set_formula(shifted_for_umya.to_string());
1268 cells_filled += 1;
1269 }
1270 }
1271
1272 umya_spreadsheet::writer::xlsx::write(&book, path)?;
1273
1274 let mut counts = BTreeMap::new();
1275 counts.insert("cells_filled".to_string(), cells_filled);
1276
1277 let summary = ChangeSummary {
1278 op_kinds: vec!["apply_formula_pattern".to_string()],
1279 affected_sheets: vec![sheet_name.to_string()],
1280 affected_bounds: vec![target_range.to_string()],
1281 counts,
1282 warnings: Vec::new(),
1283 ..Default::default()
1284 };
1285
1286 Ok(FormulaPatternApplyResult {
1287 cells_filled,
1288 summary,
1289 })
1290}
1291
1292fn validate_formula_pattern_bounds(
1293 bounds: &ScreenshotBounds,
1294 anchor_col: u32,
1295 anchor_row: u32,
1296 fill_direction: FillDirection,
1297) -> Result<()> {
1298 if anchor_col < bounds.min_col
1299 || anchor_col > bounds.max_col
1300 || anchor_row < bounds.min_row
1301 || anchor_row > bounds.max_row
1302 {
1303 let bounds_range = format!(
1304 "{}:{}",
1305 crate::utils::cell_address(bounds.min_col, bounds.min_row),
1306 crate::utils::cell_address(bounds.max_col, bounds.max_row)
1307 );
1308 bail!(
1309 "anchor_cell must be inside target_range (anchor {} not within {})",
1310 crate::utils::cell_address(anchor_col, anchor_row),
1311 bounds_range
1312 );
1313 }
1314
1315 if bounds.min_col != anchor_col || bounds.min_row != anchor_row {
1316 bail!("target_range must start at anchor_cell (anchor should be top-left of fill range)");
1317 }
1318
1319 match fill_direction {
1320 FillDirection::Down => {
1321 if bounds.min_col != bounds.max_col {
1322 bail!("fill_direction=down requires a single-column target_range");
1323 }
1324 }
1325 FillDirection::Right => {
1326 if bounds.min_row != bounds.max_row {
1327 bail!("fill_direction=right requires a single-row target_range");
1328 }
1329 }
1330 FillDirection::Both => {}
1331 }
1332 Ok(())
1333}
1334
1335#[derive(Debug, Deserialize, JsonSchema)]
1336pub struct StructureBatchParams {
1337 pub fork_id: String,
1338 pub ops: Vec<StructureOp>,
1339 #[serde(default)]
1340 pub mode: Option<BatchMode>, pub label: Option<String>,
1342}
1343
1344#[derive(Debug, Deserialize, JsonSchema)]
1345pub struct StructureBatchParamsInput {
1346 pub fork_id: String,
1347 pub ops: Vec<StructureOpInput>,
1348 #[serde(default)]
1349 pub mode: Option<BatchMode>,
1350 pub label: Option<String>,
1351}
1352
1353#[derive(Debug, Clone)]
1354pub struct StructureOpInput {
1355 op: StructureOp,
1356 alias_used: bool,
1357}
1358
1359impl From<StructureOp> for StructureOpInput {
1360 fn from(op: StructureOp) -> Self {
1361 Self {
1362 op,
1363 alias_used: false,
1364 }
1365 }
1366}
1367
1368impl<'de> Deserialize<'de> for StructureOpInput {
1369 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1370 where
1371 D: serde::Deserializer<'de>,
1372 {
1373 let mut value = serde_json::Value::deserialize(deserializer)?;
1374 let Some(obj) = value.as_object_mut() else {
1375 return Err(de::Error::custom("structure op must be an object"));
1376 };
1377
1378 let mut alias_used = false;
1379 let kind_value = if let Some(kind) = obj.get("kind") {
1380 kind.clone()
1381 } else if let Some(op) = obj.remove("op") {
1382 alias_used = true;
1383 op
1384 } else {
1385 return Err(de::Error::custom("structure op requires 'kind' or 'op'"));
1386 };
1387
1388 let Some(kind_str) = kind_value.as_str() else {
1389 return Err(de::Error::custom("structure op kind must be a string"));
1390 };
1391
1392 let normalized_kind = if kind_str == "add_sheet" {
1393 alias_used = true;
1394 "create_sheet"
1395 } else {
1396 kind_str
1397 };
1398
1399 obj.insert(
1400 "kind".to_string(),
1401 serde_json::Value::String(normalized_kind.to_string()),
1402 );
1403
1404 let op = serde_json::from_value(value).map_err(de::Error::custom)?;
1405 Ok(StructureOpInput { op, alias_used })
1406 }
1407}
1408
1409impl schemars::JsonSchema for StructureOpInput {
1410 fn schema_name() -> std::borrow::Cow<'static, str> {
1411 "StructureOp".into()
1412 }
1413
1414 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1415 StructureOp::json_schema(generator)
1416 }
1417}
1418
1419pub fn normalize_structure_batch(
1420 input: StructureBatchParamsInput,
1421) -> Result<(StructureBatchParams, Vec<Warning>)> {
1422 let mut warnings = Vec::new();
1423 let mut ops = Vec::with_capacity(input.ops.len());
1424
1425 for op_input in input.ops {
1426 if op_input.alias_used {
1427 warnings.push(Warning {
1428 code: "WARN_ALIAS_KIND".to_string(),
1429 message: "Normalized structure op alias to canonical kind".to_string(),
1430 });
1431 }
1432 ops.push(op_input.op);
1433 }
1434
1435 Ok((
1436 StructureBatchParams {
1437 fork_id: input.fork_id,
1438 ops,
1439 mode: input.mode,
1440 label: input.label,
1441 },
1442 warnings,
1443 ))
1444}
1445
1446#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1447#[serde(tag = "kind", rename_all = "snake_case")]
1448pub enum StructureOp {
1449 InsertRows {
1450 sheet_name: String,
1451 at_row: u32,
1452 count: u32,
1453 },
1454 DeleteRows {
1455 sheet_name: String,
1456 start_row: u32,
1457 count: u32,
1458 },
1459 InsertCols {
1460 sheet_name: String,
1461 at_col: String,
1462 count: u32,
1463 },
1464 DeleteCols {
1465 sheet_name: String,
1466 start_col: String,
1467 count: u32,
1468 },
1469 RenameSheet {
1470 old_name: String,
1471 new_name: String,
1472 },
1473 CreateSheet {
1474 name: String,
1475 #[serde(default)]
1476 position: Option<u32>,
1477 },
1478 DeleteSheet {
1479 name: String,
1480 },
1481 CopyRange {
1482 sheet_name: String,
1483 #[serde(default)]
1484 dest_sheet_name: Option<String>,
1485 src_range: String,
1486 dest_anchor: String,
1487 include_styles: bool,
1488 include_formulas: bool,
1489 },
1490 MoveRange {
1491 sheet_name: String,
1492 #[serde(default)]
1493 dest_sheet_name: Option<String>,
1494 src_range: String,
1495 dest_anchor: String,
1496 include_styles: bool,
1497 include_formulas: bool,
1498 },
1499}
1500
1501fn structure_ops_require_recalc(ops: &[StructureOp]) -> bool {
1502 ops.iter().any(|op| match op {
1503 StructureOp::InsertRows { .. }
1504 | StructureOp::DeleteRows { .. }
1505 | StructureOp::InsertCols { .. }
1506 | StructureOp::DeleteCols { .. }
1507 | StructureOp::RenameSheet { .. } => true,
1508 StructureOp::CopyRange {
1509 include_formulas: true,
1510 ..
1511 }
1512 | StructureOp::MoveRange {
1513 include_formulas: true,
1514 ..
1515 } => true,
1516 _ => false,
1517 })
1518}
1519
1520#[derive(Debug, Serialize, JsonSchema)]
1521pub struct StructureBatchResponse {
1522 pub fork_id: String,
1523 pub mode: String,
1524 pub change_id: Option<String>,
1525 pub ops_applied: usize,
1526 pub summary: ChangeSummary,
1527}
1528
1529#[derive(Debug, Serialize, Deserialize)]
1530struct StructureBatchStagedPayload {
1531 ops: Vec<StructureOp>,
1532}
1533
1534pub async fn structure_batch(
1535 state: Arc<AppState>,
1536 params: StructureBatchParamsInput,
1537) -> Result<StructureBatchResponse> {
1538 let (params, warnings) = normalize_structure_batch(params)?;
1539 let alias_warnings: Vec<String> = warnings
1540 .into_iter()
1541 .map(|warning| format!("{}: {}", warning.code, warning.message))
1542 .collect();
1543 let registry = state
1544 .fork_registry()
1545 .ok_or_else(|| anyhow!("fork registry not available"))?;
1546
1547 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
1548 let work_path = fork_ctx.work_path.clone();
1549
1550 let will_need_recalc = fork_ctx.recalc_needed || structure_ops_require_recalc(¶ms.ops);
1551
1552 let mode = params.mode.unwrap_or_default();
1553
1554 if mode.is_preview() {
1555 let change_id = make_short_random_id("chg", 12);
1556 let snapshot_path = stage_snapshot_path(¶ms.fork_id, &change_id);
1557 fs::create_dir_all(snapshot_path.parent().unwrap())?;
1558 fs::copy(&work_path, &snapshot_path)?;
1559
1560 let snapshot_for_apply = snapshot_path.clone();
1561 let ops_for_apply = params.ops.clone();
1562
1563 let apply_result = tokio::task::spawn_blocking(move || {
1564 apply_structure_ops_to_file(&snapshot_for_apply, &ops_for_apply)
1565 })
1566 .await??;
1567
1568 let mut summary = apply_result.summary;
1569 summary.op_kinds = vec!["structure_batch".to_string()];
1570 summary.warnings.extend(alias_warnings);
1571 set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1572 if let Ok(change_count) = tokio::task::spawn_blocking({
1575 let base_path = work_path.clone();
1576 let preview_path = snapshot_path.clone();
1577 move || {
1578 crate::core::diff::calculate_changeset(&base_path, &preview_path, None)
1579 .map(|changes| changes.len() as u64)
1580 }
1581 })
1582 .await?
1583 {
1584 summary
1585 .counts
1586 .insert("preview_change_items".to_string(), change_count);
1587 } else {
1588 summary.warnings.push(
1589 "Preview diff computation failed; run get_changeset after applying to inspect changes."
1590 .to_string(),
1591 );
1592 }
1593
1594 let staged_op = StagedOp {
1595 kind: "structure_batch".to_string(),
1596 payload: serde_json::to_value(StructureBatchStagedPayload {
1597 ops: params.ops.clone(),
1598 })?,
1599 };
1600
1601 let staged = StagedChange {
1602 change_id: change_id.clone(),
1603 created_at: Utc::now(),
1604 label: params.label.clone(),
1605 ops: vec![staged_op],
1606 summary: summary.clone(),
1607 fork_path_snapshot: Some(snapshot_path),
1608 };
1609
1610 registry.add_staged_change(¶ms.fork_id, staged)?;
1611
1612 Ok(StructureBatchResponse {
1613 fork_id: params.fork_id,
1614 mode: mode.as_str().to_string(),
1615 change_id: Some(change_id),
1616 ops_applied: apply_result.ops_applied,
1617 summary,
1618 })
1619 } else {
1620 let ops_for_apply = params.ops.clone();
1621 let apply_result = tokio::task::spawn_blocking(move || {
1622 apply_structure_ops_to_file(&work_path, &ops_for_apply)
1623 })
1624 .await??;
1625
1626 let mut summary = apply_result.summary;
1627 summary.op_kinds = vec!["structure_batch".to_string()];
1628 summary.warnings.extend(alias_warnings);
1629
1630 if will_need_recalc {
1631 registry.with_fork_mut(¶ms.fork_id, |ctx| {
1632 ctx.recalc_needed = true;
1633 Ok(())
1634 })?;
1635 }
1636 set_recalc_needed_flag(&mut summary, will_need_recalc);
1637
1638 let fork_workbook_id = WorkbookId(params.fork_id.clone());
1639 let _ = state.close_workbook(&fork_workbook_id);
1640
1641 Ok(StructureBatchResponse {
1642 fork_id: params.fork_id,
1643 mode: mode.as_str().to_string(),
1644 change_id: None,
1645 ops_applied: apply_result.ops_applied,
1646 summary,
1647 })
1648 }
1649}
1650
1651struct StructureApplyResult {
1652 ops_applied: usize,
1653 summary: ChangeSummary,
1654}
1655
1656fn apply_structure_ops_to_file(path: &Path, ops: &[StructureOp]) -> Result<StructureApplyResult> {
1657 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
1658
1659 let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
1660 let affected_bounds: Vec<String> = Vec::new();
1661 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
1662 let mut warnings: Vec<String> = vec![
1663 "Structural edits may not fully rewrite formulas/named ranges like Excel. After apply, run recalculate and review get_changeset.".to_string(),
1664 ];
1665
1666 for op in ops {
1667 match op {
1668 StructureOp::InsertRows {
1669 sheet_name,
1670 at_row,
1671 count,
1672 } => {
1673 if *at_row == 0 || *count == 0 {
1674 bail!("insert_rows requires at_row>=1 and count>=1");
1675 }
1676 {
1677 let sheet = book
1678 .get_sheet_by_name_mut(sheet_name)
1679 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1680 sheet.insert_new_row(at_row, count);
1681 }
1682 rewrite_formulas_for_sheet_row_insert(&mut book, sheet_name, *at_row, *count)?;
1683 rewrite_defined_name_formulas_for_sheet_row_insert(
1684 &mut book, sheet_name, *at_row, *count,
1685 )?;
1686 affected_sheets.insert(sheet_name.clone());
1687 counts
1688 .entry("rows_inserted".to_string())
1689 .and_modify(|v| *v += *count as u64)
1690 .or_insert(*count as u64);
1691 }
1692 StructureOp::DeleteRows {
1693 sheet_name,
1694 start_row,
1695 count,
1696 } => {
1697 if *start_row == 0 || *count == 0 {
1698 bail!("delete_rows requires start_row>=1 and count>=1");
1699 }
1700 {
1701 let sheet = book
1702 .get_sheet_by_name_mut(sheet_name)
1703 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1704 sheet.remove_row(start_row, count);
1705 }
1706 rewrite_formulas_for_sheet_row_delete(&mut book, sheet_name, *start_row, *count)?;
1707 rewrite_defined_name_formulas_for_sheet_row_delete(
1708 &mut book, sheet_name, *start_row, *count,
1709 )?;
1710 affected_sheets.insert(sheet_name.clone());
1711 counts
1712 .entry("rows_deleted".to_string())
1713 .and_modify(|v| *v += *count as u64)
1714 .or_insert(*count as u64);
1715 }
1716 StructureOp::InsertCols {
1717 sheet_name,
1718 at_col,
1719 count,
1720 } => {
1721 if at_col.trim().is_empty() || *count == 0 {
1722 bail!("insert_cols requires at_col and count>=1");
1723 }
1724 let col_letters = normalize_col_letters(at_col)?;
1725 let root_col =
1726 umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
1727 {
1728 let sheet = book
1729 .get_sheet_by_name_mut(sheet_name)
1730 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1731 sheet.insert_new_column(&col_letters, count);
1732 }
1733 rewrite_formulas_for_sheet_col_insert(&mut book, sheet_name, root_col, *count)?;
1734 rewrite_defined_name_formulas_for_sheet_col_insert(
1735 &mut book, sheet_name, root_col, *count,
1736 )?;
1737 affected_sheets.insert(sheet_name.clone());
1738 counts
1739 .entry("cols_inserted".to_string())
1740 .and_modify(|v| *v += *count as u64)
1741 .or_insert(*count as u64);
1742 }
1743 StructureOp::DeleteCols {
1744 sheet_name,
1745 start_col,
1746 count,
1747 } => {
1748 if start_col.trim().is_empty() || *count == 0 {
1749 bail!("delete_cols requires start_col and count>=1");
1750 }
1751 let col_letters = normalize_col_letters(start_col)?;
1752 let root_col =
1753 umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
1754 {
1755 let sheet = book
1756 .get_sheet_by_name_mut(sheet_name)
1757 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1758 sheet.remove_column(&col_letters, count);
1759 }
1760 rewrite_formulas_for_sheet_col_delete(&mut book, sheet_name, root_col, *count)?;
1761 rewrite_defined_name_formulas_for_sheet_col_delete(
1762 &mut book, sheet_name, root_col, *count,
1763 )?;
1764 affected_sheets.insert(sheet_name.clone());
1765 counts
1766 .entry("cols_deleted".to_string())
1767 .and_modify(|v| *v += *count as u64)
1768 .or_insert(*count as u64);
1769 }
1770 StructureOp::RenameSheet { old_name, new_name } => {
1771 let old_name = old_name.trim();
1772 let new_name = new_name.trim();
1773 if old_name.is_empty() || new_name.is_empty() {
1774 bail!("rename_sheet requires non-empty old_name and new_name");
1775 }
1776
1777 let sheet_index = book
1778 .get_sheet_collection_no_check()
1779 .iter()
1780 .position(|s| s.get_name() == old_name)
1781 .ok_or_else(|| anyhow!("sheet '{}' not found", old_name))?;
1782 book.set_sheet_name(sheet_index, new_name.to_string())
1783 .map_err(|e| anyhow!("failed to rename sheet '{}': {}", old_name, e))?;
1784
1785 rewrite_formulas_for_sheet_rename(&mut book, old_name, new_name)?;
1786 rewrite_defined_name_formulas_for_sheet_rename(&mut book, old_name, new_name)?;
1787
1788 affected_sheets.insert(old_name.to_string());
1789 affected_sheets.insert(new_name.to_string());
1790 counts
1791 .entry("sheets_renamed".to_string())
1792 .and_modify(|v| *v += 1)
1793 .or_insert(1);
1794 }
1795 StructureOp::CreateSheet { name, position } => {
1796 let name_trimmed = name.trim();
1797 if name_trimmed.is_empty() {
1798 bail!("create_sheet requires non-empty name");
1799 }
1800 let requested_position = *position;
1801 book.new_sheet(name_trimmed.to_string())
1802 .map_err(|e| anyhow!("failed to create sheet '{}': {}", name_trimmed, e))?;
1803
1804 if let Some(pos) = requested_position {
1805 let desired = pos as usize;
1806 let len = book.get_sheet_collection_no_check().len();
1807 if desired >= len {
1808 warnings.push(format!(
1809 "create_sheet position {} is out of range (sheet_count {}). Appended at end.",
1810 desired, len
1811 ));
1812 } else if desired != len - 1 {
1813 let sheets = book.get_sheet_collection_mut();
1814 let created = sheets.remove(len - 1);
1815 sheets.insert(desired, created);
1816 }
1817 }
1818
1819 affected_sheets.insert(name_trimmed.to_string());
1820 counts
1821 .entry("sheets_created".to_string())
1822 .and_modify(|v| *v += 1)
1823 .or_insert(1);
1824 }
1825 StructureOp::DeleteSheet { name } => {
1826 let name_trimmed = name.trim();
1827 if name_trimmed.is_empty() {
1828 bail!("delete_sheet requires non-empty name");
1829 }
1830 if book.get_sheet_collection_no_check().len() <= 1 {
1831 bail!("cannot delete the last remaining sheet");
1832 }
1833 book.remove_sheet_by_name(name_trimmed)
1834 .map_err(|e| anyhow!("failed to delete sheet '{}': {}", name_trimmed, e))?;
1835 affected_sheets.insert(name_trimmed.to_string());
1836 counts
1837 .entry("sheets_deleted".to_string())
1838 .and_modify(|v| *v += 1)
1839 .or_insert(1);
1840 }
1841 StructureOp::CopyRange {
1842 sheet_name,
1843 dest_sheet_name,
1844 src_range,
1845 dest_anchor,
1846 include_styles,
1847 include_formulas,
1848 } => {
1849 let dest_sheet_name = dest_sheet_name.as_deref().unwrap_or(sheet_name);
1850 let result = copy_or_move_range(
1851 &mut book,
1852 sheet_name,
1853 dest_sheet_name,
1854 src_range,
1855 dest_anchor,
1856 *include_styles,
1857 *include_formulas,
1858 false,
1859 )?;
1860 affected_sheets.insert(sheet_name.clone());
1861 affected_sheets.insert(dest_sheet_name.to_string());
1862 counts
1863 .entry("cells_copied".to_string())
1864 .and_modify(|v| *v += result.cells_written)
1865 .or_insert(result.cells_written);
1866 counts
1867 .entry("ranges_copied".to_string())
1868 .and_modify(|v| *v += 1)
1869 .or_insert(1);
1870 warnings.extend(result.warnings);
1871 }
1872 StructureOp::MoveRange {
1873 sheet_name,
1874 dest_sheet_name,
1875 src_range,
1876 dest_anchor,
1877 include_styles,
1878 include_formulas,
1879 } => {
1880 let dest_sheet_name = dest_sheet_name.as_deref().unwrap_or(sheet_name);
1881 let result = copy_or_move_range(
1882 &mut book,
1883 sheet_name,
1884 dest_sheet_name,
1885 src_range,
1886 dest_anchor,
1887 *include_styles,
1888 *include_formulas,
1889 true,
1890 )?;
1891 affected_sheets.insert(sheet_name.clone());
1892 affected_sheets.insert(dest_sheet_name.to_string());
1893 counts
1894 .entry("cells_moved".to_string())
1895 .and_modify(|v| *v += result.cells_written)
1896 .or_insert(result.cells_written);
1897 counts
1898 .entry("ranges_moved".to_string())
1899 .and_modify(|v| *v += 1)
1900 .or_insert(1);
1901 warnings.extend(result.warnings);
1902 }
1903 }
1904 }
1905
1906 umya_spreadsheet::writer::xlsx::write(&book, path)?;
1907
1908 let summary = ChangeSummary {
1909 op_kinds: vec!["structure_batch".to_string()],
1910 affected_sheets: affected_sheets.into_iter().collect(),
1911 affected_bounds,
1912 counts,
1913 warnings,
1914 ..Default::default()
1915 };
1916
1917 Ok(StructureApplyResult {
1918 ops_applied: ops.len(),
1919 summary,
1920 })
1921}
1922
1923fn normalize_col_letters(col: &str) -> Result<String> {
1924 let letters = col.trim().to_ascii_uppercase();
1925 if letters.is_empty() || !letters.chars().all(|c| c.is_ascii_alphabetic()) {
1926 bail!("invalid column reference: {}", col);
1927 }
1928 Ok(letters)
1929}
1930
1931struct CopyMoveApplyResult {
1932 cells_written: u64,
1933 warnings: Vec<String>,
1934}
1935
1936#[allow(clippy::too_many_arguments)]
1937fn ranges_intersect(
1938 a_min_col: u32,
1939 a_min_row: u32,
1940 a_max_col: u32,
1941 a_max_row: u32,
1942 b_min_col: u32,
1943 b_min_row: u32,
1944 b_max_col: u32,
1945 b_max_row: u32,
1946) -> bool {
1947 !(a_max_col < b_min_col
1948 || b_max_col < a_min_col
1949 || a_max_row < b_min_row
1950 || b_max_row < a_min_row)
1951}
1952
1953#[allow(clippy::too_many_arguments)]
1954fn copy_or_move_range(
1955 book: &mut umya_spreadsheet::Spreadsheet,
1956 src_sheet_name: &str,
1957 dest_sheet_name: &str,
1958 src_range: &str,
1959 dest_anchor: &str,
1960 include_styles: bool,
1961 include_formulas: bool,
1962 clear_source: bool,
1963) -> Result<CopyMoveApplyResult> {
1964 let src_bounds = parse_range_bounds(src_range)?;
1965 let (dest_start_col, dest_start_row) = parse_cell_ref(dest_anchor)?;
1966
1967 let width = src_bounds.cols;
1968 let height = src_bounds.rows;
1969
1970 let dest_end_col = dest_start_col
1971 .checked_add(width.saturating_sub(1))
1972 .ok_or_else(|| anyhow!("destination range overflows column bounds"))?;
1973 let dest_end_row = dest_start_row
1974 .checked_add(height.saturating_sub(1))
1975 .ok_or_else(|| anyhow!("destination range overflows row bounds"))?;
1976
1977 let same_sheet = src_sheet_name == dest_sheet_name;
1978
1979 if same_sheet
1980 && ranges_intersect(
1981 src_bounds.min_col,
1982 src_bounds.min_row,
1983 src_bounds.max_col,
1984 src_bounds.max_row,
1985 dest_start_col,
1986 dest_start_row,
1987 dest_end_col,
1988 dest_end_row,
1989 )
1990 {
1991 let dest_range = if width == 1 && height == 1 {
1992 crate::utils::cell_address(dest_start_col, dest_start_row)
1993 } else {
1994 format!(
1995 "{}:{}",
1996 crate::utils::cell_address(dest_start_col, dest_start_row),
1997 crate::utils::cell_address(dest_end_col, dest_end_row)
1998 )
1999 };
2000 bail!(
2001 "copy/move destination overlaps source (src {}, dest {})",
2002 src_range,
2003 dest_range
2004 );
2005 }
2006
2007 let delta_col = dest_start_col as i32 - src_bounds.min_col as i32;
2008 let delta_row = dest_start_row as i32 - src_bounds.min_row as i32;
2009
2010 let mut warnings: Vec<String> = Vec::new();
2011 let mut formula_shift_failures: u64 = 0;
2012 let mut formula_value_copies: u64 = 0;
2013
2014 let (src_sheet_index, dest_sheet_index) = {
2015 let sheets = book.get_sheet_collection_no_check();
2016 let src = sheets
2017 .iter()
2018 .position(|s| s.get_name() == src_sheet_name)
2019 .ok_or_else(|| anyhow!("sheet '{}' not found", src_sheet_name))?;
2020 let dest = sheets
2021 .iter()
2022 .position(|s| s.get_name() == dest_sheet_name)
2023 .ok_or_else(|| anyhow!("sheet '{}' not found", dest_sheet_name))?;
2024 (src, dest)
2025 };
2026
2027 let sheets = book.get_sheet_collection_mut();
2028
2029 if src_sheet_index == dest_sheet_index {
2030 let sheet = &mut sheets[src_sheet_index];
2031
2032 for row in 0..height {
2033 for col in 0..width {
2034 let src_col = src_bounds.min_col + col;
2035 let src_row = src_bounds.min_row + row;
2036 let dest_col = dest_start_col + col;
2037 let dest_row = dest_start_row + row;
2038
2039 let Some(src_cell) = sheet.get_cell((src_col, src_row)) else {
2040 sheet.remove_cell((dest_col, dest_row));
2041 continue;
2042 };
2043
2044 let mut set_value = true;
2045 let mut dest_formula: Option<String> = None;
2046
2047 if include_formulas && src_cell.is_formula() {
2048 let src_formula = src_cell.get_formula().to_string();
2049 match parse_base_formula(&src_formula).and_then(|ast| {
2050 shift_formula_ast(&ast, delta_col, delta_row, RelativeMode::Excel)
2051 }) {
2052 Ok(shifted) => {
2053 let shifted = shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
2054 dest_formula = Some(shifted);
2055 set_value = false;
2056 }
2057 Err(_) => {
2058 dest_formula = Some(src_formula);
2059 set_value = false;
2060 formula_shift_failures += 1;
2061 }
2062 }
2063 } else if !include_formulas && src_cell.is_formula() {
2064 formula_value_copies += 1;
2065 }
2066
2067 let src_value = src_cell.get_value().to_string();
2068 let src_style = src_cell.get_style().clone();
2069
2070 let dest_cell = sheet.get_cell_mut((dest_col, dest_row));
2071 if include_styles {
2072 dest_cell.set_style(src_style);
2073 }
2074
2075 dest_cell.get_cell_value_mut().remove_formula();
2076 if let Some(formula) = dest_formula {
2077 dest_cell.set_formula(formula);
2078 dest_cell.set_formula_result_default("");
2079 }
2080 if set_value {
2081 dest_cell.set_value(src_value);
2082 }
2083 }
2084 }
2085
2086 if clear_source {
2087 for row in 0..height {
2088 for col in 0..width {
2089 let src_col = src_bounds.min_col + col;
2090 let src_row = src_bounds.min_row + row;
2091 sheet.remove_cell((src_col, src_row));
2092 }
2093 }
2094 }
2095 } else {
2096 let (src_sheet, dest_sheet) = if src_sheet_index < dest_sheet_index {
2097 let (left, right) = sheets.split_at_mut(dest_sheet_index);
2098 (&mut left[src_sheet_index], &mut right[0])
2099 } else {
2100 let (left, right) = sheets.split_at_mut(src_sheet_index);
2101 (&mut right[0], &mut left[dest_sheet_index])
2102 };
2103
2104 for row in 0..height {
2105 for col in 0..width {
2106 let src_col = src_bounds.min_col + col;
2107 let src_row = src_bounds.min_row + row;
2108 let dest_col = dest_start_col + col;
2109 let dest_row = dest_start_row + row;
2110
2111 let Some(src_cell) = src_sheet.get_cell((src_col, src_row)) else {
2112 dest_sheet.remove_cell((dest_col, dest_row));
2113 continue;
2114 };
2115
2116 let mut set_value = true;
2117 let mut dest_formula: Option<String> = None;
2118
2119 if include_formulas && src_cell.is_formula() {
2120 let src_formula = src_cell.get_formula().to_string();
2121 match parse_base_formula(&src_formula).and_then(|ast| {
2122 shift_formula_ast(&ast, delta_col, delta_row, RelativeMode::Excel)
2123 }) {
2124 Ok(shifted) => {
2125 let shifted = shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
2126 dest_formula = Some(shifted);
2127 set_value = false;
2128 }
2129 Err(_) => {
2130 dest_formula = Some(src_formula);
2131 set_value = false;
2132 formula_shift_failures += 1;
2133 }
2134 }
2135 } else if !include_formulas && src_cell.is_formula() {
2136 formula_value_copies += 1;
2137 }
2138
2139 let src_value = src_cell.get_value().to_string();
2140 let src_style = src_cell.get_style().clone();
2141
2142 let dest_cell = dest_sheet.get_cell_mut((dest_col, dest_row));
2143 if include_styles {
2144 dest_cell.set_style(src_style);
2145 }
2146
2147 dest_cell.get_cell_value_mut().remove_formula();
2148 if let Some(formula) = dest_formula {
2149 dest_cell.set_formula(formula);
2150 dest_cell.set_formula_result_default("");
2151 }
2152 if set_value {
2153 dest_cell.set_value(src_value);
2154 }
2155 }
2156 }
2157
2158 if clear_source {
2159 for row in 0..height {
2160 for col in 0..width {
2161 let src_col = src_bounds.min_col + col;
2162 let src_row = src_bounds.min_row + row;
2163 src_sheet.remove_cell((src_col, src_row));
2164 }
2165 }
2166 }
2167 }
2168
2169 if include_formulas && formula_shift_failures > 0 {
2170 warnings.push(format!(
2171 "Failed to shift {} formula(s); copied original formula text.",
2172 formula_shift_failures
2173 ));
2174 }
2175 if !include_formulas && formula_value_copies > 0 {
2176 warnings.push(format!(
2177 "Copied cached values for {} formula cell(s) (include_formulas=false); run recalculate for fresh results.",
2178 formula_value_copies
2179 ));
2180 }
2181
2182 Ok(CopyMoveApplyResult {
2183 cells_written: width as u64 * height as u64,
2184 warnings,
2185 })
2186}
2187
2188fn rewrite_formulas_for_sheet_rename(
2189 book: &mut umya_spreadsheet::Spreadsheet,
2190 old_name: &str,
2191 new_name: &str,
2192) -> Result<()> {
2193 let new_prefix = format_sheet_prefix_for_formula(new_name);
2194
2195 for sheet in book.get_sheet_collection_mut().iter_mut() {
2196 for cell in sheet.get_cell_collection_mut() {
2197 if !cell.is_formula() {
2198 continue;
2199 }
2200 let formula_text = cell.get_formula();
2201 if formula_text.is_empty() {
2202 continue;
2203 }
2204 let formula_with_equals = if formula_text.starts_with('=') {
2205 formula_text.to_string()
2206 } else {
2207 format!("={}", formula_text)
2208 };
2209
2210 let tokenizer = Tokenizer::new(&formula_with_equals)
2211 .map_err(|e| anyhow!("failed to tokenize formula: {}", e.message))?;
2212
2213 let tokens = tokenizer.items;
2214 let mut out = String::with_capacity(formula_with_equals.len());
2215 let mut cursor = 0usize;
2216
2217 for token in &tokens {
2218 if token.start > cursor {
2219 out.push_str(&formula_with_equals[cursor..token.start]);
2220 }
2221
2222 let mut value = token.value.clone();
2223 if token.subtype == formualizer_parse::TokenSubType::Range
2224 && value.contains('!')
2225 && let Some((sheet_part, tail)) = value.split_once('!')
2226 && sheet_part_matches(sheet_part, old_name)
2227 {
2228 value = format!("{}{}", new_prefix, tail);
2229 }
2230
2231 out.push_str(&value);
2232 cursor = token.end;
2233 }
2234
2235 if cursor < formula_with_equals.len() {
2236 out.push_str(&formula_with_equals[cursor..]);
2237 }
2238
2239 let new_formula = out.strip_prefix('=').unwrap_or(&out);
2240 cell.set_formula(new_formula.to_string());
2241 }
2242 }
2243
2244 Ok(())
2245}
2246
2247fn rewrite_defined_name_formulas_for_sheet_rename(
2248 book: &mut umya_spreadsheet::Spreadsheet,
2249 old_name: &str,
2250 new_name: &str,
2251) -> Result<()> {
2252 let new_prefix = format_sheet_prefix_for_formula(new_name);
2253
2254 for defined in book.get_defined_names_mut() {
2255 let refers_to = defined.get_address();
2256 let trimmed = refers_to.trim();
2257 let had_equals = trimmed.starts_with('=');
2258 let looks_like_formula = had_equals || trimmed.contains('(');
2259 if !looks_like_formula {
2260 continue;
2261 }
2262
2263 let formula_in = if had_equals {
2264 trimmed.to_string()
2265 } else {
2266 format!("={}", trimmed)
2267 };
2268
2269 let tokenizer = Tokenizer::new(&formula_in)
2270 .map_err(|e| anyhow!("failed to tokenize formula: {}", e.message))?;
2271 let tokens = tokenizer.items;
2272
2273 let mut out = String::with_capacity(formula_in.len());
2274 let mut cursor = 0usize;
2275 let mut changed = false;
2276
2277 for token in &tokens {
2278 if token.start > cursor {
2279 out.push_str(&formula_in[cursor..token.start]);
2280 }
2281
2282 let mut value = token.value.clone();
2283 if token.subtype == formualizer_parse::TokenSubType::Range
2284 && value.contains('!')
2285 && let Some((sheet_part, tail)) = value.split_once('!')
2286 && sheet_part_matches(sheet_part, old_name)
2287 {
2288 value = format!("{}{}", new_prefix, tail);
2289 changed = true;
2290 }
2291
2292 out.push_str(&value);
2293 cursor = token.end;
2294 }
2295
2296 if cursor < formula_in.len() {
2297 out.push_str(&formula_in[cursor..]);
2298 }
2299
2300 if changed {
2301 let out_final = if had_equals {
2302 out
2303 } else {
2304 out.strip_prefix('=').unwrap_or(&out).to_string()
2305 };
2306 defined.set_address(out_final);
2307 }
2308 }
2309
2310 Ok(())
2311}
2312
2313fn rewrite_defined_name_formulas_for_sheet_col_insert(
2314 book: &mut umya_spreadsheet::Spreadsheet,
2315 sheet_name: &str,
2316 at_col: u32,
2317 count: u32,
2318) -> Result<()> {
2319 rewrite_defined_name_formulas_for_sheet_structure_change(
2320 book,
2321 sheet_name,
2322 StructureAxis::Col,
2323 StructureEdit::Insert { at: at_col, count },
2324 )
2325}
2326
2327fn rewrite_defined_name_formulas_for_sheet_col_delete(
2328 book: &mut umya_spreadsheet::Spreadsheet,
2329 sheet_name: &str,
2330 start_col: u32,
2331 count: u32,
2332) -> Result<()> {
2333 rewrite_defined_name_formulas_for_sheet_structure_change(
2334 book,
2335 sheet_name,
2336 StructureAxis::Col,
2337 StructureEdit::Delete {
2338 start: start_col,
2339 count,
2340 },
2341 )
2342}
2343
2344fn rewrite_defined_name_formulas_for_sheet_row_insert(
2345 book: &mut umya_spreadsheet::Spreadsheet,
2346 sheet_name: &str,
2347 at_row: u32,
2348 count: u32,
2349) -> Result<()> {
2350 rewrite_defined_name_formulas_for_sheet_structure_change(
2351 book,
2352 sheet_name,
2353 StructureAxis::Row,
2354 StructureEdit::Insert { at: at_row, count },
2355 )
2356}
2357
2358fn rewrite_defined_name_formulas_for_sheet_row_delete(
2359 book: &mut umya_spreadsheet::Spreadsheet,
2360 sheet_name: &str,
2361 start_row: u32,
2362 count: u32,
2363) -> Result<()> {
2364 rewrite_defined_name_formulas_for_sheet_structure_change(
2365 book,
2366 sheet_name,
2367 StructureAxis::Row,
2368 StructureEdit::Delete {
2369 start: start_row,
2370 count,
2371 },
2372 )
2373}
2374
2375fn rewrite_defined_name_formulas_for_sheet_structure_change(
2376 book: &mut umya_spreadsheet::Spreadsheet,
2377 sheet_name: &str,
2378 axis: StructureAxis,
2379 edit: StructureEdit,
2380) -> Result<()> {
2381 for defined in book.get_defined_names_mut() {
2382 let refers_to = defined.get_address();
2383 let trimmed = refers_to.trim();
2384 let had_equals = trimmed.starts_with('=');
2385 let looks_like_formula = had_equals || trimmed.contains('(');
2386 if !looks_like_formula {
2387 continue;
2388 }
2389
2390 let formula_in = if had_equals {
2391 trimmed.to_string()
2392 } else {
2393 format!("={}", trimmed)
2394 };
2395
2396 let tokenizer = Tokenizer::new(&formula_in)
2397 .map_err(|e| anyhow!("failed to tokenize formula: {}", e.message))?;
2398 let tokens = tokenizer.items;
2399
2400 let mut out = String::with_capacity(formula_in.len());
2401 let mut cursor = 0usize;
2402 let mut changed = false;
2403
2404 for token in &tokens {
2405 if token.start > cursor {
2406 out.push_str(&formula_in[cursor..token.start]);
2407 }
2408
2409 let mut value = token.value.clone();
2410 if token.subtype == formualizer_parse::TokenSubType::Range
2411 && value.contains('!')
2412 && let Some((sheet_part, coord_part)) = value.split_once('!')
2413 && sheet_part_matches(sheet_part, sheet_name)
2414 {
2415 let adjusted = adjust_ref_coord_part(coord_part, axis, edit)?;
2416 value = format!("{sheet_part}!{adjusted}");
2417 changed = true;
2418 }
2419
2420 out.push_str(&value);
2421 cursor = token.end;
2422 }
2423
2424 if cursor < formula_in.len() {
2425 out.push_str(&formula_in[cursor..]);
2426 }
2427
2428 if changed {
2429 let out_final = if had_equals {
2430 out
2431 } else {
2432 out.strip_prefix('=').unwrap_or(&out).to_string()
2433 };
2434 defined.set_address(out_final);
2435 }
2436 }
2437
2438 Ok(())
2439}
2440
2441fn rewrite_formulas_for_sheet_col_insert(
2442 book: &mut umya_spreadsheet::Spreadsheet,
2443 sheet_name: &str,
2444 at_col: u32,
2445 count: u32,
2446) -> Result<()> {
2447 rewrite_formulas_for_sheet_structure_change(
2448 book,
2449 sheet_name,
2450 StructureAxis::Col,
2451 StructureEdit::Insert { at: at_col, count },
2452 )
2453}
2454
2455fn rewrite_formulas_for_sheet_col_delete(
2456 book: &mut umya_spreadsheet::Spreadsheet,
2457 sheet_name: &str,
2458 start_col: u32,
2459 count: u32,
2460) -> Result<()> {
2461 rewrite_formulas_for_sheet_structure_change(
2462 book,
2463 sheet_name,
2464 StructureAxis::Col,
2465 StructureEdit::Delete {
2466 start: start_col,
2467 count,
2468 },
2469 )
2470}
2471
2472fn rewrite_formulas_for_sheet_row_insert(
2473 book: &mut umya_spreadsheet::Spreadsheet,
2474 sheet_name: &str,
2475 at_row: u32,
2476 count: u32,
2477) -> Result<()> {
2478 rewrite_formulas_for_sheet_structure_change(
2479 book,
2480 sheet_name,
2481 StructureAxis::Row,
2482 StructureEdit::Insert { at: at_row, count },
2483 )
2484}
2485
2486fn rewrite_formulas_for_sheet_row_delete(
2487 book: &mut umya_spreadsheet::Spreadsheet,
2488 sheet_name: &str,
2489 start_row: u32,
2490 count: u32,
2491) -> Result<()> {
2492 rewrite_formulas_for_sheet_structure_change(
2493 book,
2494 sheet_name,
2495 StructureAxis::Row,
2496 StructureEdit::Delete {
2497 start: start_row,
2498 count,
2499 },
2500 )
2501}
2502
2503#[derive(Debug, Clone, Copy)]
2504enum StructureAxis {
2505 Row,
2506 Col,
2507}
2508
2509#[derive(Debug, Clone, Copy)]
2510enum StructureEdit {
2511 Insert { at: u32, count: u32 },
2512 Delete { start: u32, count: u32 },
2513}
2514
2515fn rewrite_formulas_for_sheet_structure_change(
2516 book: &mut umya_spreadsheet::Spreadsheet,
2517 sheet_name: &str,
2518 axis: StructureAxis,
2519 edit: StructureEdit,
2520) -> Result<()> {
2521 for sheet in book.get_sheet_collection_mut().iter_mut() {
2522 if sheet.get_name() == sheet_name {
2523 continue;
2524 }
2525 for cell in sheet.get_cell_collection_mut() {
2526 if !cell.is_formula() {
2527 continue;
2528 }
2529 let formula_text = cell.get_formula();
2530 if formula_text.is_empty() {
2531 continue;
2532 }
2533 let formula_with_equals = if formula_text.starts_with('=') {
2534 formula_text.to_string()
2535 } else {
2536 format!("={}", formula_text)
2537 };
2538 let tokenizer = Tokenizer::new(&formula_with_equals)
2539 .map_err(|e| anyhow!("failed to tokenize formula: {}", e.message))?;
2540 let tokens = tokenizer.items;
2541
2542 let mut out = String::with_capacity(formula_with_equals.len());
2543 let mut cursor = 0usize;
2544 let mut changed = false;
2545
2546 for token in &tokens {
2547 if token.start > cursor {
2548 out.push_str(&formula_with_equals[cursor..token.start]);
2549 }
2550
2551 let mut value = token.value.clone();
2552 if token.subtype == formualizer_parse::TokenSubType::Range
2553 && value.contains('!')
2554 && let Some((sheet_part, coord_part)) = value.split_once('!')
2555 && sheet_part_matches(sheet_part, sheet_name)
2556 {
2557 let adjusted = adjust_ref_coord_part(coord_part, axis, edit)?;
2558 value = format!("{sheet_part}!{adjusted}");
2559 changed = true;
2560 }
2561
2562 out.push_str(&value);
2563 cursor = token.end;
2564 }
2565
2566 if cursor < formula_with_equals.len() {
2567 out.push_str(&formula_with_equals[cursor..]);
2568 }
2569
2570 if changed {
2571 let new_formula = out.strip_prefix('=').unwrap_or(&out);
2572 cell.set_formula(new_formula.to_string());
2573 }
2574 }
2575 }
2576 Ok(())
2577}
2578
2579fn adjust_ref_coord_part(
2580 coord_part: &str,
2581 axis: StructureAxis,
2582 edit: StructureEdit,
2583) -> Result<String> {
2584 if coord_part == "#REF!" {
2585 return Ok(coord_part.to_string());
2586 }
2587 if let Some((start, end)) = coord_part.split_once(':') {
2588 let start_adj = adjust_ref_segment(start, axis, edit)?;
2589 let end_adj = adjust_ref_segment(end, axis, edit)?;
2590 if start_adj == "#REF!" || end_adj == "#REF!" {
2591 return Ok("#REF!".to_string());
2592 }
2593 Ok(format!("{start_adj}:{end_adj}"))
2594 } else {
2595 Ok(adjust_ref_segment(coord_part, axis, edit)?)
2596 }
2597}
2598
2599fn adjust_ref_segment(segment: &str, axis: StructureAxis, edit: StructureEdit) -> Result<String> {
2600 use umya_spreadsheet::helper::coordinate::{
2601 coordinate_from_index_with_lock, index_from_coordinate, string_from_column_index,
2602 };
2603
2604 let (col, row, col_lock, row_lock) = index_from_coordinate(segment);
2605 let mut col = col;
2606 let mut row = row;
2607
2608 match axis {
2609 StructureAxis::Col => {
2610 if let Some(c) = col {
2611 col = match edit {
2612 StructureEdit::Insert { at, count } => Some(adjust_insert(c, at, count)),
2613 StructureEdit::Delete { start, count } => adjust_delete(c, start, count),
2614 };
2615 }
2616 }
2617 StructureAxis::Row => {
2618 if let Some(r) = row {
2619 row = match edit {
2620 StructureEdit::Insert { at, count } => Some(adjust_insert(r, at, count)),
2621 StructureEdit::Delete { start, count } => adjust_delete(r, start, count),
2622 };
2623 }
2624 }
2625 }
2626
2627 if col.is_none() && row.is_none() {
2628 return Ok("#REF!".to_string());
2629 }
2630
2631 match (col, row) {
2632 (Some(c), Some(r)) => Ok(coordinate_from_index_with_lock(
2633 &c,
2634 &r,
2635 &col_lock.unwrap_or(false),
2636 &row_lock.unwrap_or(false),
2637 )),
2638 (Some(c), None) => {
2639 let col_str = string_from_column_index(&c);
2640 Ok(format!(
2641 "{}{}",
2642 if col_lock.unwrap_or(false) { "$" } else { "" },
2643 col_str
2644 ))
2645 }
2646 (None, Some(r)) => Ok(format!(
2647 "{}{}",
2648 if row_lock.unwrap_or(false) { "$" } else { "" },
2649 r
2650 )),
2651 (None, None) => Ok("#REF!".to_string()),
2652 }
2653}
2654
2655fn adjust_insert(value: u32, at: u32, count: u32) -> u32 {
2656 if value >= at { value + count } else { value }
2657}
2658
2659fn adjust_delete(value: u32, start: u32, count: u32) -> Option<u32> {
2660 let end = start.saturating_add(count.saturating_sub(1));
2661 if value >= start && value <= end {
2662 None
2663 } else if value > end {
2664 Some(value - count)
2665 } else {
2666 Some(value)
2667 }
2668}
2669
2670fn sheet_part_matches(sheet_part: &str, old_name: &str) -> bool {
2671 let trimmed = sheet_part.trim();
2672 if let Some(stripped) = trimmed.strip_prefix('\'')
2673 && let Some(inner) = stripped.strip_suffix('\'')
2674 {
2675 return inner.replace("''", "'") == old_name;
2676 }
2677 trimmed == old_name
2678}
2679
2680fn format_sheet_prefix_for_formula(sheet_name: &str) -> String {
2681 if sheet_name_needs_quoting_for_formula(sheet_name) {
2682 let escaped = sheet_name.replace('\'', "''");
2683 format!("'{escaped}'!")
2684 } else {
2685 format!("{sheet_name}!")
2686 }
2687}
2688
2689fn sheet_name_needs_quoting_for_formula(name: &str) -> bool {
2690 if name.is_empty() {
2691 return false;
2692 }
2693 let bytes = name.as_bytes();
2694 if bytes[0].is_ascii_digit() {
2695 return true;
2696 }
2697 for &byte in bytes {
2698 match byte {
2699 b' ' | b'!' | b'"' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+'
2700 | b',' | b'-' | b'.' | b'/' | b':' | b';' | b'<' | b'=' | b'>' | b'?' | b'@' | b'['
2701 | b'\\' | b']' | b'^' | b'`' | b'{' | b'|' | b'}' | b'~' => return true,
2702 _ => {}
2703 }
2704 }
2705 let upper = name.to_uppercase();
2706 matches!(
2707 upper.as_str(),
2708 "TRUE" | "FALSE" | "NULL" | "REF" | "DIV" | "NAME" | "NUM" | "VALUE" | "N/A"
2709 )
2710}
2711
2712struct StyleApplyResult {
2713 ops_applied: usize,
2714 summary: ChangeSummary,
2715}
2716
2717pub(crate) fn stage_snapshot_path(fork_id: &str, change_id: &str) -> PathBuf {
2718 PathBuf::from("/tmp/mcp-staged").join(format!("{fork_id}_{change_id}.xlsx"))
2719}
2720
2721struct ColumnSizeApplyResult {
2722 ops_applied: usize,
2723 summary: ChangeSummary,
2724}
2725
2726fn parse_column_span(spec: &str) -> Result<(u32, u32)> {
2727 let raw = spec.trim();
2728 if raw.is_empty() {
2729 return Err(anyhow!("column range is empty"));
2730 }
2731
2732 let raw = raw.replace(' ', "");
2733 let (start, end) = if let Some((a, b)) = raw.split_once(':') {
2734 (a, b)
2735 } else if let Some((a, b)) = raw.split_once('-') {
2736 (a, b)
2737 } else {
2738 (raw.as_str(), raw.as_str())
2739 };
2740
2741 let start_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(start);
2742 let end_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(end);
2743 if start_idx == 0 || end_idx == 0 {
2744 return Err(anyhow!("invalid column span '{spec}'"));
2745 }
2746 let (min, max) = if start_idx <= end_idx {
2747 (start_idx, end_idx)
2748 } else {
2749 (end_idx, start_idx)
2750 };
2751 Ok((min, max))
2752}
2753
2754fn apply_column_size_ops_to_file(
2755 path: &Path,
2756 sheet_name: &str,
2757 ops: &[ColumnSizeOp],
2758) -> Result<ColumnSizeApplyResult> {
2759 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
2760 let sheet = book
2761 .get_sheet_by_name_mut(sheet_name)
2762 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2763
2764 let mut affected_bounds: Vec<String> = Vec::new();
2765 let mut warnings: Vec<String> = Vec::new();
2766
2767 let mut columns_sized: u64 = 0;
2768 let mut auto_ops: u64 = 0;
2769 let mut width_ops: u64 = 0;
2770
2771 for op in ops {
2772 let ColumnTarget::Columns { range } = &op.target;
2773 let (start_col, end_col) = parse_column_span(range)?;
2774 affected_bounds.push(range.clone());
2775
2776 match &op.size {
2777 ColumnSizeSpec::Width { width_chars } => {
2778 width_ops += 1;
2779 for col in start_col..=end_col {
2780 let col_dim = sheet.get_column_dimension_by_number_mut(&col);
2781 col_dim.set_width(*width_chars);
2782 col_dim.set_best_fit(false);
2783 col_dim.set_auto_width(false);
2784 columns_sized += 1;
2785 }
2786 }
2787 ColumnSizeSpec::Auto {
2788 min_width_chars,
2789 max_width_chars,
2790 } => {
2791 auto_ops += 1;
2792
2793 let mut saw_formula_without_cached = false;
2794 for cell in sheet.get_cell_collection() {
2795 let col_num = *cell.get_coordinate().get_col_num();
2796 if col_num < start_col || col_num > end_col {
2797 continue;
2798 }
2799 if cell.is_formula() && cell.get_value().is_empty() {
2800 saw_formula_without_cached = true;
2801 break;
2802 }
2803 }
2804 if saw_formula_without_cached {
2805 warnings.push(
2806 "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."
2807 .to_string(),
2808 );
2809 }
2810
2811 for col in start_col..=end_col {
2812 sheet
2813 .get_column_dimension_by_number_mut(&col)
2814 .set_auto_width(true);
2815 }
2816 sheet.calculation_auto_width();
2817
2818 for col in start_col..=end_col {
2819 let col_dim = sheet.get_column_dimension_by_number_mut(&col);
2820 col_dim.set_auto_width(false);
2821 col_dim.set_best_fit(true);
2822
2823 let mut width = *col_dim.get_width();
2824 if let Some(min_width) = min_width_chars
2825 && width < *min_width
2826 {
2827 width = *min_width;
2828 }
2829 if let Some(max_width) = max_width_chars
2830 && width > *max_width
2831 {
2832 width = *max_width;
2833 }
2834 col_dim.set_width(width);
2835 columns_sized += 1;
2836 }
2837 }
2838 }
2839 }
2840
2841 umya_spreadsheet::writer::xlsx::write(&book, path)?;
2842
2843 let mut counts = BTreeMap::new();
2844 counts.insert("columns_sized".to_string(), columns_sized);
2845 counts.insert("auto_ops".to_string(), auto_ops);
2846 counts.insert("width_ops".to_string(), width_ops);
2847
2848 Ok(ColumnSizeApplyResult {
2849 ops_applied: ops.len(),
2850 summary: ChangeSummary {
2851 op_kinds: vec!["column_size_batch".to_string()],
2852 affected_sheets: vec![sheet_name.to_string()],
2853 affected_bounds,
2854 counts,
2855 warnings,
2856 ..Default::default()
2857 },
2858 })
2859}
2860
2861struct TransformApplyResult {
2862 ops_applied: usize,
2863 summary: ChangeSummary,
2864}
2865
2866fn apply_transform_ops_to_file(path: &Path, ops: &[TransformOp]) -> Result<TransformApplyResult> {
2867 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
2868
2869 let mut sheets: BTreeSet<String> = BTreeSet::new();
2870 let mut affected_bounds: Vec<String> = Vec::new();
2871
2872 let mut cells_touched: u64 = 0;
2873 let mut cells_value_cleared: u64 = 0;
2874 let mut cells_formula_cleared: u64 = 0;
2875 let mut cells_skipped_keep_formulas: u64 = 0;
2876
2877 let mut cells_value_set: u64 = 0;
2878 let mut cells_formula_set: u64 = 0;
2879 let mut cells_value_replaced: u64 = 0;
2880 let mut cells_formula_replaced: u64 = 0;
2881
2882 for op in ops {
2883 match op {
2884 TransformOp::ClearRange {
2885 sheet_name,
2886 target,
2887 clear_values,
2888 clear_formulas,
2889 } => {
2890 let sheet = book
2891 .get_sheet_by_name_mut(sheet_name)
2892 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2893 sheets.insert(sheet_name.clone());
2894
2895 match target {
2896 TransformTarget::Range { range } => {
2897 let bounds = parse_range_bounds(range)?;
2898 affected_bounds.push(range.clone());
2899
2900 for row in bounds.min_row..=bounds.max_row {
2901 for col in bounds.min_col..=bounds.max_col {
2902 let exists = sheet.get_cell((col, row)).is_some();
2903 if !exists {
2904 continue;
2905 }
2906
2907 let cell = sheet.get_cell_mut((col, row));
2908 let was_formula = cell.is_formula();
2909 cells_touched += 1;
2910
2911 if *clear_formulas && was_formula {
2912 cell.set_formula(String::new());
2913 cells_formula_cleared += 1;
2914 }
2915
2916 if *clear_values {
2917 if was_formula && !*clear_formulas {
2918 cells_skipped_keep_formulas += 1;
2919 } else {
2920 if !cell.get_value().is_empty() {
2921 cells_value_cleared += 1;
2922 }
2923 cell.set_value(String::new());
2924 }
2925 }
2926 }
2927 }
2928 }
2929 TransformTarget::Cells { cells } => {
2930 affected_bounds.extend(cells.iter().cloned());
2931 for addr in cells {
2932 let exists = sheet.get_cell(addr.as_str()).is_some();
2933 if !exists {
2934 continue;
2935 }
2936
2937 let cell = sheet.get_cell_mut(addr.as_str());
2938 let was_formula = cell.is_formula();
2939 cells_touched += 1;
2940
2941 if *clear_formulas && was_formula {
2942 cell.set_formula(String::new());
2943 cells_formula_cleared += 1;
2944 }
2945
2946 if *clear_values {
2947 if was_formula && !*clear_formulas {
2948 cells_skipped_keep_formulas += 1;
2949 } else {
2950 if !cell.get_value().is_empty() {
2951 cells_value_cleared += 1;
2952 }
2953 cell.set_value(String::new());
2954 }
2955 }
2956 }
2957 }
2958 TransformTarget::Region { .. } => {
2959 return Err(anyhow!(
2960 "region_id targets must be resolved before apply_transform_ops_to_file"
2961 ));
2962 }
2963 }
2964 }
2965 TransformOp::FillRange {
2966 sheet_name,
2967 target,
2968 value,
2969 is_formula,
2970 overwrite_formulas,
2971 } => {
2972 let sheet = book
2973 .get_sheet_by_name_mut(sheet_name)
2974 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2975 sheets.insert(sheet_name.clone());
2976
2977 match target {
2978 TransformTarget::Range { range } => {
2979 let bounds = parse_range_bounds(range)?;
2980 affected_bounds.push(range.clone());
2981
2982 for row in bounds.min_row..=bounds.max_row {
2983 for col in bounds.min_col..=bounds.max_col {
2984 let cell = sheet.get_cell_mut((col, row));
2985 cells_touched += 1;
2986
2987 if !*is_formula && cell.is_formula() {
2988 if !*overwrite_formulas {
2989 cells_skipped_keep_formulas += 1;
2990 continue;
2991 }
2992 cell.set_formula(String::new());
2993 cells_formula_cleared += 1;
2994 }
2995
2996 if *is_formula {
2997 cell.set_formula(value.clone());
2998 cells_formula_set += 1;
2999 } else {
3000 cell.set_value(value.clone());
3001 cells_value_set += 1;
3002 }
3003 }
3004 }
3005 }
3006 TransformTarget::Cells { cells } => {
3007 affected_bounds.extend(cells.iter().cloned());
3008 for addr in cells {
3009 let cell = sheet.get_cell_mut(addr.as_str());
3010 cells_touched += 1;
3011
3012 if !*is_formula && cell.is_formula() {
3013 if !*overwrite_formulas {
3014 cells_skipped_keep_formulas += 1;
3015 continue;
3016 }
3017 cell.set_formula(String::new());
3018 cells_formula_cleared += 1;
3019 }
3020
3021 if *is_formula {
3022 cell.set_formula(value.clone());
3023 cells_formula_set += 1;
3024 } else {
3025 cell.set_value(value.clone());
3026 cells_value_set += 1;
3027 }
3028 }
3029 }
3030 TransformTarget::Region { .. } => {
3031 return Err(anyhow!(
3032 "region_id targets must be resolved before apply_transform_ops_to_file"
3033 ));
3034 }
3035 }
3036 }
3037 TransformOp::ReplaceInRange {
3038 sheet_name,
3039 target,
3040 find,
3041 replace,
3042 match_mode,
3043 case_sensitive,
3044 include_formulas,
3045 } => {
3046 let sheet = book
3047 .get_sheet_by_name_mut(sheet_name)
3048 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
3049 sheets.insert(sheet_name.clone());
3050
3051 if *match_mode == ReplaceMatchMode::Contains && !*case_sensitive {
3052 return Err(anyhow!(
3053 "match_mode 'contains' requires case_sensitive=true"
3054 ));
3055 }
3056
3057 let replace_value = |input: &str| -> Option<String> {
3058 if *match_mode == ReplaceMatchMode::Exact {
3059 if *case_sensitive {
3060 (input == find).then(|| replace.clone())
3061 } else {
3062 input.eq_ignore_ascii_case(find).then(|| replace.clone())
3063 }
3064 } else if input.contains(find) {
3065 Some(input.replace(find, replace))
3066 } else {
3067 None
3068 }
3069 };
3070
3071 match target {
3072 TransformTarget::Range { range } => {
3073 let bounds = parse_range_bounds(range)?;
3074 affected_bounds.push(range.clone());
3075
3076 for row in bounds.min_row..=bounds.max_row {
3077 for col in bounds.min_col..=bounds.max_col {
3078 let exists = sheet.get_cell((col, row)).is_some();
3079 if !exists {
3080 continue;
3081 }
3082
3083 let cell = sheet.get_cell_mut((col, row));
3084 cells_touched += 1;
3085
3086 if cell.is_formula() {
3087 if !*include_formulas {
3088 cells_skipped_keep_formulas += 1;
3089 continue;
3090 }
3091
3092 let formula = cell.get_formula().to_string();
3093 if formula.is_empty() {
3094 continue;
3095 }
3096 if let Some(next) = replace_value(&formula) {
3097 cell.set_formula(next);
3098 cells_formula_replaced += 1;
3099 }
3100 continue;
3101 }
3102
3103 let value = cell.get_value().to_string();
3104 if value.is_empty() {
3105 continue;
3106 }
3107 if let Some(next) = replace_value(&value) {
3108 cell.set_value(next);
3109 cells_value_replaced += 1;
3110 }
3111 }
3112 }
3113 }
3114 TransformTarget::Cells { cells } => {
3115 affected_bounds.extend(cells.iter().cloned());
3116 for addr in cells {
3117 let exists = sheet.get_cell(addr.as_str()).is_some();
3118 if !exists {
3119 continue;
3120 }
3121
3122 let cell = sheet.get_cell_mut(addr.as_str());
3123 cells_touched += 1;
3124
3125 if cell.is_formula() {
3126 if !*include_formulas {
3127 cells_skipped_keep_formulas += 1;
3128 continue;
3129 }
3130
3131 let formula = cell.get_formula().to_string();
3132 if formula.is_empty() {
3133 continue;
3134 }
3135 if let Some(next) = replace_value(&formula) {
3136 cell.set_formula(next);
3137 cells_formula_replaced += 1;
3138 }
3139 continue;
3140 }
3141
3142 let value = cell.get_value().to_string();
3143 if value.is_empty() {
3144 continue;
3145 }
3146 if let Some(next) = replace_value(&value) {
3147 cell.set_value(next);
3148 cells_value_replaced += 1;
3149 }
3150 }
3151 }
3152 TransformTarget::Region { .. } => {
3153 return Err(anyhow!(
3154 "region_id targets must be resolved before apply_transform_ops_to_file"
3155 ));
3156 }
3157 }
3158 }
3159 }
3160 }
3161
3162 umya_spreadsheet::writer::xlsx::write(&book, path)?;
3163
3164 let mut counts = BTreeMap::new();
3165 counts.insert("cells_touched".to_string(), cells_touched);
3166 counts.insert("cells_value_cleared".to_string(), cells_value_cleared);
3167 counts.insert("cells_formula_cleared".to_string(), cells_formula_cleared);
3168 counts.insert(
3169 "cells_skipped_keep_formulas".to_string(),
3170 cells_skipped_keep_formulas,
3171 );
3172
3173 counts.insert("cells_value_set".to_string(), cells_value_set);
3174 counts.insert("cells_formula_set".to_string(), cells_formula_set);
3175 counts.insert("cells_value_replaced".to_string(), cells_value_replaced);
3176 counts.insert("cells_formula_replaced".to_string(), cells_formula_replaced);
3177
3178 let summary = ChangeSummary {
3179 op_kinds: vec!["transform_batch".to_string()],
3180 affected_sheets: sheets.into_iter().collect(),
3181 affected_bounds,
3182 counts,
3183 warnings: Vec::new(),
3184 ..Default::default()
3185 };
3186
3187 Ok(TransformApplyResult {
3188 ops_applied: ops.len(),
3189 summary,
3190 })
3191}
3192
3193fn apply_style_ops_to_file(path: &Path, ops: &[StyleOp]) -> Result<StyleApplyResult> {
3194 use crate::styles::{
3195 StylePatchMode, apply_style_patch, descriptor_from_style, stable_style_id,
3196 };
3197
3198 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
3199
3200 let mut sheets: BTreeSet<String> = BTreeSet::new();
3201 let mut affected_bounds: Vec<String> = Vec::new();
3202 let mut cells_touched: u64 = 0;
3203 let mut cells_style_changed: u64 = 0;
3204
3205 for op in ops {
3206 let sheet = book
3207 .get_sheet_by_name_mut(&op.sheet_name)
3208 .ok_or_else(|| anyhow!("sheet '{}' not found", op.sheet_name))?;
3209 sheets.insert(op.sheet_name.clone());
3210
3211 let op_mode = op.op_mode.unwrap_or(StylePatchMode::Merge);
3212
3213 match &op.target {
3214 StyleTarget::Range { range } => {
3215 let bounds = parse_range_bounds(range)?;
3216 affected_bounds.push(range.clone());
3217 for row in bounds.min_row..=bounds.max_row {
3218 for col in bounds.min_col..=bounds.max_col {
3219 let addr = crate::utils::cell_address(col, row);
3220 let cell = sheet.get_cell_mut(addr.as_str());
3221 let before = stable_style_id(&descriptor_from_style(cell.get_style()));
3222 let next_style = apply_style_patch(cell.get_style(), &op.patch, op_mode);
3223 cell.set_style(next_style);
3224 let after = stable_style_id(&descriptor_from_style(cell.get_style()));
3225 cells_touched += 1;
3226 if before != after {
3227 cells_style_changed += 1;
3228 }
3229 }
3230 }
3231 }
3232 StyleTarget::Cells { cells } => {
3233 affected_bounds.extend(cells.iter().cloned());
3234 for addr in cells {
3235 let cell = sheet.get_cell_mut(addr.as_str());
3236 let before = stable_style_id(&descriptor_from_style(cell.get_style()));
3237 let next_style = apply_style_patch(cell.get_style(), &op.patch, op_mode);
3238 cell.set_style(next_style);
3239 let after = stable_style_id(&descriptor_from_style(cell.get_style()));
3240 cells_touched += 1;
3241 if before != after {
3242 cells_style_changed += 1;
3243 }
3244 }
3245 }
3246 StyleTarget::Region { .. } => {
3247 return Err(anyhow!(
3248 "region_id targets must be resolved before apply_style_ops_to_file"
3249 ));
3250 }
3251 }
3252 }
3253
3254 umya_spreadsheet::writer::xlsx::write(&book, path)?;
3255
3256 let mut counts = BTreeMap::new();
3257 counts.insert("cells_touched".to_string(), cells_touched);
3258 counts.insert("cells_style_changed".to_string(), cells_style_changed);
3259
3260 let summary = ChangeSummary {
3261 op_kinds: vec!["style_batch".to_string()],
3262 affected_sheets: sheets.into_iter().collect(),
3263 affected_bounds,
3264 counts,
3265 warnings: Vec::new(),
3266 ..Default::default()
3267 };
3268
3269 Ok(StyleApplyResult {
3270 ops_applied: ops.len(),
3271 summary,
3272 })
3273}
3274
3275#[derive(Debug, Deserialize, JsonSchema)]
3276pub struct GetEditsParams {
3277 pub fork_id: String,
3278}
3279
3280#[derive(Debug, Serialize, JsonSchema)]
3281pub struct GetEditsResponse {
3282 pub fork_id: String,
3283 pub edits: Vec<EditRecord>,
3284}
3285
3286#[derive(Debug, Serialize, JsonSchema)]
3287pub struct EditRecord {
3288 pub timestamp: String,
3289 pub sheet: String,
3290 pub address: String,
3291 pub value: String,
3292 pub is_formula: bool,
3293}
3294
3295pub async fn get_edits(state: Arc<AppState>, params: GetEditsParams) -> Result<GetEditsResponse> {
3296 let registry = state
3297 .fork_registry()
3298 .ok_or_else(|| anyhow!("fork registry not available"))?;
3299
3300 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
3301
3302 let edits: Vec<EditRecord> = fork_ctx
3303 .edits
3304 .iter()
3305 .map(|e| EditRecord {
3306 timestamp: e.timestamp.to_rfc3339(),
3307 sheet: e.sheet.clone(),
3308 address: e.address.clone(),
3309 value: e.value.clone(),
3310 is_formula: e.is_formula,
3311 })
3312 .collect();
3313
3314 Ok(GetEditsResponse {
3315 fork_id: params.fork_id,
3316 edits,
3317 })
3318}
3319
3320fn default_get_changeset_limit() -> u32 {
3321 200
3322}
3323
3324#[derive(Debug, Deserialize, JsonSchema)]
3325pub struct GetChangesetParams {
3326 pub fork_id: String,
3327 pub sheet_name: Option<String>,
3328 #[serde(default = "default_get_changeset_limit")]
3329 pub limit: u32,
3330 #[serde(default)]
3331 pub offset: u32,
3332 #[serde(default)]
3333 pub summary_only: bool,
3334 #[serde(default)]
3335 pub include_types: Option<Vec<String>>,
3336 #[serde(default)]
3337 pub exclude_types: Option<Vec<String>>,
3338 #[serde(default)]
3339 pub include_subtypes: Option<Vec<String>>,
3340 #[serde(default)]
3341 pub exclude_subtypes: Option<Vec<String>>,
3342}
3343
3344impl Default for GetChangesetParams {
3345 fn default() -> Self {
3346 Self {
3347 fork_id: String::new(),
3348 sheet_name: None,
3349 limit: default_get_changeset_limit(),
3350 offset: 0,
3351 summary_only: false,
3352 include_types: None,
3353 exclude_types: None,
3354 include_subtypes: None,
3355 exclude_subtypes: None,
3356 }
3357 }
3358}
3359
3360#[derive(Debug, Serialize, JsonSchema)]
3361pub struct ChangesetSummary {
3362 pub total_changes: u32,
3363 pub returned_changes: u32,
3364 pub truncated: bool,
3365 pub next_offset: Option<u32>,
3366 pub counts_by_kind: BTreeMap<String, u32>,
3367 pub counts_by_type: BTreeMap<String, u32>,
3368 pub counts_by_subtype: BTreeMap<String, u32>,
3369 pub affected_sheets: Vec<String>,
3370}
3371
3372#[derive(Debug, Serialize, JsonSchema)]
3373pub struct GetChangesetResponse {
3374 pub fork_id: String,
3375 pub base_workbook: String,
3376 #[serde(skip_serializing_if = "Option::is_none")]
3377 pub client_base_workbook: Option<String>,
3378 pub changes: Vec<crate::diff::Change>,
3379 pub summary: ChangesetSummary,
3380}
3381
3382fn normalize_filter(values: &Option<Vec<String>>) -> Option<BTreeSet<String>> {
3383 values.as_ref().map(|items| {
3384 items
3385 .iter()
3386 .map(|s| s.to_ascii_lowercase())
3387 .collect::<BTreeSet<_>>()
3388 })
3389}
3390
3391fn change_kind_key(change: &crate::diff::Change) -> &'static str {
3392 match change {
3393 crate::diff::Change::Cell(_) => "cell",
3394 crate::diff::Change::Table(_) => "table",
3395 crate::diff::Change::Name(_) => "name",
3396 }
3397}
3398
3399fn change_type_key(change: &crate::diff::Change) -> &'static str {
3400 use crate::diff::merge::CellDiff;
3401 match change {
3402 crate::diff::Change::Cell(cell) => match &cell.diff {
3403 CellDiff::Added { .. } => "added",
3404 CellDiff::Deleted { .. } => "deleted",
3405 CellDiff::Modified { .. } => "modified",
3406 },
3407 crate::diff::Change::Table(table) => match table {
3408 crate::diff::tables::TableDiff::TableAdded { .. } => "table_added",
3409 crate::diff::tables::TableDiff::TableDeleted { .. } => "table_deleted",
3410 crate::diff::tables::TableDiff::TableModified { .. } => "table_modified",
3411 },
3412 crate::diff::Change::Name(name) => match name {
3413 crate::diff::names::NameDiff::NameAdded { .. } => "name_added",
3414 crate::diff::names::NameDiff::NameDeleted { .. } => "name_deleted",
3415 crate::diff::names::NameDiff::NameModified { .. } => "name_modified",
3416 },
3417 }
3418}
3419
3420fn change_subtype_key(change: &crate::diff::Change) -> Option<&'static str> {
3421 use crate::diff::merge::{CellDiff, ModificationType};
3422 match change {
3423 crate::diff::Change::Cell(cell) => match &cell.diff {
3424 CellDiff::Modified { subtype, .. } => Some(match subtype {
3425 ModificationType::FormulaEdit => "formula_edit",
3426 ModificationType::RecalcResult => "recalc_result",
3427 ModificationType::ValueEdit => "value_edit",
3428 ModificationType::StyleEdit => "style_edit",
3429 }),
3430 _ => None,
3431 },
3432 _ => None,
3433 }
3434}
3435
3436fn change_sheet_name(change: &crate::diff::Change) -> Option<&str> {
3437 match change {
3438 crate::diff::Change::Cell(cell) => Some(cell.sheet.as_str()),
3439 crate::diff::Change::Table(table) => match table {
3440 crate::diff::tables::TableDiff::TableAdded { sheet, .. }
3441 | crate::diff::tables::TableDiff::TableDeleted { sheet, .. }
3442 | crate::diff::tables::TableDiff::TableModified { sheet, .. } => Some(sheet.as_str()),
3443 },
3444 crate::diff::Change::Name(name) => match name {
3445 crate::diff::names::NameDiff::NameAdded { scope_sheet, .. }
3446 | crate::diff::names::NameDiff::NameDeleted { scope_sheet, .. }
3447 | crate::diff::names::NameDiff::NameModified { scope_sheet, .. } => {
3448 scope_sheet.as_deref()
3449 }
3450 },
3451 }
3452}
3453
3454fn change_passes_filters(
3455 change: &crate::diff::Change,
3456 include_types: &Option<BTreeSet<String>>,
3457 exclude_types: &Option<BTreeSet<String>>,
3458 include_subtypes: &Option<BTreeSet<String>>,
3459 exclude_subtypes: &Option<BTreeSet<String>>,
3460) -> bool {
3461 let type_key = change_type_key(change);
3462 let subtype_key = change_subtype_key(change);
3463
3464 if let Some(include) = include_types
3465 && !include.contains(type_key)
3466 {
3467 return false;
3468 }
3469 if let Some(exclude) = exclude_types
3470 && exclude.contains(type_key)
3471 {
3472 return false;
3473 }
3474
3475 if let Some(include) = include_subtypes
3476 && subtype_key.is_none_or(|subtype| !include.contains(subtype))
3477 {
3478 return false;
3479 }
3480 if let Some(exclude) = exclude_subtypes
3481 && subtype_key.is_some_and(|subtype| exclude.contains(subtype))
3482 {
3483 return false;
3484 }
3485
3486 true
3487}
3488
3489pub async fn get_changeset(
3490 state: Arc<AppState>,
3491 params: GetChangesetParams,
3492) -> Result<GetChangesetResponse> {
3493 let registry = state
3494 .fork_registry()
3495 .ok_or_else(|| anyhow!("fork registry not available"))?;
3496
3497 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
3498
3499 let raw_changes = tokio::task::spawn_blocking({
3500 let base_path = fork_ctx.base_path.clone();
3501 let work_path = fork_ctx.work_path.clone();
3502 let sheet_filter = params.sheet_name.clone();
3503 move || {
3504 crate::core::diff::calculate_changeset(&base_path, &work_path, sheet_filter.as_deref())
3505 }
3506 })
3507 .await??;
3508
3509 let include_types = normalize_filter(¶ms.include_types);
3510 let exclude_types = normalize_filter(¶ms.exclude_types);
3511 let include_subtypes = normalize_filter(¶ms.include_subtypes);
3512 let exclude_subtypes = normalize_filter(¶ms.exclude_subtypes);
3513
3514 let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
3515 let mut counts_by_kind: BTreeMap<String, u32> = BTreeMap::new();
3516 let mut counts_by_type: BTreeMap<String, u32> = BTreeMap::new();
3517 let mut counts_by_subtype: BTreeMap<String, u32> = BTreeMap::new();
3518
3519 let mut filtered: Vec<crate::diff::Change> = Vec::new();
3520 for change in raw_changes {
3521 if !change_passes_filters(
3522 &change,
3523 &include_types,
3524 &exclude_types,
3525 &include_subtypes,
3526 &exclude_subtypes,
3527 ) {
3528 continue;
3529 }
3530
3531 *counts_by_kind
3532 .entry(change_kind_key(&change).to_string())
3533 .or_default() += 1;
3534 *counts_by_type
3535 .entry(change_type_key(&change).to_string())
3536 .or_default() += 1;
3537 if let Some(subtype) = change_subtype_key(&change) {
3538 *counts_by_subtype.entry(subtype.to_string()).or_default() += 1;
3539 }
3540 if let Some(sheet) = change_sheet_name(&change) {
3541 affected_sheets.insert(sheet.to_string());
3542 }
3543
3544 filtered.push(change);
3545 }
3546
3547 let limit = params.limit.clamp(1, 2000) as usize;
3548 let offset = params.offset as usize;
3549 let total = filtered.len();
3550
3551 let (returned_changes, changes, truncated, next_offset) = if params.summary_only {
3552 (0u32, Vec::new(), false, None)
3553 } else {
3554 let end = offset.saturating_add(limit);
3555 let truncated = end < total;
3556 let next_offset = truncated.then_some(end as u32);
3557 let changes: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
3558 (changes.len() as u32, changes, truncated, next_offset)
3559 };
3560
3561 let summary = ChangesetSummary {
3562 total_changes: total as u32,
3563 returned_changes,
3564 truncated,
3565 next_offset,
3566 counts_by_kind,
3567 counts_by_type,
3568 counts_by_subtype,
3569 affected_sheets: affected_sheets.into_iter().collect(),
3570 };
3571
3572 Ok(GetChangesetResponse {
3573 fork_id: params.fork_id,
3574 base_workbook: fork_ctx.base_path.display().to_string(),
3575 client_base_workbook: state
3576 .config()
3577 .map_path_for_client(&fork_ctx.base_path)
3578 .map(|p| p.display().to_string()),
3579 changes,
3580 summary,
3581 })
3582}
3583
3584#[derive(Debug, Deserialize, JsonSchema)]
3585pub struct RecalculateParams {
3586 pub fork_id: String,
3587 #[serde(default = "default_timeout")]
3588 pub timeout_ms: u64,
3589 #[serde(default)]
3590 pub backend: Option<RecalcBackendKind>,
3591}
3592
3593fn default_timeout() -> u64 {
3594 30_000
3595}
3596
3597#[derive(Debug, Serialize, JsonSchema)]
3598pub struct RecalculateResponse {
3599 pub fork_id: String,
3600 pub duration_ms: u64,
3601 pub backend: String,
3602 #[serde(skip_serializing_if = "Option::is_none")]
3603 pub cells_evaluated: Option<u64>,
3604 #[serde(skip_serializing_if = "Option::is_none")]
3605 pub eval_errors: Option<Vec<String>>,
3606}
3607
3608pub async fn recalculate(
3609 state: Arc<AppState>,
3610 params: RecalculateParams,
3611) -> Result<RecalculateResponse> {
3612 let backend = state
3613 .recalc_backend(params.backend)
3614 .ok_or_else(|| anyhow!("requested recalc backend not available"))?;
3615
3616 recalculate_with_backend(state, params, backend).await
3617}
3618
3619pub async fn recalculate_with_backend(
3620 state: Arc<AppState>,
3621 params: RecalculateParams,
3622 backend: Arc<dyn RecalcBackend>,
3623) -> Result<RecalculateResponse> {
3624 let registry = state
3625 .fork_registry()
3626 .ok_or_else(|| anyhow!("fork registry not available"))?;
3627
3628 let semaphore = state
3629 .recalc_semaphore()
3630 .ok_or_else(|| anyhow!("recalc semaphore not available"))?;
3631
3632 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
3633
3634 let _permit = semaphore
3635 .0
3636 .acquire()
3637 .await
3638 .map_err(|e| anyhow!("failed to acquire recalc permit: {}", e))?;
3639
3640 let timeout_ms = if params.timeout_ms == 0 {
3641 None
3642 } else {
3643 Some(params.timeout_ms)
3644 };
3645 let result =
3646 crate::core::recalc::execute_with_backend(&fork_ctx.work_path, timeout_ms, backend).await?;
3647
3648 registry.with_fork_mut(¶ms.fork_id, |ctx| {
3649 ctx.recalc_needed = false;
3650 Ok(())
3651 })?;
3652
3653 let fork_workbook_id = WorkbookId(params.fork_id.clone());
3654 let _ = state.close_workbook(&fork_workbook_id);
3655
3656 Ok(RecalculateResponse {
3657 fork_id: params.fork_id,
3658 duration_ms: result.duration_ms,
3659 backend: result.backend,
3660 cells_evaluated: result.cells_evaluated,
3661 eval_errors: result.eval_errors,
3662 })
3663}
3664
3665#[derive(Debug, Deserialize, JsonSchema)]
3666pub struct ListForksParams {}
3667
3668#[derive(Debug, Serialize, JsonSchema)]
3669pub struct ListForksResponse {
3670 pub forks: Vec<ForkSummary>,
3671}
3672
3673#[derive(Debug, Serialize, JsonSchema)]
3674pub struct ForkSummary {
3675 pub fork_id: String,
3676 pub base_path: String,
3677 #[serde(skip_serializing_if = "Option::is_none")]
3678 pub client_base_path: Option<String>,
3679 pub age_seconds: u64,
3680 pub edit_count: usize,
3681 pub recalc_needed: bool,
3682}
3683
3684pub async fn list_forks(
3685 state: Arc<AppState>,
3686 _params: ListForksParams,
3687) -> Result<ListForksResponse> {
3688 let registry = state
3689 .fork_registry()
3690 .ok_or_else(|| anyhow!("fork registry not available"))?;
3691
3692 let config = state.config();
3693 let forks: Vec<ForkSummary> = registry
3694 .list_forks()
3695 .into_iter()
3696 .map(|f| {
3697 let base_path = f.base_path;
3698 let client_base_path = if config.path_mappings.is_empty() {
3699 None
3700 } else {
3701 config
3702 .map_path_for_client(PathBuf::from(&base_path))
3703 .map(|p| p.display().to_string())
3704 };
3705 ForkSummary {
3706 fork_id: f.fork_id,
3707 base_path,
3708 client_base_path,
3709 age_seconds: f.created_at.elapsed().as_secs(),
3710 edit_count: f.edit_count,
3711 recalc_needed: f.recalc_needed,
3712 }
3713 })
3714 .collect();
3715
3716 Ok(ListForksResponse { forks })
3717}
3718
3719#[derive(Debug, Deserialize, JsonSchema)]
3720pub struct DiscardForkParams {
3721 pub fork_id: String,
3722}
3723
3724#[derive(Debug, Serialize, JsonSchema)]
3725pub struct DiscardForkResponse {
3726 pub fork_id: String,
3727 pub discarded: bool,
3728}
3729
3730pub async fn discard_fork(
3731 state: Arc<AppState>,
3732 params: DiscardForkParams,
3733) -> Result<DiscardForkResponse> {
3734 let registry = state
3735 .fork_registry()
3736 .ok_or_else(|| anyhow!("fork registry not available"))?;
3737
3738 registry.discard_fork(¶ms.fork_id)?;
3739
3740 Ok(DiscardForkResponse {
3741 fork_id: params.fork_id,
3742 discarded: true,
3743 })
3744}
3745
3746#[derive(Debug, Deserialize, JsonSchema)]
3747pub struct SaveForkParams {
3748 pub fork_id: String,
3749 pub target_path: Option<String>,
3751 #[serde(default = "default_drop_fork")]
3753 pub drop_fork: bool,
3754}
3755
3756fn default_drop_fork() -> bool {
3757 true
3758}
3759
3760#[derive(Debug, Serialize, JsonSchema)]
3761pub struct SaveForkResponse {
3762 pub fork_id: String,
3763 pub saved_to: String,
3764 #[serde(skip_serializing_if = "Option::is_none")]
3765 pub client_saved_to: Option<String>,
3766 pub fork_dropped: bool,
3767}
3768
3769pub async fn save_fork(state: Arc<AppState>, params: SaveForkParams) -> Result<SaveForkResponse> {
3770 let registry = state
3771 .fork_registry()
3772 .ok_or_else(|| anyhow!("fork registry not available"))?;
3773
3774 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
3775 let config = state.config();
3776 let workspace_root = &config.workspace_root;
3777
3778 let (target, is_overwrite) = match params.target_path {
3779 Some(p) => {
3780 let resolved = config.resolve_user_path(&p);
3781 let is_overwrite = if resolved.exists() {
3782 let base_canon = fork_ctx.base_path.canonicalize().map_err(|e| {
3783 anyhow!("failed to canonicalize base_path for overwrite check: {e}")
3784 })?;
3785 let target_canon = resolved.canonicalize().map_err(|e| {
3786 anyhow!("failed to canonicalize target_path for overwrite check: {e}")
3787 })?;
3788 target_canon == base_canon
3789 } else {
3790 false
3791 };
3792 (resolved, is_overwrite)
3793 }
3794 None => (fork_ctx.base_path.clone(), true),
3795 };
3796
3797 if is_overwrite && !config.allow_overwrite {
3798 return Err(anyhow!(
3799 "overwriting original file is disabled. Use --allow-overwrite flag or specify a different target_path"
3800 ));
3801 }
3802
3803 let base_path = fork_ctx.base_path.clone();
3804 registry.save_fork(¶ms.fork_id, &target, workspace_root, params.drop_fork)?;
3805
3806 if is_overwrite {
3807 state.evict_by_path(&base_path);
3808 }
3809
3810 Ok(SaveForkResponse {
3811 fork_id: params.fork_id,
3812 saved_to: target.display().to_string(),
3813 client_saved_to: config
3814 .map_path_for_client(&target)
3815 .map(|p| p.display().to_string()),
3816 fork_dropped: params.drop_fork,
3817 })
3818}
3819
3820#[derive(Debug, Deserialize, JsonSchema)]
3821pub struct CheckpointForkParams {
3822 pub fork_id: String,
3823 pub label: Option<String>,
3824}
3825
3826#[derive(Debug, Serialize, JsonSchema)]
3827pub struct CheckpointInfo {
3828 pub checkpoint_id: String,
3829 pub created_at: String,
3830 pub label: Option<String>,
3831}
3832
3833#[derive(Debug, Serialize, JsonSchema)]
3834pub struct CheckpointForkResponse {
3835 pub fork_id: String,
3836 pub checkpoint: CheckpointInfo,
3837 pub total_checkpoints: usize,
3838}
3839
3840pub async fn checkpoint_fork(
3841 state: Arc<AppState>,
3842 params: CheckpointForkParams,
3843) -> Result<CheckpointForkResponse> {
3844 let registry = state
3845 .fork_registry()
3846 .ok_or_else(|| anyhow!("fork registry not available"))?;
3847
3848 registry.get_fork(¶ms.fork_id)?;
3849 let checkpoint = registry.create_checkpoint(¶ms.fork_id, params.label.clone())?;
3850 let total = registry.list_checkpoints(¶ms.fork_id)?.len();
3851
3852 Ok(CheckpointForkResponse {
3853 fork_id: params.fork_id,
3854 checkpoint: CheckpointInfo {
3855 checkpoint_id: checkpoint.checkpoint_id,
3856 created_at: checkpoint.created_at.to_rfc3339(),
3857 label: checkpoint.label,
3858 },
3859 total_checkpoints: total,
3860 })
3861}
3862
3863#[derive(Debug, Deserialize, JsonSchema)]
3864pub struct ListCheckpointsParams {
3865 pub fork_id: String,
3866}
3867
3868#[derive(Debug, Serialize, JsonSchema)]
3869pub struct ListCheckpointsResponse {
3870 pub fork_id: String,
3871 pub checkpoints: Vec<CheckpointInfo>,
3872}
3873
3874pub async fn list_checkpoints(
3875 state: Arc<AppState>,
3876 params: ListCheckpointsParams,
3877) -> Result<ListCheckpointsResponse> {
3878 let registry = state
3879 .fork_registry()
3880 .ok_or_else(|| anyhow!("fork registry not available"))?;
3881
3882 let checkpoints = registry.list_checkpoints(¶ms.fork_id)?;
3883 let checkpoints = checkpoints
3884 .into_iter()
3885 .map(|cp| CheckpointInfo {
3886 checkpoint_id: cp.checkpoint_id,
3887 created_at: cp.created_at.to_rfc3339(),
3888 label: cp.label,
3889 })
3890 .collect();
3891
3892 Ok(ListCheckpointsResponse {
3893 fork_id: params.fork_id,
3894 checkpoints,
3895 })
3896}
3897
3898#[derive(Debug, Deserialize, JsonSchema)]
3899pub struct RestoreCheckpointParams {
3900 pub fork_id: String,
3901 pub checkpoint_id: String,
3902}
3903
3904#[derive(Debug, Serialize, JsonSchema)]
3905pub struct RestoreCheckpointResponse {
3906 pub fork_id: String,
3907 pub restored_checkpoint: CheckpointInfo,
3908}
3909
3910pub async fn restore_checkpoint(
3911 state: Arc<AppState>,
3912 params: RestoreCheckpointParams,
3913) -> Result<RestoreCheckpointResponse> {
3914 let registry = state
3915 .fork_registry()
3916 .ok_or_else(|| anyhow!("fork registry not available"))?;
3917
3918 let checkpoint = registry.restore_checkpoint(¶ms.fork_id, ¶ms.checkpoint_id)?;
3919 let fork_workbook_id = WorkbookId(params.fork_id.clone());
3920 let _ = state.close_workbook(&fork_workbook_id);
3921
3922 Ok(RestoreCheckpointResponse {
3923 fork_id: params.fork_id,
3924 restored_checkpoint: CheckpointInfo {
3925 checkpoint_id: checkpoint.checkpoint_id,
3926 created_at: checkpoint.created_at.to_rfc3339(),
3927 label: checkpoint.label,
3928 },
3929 })
3930}
3931
3932#[derive(Debug, Deserialize, JsonSchema)]
3933pub struct DeleteCheckpointParams {
3934 pub fork_id: String,
3935 pub checkpoint_id: String,
3936}
3937
3938#[derive(Debug, Serialize, JsonSchema)]
3939pub struct DeleteCheckpointResponse {
3940 pub fork_id: String,
3941 pub checkpoint_id: String,
3942 pub deleted: bool,
3943}
3944
3945pub async fn delete_checkpoint(
3946 state: Arc<AppState>,
3947 params: DeleteCheckpointParams,
3948) -> Result<DeleteCheckpointResponse> {
3949 let registry = state
3950 .fork_registry()
3951 .ok_or_else(|| anyhow!("fork registry not available"))?;
3952
3953 registry.delete_checkpoint(¶ms.fork_id, ¶ms.checkpoint_id)?;
3954
3955 Ok(DeleteCheckpointResponse {
3956 fork_id: params.fork_id,
3957 checkpoint_id: params.checkpoint_id,
3958 deleted: true,
3959 })
3960}
3961
3962#[derive(Debug, Deserialize, JsonSchema)]
3963pub struct ListStagedChangesParams {
3964 pub fork_id: String,
3965}
3966
3967#[derive(Debug, Serialize, JsonSchema)]
3968pub struct StagedChangeInfo {
3969 pub change_id: String,
3970 pub created_at: String,
3971 pub label: Option<String>,
3972 pub summary: ChangeSummary,
3973}
3974
3975#[derive(Debug, Serialize, JsonSchema)]
3976pub struct ListStagedChangesResponse {
3977 pub fork_id: String,
3978 pub staged_changes: Vec<StagedChangeInfo>,
3979}
3980
3981pub async fn list_staged_changes(
3982 state: Arc<AppState>,
3983 params: ListStagedChangesParams,
3984) -> Result<ListStagedChangesResponse> {
3985 let registry = state
3986 .fork_registry()
3987 .ok_or_else(|| anyhow!("fork registry not available"))?;
3988
3989 let staged = registry.list_staged_changes(¶ms.fork_id)?;
3990 let staged_changes = staged
3991 .into_iter()
3992 .map(|c| StagedChangeInfo {
3993 change_id: c.change_id,
3994 created_at: c.created_at.to_rfc3339(),
3995 label: c.label,
3996 summary: c.summary,
3997 })
3998 .collect();
3999
4000 Ok(ListStagedChangesResponse {
4001 fork_id: params.fork_id,
4002 staged_changes,
4003 })
4004}
4005
4006#[derive(Debug, Deserialize, JsonSchema)]
4007pub struct ApplyStagedChangeParams {
4008 pub fork_id: String,
4009 pub change_id: String,
4010}
4011
4012#[derive(Debug, Serialize, JsonSchema)]
4013pub struct ApplyStagedChangeResponse {
4014 pub fork_id: String,
4015 pub change_id: String,
4016 pub ops_applied: usize,
4017 pub summary: ChangeSummary,
4018}
4019
4020#[derive(Debug, Deserialize)]
4021struct EditBatchStagedPayload {
4022 sheet_name: String,
4023 edits: Vec<CellEdit>,
4024}
4025
4026pub async fn apply_staged_change(
4027 state: Arc<AppState>,
4028 params: ApplyStagedChangeParams,
4029) -> Result<ApplyStagedChangeResponse> {
4030 let registry = state
4031 .fork_registry()
4032 .ok_or_else(|| anyhow!("fork registry not available"))?;
4033
4034 let staged_list = registry.list_staged_changes(¶ms.fork_id)?;
4035 let staged = staged_list
4036 .iter()
4037 .find(|c| c.change_id == params.change_id)
4038 .cloned()
4039 .ok_or_else(|| anyhow!("staged change not found: {}", params.change_id))?;
4040
4041 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
4042 let work_path = fork_ctx.work_path.clone();
4043
4044 let initial_recalc_needed = fork_ctx.recalc_needed;
4045 let mut recalc_triggered = false;
4046
4047 let mut ops_applied = 0usize;
4048
4049 for op in &staged.ops {
4050 match op.kind.as_str() {
4051 "edit_batch" => {
4052 recalc_triggered = true;
4053 let payload: EditBatchStagedPayload = serde_json::from_value(op.payload.clone())
4054 .map_err(|e| anyhow!("invalid edit_batch payload: {}", e))?;
4055
4056 let edits_to_apply: Vec<_> = payload
4057 .edits
4058 .iter()
4059 .map(|e| EditOp {
4060 timestamp: Utc::now(),
4061 sheet: payload.sheet_name.clone(),
4062 address: e.address.clone(),
4063 value: e.value.clone(),
4064 is_formula: e.is_formula,
4065 })
4066 .collect();
4067
4068 tokio::task::spawn_blocking({
4069 let sheet_name = payload.sheet_name.clone();
4070 let edits = payload.edits.clone();
4071 let work_path = work_path.clone();
4072 move || {
4073 let core_edits = edits
4074 .into_iter()
4075 .map(|edit| crate::core::types::CellEdit {
4076 address: edit.address,
4077 value: edit.value,
4078 is_formula: edit.is_formula,
4079 })
4080 .collect::<Vec<_>>();
4081 crate::core::write::apply_edits_to_file(
4082 &work_path,
4083 &sheet_name,
4084 &core_edits,
4085 )
4086 }
4087 })
4088 .await??;
4089
4090 registry.with_fork_mut(¶ms.fork_id, |ctx| {
4091 ctx.edits.extend(edits_to_apply);
4092 ctx.recalc_needed = true;
4093 Ok(())
4094 })?;
4095
4096 ops_applied += 1;
4097 }
4098 "style_batch" => {
4099 let payload: StyleBatchStagedPayload =
4100 serde_json::from_value(op.payload.clone())
4101 .map_err(|e| anyhow!("invalid style_batch payload: {}", e))?;
4102
4103 tokio::task::spawn_blocking({
4104 let ops = payload.ops.clone();
4105 let work_path = work_path.clone();
4106 move || apply_style_ops_to_file(&work_path, &ops)
4107 })
4108 .await??;
4109
4110 ops_applied += 1;
4111 }
4112 "column_size_batch" => {
4113 let payload: ColumnSizeBatchStagedPayload =
4114 serde_json::from_value(op.payload.clone())
4115 .map_err(|e| anyhow!("invalid column_size_batch payload: {}", e))?;
4116
4117 tokio::task::spawn_blocking({
4118 let sheet_name = payload.sheet_name.clone();
4119 let ops = payload.ops.clone();
4120 let work_path = work_path.clone();
4121 move || apply_column_size_ops_to_file(&work_path, &sheet_name, &ops)
4122 })
4123 .await??;
4124
4125 ops_applied += 1;
4126 }
4127 "transform_batch" => {
4128 recalc_triggered = true;
4129 let payload: TransformBatchStagedPayload =
4130 serde_json::from_value(op.payload.clone())
4131 .map_err(|e| anyhow!("invalid transform_batch payload: {}", e))?;
4132
4133 tokio::task::spawn_blocking({
4134 let ops = payload.ops.clone();
4135 let work_path = work_path.clone();
4136 move || apply_transform_ops_to_file(&work_path, &ops)
4137 })
4138 .await??;
4139
4140 ops_applied += 1;
4141 }
4142 "apply_formula_pattern" => {
4143 recalc_triggered = true;
4144 let payload: ApplyFormulaPatternStagedPayload =
4145 serde_json::from_value(op.payload.clone())
4146 .map_err(|e| anyhow!("invalid apply_formula_pattern payload: {}", e))?;
4147
4148 let bounds = parse_range_bounds(&payload.target_range)?;
4149 let (anchor_col, anchor_row) = parse_cell_ref(&payload.anchor_cell)?;
4150 let fill_direction = payload.fill_direction.unwrap_or_default();
4151 validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
4152 let relative_mode: RelativeMode = payload.relative_mode.unwrap_or_default().into();
4153
4154 tokio::task::spawn_blocking({
4155 let sheet_name = payload.sheet_name.clone();
4156 let target_range = payload.target_range.clone();
4157 let base_formula = payload.base_formula.clone();
4158 let work_path = work_path.clone();
4159 move || {
4160 apply_formula_pattern_to_file(
4161 &work_path,
4162 &sheet_name,
4163 &target_range,
4164 anchor_col,
4165 anchor_row,
4166 &base_formula,
4167 relative_mode,
4168 )
4169 }
4170 })
4171 .await??;
4172
4173 ops_applied += 1;
4174 }
4175 "structure_batch" => {
4176 let payload: StructureBatchStagedPayload =
4177 serde_json::from_value(op.payload.clone())
4178 .map_err(|e| anyhow!("invalid structure_batch payload: {}", e))?;
4179
4180 if structure_ops_require_recalc(&payload.ops) {
4181 recalc_triggered = true;
4182 }
4183
4184 tokio::task::spawn_blocking({
4185 let ops = payload.ops.clone();
4186 let work_path = work_path.clone();
4187 move || apply_structure_ops_to_file(&work_path, &ops)
4188 })
4189 .await??;
4190
4191 ops_applied += 1;
4192 }
4193 "sheet_layout_batch" => {
4194 let payload: crate::tools::sheet_layout::SheetLayoutBatchStagedPayload =
4195 serde_json::from_value(op.payload.clone())
4196 .map_err(|e| anyhow!("invalid sheet_layout_batch payload: {}", e))?;
4197
4198 tokio::task::spawn_blocking({
4199 let ops = payload.ops.clone();
4200 let work_path = work_path.clone();
4201 move || {
4202 crate::tools::sheet_layout::apply_sheet_layout_ops_to_file(&work_path, &ops)
4203 }
4204 })
4205 .await??;
4206
4207 ops_applied += 1;
4208 }
4209 "rules_batch" => {
4210 let payload: crate::tools::rules_batch::RulesBatchStagedPayload =
4211 serde_json::from_value(op.payload.clone())
4212 .map_err(|e| anyhow!("invalid rules_batch payload: {}", e))?;
4213
4214 tokio::task::spawn_blocking({
4215 let ops = payload.ops.clone();
4216 let work_path = work_path.clone();
4217 move || crate::tools::rules_batch::apply_rules_ops_to_file(&work_path, &ops)
4218 })
4219 .await??;
4220
4221 ops_applied += 1;
4222 }
4223 other => {
4224 return Err(anyhow!("unsupported staged op kind: {}", other));
4225 }
4226 }
4227 }
4228
4229 let recalc_needed_now = initial_recalc_needed || recalc_triggered;
4230 if recalc_needed_now {
4231 registry.with_fork_mut(¶ms.fork_id, |ctx| {
4232 ctx.recalc_needed = true;
4233 Ok(())
4234 })?;
4235 }
4236
4237 registry.discard_staged_change(¶ms.fork_id, ¶ms.change_id)?;
4238 let fork_workbook_id = WorkbookId(params.fork_id.clone());
4239 let _ = state.close_workbook(&fork_workbook_id);
4240
4241 let mut summary = staged.summary;
4242 set_recalc_needed_flag(&mut summary, recalc_needed_now);
4243
4244 Ok(ApplyStagedChangeResponse {
4245 fork_id: params.fork_id,
4246 change_id: params.change_id,
4247 ops_applied,
4248 summary,
4249 })
4250}
4251
4252#[derive(Debug, Deserialize, JsonSchema)]
4253pub struct DiscardStagedChangeParams {
4254 pub fork_id: String,
4255 pub change_id: String,
4256}
4257
4258#[derive(Debug, Serialize, JsonSchema)]
4259pub struct DiscardStagedChangeResponse {
4260 pub fork_id: String,
4261 pub change_id: String,
4262 pub discarded: bool,
4263}
4264
4265pub async fn discard_staged_change(
4266 state: Arc<AppState>,
4267 params: DiscardStagedChangeParams,
4268) -> Result<DiscardStagedChangeResponse> {
4269 let registry = state
4270 .fork_registry()
4271 .ok_or_else(|| anyhow!("fork registry not available"))?;
4272
4273 registry.discard_staged_change(¶ms.fork_id, ¶ms.change_id)?;
4274
4275 Ok(DiscardStagedChangeResponse {
4276 fork_id: params.fork_id,
4277 change_id: params.change_id,
4278 discarded: true,
4279 })
4280}
4281
4282const MAX_SCREENSHOT_ROWS: u32 = 100;
4283const MAX_SCREENSHOT_COLS: u32 = 30;
4284const DEFAULT_SCREENSHOT_RANGE: &str = "A1:M40";
4285#[cfg(feature = "recalc-libreoffice")]
4286const DEFAULT_MAX_PNG_DIM_PX: u32 = 4096;
4287#[cfg(feature = "recalc-libreoffice")]
4288const DEFAULT_MAX_PNG_AREA_PX: u64 = 12_000_000;
4289
4290#[derive(Debug, Deserialize, JsonSchema)]
4291pub struct ScreenshotSheetParams {
4292 #[serde(alias = "workbook_id")]
4293 pub workbook_or_fork_id: WorkbookId,
4294 pub sheet_name: String,
4295 #[serde(default)]
4296 pub range: Option<String>,
4297}
4298
4299#[derive(Debug, Serialize, JsonSchema)]
4300pub struct ScreenshotSheetResponse {
4301 pub workbook_id: String,
4302 pub sheet_name: String,
4303 pub range: String,
4304 pub output_path: String,
4305 #[serde(skip_serializing_if = "Option::is_none")]
4306 pub client_output_path: Option<String>,
4307 pub size_bytes: u64,
4308 pub duration_ms: u64,
4309}
4310
4311pub async fn screenshot_sheet(
4312 state: Arc<AppState>,
4313 params: ScreenshotSheetParams,
4314) -> Result<ScreenshotSheetResponse> {
4315 let range = params.range.as_deref().unwrap_or(DEFAULT_SCREENSHOT_RANGE);
4316 let bounds = validate_screenshot_range(range)?;
4317
4318 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
4319 let workbook_path = workbook.path.clone();
4320
4321 let _ = workbook.with_sheet(¶ms.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
4322
4323 let safe_range = sanitize_filename_component(&range.replace(':', "-"));
4324 let safe_sheet = sanitize_filename_component(¶ms.sheet_name).replace(' ', "_");
4325 let safe_slug = sanitize_filename_component(&workbook.slug);
4326 let filename = format!("{}_{}_{}.png", safe_slug, safe_sheet, safe_range);
4327
4328 let config = state.config();
4329 let screenshot_dir = config.screenshot_dir.clone();
4330 tokio::fs::create_dir_all(&screenshot_dir).await?;
4331 let output_path = screenshot_dir.join(&filename);
4332
4333 let semaphore = state
4334 .screenshot_semaphore()
4335 .ok_or_else(|| anyhow!("screenshot semaphore not available"))?;
4336
4337 let _permit = semaphore
4339 .0
4340 .acquire()
4341 .await
4342 .map_err(|e| anyhow!("failed to acquire screenshot permit: {}", e))?;
4343
4344 #[cfg(not(feature = "recalc-libreoffice"))]
4345 {
4346 let _ = workbook_path;
4347 let _ = output_path;
4348 let _ = bounds;
4349 return Err(anyhow!(
4350 "screenshot backend unavailable (build without recalc-libreoffice feature)"
4351 ));
4352 }
4353
4354 #[cfg(feature = "recalc-libreoffice")]
4355 {
4356 let executor =
4357 crate::recalc::ScreenshotExecutor::new(&crate::recalc::RecalcConfig::default());
4358 let result = executor
4359 .screenshot(
4360 &workbook_path,
4361 &output_path,
4362 ¶ms.sheet_name,
4363 Some(range),
4364 )
4365 .await?;
4366
4367 enforce_png_pixel_limits(&result.output_path, range, &bounds).await?;
4368
4369 return Ok(ScreenshotSheetResponse {
4370 workbook_id: params.workbook_or_fork_id.0,
4371 sheet_name: params.sheet_name,
4372 range: range.to_string(),
4373 output_path: format!("file://{}", result.output_path.display()),
4374 client_output_path: config
4375 .map_path_for_client(&result.output_path)
4376 .map(|p| format!("file://{}", p.display())),
4377 size_bytes: result.size_bytes,
4378 duration_ms: result.duration_ms,
4379 });
4380 }
4381}
4382
4383#[derive(Debug, Clone, Copy)]
4384struct ScreenshotBounds {
4385 min_col: u32,
4386 max_col: u32,
4387 min_row: u32,
4388 max_row: u32,
4389 rows: u32,
4390 cols: u32,
4391}
4392
4393fn validate_screenshot_range(range: &str) -> Result<ScreenshotBounds> {
4394 let bounds = parse_range_bounds(range)?;
4395
4396 if bounds.rows > MAX_SCREENSHOT_ROWS || bounds.cols > MAX_SCREENSHOT_COLS {
4397 let row_tiles = div_ceil(bounds.rows, MAX_SCREENSHOT_ROWS);
4398 let col_tiles = div_ceil(bounds.cols, MAX_SCREENSHOT_COLS);
4399 let total_tiles = row_tiles * col_tiles;
4400
4401 let display_limit = 50usize;
4402 let display_ranges = suggest_tiled_ranges(
4403 &bounds,
4404 MAX_SCREENSHOT_ROWS,
4405 MAX_SCREENSHOT_COLS,
4406 Some(display_limit),
4407 );
4408
4409 let mut msg = format!(
4410 "Requested range {range} is too large for a single screenshot ({} rows x {} cols; max {} x {}). \
4411Split into {} tile(s) ({} row tiles x {} col tiles). Suggested ranges: {}",
4412 bounds.rows,
4413 bounds.cols,
4414 MAX_SCREENSHOT_ROWS,
4415 MAX_SCREENSHOT_COLS,
4416 total_tiles,
4417 row_tiles,
4418 col_tiles,
4419 display_ranges.join(", ")
4420 );
4421 if total_tiles as usize > display_limit {
4422 msg.push_str(&format!(
4423 " ... and {} more.",
4424 total_tiles as usize - display_limit
4425 ));
4426 }
4427 return Err(anyhow!(msg));
4428 }
4429
4430 Ok(bounds)
4431}
4432
4433fn parse_cell_ref(cell: &str) -> Result<(u32, u32)> {
4434 use umya_spreadsheet::helper::coordinate::index_from_coordinate;
4435 let (col, row, _, _) = index_from_coordinate(cell);
4436 match (col, row) {
4437 (Some(c), Some(r)) => Ok((c, r)),
4438 _ => Err(anyhow!("Invalid cell reference: {}", cell)),
4439 }
4440}
4441
4442fn parse_range_bounds(range: &str) -> Result<ScreenshotBounds> {
4443 let parts: Vec<&str> = range.split(':').collect();
4444 if parts.is_empty() || parts.len() > 2 {
4445 return Err(anyhow!("Invalid range format. Expected 'A1' or 'A1:Z99'"));
4446 }
4447
4448 let start = parse_cell_ref(parts[0])?;
4449 let end = if parts.len() == 2 {
4450 parse_cell_ref(parts[1])?
4451 } else {
4452 start
4453 };
4454
4455 let min_col = start.0.min(end.0);
4456 let max_col = start.0.max(end.0);
4457 let min_row = start.1.min(end.1);
4458 let max_row = start.1.max(end.1);
4459
4460 let rows = max_row - min_row + 1;
4461 let cols = max_col - min_col + 1;
4462
4463 Ok(ScreenshotBounds {
4464 min_col,
4465 max_col,
4466 min_row,
4467 max_row,
4468 rows,
4469 cols,
4470 })
4471}
4472
4473fn div_ceil(n: u32, d: u32) -> u32 {
4474 n.div_ceil(d)
4475}
4476
4477fn suggest_tiled_ranges(
4478 bounds: &ScreenshotBounds,
4479 max_rows: u32,
4480 max_cols: u32,
4481 limit: Option<usize>,
4482) -> Vec<String> {
4483 use umya_spreadsheet::helper::coordinate::coordinate_from_index;
4484
4485 let mut out = Vec::new();
4486 let mut row_start = bounds.min_row;
4487 while row_start <= bounds.max_row {
4488 let row_end = (row_start + max_rows - 1).min(bounds.max_row);
4489 let mut col_start = bounds.min_col;
4490 while col_start <= bounds.max_col {
4491 let col_end = (col_start + max_cols - 1).min(bounds.max_col);
4492 let start_cell = coordinate_from_index(&col_start, &row_start);
4493 let end_cell = coordinate_from_index(&col_end, &row_end);
4494 out.push(format!("{start_cell}:{end_cell}"));
4495 if let Some(lim) = limit
4496 && out.len() >= lim
4497 {
4498 return out;
4499 }
4500 col_start = col_end + 1;
4501 }
4502 row_start = row_end + 1;
4503 if let Some(lim) = limit
4504 && out.len() >= lim
4505 {
4506 return out;
4507 }
4508 }
4509 out
4510}
4511
4512#[cfg(feature = "recalc-libreoffice")]
4513fn suggest_split_single_tile(bounds: &ScreenshotBounds) -> Vec<String> {
4514 use umya_spreadsheet::helper::coordinate::coordinate_from_index;
4515
4516 if bounds.rows >= bounds.cols && bounds.rows > 1 {
4517 let mid_row = bounds.min_row + (bounds.rows / 2) - 1;
4518 let start1 = coordinate_from_index(&bounds.min_col, &bounds.min_row);
4519 let end1 = coordinate_from_index(&bounds.max_col, &mid_row);
4520 let start2 = coordinate_from_index(&bounds.min_col, &(mid_row + 1));
4521 let end2 = coordinate_from_index(&bounds.max_col, &bounds.max_row);
4522 vec![format!("{start1}:{end1}"), format!("{start2}:{end2}")]
4523 } else if bounds.cols > 1 {
4524 let mid_col = bounds.min_col + (bounds.cols / 2) - 1;
4525 let start1 = coordinate_from_index(&bounds.min_col, &bounds.min_row);
4526 let end1 = coordinate_from_index(&mid_col, &bounds.max_row);
4527 let start2 = coordinate_from_index(&(mid_col + 1), &bounds.min_row);
4528 let end2 = coordinate_from_index(&bounds.max_col, &bounds.max_row);
4529 vec![format!("{start1}:{end1}"), format!("{start2}:{end2}")]
4530 } else {
4531 vec![range_from_bounds(bounds)]
4532 }
4533}
4534
4535#[cfg(feature = "recalc-libreoffice")]
4536fn range_from_bounds(bounds: &ScreenshotBounds) -> String {
4537 use umya_spreadsheet::helper::coordinate::coordinate_from_index;
4538 let start = coordinate_from_index(&bounds.min_col, &bounds.min_row);
4539 let end = coordinate_from_index(&bounds.max_col, &bounds.max_row);
4540 format!("{start}:{end}")
4541}
4542
4543#[cfg(feature = "recalc-libreoffice")]
4544async fn enforce_png_pixel_limits(
4545 path: &std::path::Path,
4546 range: &str,
4547 bounds: &ScreenshotBounds,
4548) -> Result<()> {
4549 use image::GenericImageView;
4550 use image::ImageReader;
4551
4552 let max_dim_px = std::env::var("SPREADSHEET_MCP_MAX_PNG_DIM_PX")
4553 .ok()
4554 .and_then(|v| v.parse::<u32>().ok())
4555 .unwrap_or(DEFAULT_MAX_PNG_DIM_PX);
4556 let max_area_px = std::env::var("SPREADSHEET_MCP_MAX_PNG_AREA_PX")
4557 .ok()
4558 .and_then(|v| v.parse::<u64>().ok())
4559 .unwrap_or(DEFAULT_MAX_PNG_AREA_PX);
4560
4561 let reader = ImageReader::open(path)
4562 .map_err(|e| anyhow!("failed to read png {}: {}", path.display(), e))?;
4563 let reader = reader
4564 .with_guessed_format()
4565 .map_err(|e| anyhow!("failed to sniff png {}: {}", path.display(), e))?;
4566 let img = reader
4567 .decode()
4568 .map_err(|e| anyhow!("failed to decode png {}: {}", path.display(), e))?;
4569 let (w, h) = img.dimensions();
4570 let area = (w as u64) * (h as u64);
4571
4572 if w > max_dim_px || h > max_dim_px || area > max_area_px {
4573 let _ = tokio::fs::remove_file(path).await;
4574
4575 let mut suggestions =
4576 suggest_tiled_ranges(bounds, MAX_SCREENSHOT_ROWS, MAX_SCREENSHOT_COLS, Some(50));
4577 let row_tiles = div_ceil(bounds.rows, MAX_SCREENSHOT_ROWS);
4578 let col_tiles = div_ceil(bounds.cols, MAX_SCREENSHOT_COLS);
4579 let total_tiles = row_tiles * col_tiles;
4580 if total_tiles == 1 {
4581 suggestions = suggest_split_single_tile(bounds);
4582 }
4583
4584 return Err(anyhow!(
4585 "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). \
4586Try smaller ranges. Suggested ranges: {}",
4587 suggestions.join(", ")
4588 ));
4589 }
4590
4591 Ok(())
4592}