1use crate::fork::{ChangeSummary, StagedChange, StagedOp};
2use crate::model::diagnostics::{
3 CommandClass, FORMULA_PARSE_FAILED_PREFIX, FormulaParseDiagnostics,
4 FormulaParseDiagnosticsBuilder, FormulaParsePolicy, validate_formula,
5};
6use crate::model::{FillDescriptor, WorkbookId};
7use crate::state::AppState;
8use crate::styles::descriptor_from_style;
9use crate::tools::param_enums::BatchMode;
10use crate::utils::make_short_random_id;
11use crate::{rules::conditional_format, styles::normalize_color_hex};
12use anyhow::{Result, anyhow, bail};
13use chrono::Utc;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, BTreeSet};
17use std::fs;
18use std::path::Path;
19use std::sync::Arc;
20use umya_spreadsheet::{
21 ConditionalFormattingOperatorValues, DataValidation, DataValidationOperatorValues,
22 DataValidationValues, DataValidations,
23};
24
25#[derive(Debug, Deserialize, JsonSchema)]
26pub struct RulesBatchParams {
27 pub fork_id: String,
28 pub ops: Vec<RulesOp>,
29 #[serde(default)]
30 pub mode: Option<BatchMode>, pub label: Option<String>,
32 #[serde(default)]
33 pub formula_parse_policy: Option<FormulaParsePolicy>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
37#[serde(tag = "kind", rename_all = "snake_case")]
38pub enum RulesOp {
39 SetDataValidation {
40 sheet_name: String,
41 target_range: String,
42 validation: DataValidationSpec,
43 },
44 AddConditionalFormat {
45 sheet_name: String,
46 target_range: String,
47 rule: ConditionalFormatRuleSpec,
48 #[serde(default)]
49 style: ConditionalFormatStyleSpec,
50 },
51 SetConditionalFormat {
52 sheet_name: String,
53 target_range: String,
54 rule: ConditionalFormatRuleSpec,
55 #[serde(default)]
56 style: ConditionalFormatStyleSpec,
57 },
58 ClearConditionalFormats {
59 sheet_name: String,
60 target_range: String,
61 },
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
65#[serde(tag = "kind", rename_all = "snake_case")]
66pub enum ConditionalFormatRuleSpec {
67 CellIs {
68 operator: ConditionalFormatOperator,
69 formula: String,
70 },
71 Expression {
72 formula: String,
73 },
74}
75
76#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
77#[serde(rename_all = "snake_case")]
78pub enum ConditionalFormatOperator {
79 #[serde(alias = "lessThan")]
80 LessThan,
81 #[serde(alias = "lessThanOrEqual")]
82 LessThanOrEqual,
83 #[serde(alias = "greaterThan")]
84 GreaterThan,
85 #[serde(alias = "greaterThanOrEqual")]
86 GreaterThanOrEqual,
87 #[serde(alias = "equal")]
88 Equal,
89 #[serde(alias = "notEqual")]
90 NotEqual,
91 #[serde(alias = "between")]
92 Between,
93 #[serde(alias = "notBetween")]
94 NotBetween,
95}
96
97impl ConditionalFormatOperator {
98 fn to_umya(self) -> ConditionalFormattingOperatorValues {
99 match self {
100 Self::LessThan => ConditionalFormattingOperatorValues::LessThan,
101 Self::LessThanOrEqual => ConditionalFormattingOperatorValues::LessThanOrEqual,
102 Self::GreaterThan => ConditionalFormattingOperatorValues::GreaterThan,
103 Self::GreaterThanOrEqual => ConditionalFormattingOperatorValues::GreaterThanOrEqual,
104 Self::Equal => ConditionalFormattingOperatorValues::Equal,
105 Self::NotEqual => ConditionalFormattingOperatorValues::NotEqual,
106 Self::Between => ConditionalFormattingOperatorValues::Between,
107 Self::NotBetween => ConditionalFormattingOperatorValues::NotBetween,
108 }
109 }
110}
111
112#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
113pub struct ConditionalFormatStyleSpec {
114 #[serde(default)]
115 pub fill_color: Option<String>,
116 #[serde(default)]
117 pub font_color: Option<String>,
118 #[serde(default)]
119 pub bold: Option<bool>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
123pub struct DataValidationSpec {
124 pub kind: DataValidationKind,
125 pub formula1: String,
126 #[serde(default)]
127 pub formula2: Option<String>,
128 #[serde(default)]
129 pub allow_blank: Option<bool>,
130 #[serde(default)]
131 pub prompt: Option<ValidationMessage>,
132 #[serde(default)]
133 pub error: Option<ValidationMessage>,
134}
135
136#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
137#[serde(rename_all = "snake_case")]
138pub enum DataValidationKind {
139 List,
140 Whole,
141 Decimal,
142 Date,
143 Custom,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
147pub struct ValidationMessage {
148 pub title: String,
149 pub message: String,
150}
151
152#[derive(Debug, Serialize, JsonSchema)]
153pub struct RulesBatchResponse {
154 pub fork_id: String,
155 pub mode: String,
156 pub change_id: Option<String>,
157 pub ops_applied: usize,
158 pub summary: ChangeSummary,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
161}
162
163#[derive(Debug, Serialize, Deserialize)]
164pub(crate) struct RulesBatchStagedPayload {
165 pub(crate) ops: Vec<RulesOp>,
166 #[serde(default)]
167 pub(crate) formula_parse_policy: Option<FormulaParsePolicy>,
168}
169
170pub async fn rules_batch(
171 state: Arc<AppState>,
172 params: RulesBatchParams,
173) -> Result<RulesBatchResponse> {
174 let registry = state
175 .fork_registry()
176 .ok_or_else(|| anyhow!("fork registry not available"))?;
177
178 let fork_ctx = registry.get_fork(¶ms.fork_id)?;
179 let work_path = fork_ctx.work_path.clone();
180
181 let fork_workbook_id = WorkbookId(params.fork_id.clone());
183 let workbook = state.open_workbook(&fork_workbook_id).await?;
184 for op in ¶ms.ops {
185 match op {
186 RulesOp::SetDataValidation { sheet_name, .. } => {
187 let _ = workbook.with_sheet(sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
188 }
189 RulesOp::AddConditionalFormat { sheet_name, .. }
190 | RulesOp::SetConditionalFormat { sheet_name, .. }
191 | RulesOp::ClearConditionalFormats { sheet_name, .. } => {
192 let _ = workbook.with_sheet(sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
193 }
194 }
195 }
196
197 let policy =
198 params
199 .formula_parse_policy
200 .unwrap_or(FormulaParsePolicy::default_for_command_class(
201 CommandClass::BatchWrite,
202 ));
203
204 let mode = params.mode.unwrap_or_default();
205
206 if mode.is_preview() {
207 let change_id = make_short_random_id("chg", 12);
208 let snapshot_path = crate::tools::fork::stage_snapshot_path(¶ms.fork_id, &change_id);
209 fs::create_dir_all(snapshot_path.parent().unwrap())?;
210 fs::copy(&work_path, &snapshot_path)?;
211
212 let snapshot_for_apply = snapshot_path.clone();
213 let ops_for_apply = params.ops.clone();
214 let apply_result = tokio::task::spawn_blocking(move || {
215 apply_rules_ops_to_file(&snapshot_for_apply, &ops_for_apply, policy)
216 })
217 .await??;
218
219 let mut summary = apply_result.summary;
220 summary
221 .flags
222 .insert("recalc_needed".to_string(), fork_ctx.recalc_needed);
223
224 let staged_op = StagedOp {
225 kind: "rules_batch".to_string(),
226 payload: serde_json::to_value(RulesBatchStagedPayload {
227 ops: params.ops.clone(),
228 formula_parse_policy: Some(policy),
229 })?,
230 };
231 let staged = StagedChange {
232 change_id: change_id.clone(),
233 created_at: Utc::now(),
234 label: params.label.clone(),
235 ops: vec![staged_op],
236 summary: summary.clone(),
237 fork_path_snapshot: Some(snapshot_path),
238 };
239 registry.add_staged_change(¶ms.fork_id, staged)?;
240
241 Ok(RulesBatchResponse {
242 fork_id: params.fork_id,
243 mode: mode.as_str().to_string(),
244 change_id: Some(change_id),
245 ops_applied: apply_result.ops_applied,
246 summary,
247 formula_parse_diagnostics: apply_result.formula_parse_diagnostics,
248 })
249 } else {
250 let work_path_for_apply = work_path.clone();
251 let ops_for_apply = params.ops.clone();
252 let apply_result = tokio::task::spawn_blocking(move || {
253 apply_rules_ops_to_file(&work_path_for_apply, &ops_for_apply, policy)
254 })
255 .await??;
256
257 let mut summary = apply_result.summary;
258 summary
259 .flags
260 .insert("recalc_needed".to_string(), fork_ctx.recalc_needed);
261
262 let _ = state.close_workbook(&fork_workbook_id);
263
264 Ok(RulesBatchResponse {
265 fork_id: params.fork_id,
266 mode: mode.as_str().to_string(),
267 change_id: None,
268 ops_applied: apply_result.ops_applied,
269 summary,
270 formula_parse_diagnostics: apply_result.formula_parse_diagnostics,
271 })
272 }
273}
274
275pub(crate) struct RulesApplyResult {
276 pub(crate) ops_applied: usize,
277 pub(crate) summary: ChangeSummary,
278 pub(crate) formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
279}
280
281fn extract_rule_op_formulas(op: &RulesOp) -> Vec<(&str, &str, &str)> {
284 match op {
285 RulesOp::SetDataValidation {
286 sheet_name,
287 validation,
288 ..
289 } => {
290 let mut formulas = vec![(
291 sheet_name.as_str(),
292 "formula1",
293 validation.formula1.as_str(),
294 )];
295 if let Some(formula2) = &validation.formula2 {
296 formulas.push((sheet_name.as_str(), "formula2", formula2.as_str()));
297 }
298 formulas
299 }
300 RulesOp::AddConditionalFormat {
301 sheet_name, rule, ..
302 }
303 | RulesOp::SetConditionalFormat {
304 sheet_name, rule, ..
305 } => match rule {
306 ConditionalFormatRuleSpec::CellIs { formula, .. }
307 | ConditionalFormatRuleSpec::Expression { formula } => {
308 vec![(sheet_name.as_str(), "rule.formula", formula.as_str())]
309 }
310 },
311 RulesOp::ClearConditionalFormats { .. } => Vec::new(),
312 }
313}
314
315pub(crate) fn apply_rules_ops_to_file(
316 path: &Path,
317 ops: &[RulesOp],
318 policy: FormulaParsePolicy,
319) -> Result<RulesApplyResult> {
320 let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
321
322 let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
323 let mut affected_bounds: Vec<String> = Vec::new();
324 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
325 let mut warnings: Vec<String> = Vec::new();
326
327 let mut validations_set: u64 = 0;
328 let mut validations_replaced: u64 = 0;
329 let mut conditional_formats_added: u64 = 0;
330 let mut conditional_formats_skipped: u64 = 0;
331 let mut conditional_formats_set: u64 = 0;
332 let mut conditional_formats_replaced: u64 = 0;
333 let mut conditional_formats_set_skipped: u64 = 0;
334 let mut conditional_formats_cleared: u64 = 0;
335
336 let mut formula_parse_diagnostics_builder = FormulaParseDiagnosticsBuilder::new(policy);
337 let ops_to_apply: Vec<&RulesOp> = if policy == FormulaParsePolicy::Off {
338 ops.iter().collect()
339 } else {
340 let mut valid_ops = Vec::new();
341 for op in ops {
342 let formulas = extract_rule_op_formulas(op);
343 if formulas.is_empty() {
344 valid_ops.push(op);
345 continue;
346 }
347
348 let mut op_valid = true;
349 for (sheet_name, field, formula_text) in formulas {
350 let normalized = formula_text.trim();
351 let to_validate = normalized.strip_prefix('=').unwrap_or(normalized);
352 if to_validate.is_empty() {
353 continue;
354 }
355
356 if let Err(err_msg) = validate_formula(to_validate) {
357 if policy == FormulaParsePolicy::Fail {
358 bail!(
359 "{}{} in {}: {}",
360 FORMULA_PARSE_FAILED_PREFIX,
361 err_msg,
362 field,
363 formula_text
364 );
365 }
366 formula_parse_diagnostics_builder.record_error(
367 sheet_name,
368 field,
369 formula_text,
370 &err_msg,
371 );
372 op_valid = false;
373 }
374 }
375
376 if op_valid {
377 valid_ops.push(op);
378 }
379 }
380 valid_ops
381 };
382
383 let ops_applied = ops_to_apply.len();
384
385 let mut warned_not_parsed = false;
386 let mut warned_cf_structure = false;
387
388 for op in ops_to_apply {
389 match op {
390 RulesOp::SetDataValidation {
391 sheet_name,
392 target_range,
393 validation,
394 } => {
395 let sheet = book
396 .get_sheet_by_name_mut(sheet_name)
397 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
398
399 affected_sheets.insert(sheet_name.clone());
400 affected_bounds.push(target_range.clone());
401
402 if !warned_not_parsed && policy == FormulaParsePolicy::Off {
403 warnings.push(
404 "WARN_VALIDATION_FORMULA_NOT_PARSED: Validation formulas are applied verbatim (not parsed or validated)."
405 .to_string(),
406 );
407 warned_not_parsed = true;
408 }
409
410 let (set_inc, replaced_inc) =
411 set_data_validation(sheet, target_range, validation, &mut warnings)?;
412 validations_set += set_inc;
413 validations_replaced += replaced_inc;
414 }
415 RulesOp::AddConditionalFormat {
416 sheet_name,
417 target_range,
418 rule,
419 style,
420 } => {
421 let sheet = book
422 .get_sheet_by_name_mut(sheet_name)
423 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
424
425 affected_sheets.insert(sheet_name.clone());
426 affected_bounds.push(target_range.clone());
427
428 if !warned_cf_structure {
429 warnings.push("WARN_CF_FORMULA_NOT_ADJUSTED_ON_STRUCTURE: Conditional format formulas are not automatically rewritten on structural edits; re-apply or review after row/col insertion/deletion.".to_string());
430 warned_cf_structure = true;
431 }
432
433 let (added, skipped) =
434 add_conditional_format(sheet, target_range, rule, style, &mut warnings)?;
435 conditional_formats_added += added;
436 conditional_formats_skipped += skipped;
437 }
438 RulesOp::SetConditionalFormat {
439 sheet_name,
440 target_range,
441 rule,
442 style,
443 } => {
444 let sheet = book
445 .get_sheet_by_name_mut(sheet_name)
446 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
447
448 affected_sheets.insert(sheet_name.clone());
449 affected_bounds.push(target_range.clone());
450
451 if !warned_cf_structure {
452 warnings.push("WARN_CF_FORMULA_NOT_ADJUSTED_ON_STRUCTURE: Conditional format formulas are not automatically rewritten on structural edits; re-apply or review after row/col insertion/deletion.".to_string());
453 warned_cf_structure = true;
454 }
455
456 let (set, replaced, skipped) =
457 set_conditional_format(sheet, target_range, rule, style, &mut warnings)?;
458 conditional_formats_set += set;
459 conditional_formats_replaced += replaced;
460 conditional_formats_set_skipped += skipped;
461 }
462 RulesOp::ClearConditionalFormats {
463 sheet_name,
464 target_range,
465 } => {
466 let sheet = book
467 .get_sheet_by_name_mut(sheet_name)
468 .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
469
470 affected_sheets.insert(sheet_name.clone());
471 affected_bounds.push(target_range.clone());
472
473 let cleared = clear_conditional_formats(sheet, target_range)?;
474 conditional_formats_cleared += cleared;
475 }
476 }
477 }
478
479 umya_spreadsheet::writer::xlsx::write(&book, path)?;
480
481 counts.insert("validations_set".to_string(), validations_set);
482 counts.insert("validations_replaced".to_string(), validations_replaced);
483 counts.insert(
484 "conditional_formats_added".to_string(),
485 conditional_formats_added,
486 );
487 counts.insert(
488 "conditional_formats_skipped".to_string(),
489 conditional_formats_skipped,
490 );
491 counts.insert(
492 "conditional_formats_set".to_string(),
493 conditional_formats_set,
494 );
495 counts.insert(
496 "conditional_formats_replaced".to_string(),
497 conditional_formats_replaced,
498 );
499 counts.insert(
500 "conditional_formats_set_skipped".to_string(),
501 conditional_formats_set_skipped,
502 );
503 counts.insert(
504 "conditional_formats_cleared".to_string(),
505 conditional_formats_cleared,
506 );
507
508 let formula_parse_diagnostics = if formula_parse_diagnostics_builder.has_errors() {
509 Some(formula_parse_diagnostics_builder.build())
510 } else {
511 None
512 };
513
514 Ok(RulesApplyResult {
515 ops_applied,
516 summary: ChangeSummary {
517 op_kinds: vec!["rules_batch".to_string()],
518 affected_sheets: affected_sheets.into_iter().collect(),
519 affected_bounds,
520 counts,
521 warnings,
522 ..Default::default()
523 },
524 formula_parse_diagnostics,
525 })
526}
527
528fn normalize_sqref(input: &str) -> Result<String> {
529 let trimmed = input.trim();
530 if trimmed.is_empty() {
531 bail!("target_range is required");
532 }
533 Ok(trimmed.replace(' ', "").to_ascii_uppercase())
535}
536
537fn normalize_cf_formula(field: &str, value: &str, warnings: &mut Vec<String>) -> Result<String> {
538 let trimmed = value.trim();
539 if trimmed.is_empty() {
540 bail!("{field} is required");
541 }
542 if let Some(stripped) = trimmed.strip_prefix('=') {
543 warnings.push(format!(
544 "WARN_CF_FORMULA_PREFIX: Stripped leading '=' from {field}"
545 ));
546 return Ok(stripped.to_string());
547 }
548 Ok(trimmed.to_string())
549}
550
551fn normalize_argb_color(field: &str, input: &str, warnings: &mut Vec<String>) -> Result<String> {
552 let trimmed = input.trim();
553 let Some((argb, defaulted_alpha)) = normalize_color_hex(trimmed) else {
554 bail!("invalid color for {field}: expected #RGB/#RRGGBB/#AARRGGBB");
555 };
556 if defaulted_alpha {
557 warnings.push(format!(
558 "WARN_COLOR_ALPHA_DEFAULT: Defaulted alpha to FF for {field}"
559 ));
560 }
561 Ok(argb)
562}
563
564fn add_conditional_format(
565 sheet: &mut umya_spreadsheet::Worksheet,
566 target_range: &str,
567 rule: &ConditionalFormatRuleSpec,
568 style: &ConditionalFormatStyleSpec,
569 warnings: &mut Vec<String>,
570) -> Result<(u64, u64)> {
571 let sqref = normalize_sqref(target_range)?;
572
573 let desired = match rule {
574 ConditionalFormatRuleSpec::Expression { formula } => (
575 umya_spreadsheet::ConditionalFormatValues::Expression,
576 None,
577 normalize_cf_formula("rule.formula", formula, warnings)?,
578 ),
579 ConditionalFormatRuleSpec::CellIs { operator, formula } => (
580 umya_spreadsheet::ConditionalFormatValues::CellIs,
581 Some(operator.to_umya()),
582 normalize_cf_formula("rule.formula", formula, warnings)?,
583 ),
584 };
585
586 let fill = style.fill_color.as_deref().unwrap_or("FFFFE0E0");
588 let font = style.font_color.as_deref().unwrap_or("FF000000");
589 let bold = style.bold.unwrap_or(false);
590
591 let fill_argb = normalize_argb_color("style.fill_color", fill, warnings)?;
592 let font_argb = normalize_argb_color("style.font_color", font, warnings)?;
593
594 for existing in sheet.get_conditional_formatting_collection() {
596 let existing_sqref = existing.get_sequence_of_references().get_sqref();
597 let existing_norm = existing_sqref.replace(' ', "").to_ascii_uppercase();
598 if existing_norm != sqref {
599 continue;
600 }
601 for existing_rule in existing.get_conditional_collection() {
602 if existing_rule.get_type() != &desired.0 {
603 continue;
604 }
605 if let Some(ref op) = desired.1
606 && existing_rule.get_operator() != op
607 {
608 continue;
609 }
610 let existing_formula = existing_rule
611 .get_formula()
612 .map(|f| f.get_address_str())
613 .unwrap_or_default();
614 if existing_formula == desired.2 {
615 return Ok((0, 1));
616 }
617 }
618 }
619
620 let dxf_style = conditional_format::build_simple_dxf_style(&fill_argb, &font_argb, bold);
621
622 match desired.0 {
623 umya_spreadsheet::ConditionalFormatValues::Expression => {
624 conditional_format::append_cf_expression_rule(sheet, &sqref, &desired.2, dxf_style);
625 }
626 umya_spreadsheet::ConditionalFormatValues::CellIs => {
627 conditional_format::append_cf_cellis_rule(
628 sheet,
629 &sqref,
630 desired
631 .1
632 .clone()
633 .unwrap_or(ConditionalFormattingOperatorValues::LessThan),
634 &desired.2,
635 dxf_style,
636 );
637 }
638 _ => unreachable!("only expression and cellIs are supported"),
639 }
640
641 Ok((1, 0))
642}
643
644fn clear_conditional_formats(
645 sheet: &mut umya_spreadsheet::Worksheet,
646 target_range: &str,
647) -> Result<u64> {
648 let sqref = normalize_sqref(target_range)?;
649 let before = sheet.get_conditional_formatting_collection().len();
650 if before == 0 {
651 return Ok(0);
652 }
653
654 let mut kept: Vec<umya_spreadsheet::ConditionalFormatting> = Vec::new();
655 for cf in sheet.get_conditional_formatting_collection() {
656 let existing = cf.get_sequence_of_references().get_sqref();
657 let existing_norm = existing.replace(' ', "").to_ascii_uppercase();
658 if existing_norm != sqref {
659 kept.push(cf.clone());
660 }
661 }
662
663 let removed = before.saturating_sub(kept.len()) as u64;
664 if removed > 0 {
665 sheet.set_conditional_formatting_collection(kept);
666 }
667 Ok(removed)
668}
669
670fn cf_rule_core_matches(
671 existing: &umya_spreadsheet::ConditionalFormattingRule,
672 desired_kind: &umya_spreadsheet::ConditionalFormatValues,
673 desired_operator: Option<&ConditionalFormattingOperatorValues>,
674 desired_formula: &str,
675) -> bool {
676 if existing.get_type() != desired_kind {
677 return false;
678 }
679 if let Some(op) = desired_operator
680 && existing.get_operator() != op
681 {
682 return false;
683 }
684 let existing_formula = existing
685 .get_formula()
686 .map(|f| f.get_address_str())
687 .unwrap_or_default();
688 existing_formula == desired_formula
689}
690
691fn cf_rule_style_matches(
692 existing: &umya_spreadsheet::ConditionalFormattingRule,
693 desired_fill_argb: &str,
694 desired_font_argb: &str,
695 desired_bold: bool,
696) -> bool {
697 let Some(style) = existing.get_style() else {
698 return false;
699 };
700
701 let desc = descriptor_from_style(style);
702 let existing_bold = desc.font.as_ref().and_then(|f| f.bold).unwrap_or(false);
703 if existing_bold != desired_bold {
704 return false;
705 }
706 if desc.font.as_ref().and_then(|f| f.color.as_deref()) != Some(desired_font_argb) {
707 return false;
708 }
709
710 match &desc.fill {
711 Some(FillDescriptor::Pattern(p)) => {
712 p.foreground_color.as_deref() == Some(desired_fill_argb)
713 }
714 _ => false,
715 }
716}
717
718fn set_conditional_format(
719 sheet: &mut umya_spreadsheet::Worksheet,
720 target_range: &str,
721 rule: &ConditionalFormatRuleSpec,
722 style: &ConditionalFormatStyleSpec,
723 warnings: &mut Vec<String>,
724) -> Result<(u64, u64, u64)> {
725 let sqref = normalize_sqref(target_range)?;
726
727 let desired_kind;
728 let desired_operator: Option<ConditionalFormattingOperatorValues>;
729 let desired_formula: String;
730 match rule {
731 ConditionalFormatRuleSpec::Expression { formula } => {
732 desired_kind = umya_spreadsheet::ConditionalFormatValues::Expression;
733 desired_operator = None;
734 desired_formula = normalize_cf_formula("rule.formula", formula, warnings)?;
735 }
736 ConditionalFormatRuleSpec::CellIs { operator, formula } => {
737 desired_kind = umya_spreadsheet::ConditionalFormatValues::CellIs;
738 desired_operator = Some(operator.to_umya());
739 desired_formula = normalize_cf_formula("rule.formula", formula, warnings)?;
740 }
741 }
742
743 let fill = style.fill_color.as_deref().unwrap_or("FFFFE0E0");
745 let font = style.font_color.as_deref().unwrap_or("FF000000");
746 let bold = style.bold.unwrap_or(false);
747 let fill_argb = normalize_argb_color("style.fill_color", fill, warnings)?;
748 let font_argb = normalize_argb_color("style.font_color", font, warnings)?;
749
750 let matches: Vec<&umya_spreadsheet::ConditionalFormatting> = sheet
752 .get_conditional_formatting_collection()
753 .iter()
754 .filter(|cf| {
755 let existing = cf.get_sequence_of_references().get_sqref();
756 let existing_norm = existing.replace(' ', "").to_ascii_uppercase();
757 existing_norm == sqref
758 })
759 .collect();
760 if matches.len() == 1 {
761 let rules = matches[0].get_conditional_collection();
762 if rules.len() == 1 {
763 let existing = &rules[0];
764 if cf_rule_core_matches(
765 existing,
766 &desired_kind,
767 desired_operator.as_ref(),
768 &desired_formula,
769 ) && cf_rule_style_matches(existing, &fill_argb, &font_argb, bold)
770 {
771 return Ok((0, 0, 1));
772 }
773 }
774 }
775
776 let mut replaced: u64 = 0;
778 if !sheet.get_conditional_formatting_collection().is_empty() {
779 let mut kept: Vec<umya_spreadsheet::ConditionalFormatting> = Vec::new();
780 for cf in sheet.get_conditional_formatting_collection() {
781 let existing = cf.get_sequence_of_references().get_sqref();
782 let existing_norm = existing.replace(' ', "").to_ascii_uppercase();
783 if existing_norm == sqref {
784 replaced += 1;
785 } else {
786 kept.push(cf.clone());
787 }
788 }
789 if replaced > 0 {
790 sheet.set_conditional_formatting_collection(kept);
791 }
792 }
793
794 let dxf_style = conditional_format::build_simple_dxf_style(&fill_argb, &font_argb, bold);
795 match desired_kind {
796 umya_spreadsheet::ConditionalFormatValues::Expression => {
797 conditional_format::append_cf_expression_rule(
798 sheet,
799 &sqref,
800 &desired_formula,
801 dxf_style,
802 );
803 }
804 umya_spreadsheet::ConditionalFormatValues::CellIs => {
805 conditional_format::append_cf_cellis_rule(
806 sheet,
807 &sqref,
808 desired_operator
809 .clone()
810 .unwrap_or(ConditionalFormattingOperatorValues::LessThan),
811 &desired_formula,
812 dxf_style,
813 );
814 }
815 _ => unreachable!("only expression and cellIs are supported"),
816 }
817
818 Ok((1, replaced, 0))
819}
820
821fn normalize_dv_formula(field: &str, value: &str, warnings: &mut Vec<String>) -> String {
822 let trimmed = value.trim();
823 if let Some(stripped) = trimmed.strip_prefix('=') {
824 warnings.push(format!(
825 "WARN_VALIDATION_FORMULA_PREFIX: Stripped leading '=' from {field}"
826 ));
827 stripped.to_string()
828 } else {
829 trimmed.to_string()
830 }
831}
832
833fn set_data_validation(
834 sheet: &mut umya_spreadsheet::Worksheet,
835 target_range: &str,
836 spec: &DataValidationSpec,
837 warnings: &mut Vec<String>,
838) -> Result<(u64, u64)> {
839 let sqref = normalize_sqref(target_range)?;
840
841 if sheet.get_data_validations_mut().is_none() {
842 sheet.set_data_validations(DataValidations::default());
843 }
844 let dvs = sheet
845 .get_data_validations_mut()
846 .ok_or_else(|| anyhow!("failed to initialize data validations"))?;
847
848 let list = dvs.get_data_validation_list_mut();
850 let before = list.len();
851 list.retain(|dv| {
852 let existing = dv.get_sequence_of_references().get_sqref();
853 let existing_norm = existing.replace(' ', "").to_ascii_uppercase();
854 existing_norm != sqref
855 });
856 let removed = before.saturating_sub(list.len());
857
858 let mut dv = DataValidation::default();
859 dv.set_type(spec.kind.to_umya());
860 dv.get_sequence_of_references_mut().set_sqref(sqref.clone());
861
862 if let Some(allow_blank) = spec.allow_blank {
863 dv.set_allow_blank(allow_blank);
864 }
865
866 let formula1 = normalize_dv_formula("formula1", &spec.formula1, warnings);
868 dv.set_formula1(formula1);
869 if let Some(f2) = spec.formula2.as_ref() {
870 let formula2 = normalize_dv_formula("formula2", f2, warnings);
871 if !formula2.is_empty() {
872 dv.set_formula2(formula2);
873 }
874 }
875
876 match spec.kind {
878 DataValidationKind::Whole | DataValidationKind::Decimal | DataValidationKind::Date => {
879 let op = if spec.formula2.as_ref().is_some_and(|s| !s.trim().is_empty()) {
880 DataValidationOperatorValues::Between
881 } else {
882 DataValidationOperatorValues::Equal
883 };
884 dv.set_operator(op);
885 }
886 DataValidationKind::List | DataValidationKind::Custom => {}
887 }
888
889 if let Some(prompt) = spec.prompt.as_ref() {
890 dv.set_show_input_message(true);
891 if !prompt.title.is_empty() {
892 dv.set_prompt_title(prompt.title.clone());
893 }
894 if !prompt.message.is_empty() {
895 dv.set_prompt(prompt.message.clone());
896 }
897 }
898
899 if let Some(error) = spec.error.as_ref() {
900 dv.set_show_error_message(true);
901 if !error.title.is_empty() {
902 dv.set_error_title(error.title.clone());
903 }
904 if !error.message.is_empty() {
905 dv.set_error_message(error.message.clone());
906 }
907 }
908
909 dvs.add_data_validation_list(dv);
910
911 Ok((1, if removed > 0 { 1 } else { 0 }))
912}
913
914impl DataValidationKind {
915 fn to_umya(self) -> DataValidationValues {
916 match self {
917 DataValidationKind::List => DataValidationValues::List,
918 DataValidationKind::Whole => DataValidationValues::Whole,
919 DataValidationKind::Decimal => DataValidationValues::Decimal,
920 DataValidationKind::Date => DataValidationValues::Date,
921 DataValidationKind::Custom => DataValidationValues::Custom,
922 }
923 }
924}