1pub mod filters;
2#[cfg(feature = "recalc")]
3pub mod fork;
4pub mod param_enums;
5#[cfg(feature = "recalc")]
6pub mod rules_batch;
7#[cfg(feature = "recalc")]
8pub mod sheet_layout;
9#[cfg(feature = "recalc")]
10pub mod structure_impact;
11pub mod vba;
12#[cfg(feature = "recalc")]
13pub mod write_normalize;
14
15use crate::analysis::{formula::FormulaGraph, stats};
16use crate::config::OutputProfile;
17use crate::model::*;
18use crate::state::AppState;
19use crate::utils::column_number_to_name;
20use crate::verification::{VerifyOptions, VerifyResponse, compare_workbooks};
21use crate::workbook::{WorkbookContext, cell_to_value};
22use anyhow::{Context, Result, anyhow};
23use regex::Regex;
24use schemars::JsonSchema;
25use serde::Deserialize;
26use std::cmp::Ordering;
27use std::collections::{BTreeMap, HashMap, HashSet};
28use std::path::PathBuf;
29use std::sync::Arc;
30
31#[cfg(feature = "recalc")]
32fn fork_recalc_needed(state: &AppState, workbook_or_fork_id: &WorkbookId) -> bool {
33 state
34 .fork_registry()
35 .and_then(|registry| registry.get_fork(workbook_or_fork_id.as_str()).ok())
36 .is_some_and(|ctx| ctx.recalc_needed)
37}
38
39#[cfg(feature = "recalc")]
40fn sheet_has_formula_in_bounds(sheet: &umya_spreadsheet::Worksheet, bounds: &[CellBounds]) -> bool {
41 if bounds.is_empty() {
42 return false;
43 }
44 for cell in sheet.get_cell_collection() {
45 if !cell.is_formula() {
46 continue;
47 }
48 let address = cell.get_coordinate().get_coordinate().to_string();
49 let Some((col, row)) = parse_address(&address) else {
50 continue;
51 };
52 if bounds
53 .iter()
54 .any(|b| col >= b.0.0 && col <= b.1.0 && row >= b.0.1 && row <= b.1.1)
55 {
56 return true;
57 }
58 }
59 false
60}
61
62const DEFAULT_TRACE_PAGE_SIZE: usize = 20;
63const TRACE_PAGE_MIN: usize = 5;
64const TRACE_PAGE_MAX: usize = 200;
65const TRACE_RANGE_THRESHOLD: usize = 4;
66const TRACE_RANGE_HIGHLIGHT_LIMIT: usize = 3;
67const TRACE_GROUP_HIGHLIGHT_LIMIT: usize = 3;
68const TRACE_CELL_HIGHLIGHT_LIMIT: usize = 5;
69const TRACE_RANGE_VALUE_SAMPLES: usize = 3;
70const TRACE_RANGE_FORMULA_SAMPLES: usize = 2;
71const TRACE_GROUP_SAMPLE_LIMIT: usize = 5;
72const TRACE_DEPENDENTS_PER_CELL_LIMIT: usize = 500;
73
74const DEFAULT_OVERVIEW_MAX_REGIONS: u32 = 25;
75const DEFAULT_OVERVIEW_MAX_HEADERS: u32 = 50;
76const DEFAULT_OVERVIEW_INCLUDE_HEADERS: bool = true;
77
78const ENTRY_POINT_MAX_ROWS: u32 = 10_000;
79const ENTRY_POINT_MAX_COLS: u32 = 200;
80
81#[cfg(feature = "recalc")]
82type CellBounds = ((u32, u32), (u32, u32));
83
84pub async fn list_workbooks(
85 state: Arc<AppState>,
86 params: ListWorkbooksParams,
87) -> Result<WorkbookListResponse> {
88 let config = state.config();
89 let output_profile = config.output_profile();
90 let include_paths = params
91 .include_paths
92 .unwrap_or(!matches!(output_profile, OutputProfile::TokenDense));
93
94 let offset = params.offset.unwrap_or(0) as usize;
95 let limit = params.limit.unwrap_or(100) as usize;
96 let filter = params.into_filter()?;
97 let mut response = state.list_workbooks(filter)?;
98 let total_count = response.workbooks.len();
99
100 if offset < total_count {
101 let end = (offset + limit).min(total_count);
102 response.workbooks = response
103 .workbooks
104 .into_iter()
105 .skip(offset)
106 .take(end - offset)
107 .collect();
108 } else {
109 response.workbooks.clear();
110 }
111
112 if !include_paths {
114 for wb in &mut response.workbooks {
115 wb.path = None;
116 wb.caps = None;
117 wb.client_path = None;
118 }
119 } else if !config.path_mappings.is_empty() {
120 for wb in &mut response.workbooks {
121 if let Some(p) = wb.path.as_ref() {
122 let abs = config.resolve_path(PathBuf::from(p));
123 wb.client_path = config
124 .map_path_for_client(&abs)
125 .map(|mapped| mapped.display().to_string());
126 }
127 }
128 }
129
130 response.next_offset = if offset + response.workbooks.len() < total_count {
132 Some((offset + response.workbooks.len()) as u32)
133 } else {
134 None
135 };
136
137 Ok(response)
138}
139
140pub async fn describe_workbook(
141 state: Arc<AppState>,
142 params: DescribeWorkbookParams,
143) -> Result<WorkbookDescription> {
144 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
145 let mut desc = workbook.describe();
146 let config = state.config();
147 if !config.path_mappings.is_empty() {
148 let internal = PathBuf::from(&desc.path);
149 desc.client_path = config
150 .map_path_for_client(&internal)
151 .map(|mapped| mapped.display().to_string());
152 }
153 Ok(desc)
154}
155
156#[derive(Debug, Deserialize, JsonSchema)]
157pub struct ListWorkbooksParams {
158 pub slug_prefix: Option<String>,
160 pub folder: Option<String>,
162 pub path_glob: Option<String>,
164 #[serde(default)]
166 pub limit: Option<u32>,
167 #[serde(default)]
169 pub offset: Option<u32>,
170 #[serde(default)]
172 pub include_paths: Option<bool>,
173}
174
175impl ListWorkbooksParams {
176 fn into_filter(self) -> Result<filters::WorkbookFilter> {
177 filters::WorkbookFilter::new(self.slug_prefix, self.folder, self.path_glob)
178 }
179}
180
181#[derive(Debug, Deserialize, JsonSchema)]
182pub struct DescribeWorkbookParams {
183 #[serde(alias = "workbook_id")]
184 pub workbook_or_fork_id: WorkbookId,
185}
186
187#[derive(Debug, Deserialize, JsonSchema)]
188pub struct ListSheetsParams {
189 #[serde(alias = "workbook_id")]
191 pub workbook_or_fork_id: WorkbookId,
192 #[serde(default)]
194 pub limit: Option<u32>,
195 #[serde(default)]
197 pub offset: Option<u32>,
198 #[serde(default)]
200 pub include_bounds: Option<bool>,
201}
202
203pub async fn list_sheets(
204 state: Arc<AppState>,
205 params: ListSheetsParams,
206) -> Result<SheetListResponse> {
207 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
208 let config = state.config();
209 let output_profile = config.output_profile();
210 let include_bounds = params
211 .include_bounds
212 .unwrap_or(!matches!(output_profile, OutputProfile::TokenDense));
213 let mut summaries = workbook.list_summaries(include_bounds)?;
214
215 let total_count = summaries.len();
216 let offset = params.offset.unwrap_or(0) as usize;
217 let limit = params.limit.unwrap_or(100) as usize;
218
219 if offset < total_count {
220 summaries = summaries.into_iter().skip(offset).take(limit).collect();
221 } else {
222 summaries.clear();
223 }
224
225 let next_offset = if offset + summaries.len() < total_count {
226 Some((offset + summaries.len()) as u32)
227 } else {
228 None
229 };
230
231 let response = SheetListResponse {
232 workbook_id: workbook.id.clone(),
233 sheets: summaries,
234 next_offset,
235 };
236 Ok(response)
237}
238
239#[derive(Debug, Deserialize, JsonSchema)]
240pub struct SheetOverviewParams {
241 #[serde(alias = "workbook_id")]
243 pub workbook_or_fork_id: WorkbookId,
244 pub sheet_name: String,
246 #[serde(default)]
248 pub max_regions: Option<u32>,
249 #[serde(default)]
251 pub max_headers: Option<u32>,
252 #[serde(default)]
254 pub include_headers: Option<bool>,
255}
256
257#[derive(Debug, Deserialize, JsonSchema)]
258pub struct WorkbookSummaryParams {
259 #[serde(alias = "workbook_id")]
261 pub workbook_or_fork_id: WorkbookId,
262 #[serde(default)]
264 pub summary_only: Option<bool>,
265 #[serde(default)]
267 pub include_entry_points: Option<bool>,
268 #[serde(default)]
270 pub include_named_ranges: Option<bool>,
271}
272
273pub async fn workbook_summary(
274 state: Arc<AppState>,
275 params: WorkbookSummaryParams,
276) -> Result<WorkbookSummaryResponse> {
277 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
278 let config = state.config();
279 let output_profile = config.output_profile();
280 let summary_only = params
281 .summary_only
282 .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
283 let include_entry_points = params.include_entry_points.unwrap_or(!summary_only);
284 let include_named_ranges = params.include_named_ranges.unwrap_or(!summary_only);
285
286 tokio::task::spawn_blocking(move || {
287 build_workbook_summary(workbook, include_entry_points, include_named_ranges)
288 })
289 .await?
290}
291
292fn build_workbook_summary(
293 workbook: Arc<WorkbookContext>,
294 include_entry_points: bool,
295 include_named_ranges: bool,
296) -> Result<WorkbookSummaryResponse> {
297 let sheet_names = workbook.sheet_names();
298
299 let mut total_cells: u64 = 0;
300 let mut total_formulas: u64 = 0;
301 let mut breakdown = WorkbookBreakdown::default();
302 let mut region_counts = RegionCountSummary::default();
303 let mut entry_points: Vec<EntryPoint> = Vec::new();
304 let mut key_named_ranges: Vec<NamedRangeDescriptor> = Vec::new();
305 let mut notes: Vec<String> = Vec::new();
306
307 for sheet_name in &sheet_names {
308 let entry = workbook.get_sheet_metrics_fast(sheet_name)?;
309 total_cells += (entry.metrics.row_count as u64) * (entry.metrics.column_count as u64);
310 total_formulas += entry.metrics.formula_cells as u64;
311
312 match entry.metrics.classification {
313 SheetClassification::Calculator => breakdown.calculator_sheets += 1,
314 SheetClassification::Metadata => breakdown.metadata_sheets += 1,
315 SheetClassification::Empty => {}
316 _ => breakdown.data_sheets += 1,
317 }
318
319 if entry.metrics.non_empty_cells == 0 {
320 continue;
321 }
322
323 match entry.metrics.classification {
324 SheetClassification::Calculator => region_counts.calculator += 1,
325 SheetClassification::Metadata => region_counts.metadata += 1,
326 SheetClassification::Empty => {}
327 _ => region_counts.data += 1,
328 }
329
330 if include_entry_points {
331 let priority = entry_point_priority(&entry.metrics.classification);
332 entry_points.push(EntryPoint {
333 sheet_name: sheet_name.clone(),
334 region_id: None,
335 bounds: entry_point_bounds(&entry.metrics),
336 rationale: format!(
337 "Fast summary p{}: {:?} sheet",
338 priority, entry.metrics.classification
339 ),
340 });
341 }
342 }
343
344 if include_entry_points {
345 entry_points.sort_by(|a, b| {
346 let pa = priority_from_rationale(&a.rationale);
347 let pb = priority_from_rationale(&b.rationale);
348 pa.cmp(&pb)
349 .then_with(|| {
350 a.bounds
351 .as_ref()
352 .map(|_| 1)
353 .cmp(&b.bounds.as_ref().map(|_| 1))
354 })
355 .then_with(|| a.sheet_name.cmp(&b.sheet_name))
356 });
357 entry_points.truncate(5);
358 }
359
360 if include_named_ranges {
361 let mut seen_ranges = std::collections::HashSet::new();
362 for item in workbook.named_items()? {
363 if item.kind != NamedItemKind::NamedRange && item.kind != NamedItemKind::Table {
364 continue;
365 }
366 if !seen_ranges.insert(item.refers_to.clone()) {
367 continue;
368 }
369 key_named_ranges.push(item);
370 if key_named_ranges.len() >= 10 {
371 break;
372 }
373 }
374 }
375
376 notes.push("Region counts and entry points are inferred from sheet metrics; use sheet_overview for full region detection.".to_string());
377
378 Ok(WorkbookSummaryResponse {
379 workbook_id: workbook.id.clone(),
380 slug: workbook.slug.clone(),
381 sheet_count: sheet_names.len(),
382 total_cells,
383 total_formulas,
384 breakdown,
385 region_counts,
386 key_named_ranges,
387 suggested_entry_points: entry_points,
388 notes,
389 })
390}
391
392fn entry_point_priority(classification: &SheetClassification) -> u32 {
393 match classification {
394 SheetClassification::Data => 1,
395 SheetClassification::Mixed => 2,
396 SheetClassification::Calculator => 3,
397 SheetClassification::Metadata => 4,
398 SheetClassification::Empty => 5,
399 }
400}
401
402fn entry_point_bounds(metrics: &crate::workbook::SheetMetrics) -> Option<String> {
403 if metrics.row_count == 0 || metrics.column_count == 0 {
404 return None;
405 }
406 if metrics.row_count > ENTRY_POINT_MAX_ROWS || metrics.column_count > ENTRY_POINT_MAX_COLS {
407 return None;
408 }
409 let end_col = column_number_to_name(metrics.column_count.max(1));
410 let end_cell = format!("{}{}", end_col, metrics.row_count.max(1));
411 Some(format!("A1:{}", end_cell))
412}
413
414fn priority_from_rationale(rationale: &str) -> u32 {
415 if rationale.contains("p0") {
416 0
417 } else if rationale.contains("p1") {
418 1
419 } else if rationale.contains("p2") {
420 2
421 } else if rationale.contains("p3") {
422 3
423 } else if rationale.contains("p4") {
424 4
425 } else {
426 5
427 }
428}
429
430pub async fn sheet_overview(
431 state: Arc<AppState>,
432 params: SheetOverviewParams,
433) -> Result<SheetOverviewResponse> {
434 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
435 let sheet_name = params.sheet_name.clone();
436 let mut overview =
437 tokio::task::spawn_blocking(move || workbook.sheet_overview(&sheet_name)).await??;
438
439 let max_regions = params
440 .max_regions
441 .unwrap_or(DEFAULT_OVERVIEW_MAX_REGIONS)
442 .max(1);
443 let max_headers = params
444 .max_headers
445 .unwrap_or(DEFAULT_OVERVIEW_MAX_HEADERS)
446 .max(1);
447 let include_headers = params
448 .include_headers
449 .unwrap_or(DEFAULT_OVERVIEW_INCLUDE_HEADERS);
450
451 let region_limit = if params.max_regions == Some(0) {
452 usize::MAX
453 } else {
454 max_regions as usize
455 };
456 let header_limit = if params.max_headers == Some(0) {
457 usize::MAX
458 } else {
459 max_headers as usize
460 };
461
462 let total_regions = overview.detected_regions.len() as u32;
463 let mut headers_truncated = false;
464
465 for region in &mut overview.detected_regions {
466 let header_count = region.header_count.max(region.headers.len() as u32);
467 region.header_count = header_count;
468 if !include_headers {
469 region.headers.clear();
470 } else if region.headers.len() > header_limit {
471 region.headers.truncate(header_limit);
472 }
473 region.headers_truncated = region.headers.len() as u32 != header_count;
474 headers_truncated |= region.headers_truncated;
475 }
476
477 let regions_truncated = if overview.detected_regions.len() > region_limit {
478 overview.detected_regions.truncate(region_limit);
479 true
480 } else {
481 false
482 };
483
484 overview.detected_region_count = total_regions;
485 overview.detected_regions_truncated = regions_truncated;
486
487 if regions_truncated {
488 overview.notes.push(format!(
489 "Detected regions truncated to {} ({} total).",
490 region_limit, total_regions
491 ));
492 }
493 if headers_truncated {
494 overview.notes.push(format!(
495 "Region headers truncated to {} columns.",
496 header_limit
497 ));
498 }
499
500 Ok(overview)
501}
502
503fn default_start_row() -> u32 {
504 1
505}
506
507fn default_page_size() -> u32 {
508 50
509}
510
511fn default_include_formulas() -> bool {
512 true
513}
514
515fn default_include_header() -> bool {
516 true
517}
518
519#[derive(Debug, Deserialize, JsonSchema)]
520pub struct SheetPageParams {
521 #[serde(alias = "workbook_id")]
523 pub workbook_or_fork_id: WorkbookId,
524 pub sheet_name: String,
526 #[serde(default = "default_start_row")]
528 pub start_row: u32,
529 #[serde(default = "default_page_size")]
531 pub page_size: u32,
532 #[serde(default)]
534 pub columns: Option<Vec<String>>,
535 #[serde(default)]
537 pub columns_by_header: Option<Vec<String>>,
538 #[serde(default = "default_include_formulas")]
540 pub include_formulas: bool,
541 #[serde(default)]
543 pub include_styles: bool,
544 #[serde(default = "default_include_header")]
546 pub include_header: bool,
547 #[serde(default)]
549 pub format: Option<SheetPageFormat>,
550}
551
552impl Default for SheetPageParams {
553 fn default() -> Self {
554 SheetPageParams {
555 workbook_or_fork_id: WorkbookId(String::new()),
556 sheet_name: String::new(),
557 start_row: default_start_row(),
558 page_size: default_page_size(),
559 columns: None,
560 columns_by_header: None,
561 include_formulas: default_include_formulas(),
562 include_styles: false,
563 include_header: default_include_header(),
564 format: None,
565 }
566 }
567}
568
569fn default_find_limit() -> u32 {
570 50
571}
572
573#[derive(Debug, Deserialize, JsonSchema)]
574pub struct FindValueParams {
575 #[serde(alias = "workbook_id")]
577 pub workbook_or_fork_id: WorkbookId,
578 pub query: String,
580 #[serde(default)]
582 pub label: Option<String>,
583 #[serde(default)]
585 pub mode: Option<FindMode>,
586 #[serde(default)]
588 pub match_mode: Option<MatchMode>,
589 #[serde(default)]
591 pub case_sensitive: bool,
592 #[serde(default)]
594 pub sheet_name: Option<String>,
595 #[serde(default)]
597 pub region_id: Option<u32>,
598 #[serde(default)]
600 pub table_name: Option<String>,
601 #[serde(default)]
603 pub value_types: Option<Vec<ValueTypeFilter>>,
604 #[serde(default)]
606 pub search_headers_only: bool,
607 #[serde(default)]
609 pub direction: Option<LabelDirection>,
610 #[serde(default = "default_find_limit")]
612 pub limit: u32,
613 #[serde(default)]
615 pub offset: Option<u32>,
616 #[serde(default)]
618 pub context: Option<FindContext>,
619 #[serde(default)]
621 pub context_width: Option<u32>,
622}
623
624impl Default for FindValueParams {
625 fn default() -> Self {
626 Self {
627 workbook_or_fork_id: WorkbookId(String::new()),
628 query: String::new(),
629 label: None,
630 mode: None,
631 match_mode: None,
632 case_sensitive: false,
633 sheet_name: None,
634 region_id: None,
635 table_name: None,
636 value_types: None,
637 search_headers_only: false,
638 direction: None,
639 limit: default_find_limit(),
640 offset: None,
641 context: None,
642 context_width: None,
643 }
644 }
645}
646
647#[derive(Debug, Deserialize, JsonSchema, Default)]
648pub struct ReadTableParams {
649 #[serde(alias = "workbook_id")]
651 pub workbook_or_fork_id: WorkbookId,
652 #[serde(default)]
654 pub sheet_name: Option<String>,
655 #[serde(default)]
657 pub table_name: Option<String>,
658 #[serde(default)]
660 pub region_id: Option<u32>,
661 #[serde(default)]
663 pub range: Option<String>,
664 #[serde(default)]
666 pub header_row: Option<u32>,
667 #[serde(default)]
669 pub header_rows: Option<u32>,
670 #[serde(default)]
672 pub columns: Option<Vec<String>>,
673 #[serde(default)]
675 pub filters: Option<Vec<TableFilter>>,
676 #[serde(default)]
678 pub sample_mode: Option<SampleMode>,
679 #[serde(default)]
681 pub limit: Option<u32>,
682 #[serde(default)]
684 pub offset: Option<u32>,
685 #[serde(default)]
687 pub format: Option<TableOutputFormat>,
688 #[serde(default)]
690 pub include_headers: Option<bool>,
691 #[serde(default)]
693 pub include_types: Option<bool>,
694}
695
696#[derive(Debug, Deserialize, JsonSchema, Clone)]
697pub struct TableFilter {
698 pub column: String,
700 pub op: FilterOp,
702 pub value: serde_json::Value,
704}
705
706#[derive(Debug, Deserialize, JsonSchema, Default)]
707pub struct TableProfileParams {
708 #[serde(alias = "workbook_id")]
710 pub workbook_or_fork_id: WorkbookId,
711 #[serde(default)]
713 pub sheet_name: Option<String>,
714 #[serde(default)]
716 pub region_id: Option<u32>,
717 #[serde(default)]
719 pub table_name: Option<String>,
720 #[serde(default)]
722 pub sample_mode: Option<SampleMode>,
723 #[serde(default)]
725 pub sample_size: Option<u32>,
726 #[serde(default)]
728 pub summary_only: Option<bool>,
729}
730
731#[derive(Debug, Deserialize, JsonSchema)]
732pub struct RangeValuesParams {
733 #[serde(alias = "workbook_id")]
735 pub workbook_or_fork_id: WorkbookId,
736 pub sheet_name: String,
738 pub ranges: Vec<String>,
740 #[serde(default)]
742 pub include_headers: Option<bool>,
743 #[serde(default)]
745 pub include_formulas: Option<bool>,
746 #[serde(default)]
748 pub format: Option<TableOutputFormat>,
749 #[serde(default)]
751 pub page_size: Option<u32>,
752}
753
754#[derive(Debug, Deserialize, JsonSchema)]
755pub struct InspectCellsParams {
756 #[serde(alias = "workbook_id")]
758 pub workbook_or_fork_id: WorkbookId,
759 pub sheet_name: String,
761 pub targets: Vec<String>,
763 #[serde(default)]
765 pub include_empty: Option<bool>,
766 #[serde(default)]
769 pub budget: Option<u32>,
770}
771
772pub async fn sheet_page(
773 state: Arc<AppState>,
774 params: SheetPageParams,
775) -> Result<SheetPageResponse> {
776 if params.page_size == 0 {
777 return Err(anyhow!("page_size must be greater than zero"));
778 }
779
780 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
781 let metrics = workbook.get_sheet_metrics_fast(¶ms.sheet_name)?;
782 let config = state.config();
783 let output_profile = config.output_profile();
784 let format = params.format.unwrap_or(match output_profile {
785 OutputProfile::TokenDense => SheetPageFormat::Compact,
786 OutputProfile::Verbose => SheetPageFormat::Full,
787 });
788
789 let start_row = params.start_row.max(1);
790 let page_size = params.page_size.min(500);
791 let include_formulas =
792 if params.format.is_none() && matches!(output_profile, OutputProfile::TokenDense) {
793 false
794 } else {
795 params.include_formulas
796 };
797 let include_styles =
798 if params.format.is_none() && matches!(output_profile, OutputProfile::TokenDense) {
799 false
800 } else {
801 params.include_styles
802 };
803 let columns = params.columns.clone();
804 let columns_by_header = params.columns_by_header.clone();
805 let include_header = params.include_header;
806
807 let mut page = workbook.with_sheet(¶ms.sheet_name, |sheet| {
808 build_page(
809 sheet,
810 start_row,
811 page_size,
812 columns.clone(),
813 columns_by_header.clone(),
814 include_formulas,
815 include_styles,
816 include_header,
817 )
818 })?;
819
820 let max_cells = config.max_cells();
821 let max_payload_bytes = config.max_payload_bytes();
822 let cells_per_row = page.rows.first().map(|row| row.cells.len()).unwrap_or(0);
823 let original_row_count = page.rows.len();
824 let mut row_limit = cap_rows_by_cells(page.rows.len(), cells_per_row, max_cells);
825
826 if row_limit > 0 {
827 row_limit = cap_rows_by_payload_bytes(row_limit, max_payload_bytes, |count| {
828 let response = build_sheet_page_response(
829 &workbook,
830 ¶ms.sheet_name,
831 format,
832 include_header,
833 &page.header,
834 &page.rows[..count],
835 None,
836 );
837 serde_json::to_vec(&response)
838 .map(|payload| payload.len())
839 .unwrap_or(usize::MAX)
840 });
841 }
842
843 let truncated = row_limit < original_row_count;
844 if truncated {
845 page.rows.truncate(row_limit);
846 }
847
848 let last_row_index = page
849 .rows
850 .last()
851 .map(|row| row.row_index)
852 .unwrap_or(start_row.saturating_sub(1));
853 let next_start_row = if last_row_index < metrics.metrics.row_count {
854 Some(last_row_index + 1)
855 } else {
856 None
857 };
858
859 let rows_returned = page.rows.len();
860 let cells_returned = rows_returned * cells_per_row;
861 let total_rows_available = metrics.metrics.row_count;
862
863 let budget = if truncated || max_cells.is_some() || max_payload_bytes.is_some() {
865 let continuation = next_start_row.map(|nsr| {
866 format!(
867 "use start_row={} to fetch the next page ({} rows remaining)",
868 nsr,
869 total_rows_available.saturating_sub(last_row_index)
870 )
871 });
872 Some(ReadBudget {
873 max_cells,
874 max_payload_bytes,
875 rows_returned,
876 cells_returned,
877 total_rows_available: Some(total_rows_available),
878 continuation,
879 })
880 } else {
881 None
882 };
883
884 let mut response = build_sheet_page_response(
885 &workbook,
886 ¶ms.sheet_name,
887 format,
888 include_header,
889 &page.header,
890 &page.rows,
891 next_start_row,
892 );
893 response.truncated = truncated;
894 response.budget = budget;
895 Ok(response)
896}
897
898#[derive(Debug, Deserialize, JsonSchema)]
899pub struct SheetFormulaMapParams {
900 #[serde(alias = "workbook_id")]
902 pub workbook_or_fork_id: WorkbookId,
903 pub sheet_name: String,
905 pub range: Option<String>,
907 #[serde(default)]
909 pub expand: bool,
910 #[serde(default)]
912 pub limit: Option<u32>,
913 #[serde(default)]
915 pub sort_by: Option<FormulaSortBy>,
916 #[serde(default)]
918 pub summary_only: Option<bool>,
919 #[serde(default)]
921 pub include_addresses: Option<bool>,
922 #[serde(default)]
924 pub addresses_limit: Option<u32>,
925 #[serde(default)]
927 pub formula_parse_policy: Option<FormulaParsePolicy>,
928}
929
930#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
931#[serde(rename_all = "snake_case")]
932pub enum FormulaSortBy {
933 #[default]
934 Address,
935 Complexity,
936 Count,
937}
938
939#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)]
941#[serde(rename_all = "snake_case")]
942pub enum MatchMode {
943 #[default]
945 Contains,
946 Exact,
948 Prefix,
950 Regex,
952}
953
954#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)]
956#[serde(rename_all = "snake_case")]
957pub enum FindContext {
958 #[default]
960 None,
961 Neighbors,
963 Row,
965 Both,
967}
968
969#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)]
971#[serde(rename_all = "snake_case")]
972pub enum SampleMode {
973 #[default]
975 First,
976 Last,
978 Distributed,
980}
981
982#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)]
984#[serde(rename_all = "snake_case")]
985pub enum StyleGranularity {
986 #[default]
988 Runs,
989 Cells,
991}
992
993#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)]
995#[serde(rename_all = "snake_case")]
996pub enum FilterOp {
997 Eq,
999 #[serde(alias = "ne")]
1001 Neq,
1002 Gt,
1004 Lt,
1006 Gte,
1008 Lte,
1010 Contains,
1012 StartsWith,
1014 EndsWith,
1016 In,
1018}
1019
1020#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)]
1022#[serde(rename_all = "snake_case")]
1023pub enum ValueTypeFilter {
1024 Text,
1025 Number,
1026 Bool,
1027 Date,
1028 Null,
1029}
1030
1031pub async fn sheet_formula_map(
1032 state: Arc<AppState>,
1033 params: SheetFormulaMapParams,
1034) -> Result<SheetFormulaMapResponse> {
1035 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
1036 let config = state.config();
1037 let output_profile = config.output_profile();
1038 let summary_only = params
1039 .summary_only
1040 .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
1041 let include_addresses = params.include_addresses.unwrap_or(!summary_only);
1042 let addresses_limit = params.addresses_limit.unwrap_or(15);
1043 let max_items = config.max_items();
1044 let max_payload_bytes = config.max_payload_bytes();
1045
1046 let policy = params
1047 .formula_parse_policy
1048 .unwrap_or(FormulaParsePolicy::Warn);
1049 let (graph, diagnostics) =
1050 workbook.formula_graph_with_diagnostics(¶ms.sheet_name, policy)?;
1051 let formula_parse_diagnostics = if diagnostics.total_errors > 0 {
1052 Some(diagnostics)
1053 } else {
1054 None
1055 };
1056 let all_groups = graph.groups();
1057 let mut groups = Vec::new();
1058
1059 for mut group in all_groups {
1060 if let Some(range) = ¶ms.range {
1061 group.addresses.retain(|addr| address_in_range(addr, range));
1062 if group.addresses.is_empty() {
1063 continue;
1064 }
1065 }
1066
1067 let address_count = group.addresses.len();
1068
1069 if summary_only || !include_addresses {
1070 group.addresses.clear();
1071 } else if !params.expand && address_count > addresses_limit as usize {
1072 group.addresses.truncate(addresses_limit as usize);
1073 }
1074
1075 groups.push(group);
1076 }
1077
1078 let total_groups = groups.len();
1079
1080 let sort_by = params.sort_by.unwrap_or_default();
1081 match sort_by {
1082 FormulaSortBy::Address => {
1083 groups.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
1084 }
1085 FormulaSortBy::Complexity => {
1086 groups.sort_by(|a, b| b.formula.len().cmp(&a.formula.len()));
1087 }
1088 FormulaSortBy::Count => {
1089 groups.sort_by(|a, b| {
1090 let count_a = a.count.unwrap_or(a.addresses.len() as u32);
1091 let count_b = b.count.unwrap_or(b.addresses.len() as u32);
1092 count_b.cmp(&count_a)
1093 });
1094 }
1095 }
1096
1097 if let Some(limit) = params.limit
1098 && groups.len() > limit as usize
1099 {
1100 groups.truncate(limit as usize);
1101 }
1102
1103 if let Some(max_items) = max_items
1104 && groups.len() > max_items
1105 {
1106 groups.truncate(max_items);
1107 }
1108
1109 if let Some(max_bytes) = max_payload_bytes {
1110 let group_limit = cap_rows_by_payload_bytes(groups.len(), Some(max_bytes), |count| {
1111 let response = SheetFormulaMapResponse {
1112 workbook_id: workbook.id.clone(),
1113 sheet_name: params.sheet_name.clone(),
1114 groups: groups[..count].to_vec(),
1115 formula_parse_diagnostics: formula_parse_diagnostics.clone(),
1116 next_offset: None,
1117 };
1118 serde_json::to_vec(&response)
1119 .map(|payload| payload.len())
1120 .unwrap_or(usize::MAX)
1121 });
1122
1123 if group_limit < groups.len() {
1124 groups.truncate(group_limit);
1125 }
1126 }
1127
1128 let next_offset = if groups.len() < total_groups {
1129 Some(groups.len() as u32)
1130 } else {
1131 None
1132 };
1133
1134 let response = SheetFormulaMapResponse {
1135 workbook_id: workbook.id.clone(),
1136 sheet_name: params.sheet_name.clone(),
1137 groups,
1138 formula_parse_diagnostics,
1139 next_offset,
1140 };
1141 Ok(response)
1142}
1143
1144#[derive(Debug, Deserialize, JsonSchema)]
1145pub struct FormulaTraceParams {
1146 #[serde(alias = "workbook_id")]
1147 pub workbook_or_fork_id: WorkbookId,
1148 pub sheet_name: String,
1149 pub cell_address: String,
1150 pub direction: TraceDirection,
1151 pub depth: Option<u32>,
1152 pub limit: Option<u32>,
1153 #[serde(default)]
1154 pub page_size: Option<usize>,
1155 #[serde(default)]
1156 pub cursor: Option<TraceCursor>,
1157 #[serde(default)]
1159 pub formula_parse_policy: Option<FormulaParsePolicy>,
1160}
1161
1162pub async fn formula_trace(
1163 state: Arc<AppState>,
1164 params: FormulaTraceParams,
1165) -> Result<FormulaTraceResponse> {
1166 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
1167 let policy = params
1168 .formula_parse_policy
1169 .unwrap_or(FormulaParsePolicy::Warn);
1170 let (graph, diagnostics) =
1171 workbook.formula_graph_with_diagnostics(¶ms.sheet_name, policy)?;
1172 let formula_parse_diagnostics = if diagnostics.total_errors > 0 {
1173 Some(diagnostics)
1174 } else {
1175 None
1176 };
1177 let formula_lookup = build_formula_lookup(&graph);
1178 let depth = params.depth.unwrap_or(3).clamp(1, 5);
1179 let page_size = params
1180 .page_size
1181 .or_else(|| params.limit.map(|v| v as usize))
1182 .unwrap_or(DEFAULT_TRACE_PAGE_SIZE)
1183 .clamp(TRACE_PAGE_MIN, TRACE_PAGE_MAX);
1184
1185 let origin = params.cell_address.to_uppercase();
1186 let config = TraceConfig {
1187 direction: ¶ms.direction,
1188 origin: &origin,
1189 sheet_name: ¶ms.sheet_name,
1190 depth_limit: depth,
1191 page_size,
1192 };
1193 let (layers, next_cursor, notes) = build_trace_layers(
1194 &workbook,
1195 &graph,
1196 &formula_lookup,
1197 &config,
1198 params.cursor.clone(),
1199 )?;
1200
1201 let response = FormulaTraceResponse {
1202 workbook_id: workbook.id.clone(),
1203 sheet_name: params.sheet_name.clone(),
1204 origin,
1205 direction: params.direction.clone(),
1206 layers,
1207 next_cursor,
1208 formula_parse_diagnostics,
1209 notes,
1210 };
1211 Ok(response)
1212}
1213
1214#[derive(Debug, Deserialize, JsonSchema)]
1215pub struct NamedRangesParams {
1216 #[serde(alias = "workbook_id")]
1217 pub workbook_or_fork_id: WorkbookId,
1218 pub sheet_name: Option<String>,
1219 pub name_prefix: Option<String>,
1220}
1221
1222pub async fn named_ranges(
1223 state: Arc<AppState>,
1224 params: NamedRangesParams,
1225) -> Result<NamedRangesResponse> {
1226 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
1227 let mut items = workbook.named_items()?;
1228
1229 if let Some(sheet_filter) = ¶ms.sheet_name {
1230 items.retain(|item| {
1231 item.sheet_name
1232 .as_ref()
1233 .map(|name| name.eq_ignore_ascii_case(sheet_filter))
1234 .unwrap_or(false)
1235 });
1236 }
1237 if let Some(prefix) = ¶ms.name_prefix {
1238 let prefix_lower = prefix.to_ascii_lowercase();
1239 items.retain(|item| item.name.to_ascii_lowercase().starts_with(&prefix_lower));
1240 }
1241
1242 let response = NamedRangesResponse {
1243 workbook_id: workbook.id.clone(),
1244 items,
1245 };
1246 Ok(response)
1247}
1248
1249#[derive(Debug, Deserialize, JsonSchema)]
1250pub struct VerifyWorkbookParams {
1251 #[serde(alias = "baseline_id")]
1252 pub baseline_workbook_or_fork_id: WorkbookId,
1253 #[serde(alias = "current_id")]
1254 pub current_workbook_or_fork_id: WorkbookId,
1255 #[serde(default)]
1256 pub targets: Vec<String>,
1257 pub sheet_name: Option<String>,
1258 #[serde(default)]
1259 pub include_named_range_deltas: bool,
1260 #[serde(default)]
1261 pub errors_only: bool,
1262 #[serde(default)]
1263 pub targets_only: bool,
1264}
1265
1266pub async fn verify_workbook(
1267 state: Arc<AppState>,
1268 params: VerifyWorkbookParams,
1269) -> Result<VerifyResponse> {
1270 let options = VerifyOptions {
1271 targets: params.targets.clone(),
1272 sheet_filter: params.sheet_name.clone(),
1273 include_named_range_deltas: params.include_named_range_deltas,
1274 errors_only: params.errors_only,
1275 targets_only: params.targets_only,
1276 };
1277 options.validate()?;
1278
1279 let baseline_workbook = state
1280 .open_workbook(¶ms.baseline_workbook_or_fork_id)
1281 .await?;
1282 let current_workbook = state
1283 .open_workbook(¶ms.current_workbook_or_fork_id)
1284 .await?;
1285
1286 let baseline_named = if params.include_named_range_deltas {
1287 Some(
1288 named_ranges(
1289 state.clone(),
1290 NamedRangesParams {
1291 workbook_or_fork_id: params.baseline_workbook_or_fork_id.clone(),
1292 sheet_name: params.sheet_name.clone(),
1293 name_prefix: None,
1294 },
1295 )
1296 .await?,
1297 )
1298 } else {
1299 None
1300 };
1301 let current_named = if params.include_named_range_deltas {
1302 Some(
1303 named_ranges(
1304 state.clone(),
1305 NamedRangesParams {
1306 workbook_or_fork_id: params.current_workbook_or_fork_id.clone(),
1307 sheet_name: params.sheet_name.clone(),
1308 name_prefix: None,
1309 },
1310 )
1311 .await?,
1312 )
1313 } else {
1314 None
1315 };
1316
1317 compare_workbooks(
1318 params.baseline_workbook_or_fork_id.as_str().to_string(),
1319 params.current_workbook_or_fork_id.as_str().to_string(),
1320 &baseline_workbook,
1321 ¤t_workbook,
1322 &options,
1323 baseline_named.as_ref().map(|r| r.items.as_slice()),
1324 current_named.as_ref().map(|r| r.items.as_slice()),
1325 )
1326}
1327
1328#[derive(Debug, Deserialize, JsonSchema)]
1331pub struct DefineNameParams {
1332 #[serde(alias = "workbook_id")]
1333 pub fork_id: WorkbookId,
1334 pub name: String,
1336 pub refers_to: String,
1338 #[serde(default)]
1340 pub scope: Option<String>,
1341 pub scope_sheet_name: Option<String>,
1343}
1344
1345#[derive(Debug, Deserialize, JsonSchema)]
1346pub struct UpdateNameParams {
1347 #[serde(alias = "workbook_id")]
1348 pub fork_id: WorkbookId,
1349 pub name: String,
1351 pub refers_to: Option<String>,
1353 pub scope: Option<String>,
1355 pub scope_sheet_name: Option<String>,
1357}
1358
1359#[derive(Debug, Deserialize, JsonSchema)]
1360pub struct DeleteNameParams {
1361 #[serde(alias = "workbook_id")]
1362 pub fork_id: WorkbookId,
1363 pub name: String,
1365 pub scope: Option<String>,
1367 pub scope_sheet_name: Option<String>,
1369}
1370
1371pub fn parse_scope_kind(scope: Option<&str>) -> Result<NamedRangeScope> {
1372 match scope {
1373 Some("sheet") => Ok(NamedRangeScope::Sheet),
1374 Some("workbook") | None => Ok(NamedRangeScope::Workbook),
1375 Some(other) => Err(anyhow!(
1376 "invalid scope '{}': expected 'workbook' or 'sheet'",
1377 other
1378 )),
1379 }
1380}
1381
1382pub fn parse_scope_kind_optional(scope: Option<&str>) -> Result<Option<NamedRangeScope>> {
1383 match scope {
1384 Some("sheet") => Ok(Some(NamedRangeScope::Sheet)),
1385 Some("workbook") => Ok(Some(NamedRangeScope::Workbook)),
1386 None => Ok(None),
1387 Some(other) => Err(anyhow!(
1388 "invalid scope '{}': expected 'workbook' or 'sheet'",
1389 other
1390 )),
1391 }
1392}
1393
1394fn resolve_sheet_index_on_book(
1395 book: &umya_spreadsheet::Spreadsheet,
1396 sheet_name: &str,
1397) -> Result<u32> {
1398 for (idx, sheet) in book.get_sheet_collection().iter().enumerate() {
1399 if sheet.get_name() == sheet_name {
1400 return Ok(idx as u32);
1401 }
1402 }
1403 Err(anyhow!("sheet '{}' not found", sheet_name))
1404}
1405
1406pub(crate) fn define_name_in_file(
1408 path: &std::path::Path,
1409 name: &str,
1410 refers_to: &str,
1411 scope_kind: NamedRangeScope,
1412 scope_sheet_name: Option<&str>,
1413) -> Result<()> {
1414 let mut book = umya_spreadsheet::reader::xlsx::read(path)
1415 .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
1416
1417 match scope_kind {
1418 NamedRangeScope::Sheet => {
1419 let sn = scope_sheet_name
1420 .ok_or_else(|| anyhow!("scope_sheet_name required for sheet scope"))?;
1421 let sheet_index = resolve_sheet_index_on_book(&book, sn)?;
1422 let sheet = book
1423 .get_sheet_by_name_mut(sn)
1424 .ok_or_else(|| anyhow!("sheet '{}' not found", sn))?;
1425 sheet
1426 .add_defined_name(name.to_string(), refers_to.to_string())
1427 .map_err(|e| anyhow!("failed to add defined name: {e}"))?;
1428 let sheet = book
1430 .get_sheet_by_name_mut(sn)
1431 .ok_or_else(|| anyhow!("sheet '{}' disappeared", sn))?;
1432 if let Some(last) = sheet.get_defined_names_mut().last_mut()
1433 && last.get_name() == name
1434 {
1435 last.set_local_sheet_id(sheet_index);
1436 }
1437 }
1438 NamedRangeScope::Workbook => {
1439 let first_sheet: String = book
1442 .get_sheet_collection()
1443 .first()
1444 .map(|s| s.get_name().to_string())
1445 .ok_or_else(|| anyhow!("workbook has no sheets"))?;
1446 let sheet = book
1447 .get_sheet_by_name_mut(&first_sheet)
1448 .ok_or_else(|| anyhow!("sheet '{}' not found", first_sheet))?;
1449 sheet
1450 .add_defined_name(name.to_string(), refers_to.to_string())
1451 .map_err(|e| anyhow!("failed to add defined name: {e}"))?;
1452 let sheet = book
1454 .get_sheet_by_name_mut(&first_sheet)
1455 .ok_or_else(|| anyhow!("sheet disappeared"))?;
1456 let entry = sheet.get_defined_names_mut().pop();
1457 if let Some(entry) = entry {
1458 book.add_defined_names(entry);
1459 }
1460 }
1461 }
1462
1463 umya_spreadsheet::writer::xlsx::write(&book, path)?;
1464 Ok(())
1465}
1466
1467pub(crate) fn update_name_in_file(
1469 path: &std::path::Path,
1470 name: &str,
1471 new_refers_to: Option<&str>,
1472 scope_kind: Option<NamedRangeScope>,
1473 scope_sheet_name: Option<&str>,
1474) -> Result<(String, NamedRangeScope, Option<String>)> {
1475 let mut book = umya_spreadsheet::reader::xlsx::read(path)
1476 .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
1477
1478 let mut found = false;
1479 let mut previous_refers_to = String::new();
1480 let mut effective_scope = NamedRangeScope::Workbook;
1481 let mut effective_sheet: Option<String> = None;
1482
1483 if scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Workbook) {
1485 for defined in book.get_defined_names_mut().iter_mut() {
1486 if defined.get_name() == name
1487 && (scope_kind == Some(NamedRangeScope::Workbook) || !defined.has_local_sheet_id())
1488 {
1489 previous_refers_to = defined.get_address();
1490 if let Some(new_addr) = new_refers_to {
1491 defined.set_address(new_addr.to_string());
1492 }
1493 effective_scope = NamedRangeScope::Workbook;
1494 found = true;
1495 break;
1496 }
1497 }
1498 }
1499
1500 if !found && (scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Sheet)) {
1502 let sheet_names: Vec<String> = book
1503 .get_sheet_collection()
1504 .iter()
1505 .map(|s: &umya_spreadsheet::Worksheet| s.get_name().to_string())
1506 .collect();
1507 for sn in &sheet_names {
1508 if let Some(filter_sheet) = scope_sheet_name
1509 && !sn.eq_ignore_ascii_case(filter_sheet)
1510 {
1511 continue;
1512 }
1513 if let Some(sheet) = book.get_sheet_by_name_mut(sn) {
1514 for defined in sheet.get_defined_names_mut().iter_mut() {
1515 if defined.get_name() == name {
1516 previous_refers_to = defined.get_address();
1517 if let Some(new_addr) = new_refers_to {
1518 defined.set_address(new_addr.to_string());
1519 }
1520 effective_scope = NamedRangeScope::Sheet;
1521 effective_sheet = Some(sn.clone());
1522 found = true;
1523 break;
1524 }
1525 }
1526 }
1527 if found {
1528 break;
1529 }
1530 }
1531 }
1532
1533 if !found {
1534 return Err(anyhow!("named range '{}' not found", name));
1535 }
1536
1537 umya_spreadsheet::writer::xlsx::write(&book, path)?;
1538 Ok((previous_refers_to, effective_scope, effective_sheet))
1539}
1540
1541pub(crate) fn delete_name_in_file(
1543 path: &std::path::Path,
1544 name: &str,
1545 scope_kind: Option<NamedRangeScope>,
1546 scope_sheet_name: Option<&str>,
1547) -> Result<bool> {
1548 let mut book = umya_spreadsheet::reader::xlsx::read(path)
1549 .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
1550
1551 let mut deleted = false;
1552
1553 if scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Workbook) {
1555 let names = book.get_defined_names_mut();
1556 let before_len = names.len();
1557 names.retain(|d: &umya_spreadsheet::DefinedName| d.get_name() != name);
1558 if names.len() < before_len {
1559 deleted = true;
1560 }
1561 }
1562
1563 if !deleted && (scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Sheet)) {
1565 let sheet_names: Vec<String> = book
1566 .get_sheet_collection()
1567 .iter()
1568 .map(|s: &umya_spreadsheet::Worksheet| s.get_name().to_string())
1569 .collect();
1570 for sn in &sheet_names {
1571 if let Some(filter_sheet) = scope_sheet_name
1572 && !sn.eq_ignore_ascii_case(filter_sheet)
1573 {
1574 continue;
1575 }
1576 if let Some(sheet) = book.get_sheet_by_name_mut(sn) {
1577 let names = sheet.get_defined_names_mut();
1578 let before_len = names.len();
1579 names.retain(|d: &umya_spreadsheet::DefinedName| d.get_name() != name);
1580 if names.len() < before_len {
1581 deleted = true;
1582 break;
1583 }
1584 }
1585 }
1586 }
1587
1588 if !deleted {
1589 return Err(anyhow!("named range '{}' not found", name));
1590 }
1591
1592 umya_spreadsheet::writer::xlsx::write(&book, path)?;
1593 Ok(true)
1594}
1595
1596#[cfg(feature = "recalc")]
1597pub async fn define_name(
1598 state: Arc<AppState>,
1599 params: DefineNameParams,
1600) -> Result<DefineNameResponse> {
1601 let scope_kind = parse_scope_kind(params.scope.as_deref())?;
1602 if scope_kind == NamedRangeScope::Sheet && params.scope_sheet_name.is_none() {
1603 return Err(anyhow!(
1604 "scope_sheet_name is required when scope is 'sheet'"
1605 ));
1606 }
1607 if params.name.trim().is_empty() {
1608 return Err(anyhow!("name must not be empty"));
1609 }
1610 if params.refers_to.trim().is_empty() {
1611 return Err(anyhow!("refers_to must not be empty"));
1612 }
1613
1614 let registry = state
1615 .fork_registry()
1616 .ok_or_else(|| anyhow!("fork registry not available (recalc feature required)"))?;
1617 let fork_ctx = registry.get_fork(params.fork_id.as_str())?;
1618 let work_path = fork_ctx.work_path.clone();
1619
1620 let name = params.name.clone();
1621 let refers_to = params.refers_to.clone();
1622 let scope_sheet = params.scope_sheet_name.clone();
1623
1624 tokio::task::spawn_blocking(move || {
1625 define_name_in_file(
1626 &work_path,
1627 &name,
1628 &refers_to,
1629 scope_kind,
1630 scope_sheet.as_deref(),
1631 )
1632 })
1633 .await??;
1634
1635 registry.with_fork_mut(params.fork_id.as_str(), |ctx| {
1637 ctx.recalc_needed = true;
1638 Ok(())
1639 })?;
1640 let fork_workbook_id = WorkbookId(params.fork_id.as_str().to_string());
1641 let _ = state.close_workbook(&fork_workbook_id);
1642
1643 Ok(DefineNameResponse {
1644 workbook_id: params.fork_id,
1645 name: params.name,
1646 refers_to: params.refers_to,
1647 scope_kind,
1648 scope_sheet_name: params.scope_sheet_name,
1649 })
1650}
1651
1652#[cfg(feature = "recalc")]
1653pub async fn update_name(
1654 state: Arc<AppState>,
1655 params: UpdateNameParams,
1656) -> Result<UpdateNameResponse> {
1657 let scope_kind = parse_scope_kind_optional(params.scope.as_deref())?;
1658 if params.name.trim().is_empty() {
1659 return Err(anyhow!("name must not be empty"));
1660 }
1661
1662 let registry = state
1663 .fork_registry()
1664 .ok_or_else(|| anyhow!("fork registry not available (recalc feature required)"))?;
1665 let fork_ctx = registry.get_fork(params.fork_id.as_str())?;
1666 let work_path = fork_ctx.work_path.clone();
1667
1668 let name = params.name.clone();
1669 let new_refers_to = params.refers_to.clone();
1670 let scope_sheet = params.scope_sheet_name.clone();
1671
1672 let (previous_refers_to, effective_scope, effective_sheet) =
1673 tokio::task::spawn_blocking(move || {
1674 update_name_in_file(
1675 &work_path,
1676 &name,
1677 new_refers_to.as_deref(),
1678 scope_kind,
1679 scope_sheet.as_deref(),
1680 )
1681 })
1682 .await??;
1683
1684 registry.with_fork_mut(params.fork_id.as_str(), |ctx| {
1685 ctx.recalc_needed = true;
1686 Ok(())
1687 })?;
1688 let fork_workbook_id = WorkbookId(params.fork_id.as_str().to_string());
1689 let _ = state.close_workbook(&fork_workbook_id);
1690
1691 let final_refers_to = params
1692 .refers_to
1693 .unwrap_or_else(|| previous_refers_to.clone());
1694
1695 Ok(UpdateNameResponse {
1696 workbook_id: params.fork_id,
1697 name: params.name,
1698 refers_to: final_refers_to,
1699 scope_kind: effective_scope,
1700 scope_sheet_name: effective_sheet.or(params.scope_sheet_name),
1701 previous_refers_to: Some(previous_refers_to),
1702 })
1703}
1704
1705#[cfg(feature = "recalc")]
1706pub async fn delete_name(
1707 state: Arc<AppState>,
1708 params: DeleteNameParams,
1709) -> Result<DeleteNameResponse> {
1710 let scope_kind = parse_scope_kind_optional(params.scope.as_deref())?;
1711 if params.name.trim().is_empty() {
1712 return Err(anyhow!("name must not be empty"));
1713 }
1714
1715 let registry = state
1716 .fork_registry()
1717 .ok_or_else(|| anyhow!("fork registry not available (recalc feature required)"))?;
1718 let fork_ctx = registry.get_fork(params.fork_id.as_str())?;
1719 let work_path = fork_ctx.work_path.clone();
1720
1721 let name = params.name.clone();
1722 let scope_sheet = params.scope_sheet_name.clone();
1723
1724 tokio::task::spawn_blocking(move || {
1725 delete_name_in_file(&work_path, &name, scope_kind, scope_sheet.as_deref())
1726 })
1727 .await??;
1728
1729 registry.with_fork_mut(params.fork_id.as_str(), |ctx| {
1730 ctx.recalc_needed = true;
1731 Ok(())
1732 })?;
1733 let fork_workbook_id = WorkbookId(params.fork_id.as_str().to_string());
1734 let _ = state.close_workbook(&fork_workbook_id);
1735
1736 Ok(DeleteNameResponse {
1737 workbook_id: params.fork_id,
1738 name: params.name,
1739 deleted: true,
1740 })
1741}
1742
1743struct PageBuildResult {
1744 rows: Vec<RowSnapshot>,
1745 header: Option<RowSnapshot>,
1746}
1747
1748#[allow(clippy::too_many_arguments)]
1749fn build_page(
1750 sheet: &umya_spreadsheet::Worksheet,
1751 start_row: u32,
1752 page_size: u32,
1753 columns: Option<Vec<String>>,
1754 columns_by_header: Option<Vec<String>>,
1755 include_formulas: bool,
1756 include_styles: bool,
1757 include_header: bool,
1758) -> PageBuildResult {
1759 let max_col = sheet.get_highest_column();
1760 let end_row = (start_row + page_size - 1).min(sheet.get_highest_row().max(start_row));
1761 let column_indices =
1762 resolve_columns_with_headers(sheet, columns.as_ref(), columns_by_header.as_ref(), max_col);
1763
1764 let header = if include_header {
1765 Some(build_row_snapshot(
1766 sheet,
1767 1,
1768 &column_indices,
1769 include_formulas,
1770 include_styles,
1771 ))
1772 } else {
1773 None
1774 };
1775
1776 let mut rows = Vec::new();
1777 for row_idx in start_row..=end_row {
1778 rows.push(build_row_snapshot(
1779 sheet,
1780 row_idx,
1781 &column_indices,
1782 include_formulas,
1783 include_styles,
1784 ));
1785 }
1786
1787 PageBuildResult { rows, header }
1788}
1789
1790fn build_row_snapshot(
1791 sheet: &umya_spreadsheet::Worksheet,
1792 row_index: u32,
1793 columns: &[u32],
1794 include_formulas: bool,
1795 include_styles: bool,
1796) -> RowSnapshot {
1797 let mut cells = Vec::new();
1798 for &col in columns {
1799 if let Some(cell) = sheet.get_cell((col, row_index)) {
1800 cells.push(build_cell_snapshot(cell, include_formulas, include_styles));
1801 } else {
1802 let address = crate::utils::cell_address(col, row_index);
1803 cells.push(CellSnapshot {
1804 address,
1805 value: None,
1806 formula: None,
1807 cached_value: None,
1808 number_format: None,
1809 style_tags: Vec::new(),
1810 notes: Vec::new(),
1811 });
1812 }
1813 }
1814
1815 RowSnapshot { row_index, cells }
1816}
1817
1818fn build_cell_snapshot(
1819 cell: &umya_spreadsheet::Cell,
1820 include_formulas: bool,
1821 include_styles: bool,
1822) -> CellSnapshot {
1823 let address = cell.get_coordinate().get_coordinate();
1824 let value = crate::workbook::cell_to_value(cell);
1825 let formula = if include_formulas && cell.is_formula() {
1826 Some(cell.get_formula().to_string())
1827 } else {
1828 None
1829 };
1830 let cached_value = if cell.is_formula() {
1831 value.clone()
1832 } else {
1833 None
1834 };
1835 let number_format = if include_styles {
1836 cell.get_style()
1837 .get_number_format()
1838 .map(|fmt| fmt.get_format_code().to_string())
1839 } else {
1840 None
1841 };
1842 let style_tags = if include_styles {
1843 crate::analysis::style::tag_cell(cell)
1844 .map(|(_, tagging)| tagging.tags)
1845 .unwrap_or_default()
1846 } else {
1847 Vec::new()
1848 };
1849
1850 CellSnapshot {
1851 address,
1852 value,
1853 formula,
1854 cached_value,
1855 number_format,
1856 style_tags,
1857 notes: Vec::new(),
1858 }
1859}
1860
1861fn resolve_columns(columns: Option<&Vec<String>>, max_column: u32) -> Vec<u32> {
1862 use std::collections::BTreeSet;
1863 use umya_spreadsheet::helper::coordinate::column_index_from_string;
1864
1865 let mut indices = BTreeSet::new();
1866 if let Some(specs) = columns {
1867 for spec in specs {
1868 if let Some((start, end)) = spec.split_once(':') {
1869 let start_idx = column_index_from_string(start);
1870 let end_idx = column_index_from_string(end);
1871 let (min_idx, max_idx) = if start_idx <= end_idx {
1872 (start_idx, end_idx)
1873 } else {
1874 (end_idx, start_idx)
1875 };
1876 for idx in min_idx..=max_idx {
1877 indices.insert(idx);
1878 }
1879 } else {
1880 indices.insert(column_index_from_string(spec));
1881 }
1882 }
1883 } else {
1884 for idx in 1..=max_column.max(1) {
1885 indices.insert(idx);
1886 }
1887 }
1888
1889 indices.into_iter().collect()
1890}
1891
1892fn resolve_columns_with_headers(
1893 sheet: &umya_spreadsheet::Worksheet,
1894 columns: Option<&Vec<String>>,
1895 columns_by_header: Option<&Vec<String>>,
1896 max_column: u32,
1897) -> Vec<u32> {
1898 use std::collections::BTreeSet;
1899
1900 if columns_by_header.is_none() {
1901 return resolve_columns(columns, max_column);
1902 }
1903
1904 let mut selected: BTreeSet<u32> = if columns.is_some() {
1905 resolve_columns(columns, max_column).into_iter().collect()
1906 } else {
1907 BTreeSet::new()
1908 };
1909 let mut matched_header = false;
1910 let header_targets: Vec<String> = columns_by_header
1911 .unwrap()
1912 .iter()
1913 .map(|h| h.trim().to_ascii_lowercase())
1914 .collect();
1915
1916 for col_idx in 1..=max_column.max(1) {
1917 let header_cell = sheet.get_cell((col_idx, 1u32));
1918 let header_value = header_cell
1919 .and_then(cell_to_value)
1920 .map(cell_value_to_string_lower);
1921 if let Some(hval) = header_value
1922 && header_targets.iter().any(|target| target == &hval)
1923 {
1924 selected.insert(col_idx);
1925 matched_header = true;
1926 }
1927 }
1928
1929 if !matched_header && columns.is_none() {
1930 resolve_columns(None, max_column)
1931 } else {
1932 selected.into_iter().collect()
1933 }
1934}
1935
1936fn cell_value_to_string_lower(value: CellValue) -> String {
1937 match value {
1938 CellValue::Text(s) => s.to_ascii_lowercase(),
1939 CellValue::Number(n) => n.to_string().to_ascii_lowercase(),
1940 CellValue::Bool(b) => b.to_string(),
1941 CellValue::Error(e) => e.to_ascii_lowercase(),
1942 CellValue::Date(d) => d.to_ascii_lowercase(),
1943 }
1944}
1945
1946fn cell_value_to_plain_string(value: &CellValue) -> String {
1947 match value {
1948 CellValue::Text(s) => s.clone(),
1949 CellValue::Number(n) => n.to_string(),
1950 CellValue::Bool(b) => b.to_string(),
1951 CellValue::Error(e) => e.clone(),
1952 CellValue::Date(d) => d.clone(),
1953 }
1954}
1955
1956fn cell_value_to_kind(value: &CellValue) -> CellValueKind {
1957 match value {
1958 CellValue::Text(_) => CellValueKind::Text,
1959 CellValue::Number(_) => CellValueKind::Number,
1960 CellValue::Bool(_) => CellValueKind::Bool,
1961 CellValue::Error(_) => CellValueKind::Error,
1962 CellValue::Date(_) => CellValueKind::Date,
1963 }
1964}
1965
1966fn cell_value_to_primitive(value: &CellValue) -> CellValuePrimitive {
1967 match value {
1968 CellValue::Text(s) => CellValuePrimitive::Text(s.clone()),
1969 CellValue::Number(n) => CellValuePrimitive::Number(*n),
1970 CellValue::Bool(b) => CellValuePrimitive::Bool(*b),
1971 CellValue::Error(e) => CellValuePrimitive::Text(e.clone()),
1972 CellValue::Date(d) => CellValuePrimitive::Text(d.clone()),
1973 }
1974}
1975
1976fn csv_escape_field(field: &str) -> String {
1977 if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
1978 let mut escaped = String::with_capacity(field.len() + 2);
1979 escaped.push('"');
1980 for ch in field.chars() {
1981 if ch == '"' {
1982 escaped.push('"');
1983 }
1984 escaped.push(ch);
1985 }
1986 escaped.push('"');
1987 escaped
1988 } else {
1989 field.to_string()
1990 }
1991}
1992
1993fn push_csv_row<I>(buffer: &mut String, fields: I)
1994where
1995 I: IntoIterator<Item = String>,
1996{
1997 let mut first = true;
1998 for field in fields {
1999 if !first {
2000 buffer.push(',');
2001 }
2002 first = false;
2003 let escaped = csv_escape_field(&field);
2004 buffer.push_str(&escaped);
2005 }
2006 buffer.push('\n');
2007}
2008
2009fn table_rows_to_values(
2010 headers: &[String],
2011 rows: &[TableRow],
2012) -> Vec<Vec<Option<CellValuePrimitive>>> {
2013 let mut out = Vec::with_capacity(rows.len());
2014 for row in rows {
2015 let mut vals = Vec::with_capacity(headers.len());
2016 for header in headers {
2017 let value = row
2018 .get(header)
2019 .and_then(|cell| cell.as_ref())
2020 .map(cell_value_to_primitive);
2021 vals.push(value);
2022 }
2023 out.push(vals);
2024 }
2025 out
2026}
2027
2028fn table_rows_to_types(headers: &[String], rows: &[TableRow]) -> Vec<Vec<Option<CellValueKind>>> {
2029 let mut out = Vec::with_capacity(rows.len());
2030 for row in rows {
2031 let mut kinds = Vec::with_capacity(headers.len());
2032 for header in headers {
2033 let kind = row
2034 .get(header)
2035 .and_then(|cell| cell.as_ref())
2036 .map(cell_value_to_kind);
2037 kinds.push(kind);
2038 }
2039 out.push(kinds);
2040 }
2041 out
2042}
2043
2044fn table_rows_to_csv(headers: &[String], rows: &[TableRow], include_headers: bool) -> String {
2045 let mut csv = String::new();
2046 if include_headers {
2047 push_csv_row(&mut csv, headers.iter().cloned());
2048 }
2049 for row in rows {
2050 let values = headers.iter().map(|header| {
2051 row.get(header)
2052 .and_then(|cell| cell.as_ref())
2053 .map(cell_value_to_plain_string)
2054 .unwrap_or_default()
2055 });
2056 push_csv_row(&mut csv, values);
2057 }
2058 csv
2059}
2060
2061fn filter_table_row(row: &TableRow, headers: &[String]) -> TableRow {
2062 let mut filtered = TableRow::new();
2063 for header in headers {
2064 if let Some(value) = row.get(header) {
2065 filtered.insert(header.clone(), value.clone());
2066 }
2067 }
2068 filtered
2069}
2070
2071type ReadTablePayload = (
2072 Vec<String>,
2073 Vec<TableRow>,
2074 Option<Vec<Vec<Option<CellValuePrimitive>>>>,
2075 Option<Vec<Vec<Option<CellValueKind>>>>,
2076 Option<String>,
2077);
2078
2079fn build_read_table_payload(
2080 format: TableOutputFormat,
2081 headers: &[String],
2082 rows: &[TableRow],
2083 include_headers: bool,
2084 include_types: bool,
2085) -> ReadTablePayload {
2086 let headers_out = if include_headers {
2087 headers.to_vec()
2088 } else {
2089 Vec::new()
2090 };
2091
2092 let types_out = if include_types {
2093 Some(table_rows_to_types(headers, rows))
2094 } else {
2095 None
2096 };
2097
2098 match format {
2099 TableOutputFormat::Json | TableOutputFormat::Rows => {
2100 (headers_out, rows.to_vec(), None, types_out, None)
2101 }
2102 TableOutputFormat::Values | TableOutputFormat::Dense => (
2103 headers_out,
2104 Vec::new(),
2105 Some(table_rows_to_values(headers, rows)),
2106 types_out,
2107 None,
2108 ),
2109 TableOutputFormat::Csv => (
2110 Vec::new(),
2111 Vec::new(),
2112 None,
2113 types_out,
2114 Some(table_rows_to_csv(headers, rows, include_headers)),
2115 ),
2116 }
2117}
2118
2119fn cell_matrix_to_values(rows: &[Vec<Option<CellValue>>]) -> Vec<Vec<Option<CellValuePrimitive>>> {
2120 let mut out = Vec::with_capacity(rows.len());
2121 for row in rows {
2122 let mut vals = Vec::with_capacity(row.len());
2123 for cell in row {
2124 vals.push(cell.as_ref().map(cell_value_to_primitive));
2125 }
2126 out.push(vals);
2127 }
2128 out
2129}
2130
2131fn cell_matrix_to_csv(rows: &[Vec<Option<CellValue>>]) -> String {
2132 let mut csv = String::new();
2133 for row in rows {
2134 let values = row.iter().map(|cell| {
2135 cell.as_ref()
2136 .map(cell_value_to_plain_string)
2137 .unwrap_or_default()
2138 });
2139 push_csv_row(&mut csv, values);
2140 }
2141 csv
2142}
2143
2144#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2145enum DensePrimitiveKey {
2146 Text(String),
2147 NumberBits(u64),
2148 Bool(bool),
2149}
2150
2151fn dense_key(value: &CellValuePrimitive) -> DensePrimitiveKey {
2152 match value {
2153 CellValuePrimitive::Text(text) => DensePrimitiveKey::Text(text.clone()),
2154 CellValuePrimitive::Number(number) => DensePrimitiveKey::NumberBits(number.to_bits()),
2155 CellValuePrimitive::Bool(flag) => DensePrimitiveKey::Bool(*flag),
2156 }
2157}
2158
2159fn row_to_dense_runs(indexes: &[u32]) -> Vec<RangeValuesDenseRun> {
2160 if indexes.is_empty() {
2161 return Vec::new();
2162 }
2163 let mut runs = Vec::new();
2164 let mut current = indexes[0];
2165 let mut len: u32 = 1;
2166
2167 for idx in indexes.iter().copied().skip(1) {
2168 if idx == current {
2169 len += 1;
2170 } else {
2171 runs.push(RangeValuesDenseRun {
2172 value_idx: current,
2173 len,
2174 });
2175 current = idx;
2176 len = 1;
2177 }
2178 }
2179
2180 runs.push(RangeValuesDenseRun {
2181 value_idx: current,
2182 len,
2183 });
2184 runs
2185}
2186
2187fn cell_matrix_to_dense(
2188 rows: &[Vec<Option<CellValue>>],
2189 formulas: Option<&[Vec<Option<String>>]>,
2190) -> RangeValuesDensePayload {
2191 let primitive_rows = cell_matrix_to_values(rows);
2192 let mut dictionary: Vec<Option<CellValuePrimitive>> = vec![None];
2193 let mut dict_index: HashMap<DensePrimitiveKey, u32> = HashMap::new();
2194 let mut row_runs: Vec<Vec<RangeValuesDenseRun>> = Vec::with_capacity(primitive_rows.len());
2195
2196 for row in &primitive_rows {
2197 let mut indexes = Vec::with_capacity(row.len());
2198 for cell in row {
2199 let idx = match cell {
2200 None => 0,
2201 Some(value) => {
2202 let key = dense_key(value);
2203 if let Some(existing) = dict_index.get(&key).copied() {
2204 existing
2205 } else {
2206 let next = dictionary.len() as u32;
2207 dictionary.push(Some(value.clone()));
2208 dict_index.insert(key, next);
2209 next
2210 }
2211 }
2212 };
2213 indexes.push(idx);
2214 }
2215 row_runs.push(row_to_dense_runs(&indexes));
2216 }
2217
2218 let dense_formulas = formulas
2219 .map(|matrix| {
2220 let mut out = Vec::new();
2221 for (row_idx, row) in matrix.iter().enumerate() {
2222 for (col_idx, formula) in row.iter().enumerate() {
2223 if let Some(formula) = formula {
2224 out.push(RangeValuesDenseFormula {
2225 row: row_idx as u32,
2226 col: col_idx as u32,
2227 formula: formula.clone(),
2228 });
2229 }
2230 }
2231 }
2232 out
2233 })
2234 .unwrap_or_default();
2235
2236 let col_count = primitive_rows.first().map_or(0, |row| row.len() as u32);
2237
2238 RangeValuesDensePayload {
2239 encoding: "dense_v1".to_string(),
2240 col_count,
2241 dictionary,
2242 row_runs,
2243 formulas: dense_formulas,
2244 }
2245}
2246
2247fn cell_matrix_to_rows_keyed(
2248 range: &str,
2249 rows: &[Vec<Option<CellValue>>],
2250) -> Vec<RangeValuesRowEntry> {
2251 let ((start_col, start_row), _) = parse_range(range).unwrap_or(((1, 1), (1, 1)));
2252 let mut out = Vec::with_capacity(rows.len());
2253 for (row_offset, row) in rows.iter().enumerate() {
2254 let row_number = start_row + row_offset as u32;
2255 let mut cells = std::collections::BTreeMap::new();
2256 for (col_offset, cell) in row.iter().enumerate() {
2257 if let Some(value) = cell {
2258 let col_letter = column_number_to_name(start_col + col_offset as u32);
2259 cells.insert(col_letter, cell_value_to_primitive(value));
2260 }
2261 }
2262 out.push(RangeValuesRowEntry {
2263 row: row_number,
2264 cells,
2265 });
2266 }
2267 out
2268}
2269
2270fn build_range_values_entry(
2271 format: TableOutputFormat,
2272 range: &str,
2273 rows: &[Vec<Option<CellValue>>],
2274 formulas: Option<&[Vec<Option<String>>]>,
2275 next_start_row: Option<u32>,
2276) -> RangeValuesEntry {
2277 match format {
2278 TableOutputFormat::Json => RangeValuesEntry {
2279 range: range.to_string(),
2280 rows: Some(rows.to_vec()),
2281 formulas: formulas.map(|matrix| matrix.to_vec()),
2282 values: None,
2283 dense: None,
2284 csv: None,
2285 rows_keyed: None,
2286 next_start_row,
2287 },
2288 TableOutputFormat::Values => RangeValuesEntry {
2289 range: range.to_string(),
2290 rows: None,
2291 formulas: None,
2292 values: Some(cell_matrix_to_values(rows)),
2293 dense: None,
2294 csv: None,
2295 rows_keyed: None,
2296 next_start_row,
2297 },
2298 TableOutputFormat::Csv => RangeValuesEntry {
2299 range: range.to_string(),
2300 rows: None,
2301 formulas: None,
2302 values: None,
2303 dense: None,
2304 csv: Some(cell_matrix_to_csv(rows)),
2305 rows_keyed: None,
2306 next_start_row,
2307 },
2308 TableOutputFormat::Dense => RangeValuesEntry {
2309 range: range.to_string(),
2310 rows: None,
2311 formulas: None,
2312 values: None,
2313 dense: Some(cell_matrix_to_dense(rows, formulas)),
2314 csv: None,
2315 rows_keyed: None,
2316 next_start_row,
2317 },
2318 TableOutputFormat::Rows => RangeValuesEntry {
2319 range: range.to_string(),
2320 rows: None,
2321 formulas: None,
2322 values: None,
2323 dense: None,
2324 csv: None,
2325 rows_keyed: Some(cell_matrix_to_rows_keyed(range, rows)),
2326 next_start_row,
2327 },
2328 }
2329}
2330
2331fn cap_rows_by_cells(row_count: usize, cells_per_row: usize, max_cells: Option<usize>) -> usize {
2332 let Some(max_cells) = max_cells else {
2333 return row_count;
2334 };
2335 if cells_per_row == 0 {
2336 return row_count;
2337 }
2338 let allowed = max_cells / cells_per_row;
2339 row_count.min(allowed)
2340}
2341
2342fn cap_rows_by_payload_bytes<F>(
2343 row_count: usize,
2344 max_bytes: Option<usize>,
2345 mut size_for_rows: F,
2346) -> usize
2347where
2348 F: FnMut(usize) -> usize,
2349{
2350 let Some(max_bytes) = max_bytes else {
2351 return row_count;
2352 };
2353 if row_count == 0 {
2354 return 0;
2355 }
2356 let mut low = 0usize;
2357 let mut high = row_count;
2358 while low < high {
2359 let mid = (low + high).div_ceil(2);
2360 if size_for_rows(mid) <= max_bytes {
2361 low = mid;
2362 } else {
2363 high = mid - 1;
2364 }
2365 }
2366 low
2367}
2368
2369fn build_compact_payload(
2370 header: &Option<RowSnapshot>,
2371 rows: &[RowSnapshot],
2372 include_header: bool,
2373) -> SheetPageCompact {
2374 let headers = derive_headers(header, rows);
2375 let header_row = if include_header {
2376 header
2377 .as_ref()
2378 .map(|h| h.cells.iter().map(|c| c.value.clone()).collect())
2379 .unwrap_or_default()
2380 } else {
2381 Vec::new()
2382 };
2383 let data_rows = rows
2384 .iter()
2385 .map(|row| {
2386 let mut vals: Vec<Option<CellValue>> = Vec::new();
2387 vals.push(Some(CellValue::Number(row.row_index as f64)));
2388 vals.extend(row.cells.iter().map(|c| c.value.clone()));
2389 vals
2390 })
2391 .collect();
2392
2393 SheetPageCompact {
2394 headers,
2395 header_row,
2396 rows: data_rows,
2397 }
2398}
2399
2400fn build_values_only_payload(
2401 header: &Option<RowSnapshot>,
2402 rows: &[RowSnapshot],
2403 include_header: bool,
2404) -> SheetPageValues {
2405 let mut data = Vec::new();
2406 if include_header && let Some(h) = header {
2407 data.push(h.cells.iter().map(|c| c.value.clone()).collect());
2408 }
2409 for row in rows {
2410 data.push(row.cells.iter().map(|c| c.value.clone()).collect());
2411 }
2412
2413 SheetPageValues { rows: data }
2414}
2415
2416fn build_sheet_page_response(
2417 workbook: &WorkbookContext,
2418 sheet_name: &str,
2419 format: SheetPageFormat,
2420 include_header: bool,
2421 header: &Option<RowSnapshot>,
2422 rows: &[RowSnapshot],
2423 next_start_row: Option<u32>,
2424) -> SheetPageResponse {
2425 let compact_payload = if matches!(format, SheetPageFormat::Compact) {
2426 Some(build_compact_payload(header, rows, include_header))
2427 } else {
2428 None
2429 };
2430
2431 let values_only_payload = if matches!(format, SheetPageFormat::ValuesOnly) {
2432 Some(build_values_only_payload(header, rows, include_header))
2433 } else {
2434 None
2435 };
2436
2437 let rows_payload = if matches!(format, SheetPageFormat::Full) {
2438 rows.to_vec()
2439 } else {
2440 Vec::new()
2441 };
2442
2443 let header_row = if include_header && matches!(format, SheetPageFormat::Full) {
2444 header.clone()
2445 } else {
2446 None
2447 };
2448
2449 SheetPageResponse {
2450 workbook_id: workbook.id.clone(),
2451 sheet_name: sheet_name.to_string(),
2452 rows: rows_payload,
2453 next_start_row,
2454 header_row,
2455 compact: compact_payload,
2456 values_only: values_only_payload,
2457 format,
2458 truncated: false,
2459 budget: None,
2460 }
2461}
2462
2463fn derive_headers(header: &Option<RowSnapshot>, rows: &[RowSnapshot]) -> Vec<String> {
2464 if let Some(h) = header {
2465 let mut headers: Vec<String> = h
2466 .cells
2467 .iter()
2468 .map(|c| match &c.value {
2469 Some(CellValue::Text(t)) => t.clone(),
2470 Some(CellValue::Number(n)) => n.to_string(),
2471 Some(CellValue::Bool(b)) => b.to_string(),
2472 Some(CellValue::Date(d)) => d.clone(),
2473 Some(CellValue::Error(e)) => e.clone(),
2474 None => c.address.clone(),
2475 })
2476 .collect();
2477 headers.insert(0, "Row".to_string());
2478 headers
2479 } else if let Some(first) = rows.first() {
2480 let mut headers = Vec::new();
2481 headers.push("Row".to_string());
2482 for cell in &first.cells {
2483 headers.push(cell.address.clone());
2484 }
2485 headers
2486 } else {
2487 vec![]
2488 }
2489}
2490fn default_stats_sample() -> usize {
2491 500
2492}
2493
2494#[derive(Debug, Deserialize, JsonSchema)]
2495pub struct SheetStatisticsParams {
2496 #[serde(alias = "workbook_id")]
2498 pub workbook_or_fork_id: WorkbookId,
2499 pub sheet_name: String,
2501 #[serde(default)]
2503 pub sample_rows: Option<usize>,
2504 #[serde(default)]
2506 pub summary_only: Option<bool>,
2507}
2508
2509pub async fn sheet_statistics(
2510 state: Arc<AppState>,
2511 params: SheetStatisticsParams,
2512) -> Result<SheetStatisticsResponse> {
2513 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
2514 let config = state.config();
2515 let output_profile = config.output_profile();
2516 let summary_only = params
2517 .summary_only
2518 .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
2519 let sheet_metrics = workbook.get_sheet_metrics_fast(¶ms.sheet_name)?;
2520 let sample_rows = params.sample_rows.unwrap_or_else(default_stats_sample);
2521 let stats = workbook.with_sheet(¶ms.sheet_name, |sheet| {
2522 stats::compute_sheet_statistics(sheet, sample_rows)
2523 })?;
2524 let mut numeric_columns = stats.numeric_columns;
2525 let mut text_columns = stats.text_columns;
2526
2527 if summary_only {
2528 for column in &mut numeric_columns {
2529 column.samples.clear();
2530 }
2531 for column in &mut text_columns {
2532 column.samples.clear();
2533 }
2534 }
2535
2536 let max_items = config.max_items();
2537 let max_payload_bytes = config.max_payload_bytes();
2538
2539 if let Some(max_items) = max_items {
2540 if numeric_columns.len() > max_items {
2541 numeric_columns.truncate(max_items);
2542 }
2543 if text_columns.len() > max_items {
2544 text_columns.truncate(max_items);
2545 }
2546 }
2547
2548 if let Some(max_bytes) = max_payload_bytes {
2549 let response_size = |numeric: &Vec<ColumnSummary>, text: &Vec<ColumnSummary>| {
2550 let response = SheetStatisticsResponse {
2551 workbook_id: workbook.id.clone(),
2552 sheet_name: params.sheet_name.clone(),
2553 row_count: sheet_metrics.metrics.row_count,
2554 column_count: sheet_metrics.metrics.column_count,
2555 density: stats.density,
2556 numeric_columns: numeric.clone(),
2557 text_columns: text.clone(),
2558 null_counts: stats.null_counts.clone(),
2559 duplicate_warnings: stats.duplicate_warnings.clone(),
2560 };
2561 serde_json::to_vec(&response)
2562 .map(|payload| payload.len())
2563 .unwrap_or(usize::MAX)
2564 };
2565
2566 let mut current_size = response_size(&numeric_columns, &text_columns);
2567 if current_size > max_bytes && !summary_only {
2568 for column in &mut numeric_columns {
2569 column.samples.clear();
2570 }
2571 for column in &mut text_columns {
2572 column.samples.clear();
2573 }
2574 current_size = response_size(&numeric_columns, &text_columns);
2575 }
2576
2577 while current_size > max_bytes && (!text_columns.is_empty() || !numeric_columns.is_empty())
2578 {
2579 if !text_columns.is_empty() {
2580 text_columns.pop();
2581 } else if !numeric_columns.is_empty() {
2582 numeric_columns.pop();
2583 }
2584 current_size = response_size(&numeric_columns, &text_columns);
2585 }
2586 }
2587
2588 Ok(SheetStatisticsResponse {
2589 workbook_id: workbook.id.clone(),
2590 sheet_name: params.sheet_name,
2591 row_count: sheet_metrics.metrics.row_count,
2592 column_count: sheet_metrics.metrics.column_count,
2593 density: stats.density,
2594 numeric_columns,
2595 text_columns,
2596 null_counts: stats.null_counts,
2597 duplicate_warnings: stats.duplicate_warnings,
2598 })
2599}
2600
2601fn address_in_range(address: &str, range: &str) -> bool {
2602 parse_range(range).is_none_or(|((start_col, start_row), (end_col, end_row))| {
2603 if let Some((col, row)) = parse_address(address) {
2604 col >= start_col && col <= end_col && row >= start_row && row <= end_row
2605 } else {
2606 false
2607 }
2608 })
2609}
2610
2611fn parse_range(range: &str) -> Option<((u32, u32), (u32, u32))> {
2612 let mut parts = range.split(':');
2613 let start = parts.next()?;
2614 let end = parts.next().unwrap_or(start);
2615 let start_idx = parse_address(start)?;
2616 let end_idx = parse_address(end)?;
2617 Some((
2618 (start_idx.0.min(end_idx.0), start_idx.1.min(end_idx.1)),
2619 (start_idx.0.max(end_idx.0), start_idx.1.max(end_idx.1)),
2620 ))
2621}
2622
2623fn parse_address(address: &str) -> Option<(u32, u32)> {
2624 use umya_spreadsheet::helper::coordinate::index_from_coordinate;
2625 let (col, row, _, _) = index_from_coordinate(address);
2626 match (col, row) {
2627 (Some(c), Some(r)) => Some((c, r)),
2628 _ => None,
2629 }
2630}
2631
2632#[derive(Clone)]
2633struct TableTarget {
2634 sheet_name: String,
2635 table_name: Option<String>,
2636 range: ((u32, u32), (u32, u32)),
2637 header_hint: Option<u32>,
2638}
2639
2640fn resolve_table_target(
2641 workbook: &WorkbookContext,
2642 params: &ReadTableParams,
2643) -> Result<TableTarget> {
2644 if let Some(region_id) = params.region_id
2645 && let Some(sheet) = ¶ms.sheet_name
2646 && let Ok(region) = workbook.detected_region(sheet, region_id)
2647 {
2648 return Ok(TableTarget {
2649 sheet_name: sheet.clone(),
2650 table_name: None,
2651 range: parse_range(®ion.bounds).unwrap_or(((1, 1), (1, 1))),
2652 header_hint: region.header_row,
2653 });
2654 }
2655
2656 if let Some(table_name) = ¶ms.table_name {
2657 let items = workbook.named_items()?;
2658 for item in items {
2659 if item.name.eq_ignore_ascii_case(table_name)
2660 || item
2661 .name
2662 .to_ascii_lowercase()
2663 .contains(&table_name.to_ascii_lowercase())
2664 {
2665 let mut sheet_name = item
2666 .sheet_name
2667 .clone()
2668 .or_else(|| params.sheet_name.clone())
2669 .unwrap_or_else(|| workbook.sheet_names().first().cloned().unwrap_or_default());
2670 let refers_to = item.refers_to.trim_start_matches('=');
2671 let mut range_part = refers_to;
2672 if let Some((sheet_part, rest)) = refers_to.split_once('!') {
2673 sheet_name = sheet_part.trim_matches('\'').to_string();
2674 range_part = rest;
2675 }
2676 if let Some(range) = parse_range(range_part) {
2677 return Ok(TableTarget {
2678 sheet_name,
2679 table_name: Some(item.name.clone()),
2680 range,
2681 header_hint: if item.kind == NamedItemKind::Table {
2682 Some(range.0.1)
2683 } else {
2684 None
2685 },
2686 });
2687 }
2688 }
2689 }
2690 }
2691
2692 let sheet_name = params
2693 .sheet_name
2694 .clone()
2695 .unwrap_or_else(|| workbook.sheet_names().first().cloned().unwrap_or_default());
2696
2697 if let Some(rng) = ¶ms.range
2698 && let Some(range) = parse_range(rng)
2699 {
2700 return Ok(TableTarget {
2701 sheet_name,
2702 table_name: None,
2703 range,
2704 header_hint: None,
2705 });
2706 }
2707
2708 let metrics = workbook.get_sheet_metrics_fast(&sheet_name)?;
2709 let end_col = metrics.metrics.column_count.max(1);
2710 let end_row = metrics.metrics.row_count.max(1);
2711 Ok(TableTarget {
2712 sheet_name,
2713 table_name: None,
2714 range: ((1, 1), (end_col, end_row)),
2715 header_hint: None,
2716 })
2717}
2718
2719#[allow(clippy::too_many_arguments)]
2720fn extract_table_rows(
2721 sheet: &umya_spreadsheet::Worksheet,
2722 target: &TableTarget,
2723 header_row: Option<u32>,
2724 header_rows: Option<u32>,
2725 columns: Option<Vec<String>>,
2726 filters: Option<Vec<TableFilter>>,
2727 limit: usize,
2728 offset: usize,
2729 sample_mode: SampleMode,
2730) -> Result<(Vec<String>, Vec<TableRow>, u32)> {
2731 let ((start_col, start_row), (end_col, end_row)) = target.range;
2732 let mut header_start = header_row.or(target.header_hint).unwrap_or(start_row);
2733 if header_start < start_row {
2734 header_start = start_row;
2735 }
2736 if header_start > end_row {
2737 header_start = start_row;
2738 }
2739 let header_rows_count = header_rows.unwrap_or(1).max(1);
2740 let data_start_row = (header_start + header_rows_count).max(start_row + header_rows_count);
2741 let column_indices: Vec<u32> = if let Some(cols) = columns.as_ref() {
2742 resolve_columns(Some(cols), end_col).into_iter().collect()
2743 } else {
2744 (start_col..=end_col).collect()
2745 };
2746
2747 let headers = build_headers(sheet, &column_indices, header_start, header_rows_count);
2748 let mut all_rows: Vec<TableRow> = Vec::new();
2749 let mut total_rows: u32 = 0;
2750
2751 for row_idx in data_start_row..=end_row {
2752 let mut row = BTreeMap::new();
2753 for (i, col_idx) in column_indices.iter().enumerate() {
2754 let header = headers
2755 .get(i)
2756 .cloned()
2757 .unwrap_or_else(|| format!("Col{col_idx}"));
2758 let value = sheet.get_cell((*col_idx, row_idx)).and_then(cell_to_value);
2759 row.insert(header, value);
2760 }
2761 if !row_passes_filters(&row, filters.as_ref()) {
2762 continue;
2763 }
2764 total_rows += 1;
2765 if matches!(sample_mode, SampleMode::First) && total_rows as usize > offset + limit {
2766 continue;
2767 }
2768 all_rows.push(row);
2769 }
2770
2771 let rows = sample_rows(all_rows, limit, offset, sample_mode);
2772
2773 Ok((headers, rows, total_rows))
2774}
2775
2776fn build_headers(
2777 sheet: &umya_spreadsheet::Worksheet,
2778 columns: &[u32],
2779 header_start: u32,
2780 header_rows: u32,
2781) -> Vec<String> {
2782 let mut headers = Vec::new();
2783 for col_idx in columns {
2784 let mut parts = Vec::new();
2785 for h in header_start..(header_start + header_rows) {
2786 let (origin_col, origin_row) = sheet.map_merged_cell((*col_idx, h));
2787 if let Some(value) = sheet
2788 .get_cell((origin_col, origin_row))
2789 .and_then(cell_to_value)
2790 {
2791 match value {
2792 CellValue::Text(ref s) if s.trim().is_empty() => {}
2793 CellValue::Text(s) => parts.push(s),
2794 CellValue::Number(n) => parts.push(n.to_string()),
2795 CellValue::Bool(b) => parts.push(b.to_string()),
2796 CellValue::Error(e) => parts.push(e),
2797 CellValue::Date(d) => parts.push(d),
2798 }
2799 }
2800 }
2801 if parts.is_empty() {
2802 headers.push(crate::utils::column_number_to_name(*col_idx));
2803 } else {
2804 headers.push(parts.join(" / "));
2805 }
2806 }
2807
2808 if headers.iter().all(|h| h.trim().is_empty()) {
2809 return columns
2810 .iter()
2811 .map(|c| crate::utils::column_number_to_name(*c))
2812 .collect();
2813 }
2814
2815 dedupe_headers(headers)
2816}
2817
2818fn dedupe_headers(mut headers: Vec<String>) -> Vec<String> {
2819 let mut seen: HashMap<String, u32> = HashMap::new();
2820 for h in headers.iter_mut() {
2821 let key = h.clone();
2822 if key.trim().is_empty() {
2823 continue;
2824 }
2825 let count = seen.entry(key.clone()).or_insert(0);
2826 if *count > 0 {
2827 h.push_str(&format!("_{}", *count + 1));
2828 }
2829 *count += 1;
2830 }
2831 headers
2832}
2833
2834fn row_passes_filters(row: &TableRow, filters: Option<&Vec<TableFilter>>) -> bool {
2835 if let Some(filters) = filters {
2836 for filter in filters {
2837 if let Some(value) = row.get(&filter.column) {
2838 match filter.op {
2839 FilterOp::Eq => {
2840 if !value_eq(value, &filter.value) {
2841 return false;
2842 }
2843 }
2844 FilterOp::Neq => {
2845 if value_eq(value, &filter.value) {
2846 return false;
2847 }
2848 }
2849 FilterOp::Contains => {
2850 if !value_contains(value, &filter.value) {
2851 return false;
2852 }
2853 }
2854 FilterOp::Gt => {
2855 if !value_gt(value, &filter.value) {
2856 return false;
2857 }
2858 }
2859 FilterOp::Lt => {
2860 if !value_lt(value, &filter.value) {
2861 return false;
2862 }
2863 }
2864 FilterOp::Gte => {
2865 if !value_gte(value, &filter.value) {
2866 return false;
2867 }
2868 }
2869 FilterOp::Lte => {
2870 if !value_lte(value, &filter.value) {
2871 return false;
2872 }
2873 }
2874 FilterOp::StartsWith => {
2875 if !value_starts_with(value, &filter.value) {
2876 return false;
2877 }
2878 }
2879 FilterOp::EndsWith => {
2880 if !value_ends_with(value, &filter.value) {
2881 return false;
2882 }
2883 }
2884 FilterOp::In => {
2885 let list = filter
2886 .value
2887 .as_array()
2888 .cloned()
2889 .unwrap_or_else(|| vec![filter.value.clone()]);
2890 if !list.iter().any(|cmp| value_eq(value, cmp)) {
2891 return false;
2892 }
2893 }
2894 }
2895 }
2896 }
2897 }
2898 true
2899}
2900
2901fn value_eq(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2902 match (cell, cmp) {
2903 (Some(CellValue::Text(s)), serde_json::Value::String(t)) => s == t,
2904 (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2905 v.as_f64().is_some_and(|f| (*n - f).abs() < f64::EPSILON)
2906 }
2907 (Some(CellValue::Number(n)), serde_json::Value::String(t)) => t
2908 .parse::<f64>()
2909 .map(|f| (*n - f).abs() < f64::EPSILON)
2910 .unwrap_or(false),
2911 (Some(CellValue::Bool(b)), serde_json::Value::Bool(v)) => b == v,
2912 (Some(CellValue::Bool(b)), serde_json::Value::String(t)) => {
2913 t.eq_ignore_ascii_case("true") == *b
2914 }
2915 (Some(CellValue::Date(d)), serde_json::Value::String(t)) => d == t,
2916 (None, serde_json::Value::Null) => true,
2917 _ => false,
2918 }
2919}
2920
2921fn value_contains(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2922 if let (Some(CellValue::Text(s)), serde_json::Value::String(t)) = (cell, cmp) {
2923 return s.to_ascii_lowercase().contains(&t.to_ascii_lowercase());
2924 }
2925 false
2926}
2927
2928fn value_gt(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2929 match (cell, cmp) {
2930 (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2931 v.as_f64().is_some_and(|f| *n > f)
2932 }
2933 _ => false,
2934 }
2935}
2936
2937fn value_lt(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2938 match (cell, cmp) {
2939 (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2940 v.as_f64().is_some_and(|f| *n < f)
2941 }
2942 _ => false,
2943 }
2944}
2945
2946fn value_gte(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2947 match (cell, cmp) {
2948 (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2949 v.as_f64().is_some_and(|f| *n >= f)
2950 }
2951 _ => false,
2952 }
2953}
2954
2955fn value_lte(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2956 match (cell, cmp) {
2957 (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2958 v.as_f64().is_some_and(|f| *n <= f)
2959 }
2960 _ => false,
2961 }
2962}
2963
2964fn value_starts_with(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2965 if let (Some(CellValue::Text(s)), serde_json::Value::String(t)) = (cell, cmp) {
2966 return s.to_ascii_lowercase().starts_with(&t.to_ascii_lowercase());
2967 }
2968 false
2969}
2970
2971fn value_ends_with(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2972 if let (Some(CellValue::Text(s)), serde_json::Value::String(t)) = (cell, cmp) {
2973 return s.to_ascii_lowercase().ends_with(&t.to_ascii_lowercase());
2974 }
2975 false
2976}
2977
2978fn sample_rows(
2979 rows: Vec<TableRow>,
2980 limit: usize,
2981 offset: usize,
2982 mode: SampleMode,
2983) -> Vec<TableRow> {
2984 if rows.is_empty() {
2985 return rows;
2986 }
2987
2988 match mode {
2989 SampleMode::Distributed => {
2990 if limit == 0 {
2991 return Vec::new();
2992 }
2993 let mut indices = Vec::new();
2994 let span = rows.len().saturating_sub(1);
2995 let step = std::cmp::max(1, span / std::cmp::max(1, limit.saturating_sub(1)));
2996 let mut idx = offset;
2997 while idx < rows.len() && indices.len() < limit {
2998 indices.push(idx);
2999 idx = idx.saturating_add(step);
3000 if idx == indices.last().copied().unwrap_or(0) {
3001 idx += 1;
3002 }
3003 }
3004 if indices.len() < limit {
3005 let last_idx = rows.len().saturating_sub(1);
3006 if !indices.contains(&last_idx) {
3007 indices.push(last_idx);
3008 }
3009 }
3010 indices
3011 .into_iter()
3012 .filter_map(|i| rows.get(i).cloned())
3013 .collect()
3014 }
3015 SampleMode::Last => {
3016 let start = rows.len().saturating_sub(limit + offset);
3017 rows.into_iter().skip(start + offset).take(limit).collect()
3018 }
3019 SampleMode::First => rows.into_iter().skip(offset).take(limit).collect(),
3020 }
3021}
3022
3023fn summarize_columns(headers: &[String], rows: &[TableRow]) -> Vec<ColumnTypeSummary> {
3024 let mut summaries = Vec::new();
3025 for header in headers {
3026 let mut nulls = 0u32;
3027 let mut distinct_set: HashSet<String> = HashSet::new();
3028 let mut values: Vec<f64> = Vec::new();
3029 let mut top_counts: HashMap<String, u32> = HashMap::new();
3030
3031 for row in rows {
3032 match row.get(header) {
3033 Some(Some(CellValue::Number(n))) => {
3034 values.push(*n);
3035 let key = n.to_string();
3036 *top_counts.entry(key).or_default() += 1;
3037 }
3038 Some(Some(CellValue::Text(s))) => {
3039 distinct_set.insert(s.clone());
3040 *top_counts.entry(s.clone()).or_default() += 1;
3041 }
3042 Some(Some(CellValue::Bool(b))) => {
3043 let key = b.to_string();
3044 distinct_set.insert(key.clone());
3045 *top_counts.entry(key).or_default() += 1;
3046 }
3047 Some(Some(CellValue::Date(d))) => {
3048 distinct_set.insert(d.clone());
3049 *top_counts.entry(d.clone()).or_default() += 1;
3050 }
3051 Some(Some(CellValue::Error(e))) => {
3052 distinct_set.insert(e.clone());
3053 *top_counts.entry(e.clone()).or_default() += 1;
3054 }
3055 _ => {
3056 nulls += 1;
3057 }
3058 }
3059 }
3060
3061 let inferred_type = if !values.is_empty() {
3062 "number"
3063 } else if !distinct_set.is_empty() {
3064 "text"
3065 } else {
3066 "unknown"
3067 }
3068 .to_string();
3069
3070 let min = values.iter().cloned().reduce(f64::min);
3071 let max = values.iter().cloned().reduce(f64::max);
3072 let mean = if values.is_empty() {
3073 None
3074 } else {
3075 Some(values.iter().sum::<f64>() / values.len() as f64)
3076 };
3077
3078 let mut top_values: Vec<(String, u32)> = top_counts.into_iter().collect();
3079 top_values.sort_by(|a, b| b.1.cmp(&a.1));
3080 let top_values = top_values.into_iter().take(3).map(|(v, _)| v).collect();
3081
3082 summaries.push(ColumnTypeSummary {
3083 name: header.clone(),
3084 inferred_type,
3085 nulls,
3086 distinct: distinct_set.len() as u32,
3087 top_values,
3088 min,
3089 max,
3090 mean,
3091 });
3092 }
3093 summaries
3094}
3095
3096#[allow(clippy::too_many_arguments)]
3097fn collect_value_matches(
3098 sheet: &umya_spreadsheet::Worksheet,
3099 sheet_name: &str,
3100 mode: &FindMode,
3101 match_mode: MatchMode,
3102 direction: &LabelDirection,
3103 params: &FindValueParams,
3104 region: Option<&DetectedRegion>,
3105 default_bounds: ((u32, u32), (u32, u32)),
3106 offset: u32,
3107 limit: u32,
3108 seen_so_far: u32,
3109) -> Result<(Vec<FindValueMatch>, u32, bool)> {
3110 let mut results = Vec::new();
3111 let mut seen = seen_so_far;
3112 let regex = if match_mode == MatchMode::Regex {
3113 Regex::new(¶ms.query).ok()
3114 } else {
3115 None
3116 };
3117 let bounds = region
3118 .as_ref()
3119 .and_then(|r| parse_range(&r.bounds))
3120 .unwrap_or(default_bounds);
3121
3122 let header_row = region.and_then(|r| r.header_row).unwrap_or(1);
3123
3124 let context_mode = params.context.unwrap_or_default();
3125 let include_neighbors = matches!(context_mode, FindContext::Neighbors | FindContext::Both);
3126 let include_row_context = matches!(context_mode, FindContext::Row | FindContext::Both);
3127 let context_width = params.context_width.unwrap_or(3).max(1);
3128
3129 for cell in sheet.get_cell_collection() {
3130 let coord = cell.get_coordinate();
3131 let col = *coord.get_col_num();
3132 let row = *coord.get_row_num();
3133 if col < bounds.0.0 || col > bounds.1.0 || row < bounds.0.1 || row > bounds.1.1 {
3134 continue;
3135 }
3136 if params.search_headers_only && row != header_row {
3137 continue;
3138 }
3139
3140 let value = cell_to_value(cell);
3141 if let Some(ref allowed) = params.value_types
3142 && !value_type_matches(&value, allowed)
3143 {
3144 continue;
3145 }
3146 if matches!(mode, FindMode::Value) {
3147 if !value_matches(
3148 &value,
3149 ¶ms.query,
3150 match_mode,
3151 params.case_sensitive,
3152 ®ex,
3153 ) {
3154 continue;
3155 }
3156 } else if let Some(label) = ¶ms.label {
3157 if !label_matches(cell, label, match_mode, params.case_sensitive, ®ex) {
3158 continue;
3159 }
3160 } else {
3161 continue;
3162 }
3163
3164 if seen < offset {
3165 seen += 1;
3166 continue;
3167 }
3168
3169 if results.len() as u32 >= limit {
3170 return Ok((results, seen, true));
3171 }
3172
3173 let neighbors = if include_neighbors {
3174 collect_neighbors(sheet, row, col)
3175 } else {
3176 None
3177 };
3178 let (label_hit, match_value) = if matches!(mode, FindMode::Label) {
3179 let target_value = match direction {
3180 LabelDirection::Right => sheet.get_cell((col + 1, row)),
3181 LabelDirection::Below => sheet.get_cell((col, row + 1)),
3182 LabelDirection::Any => sheet
3183 .get_cell((col + 1, row))
3184 .or_else(|| sheet.get_cell((col, row + 1))),
3185 }
3186 .and_then(cell_to_value);
3187 if target_value.is_none() {
3188 continue;
3189 }
3190 (
3191 Some(LabelHit {
3192 label_address: coord.get_coordinate(),
3193 label: label_from_cell(cell),
3194 }),
3195 target_value,
3196 )
3197 } else {
3198 (None, value.clone())
3199 };
3200
3201 let row_context = if include_row_context {
3202 build_row_context(sheet, row, col, context_width)
3203 } else {
3204 None
3205 };
3206
3207 results.push(FindValueMatch {
3208 address: coord.get_coordinate(),
3209 sheet_name: sheet_name.to_string(),
3210 value: match_value,
3211 row_context,
3212 neighbors,
3213 label_hit,
3214 });
3215 seen += 1;
3216 }
3217
3218 Ok((results, seen, false))
3219}
3220
3221fn label_from_cell(cell: &umya_spreadsheet::Cell) -> String {
3222 cell_to_value(cell)
3223 .map(|v| match v {
3224 CellValue::Text(s) => s,
3225 CellValue::Number(n) => n.to_string(),
3226 CellValue::Bool(b) => b.to_string(),
3227 CellValue::Date(d) => d,
3228 CellValue::Error(e) => e,
3229 })
3230 .unwrap_or_else(|| cell.get_value().to_string())
3231}
3232
3233fn value_matches(
3234 value: &Option<CellValue>,
3235 query: &str,
3236 mode: MatchMode,
3237 case_sensitive: bool,
3238 regex: &Option<Regex>,
3239) -> bool {
3240 if value.is_none() {
3241 return false;
3242 }
3243 let haystack = cell_value_to_string_lower(value.clone().unwrap());
3244 let needle = if case_sensitive {
3245 query.to_string()
3246 } else {
3247 query.to_ascii_lowercase()
3248 };
3249
3250 match mode {
3251 MatchMode::Exact => haystack == needle,
3252 MatchMode::Prefix => haystack.starts_with(&needle),
3253 MatchMode::Regex => regex
3254 .as_ref()
3255 .map(|re| re.is_match(&haystack))
3256 .unwrap_or(false),
3257 MatchMode::Contains => haystack.contains(&needle),
3258 }
3259}
3260
3261fn label_matches(
3262 cell: &umya_spreadsheet::Cell,
3263 label: &str,
3264 mode: MatchMode,
3265 case_sensitive: bool,
3266 regex: &Option<Regex>,
3267) -> bool {
3268 let value = cell_to_value(cell);
3269 if value.is_none() {
3270 return false;
3271 }
3272 let haystack = cell_value_to_string_lower(value.unwrap());
3273 let needle = if case_sensitive {
3274 label.to_string()
3275 } else {
3276 label.to_ascii_lowercase()
3277 };
3278 match mode {
3279 MatchMode::Exact => haystack == needle,
3280 MatchMode::Prefix => haystack.starts_with(&needle),
3281 MatchMode::Regex => regex
3282 .as_ref()
3283 .map(|re| re.is_match(&haystack))
3284 .unwrap_or(false),
3285 MatchMode::Contains => haystack.contains(&needle),
3286 }
3287}
3288
3289fn value_type_matches(value: &Option<CellValue>, allowed: &[ValueTypeFilter]) -> bool {
3290 if value.is_none() {
3291 return allowed.contains(&ValueTypeFilter::Null);
3292 }
3293 match value.as_ref().unwrap() {
3294 CellValue::Text(_) => allowed.contains(&ValueTypeFilter::Text),
3295 CellValue::Number(_) => allowed.contains(&ValueTypeFilter::Number),
3296 CellValue::Bool(_) => allowed.contains(&ValueTypeFilter::Bool),
3297 CellValue::Date(_) => allowed.contains(&ValueTypeFilter::Date),
3298 CellValue::Error(_) => true,
3299 }
3300}
3301
3302fn collect_neighbors(
3303 sheet: &umya_spreadsheet::Worksheet,
3304 row: u32,
3305 col: u32,
3306) -> Option<NeighborValues> {
3307 Some(NeighborValues {
3308 left: if col > 1 {
3309 sheet.get_cell((col - 1, row)).and_then(cell_to_value)
3310 } else {
3311 None
3312 },
3313 right: sheet.get_cell((col + 1, row)).and_then(cell_to_value),
3314 up: if row > 1 {
3315 sheet.get_cell((col, row - 1)).and_then(cell_to_value)
3316 } else {
3317 None
3318 },
3319 down: sheet.get_cell((col, row + 1)).and_then(cell_to_value),
3320 })
3321}
3322
3323fn build_row_context(
3324 sheet: &umya_spreadsheet::Worksheet,
3325 row: u32,
3326 col: u32,
3327 width: u32,
3328) -> Option<RowContext> {
3329 let width = width.max(1);
3330 let half = width / 2;
3331 let max_col = sheet.get_highest_column().max(1);
3332 let start_col = col.saturating_sub(half).max(1);
3333 let end_col = (col + half).min(max_col);
3334
3335 let mut headers = Vec::new();
3336 let mut values = Vec::new();
3337
3338 for current_col in start_col..=end_col {
3339 let header_value = sheet
3340 .get_cell((current_col, 1u32))
3341 .and_then(cell_to_value)
3342 .map(|v| match v {
3343 CellValue::Text(s) => s,
3344 CellValue::Number(n) => n.to_string(),
3345 CellValue::Bool(b) => b.to_string(),
3346 CellValue::Date(d) => d,
3347 CellValue::Error(e) => e,
3348 })
3349 .unwrap_or_else(|| format!("Col{}", current_col));
3350 let value = sheet.get_cell((current_col, row)).and_then(cell_to_value);
3351 headers.push(header_value);
3352 values.push(value);
3353 }
3354
3355 Some(RowContext { headers, values })
3356}
3357
3358fn default_find_formula_limit() -> u32 {
3359 50
3360}
3361
3362#[derive(Debug, Deserialize, JsonSchema, Default)]
3363pub struct FindFormulaParams {
3364 #[serde(alias = "workbook_id")]
3366 pub workbook_or_fork_id: WorkbookId,
3367 pub query: String,
3369 pub sheet_name: Option<String>,
3371 #[serde(default)]
3373 pub case_sensitive: bool,
3374 #[serde(default)]
3376 pub include_context: bool,
3377 #[serde(default = "default_find_formula_limit")]
3379 pub limit: u32,
3380 #[serde(default)]
3382 pub offset: u32,
3383 #[serde(default)]
3385 pub context_rows: Option<u32>,
3386 #[serde(default)]
3388 pub context_cols: Option<u32>,
3389}
3390
3391pub async fn find_formula(
3392 state: Arc<AppState>,
3393 params: FindFormulaParams,
3394) -> Result<FindFormulaResponse> {
3395 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
3396 let query = if params.case_sensitive {
3397 params.query.clone()
3398 } else {
3399 params.query.to_ascii_lowercase()
3400 };
3401
3402 let sheet_names: Vec<String> = if let Some(sheet) = ¶ms.sheet_name {
3403 vec![sheet.clone()]
3404 } else {
3405 workbook.sheet_names()
3406 };
3407
3408 let limit = params.limit.clamp(1, 500);
3409 let offset = params.offset;
3410 let context_rows = params.context_rows.unwrap_or(1);
3411 let context_cols = params.context_cols.unwrap_or(1);
3412
3413 let mut matches = Vec::new();
3414 let mut seen: u32 = 0;
3415 let mut truncated = false;
3416
3417 for sheet_name in sheet_names {
3418 let (sheet_matches, sheet_seen, sheet_truncated) =
3419 workbook.with_sheet(&sheet_name, |sheet| {
3420 collect_formula_matches(
3421 sheet,
3422 &sheet_name,
3423 &query,
3424 params.case_sensitive,
3425 params.include_context,
3426 context_rows,
3427 context_cols,
3428 offset,
3429 limit,
3430 seen,
3431 )
3432 })?;
3433
3434 seen = sheet_seen;
3435 truncated |= sheet_truncated;
3436 matches.extend(sheet_matches);
3437
3438 if truncated {
3439 break;
3440 }
3441 }
3442
3443 let next_offset = if truncated {
3444 Some(offset.saturating_add(matches.len() as u32))
3445 } else {
3446 None
3447 };
3448
3449 let response = FindFormulaResponse {
3450 workbook_id: workbook.id.clone(),
3451 matches,
3452 next_offset,
3453 };
3454 Ok(response)
3455}
3456
3457#[derive(Debug, Deserialize, JsonSchema)]
3458pub struct ScanVolatilesParams {
3459 #[serde(alias = "workbook_id")]
3461 pub workbook_or_fork_id: WorkbookId,
3462 pub sheet_name: Option<String>,
3464 #[serde(default)]
3466 pub summary_only: Option<bool>,
3467 #[serde(default)]
3469 pub include_addresses: Option<bool>,
3470 #[serde(default)]
3472 pub addresses_limit: Option<u32>,
3473 #[serde(default)]
3475 pub limit: Option<u32>,
3476 #[serde(default)]
3478 pub offset: Option<u32>,
3479 #[serde(default)]
3481 pub formula_parse_policy: Option<FormulaParsePolicy>,
3482}
3483
3484pub async fn scan_volatiles(
3485 state: Arc<AppState>,
3486 params: ScanVolatilesParams,
3487) -> Result<VolatileScanResponse> {
3488 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
3489 let config = state.config();
3490 let output_profile = config.output_profile();
3491 let summary_only = params
3492 .summary_only
3493 .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
3494 let include_addresses = params.include_addresses.unwrap_or(!summary_only);
3495 let addresses_limit = params.addresses_limit.unwrap_or(15);
3496 let policy = params
3497 .formula_parse_policy
3498 .unwrap_or(FormulaParsePolicy::Warn);
3499 let mut combined_builder = FormulaParseDiagnosticsBuilder::new(policy);
3500 let max_items = config.max_items();
3501 let max_payload_bytes = config.max_payload_bytes();
3502
3503 let mut target_sheets: Vec<String> = if let Some(sheet) = ¶ms.sheet_name {
3504 vec![sheet.clone()]
3505 } else {
3506 workbook.sheet_names()
3507 };
3508 target_sheets.sort_by(|left, right| {
3509 left.to_ascii_lowercase()
3510 .cmp(&right.to_ascii_lowercase())
3511 .then_with(|| left.cmp(right))
3512 });
3513
3514 let mut items = Vec::new();
3515 for sheet_name in target_sheets {
3516 let graph = workbook.formula_graph_with_diagnostics_builder(
3517 &sheet_name,
3518 policy,
3519 &mut combined_builder,
3520 )?;
3521 let mut groups = graph
3522 .groups()
3523 .into_iter()
3524 .filter(|group| group.is_volatile)
3525 .collect::<Vec<_>>();
3526 groups.sort_by(|left, right| {
3527 left.formula
3528 .cmp(&right.formula)
3529 .then_with(|| left.fingerprint.cmp(&right.fingerprint))
3530 .then_with(|| left.addresses.len().cmp(&right.addresses.len()))
3531 });
3532
3533 for group in groups {
3534 if summary_only {
3535 items.push(VolatileScanEntry {
3536 address: String::new(),
3537 sheet_name: sheet_name.clone(),
3538 function: "volatile".to_string(),
3539 note: Some(format!("Count: {}", group.addresses.len())),
3540 });
3541 continue;
3542 }
3543
3544 if include_addresses {
3545 let mut addresses = group.addresses;
3546 addresses.sort();
3547 for address in addresses.into_iter().take(addresses_limit as usize) {
3548 items.push(VolatileScanEntry {
3549 address,
3550 sheet_name: sheet_name.clone(),
3551 function: "volatile".to_string(),
3552 note: Some(group.formula.clone()),
3553 });
3554 }
3555 } else {
3556 items.push(VolatileScanEntry {
3557 address: String::new(),
3558 sheet_name: sheet_name.clone(),
3559 function: "volatile".to_string(),
3560 note: Some(group.formula.clone()),
3561 });
3562 }
3563 }
3564 }
3565
3566 let diagnostics = combined_builder.build();
3567 let formula_parse_diagnostics = if diagnostics.total_errors > 0 {
3568 Some(diagnostics)
3569 } else {
3570 None
3571 };
3572
3573 items.sort_by(|left, right| {
3574 left.sheet_name
3575 .cmp(&right.sheet_name)
3576 .then_with(|| left.function.cmp(&right.function))
3577 .then_with(|| left.note.cmp(&right.note))
3578 .then_with(|| left.address.cmp(&right.address))
3579 });
3580
3581 let total_items = items.len();
3582 let offset = params.offset.unwrap_or(0) as usize;
3583 let page_limit = params
3584 .limit
3585 .map(|limit| limit.max(1) as usize)
3586 .unwrap_or(usize::MAX);
3587 let start = offset.min(total_items);
3588 let end = start.saturating_add(page_limit).min(total_items);
3589 let mut page_items = items[start..end].to_vec();
3590
3591 if let Some(max_items) = max_items
3592 && page_items.len() > max_items
3593 {
3594 page_items.truncate(max_items);
3595 }
3596
3597 if let Some(max_bytes) = max_payload_bytes {
3598 let item_limit = cap_rows_by_payload_bytes(page_items.len(), Some(max_bytes), |count| {
3599 let response = VolatileScanResponse {
3600 workbook_id: workbook.id.clone(),
3601 items: page_items[..count].to_vec(),
3602 formula_parse_diagnostics: formula_parse_diagnostics.clone(),
3603 next_offset: None,
3604 };
3605 serde_json::to_vec(&response)
3606 .map(|payload| payload.len())
3607 .unwrap_or(usize::MAX)
3608 });
3609
3610 if item_limit < page_items.len() {
3611 page_items.truncate(item_limit);
3612 }
3613 }
3614
3615 let emitted = page_items.len();
3616 let absolute_next = offset.saturating_add(emitted);
3617 let next_offset = if emitted > 0 && absolute_next < total_items {
3618 Some(absolute_next as u32)
3619 } else {
3620 None
3621 };
3622
3623 let response = VolatileScanResponse {
3624 workbook_id: workbook.id.clone(),
3625 items: page_items,
3626 formula_parse_diagnostics,
3627 next_offset,
3628 };
3629 Ok(response)
3630}
3631
3632#[derive(Debug, Deserialize, JsonSchema)]
3633pub struct WorkbookStyleSummaryParams {
3634 #[serde(alias = "workbook_id")]
3636 pub workbook_or_fork_id: WorkbookId,
3637 pub max_styles: Option<u32>,
3639 pub max_conditional_formats: Option<u32>,
3641 pub max_cells_scan: Option<u32>,
3643 #[serde(default)]
3645 pub summary_only: Option<bool>,
3646 #[serde(default)]
3648 pub include_descriptor: Option<bool>,
3649 #[serde(default)]
3651 pub include_example_cells: Option<bool>,
3652 #[serde(default)]
3654 pub include_theme: Option<bool>,
3655 #[serde(default)]
3657 pub include_conditional_formats: Option<bool>,
3658}
3659
3660#[derive(Debug)]
3661struct WorkbookStyleAccum {
3662 descriptor: StyleDescriptor,
3663 occurrences: u32,
3664 tags: HashSet<String>,
3665 example_cells: Vec<String>,
3666}
3667
3668impl WorkbookStyleAccum {
3669 fn new(descriptor: StyleDescriptor) -> Self {
3670 Self {
3671 descriptor,
3672 occurrences: 0,
3673 tags: HashSet::new(),
3674 example_cells: Vec::new(),
3675 }
3676 }
3677}
3678
3679pub async fn workbook_style_summary(
3680 state: Arc<AppState>,
3681 params: WorkbookStyleSummaryParams,
3682) -> Result<WorkbookStyleSummaryResponse> {
3683 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
3684 let config = state.config();
3685 let output_profile = config.output_profile();
3686 let summary_only = params
3687 .summary_only
3688 .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
3689 let include_descriptor = params.include_descriptor.unwrap_or(!summary_only);
3690 let include_example_cells = params.include_example_cells.unwrap_or(!summary_only);
3691 let include_theme = params.include_theme.unwrap_or(!summary_only);
3692 let include_conditional_formats = params.include_conditional_formats.unwrap_or(!summary_only);
3693 let max_payload_bytes = config.max_payload_bytes();
3694 let sheet_names = workbook.sheet_names();
3695
3696 const STYLE_EXAMPLE_LIMIT: usize = 5;
3697 const STYLE_LIMIT_DEFAULT: usize = 200;
3698 const CF_LIMIT_DEFAULT: usize = 200;
3699 const CELL_SCAN_LIMIT_DEFAULT: usize = 500_000;
3700
3701 let style_limit = params
3702 .max_styles
3703 .map(|v| v as usize)
3704 .unwrap_or(STYLE_LIMIT_DEFAULT);
3705 let style_limit = config
3706 .max_items()
3707 .map(|limit| style_limit.min(limit))
3708 .unwrap_or(style_limit);
3709 let cf_limit = params
3710 .max_conditional_formats
3711 .map(|v| v as usize)
3712 .unwrap_or(CF_LIMIT_DEFAULT);
3713 let cf_limit = config
3714 .max_items()
3715 .map(|limit| cf_limit.min(limit))
3716 .unwrap_or(cf_limit);
3717 let cell_scan_limit = params
3718 .max_cells_scan
3719 .map(|v| v as usize)
3720 .unwrap_or(CELL_SCAN_LIMIT_DEFAULT);
3721
3722 let mut acc: HashMap<String, WorkbookStyleAccum> = HashMap::new();
3723 let mut scanned_cells: usize = 0;
3724 let mut scan_truncated = false;
3725
3726 for sheet_name in &sheet_names {
3727 if scan_truncated {
3728 break;
3729 }
3730 workbook.with_sheet(sheet_name, |sheet| {
3731 for cell in sheet.get_cell_collection() {
3732 if scanned_cells >= cell_scan_limit {
3733 scan_truncated = true;
3734 break;
3735 }
3736 scanned_cells += 1;
3737
3738 let address = cell.get_coordinate().get_coordinate().to_string();
3739 let descriptor = crate::styles::descriptor_from_style(cell.get_style());
3740 let style_id = crate::styles::stable_style_id(&descriptor);
3741
3742 let entry = acc
3743 .entry(style_id.clone())
3744 .or_insert_with(|| WorkbookStyleAccum::new(descriptor.clone()));
3745 entry.occurrences += 1;
3746 if entry.example_cells.len() < STYLE_EXAMPLE_LIMIT {
3747 entry.example_cells.push(format!("{sheet_name}!{address}"));
3748 }
3749
3750 if let Some((_, tagging)) = crate::analysis::style::tag_cell(cell) {
3751 for tag in tagging.tags {
3752 entry.tags.insert(tag);
3753 }
3754 }
3755 }
3756 })?;
3757 }
3758
3759 let total_styles = acc.len() as u32;
3760 let mut styles: Vec<WorkbookStyleUsage> = acc
3761 .into_iter()
3762 .map(|(style_id, entry)| {
3763 let mut tags: Vec<String> = entry.tags.into_iter().collect();
3764 tags.sort();
3765 WorkbookStyleUsage {
3766 style_id,
3767 occurrences: entry.occurrences,
3768 tags,
3769 example_cells: if include_example_cells {
3770 entry.example_cells
3771 } else {
3772 Vec::new()
3773 },
3774 descriptor: if include_descriptor {
3775 Some(entry.descriptor)
3776 } else {
3777 None
3778 },
3779 }
3780 })
3781 .collect();
3782
3783 styles.sort_by(|a, b| {
3784 b.occurrences
3785 .cmp(&a.occurrences)
3786 .then_with(|| a.style_id.cmp(&b.style_id))
3787 });
3788
3789 let inferred_default_style_id = styles.first().map(|s| s.style_id.clone());
3790 let mut inferred_default_font = styles
3791 .first()
3792 .and_then(|s| s.descriptor.as_ref().and_then(|d| d.font.clone()));
3793
3794 let mut styles_truncated = if styles.len() > style_limit {
3795 styles.truncate(style_limit);
3796 true
3797 } else {
3798 false
3799 };
3800
3801 let theme = workbook.with_spreadsheet(|book| {
3802 let theme = book.get_theme();
3803 let elements = theme.get_theme_elements();
3804 let scheme = elements.get_color_scheme();
3805 let mut colors = BTreeMap::new();
3806
3807 let mut insert_color = |name: &str, value: String| {
3808 if !value.trim().is_empty() {
3809 colors.insert(name.to_string(), value);
3810 }
3811 };
3812
3813 insert_color("dk1", scheme.get_dk1().get_val());
3814 insert_color("lt1", scheme.get_lt1().get_val());
3815 insert_color("dk2", scheme.get_dk2().get_val());
3816 insert_color("lt2", scheme.get_lt2().get_val());
3817 insert_color("accent1", scheme.get_accent1().get_val());
3818 insert_color("accent2", scheme.get_accent2().get_val());
3819 insert_color("accent3", scheme.get_accent3().get_val());
3820 insert_color("accent4", scheme.get_accent4().get_val());
3821 insert_color("accent5", scheme.get_accent5().get_val());
3822 insert_color("accent6", scheme.get_accent6().get_val());
3823 insert_color("hlink", scheme.get_hlink().get_val());
3824 insert_color("fol_hlink", scheme.get_fol_hlink().get_val());
3825
3826 let font_scheme = elements.get_font_scheme();
3827 let major = font_scheme.get_major_font();
3828 let minor = font_scheme.get_minor_font();
3829 let font_scheme_summary = ThemeFontSchemeSummary {
3830 major_latin: Some(major.get_latin_font().get_typeface().to_string())
3831 .filter(|s| !s.trim().is_empty()),
3832 major_east_asian: Some(major.get_east_asian_font().get_typeface().to_string())
3833 .filter(|s| !s.trim().is_empty()),
3834 major_complex_script: Some(major.get_complex_script_font().get_typeface().to_string())
3835 .filter(|s| !s.trim().is_empty()),
3836 minor_latin: Some(minor.get_latin_font().get_typeface().to_string())
3837 .filter(|s| !s.trim().is_empty()),
3838 minor_east_asian: Some(minor.get_east_asian_font().get_typeface().to_string())
3839 .filter(|s| !s.trim().is_empty()),
3840 minor_complex_script: Some(minor.get_complex_script_font().get_typeface().to_string())
3841 .filter(|s| !s.trim().is_empty()),
3842 };
3843
3844 ThemeSummary {
3845 name: Some(theme.get_name().to_string()).filter(|s| !s.trim().is_empty()),
3846 colors,
3847 font_scheme: font_scheme_summary,
3848 }
3849 })?;
3850
3851 if inferred_default_font.is_none()
3852 && let Some(name) = theme
3853 .font_scheme
3854 .minor_latin
3855 .clone()
3856 .or_else(|| theme.font_scheme.major_latin.clone())
3857 {
3858 inferred_default_font = Some(FontDescriptor {
3859 name: Some(name),
3860 size: None,
3861 bold: None,
3862 italic: None,
3863 underline: None,
3864 strikethrough: None,
3865 color: None,
3866 });
3867 }
3868
3869 let theme = if include_theme { Some(theme) } else { None };
3870
3871 let mut conditional_formats: Vec<ConditionalFormatSummary> = Vec::new();
3872 let mut conditional_formats_truncated = false;
3873 if include_conditional_formats {
3874 use umya_spreadsheet::structs::EnumTrait;
3875 for sheet_name in &sheet_names {
3876 if conditional_formats_truncated {
3877 break;
3878 }
3879 workbook.with_sheet(sheet_name, |sheet| {
3880 for cf in sheet.get_conditional_formatting_collection() {
3881 if conditional_formats.len() >= cf_limit {
3882 conditional_formats_truncated = true;
3883 break;
3884 }
3885 let range = cf.get_sequence_of_references().get_sqref().to_string();
3886 let mut types: HashSet<String> = HashSet::new();
3887 for rule in cf.get_conditional_collection() {
3888 types.insert(rule.get_type().get_value_string().to_string());
3889 }
3890 let mut rule_types: Vec<String> = types.into_iter().collect();
3891 rule_types.sort();
3892 conditional_formats.push(ConditionalFormatSummary {
3893 sheet_name: sheet_name.clone(),
3894 range,
3895 rule_types,
3896 rule_count: cf.get_conditional_collection().len() as u32,
3897 });
3898 }
3899 })?;
3900 }
3901 }
3902
3903 let mut notes: Vec<String> = Vec::new();
3904 if scan_truncated {
3905 notes.push(format!(
3906 "Stopped scanning after {cell_scan_limit} cells; style counts may be incomplete."
3907 ));
3908 }
3909 notes.push(
3910 "Named styles are not directly exposed by umya-spreadsheet; styles here are inferred from cell formatting."
3911 .to_string(),
3912 );
3913
3914 if let Some(max_bytes) = max_payload_bytes {
3915 let style_limit = cap_rows_by_payload_bytes(styles.len(), Some(max_bytes), |count| {
3916 let response = WorkbookStyleSummaryResponse {
3917 workbook_id: workbook.id.clone(),
3918 theme: theme.clone(),
3919 inferred_default_style_id: inferred_default_style_id.clone(),
3920 inferred_default_font: inferred_default_font.clone(),
3921 styles: styles[..count].to_vec(),
3922 total_styles,
3923 styles_truncated: false,
3924 conditional_formats: conditional_formats.clone(),
3925 conditional_formats_truncated,
3926 scan_truncated,
3927 notes: notes.clone(),
3928 };
3929 serde_json::to_vec(&response)
3930 .map(|payload| payload.len())
3931 .unwrap_or(usize::MAX)
3932 });
3933
3934 if style_limit < styles.len() {
3935 styles.truncate(style_limit);
3936 styles_truncated = true;
3937 }
3938
3939 if !conditional_formats.is_empty() {
3940 let cf_limit =
3941 cap_rows_by_payload_bytes(conditional_formats.len(), Some(max_bytes), |count| {
3942 let response = WorkbookStyleSummaryResponse {
3943 workbook_id: workbook.id.clone(),
3944 theme: theme.clone(),
3945 inferred_default_style_id: inferred_default_style_id.clone(),
3946 inferred_default_font: inferred_default_font.clone(),
3947 styles: styles.clone(),
3948 total_styles,
3949 styles_truncated,
3950 conditional_formats: conditional_formats[..count].to_vec(),
3951 conditional_formats_truncated: false,
3952 scan_truncated,
3953 notes: notes.clone(),
3954 };
3955 serde_json::to_vec(&response)
3956 .map(|payload| payload.len())
3957 .unwrap_or(usize::MAX)
3958 });
3959
3960 if cf_limit < conditional_formats.len() {
3961 conditional_formats.truncate(cf_limit);
3962 conditional_formats_truncated = true;
3963 }
3964 }
3965 }
3966
3967 Ok(WorkbookStyleSummaryResponse {
3968 workbook_id: workbook.id.clone(),
3969 theme,
3970 inferred_default_style_id,
3971 inferred_default_font,
3972 styles,
3973 total_styles,
3974 styles_truncated,
3975 conditional_formats,
3976 conditional_formats_truncated,
3977 scan_truncated,
3978 notes,
3979 })
3980}
3981
3982#[derive(Debug, Deserialize, JsonSchema)]
3983pub struct SheetStylesParams {
3984 #[serde(alias = "workbook_id")]
3986 pub workbook_or_fork_id: WorkbookId,
3987 pub sheet_name: String,
3989 #[serde(default)]
3991 pub scope: Option<SheetStylesScope>,
3992 #[serde(default)]
3994 pub granularity: Option<StyleGranularity>,
3995 #[serde(default)]
3997 pub max_items: Option<usize>,
3998 #[serde(default)]
4000 pub summary_only: Option<bool>,
4001 #[serde(default)]
4003 pub include_descriptor: Option<bool>,
4004 #[serde(default)]
4006 pub include_ranges: Option<bool>,
4007 #[serde(default)]
4009 pub include_example_cells: Option<bool>,
4010}
4011
4012#[derive(Debug, Deserialize, JsonSchema)]
4013#[serde(tag = "kind", rename_all = "snake_case")]
4014pub enum SheetStylesScope {
4015 Range { range: String },
4016 Region { region_id: u32 },
4017}
4018
4019#[derive(Debug)]
4020struct StyleAccum {
4021 descriptor: StyleDescriptor,
4022 occurrences: u32,
4023 tags: HashSet<String>,
4024 example_cells: Vec<String>,
4025 positions: Vec<(u32, u32)>,
4026}
4027
4028impl StyleAccum {
4029 fn new(descriptor: StyleDescriptor) -> Self {
4030 Self {
4031 descriptor,
4032 occurrences: 0,
4033 tags: HashSet::new(),
4034 example_cells: Vec::new(),
4035 positions: Vec::new(),
4036 }
4037 }
4038}
4039
4040pub async fn sheet_styles(
4041 state: Arc<AppState>,
4042 params: SheetStylesParams,
4043) -> Result<SheetStylesResponse> {
4044 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
4045 let config = state.config();
4046 let output_profile = config.output_profile();
4047 let summary_only = params
4048 .summary_only
4049 .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
4050 let include_descriptor = params.include_descriptor.unwrap_or(!summary_only);
4051 let include_ranges = params.include_ranges.unwrap_or(!summary_only);
4052 let include_example_cells = params.include_example_cells.unwrap_or(!summary_only);
4053 const STYLE_EXAMPLE_LIMIT: usize = 5;
4054 const STYLE_RANGE_LIMIT: usize = 50;
4055 const STYLE_LIMIT: usize = 200;
4056 const MAX_MAX_ITEMS: usize = 5000;
4057
4058 let max_payload_bytes = config.max_payload_bytes();
4059 let style_limit = config
4060 .max_items()
4061 .map(|limit| STYLE_LIMIT.min(limit))
4062 .unwrap_or(STYLE_LIMIT);
4063
4064 let metrics = workbook.get_sheet_metrics_fast(¶ms.sheet_name)?;
4065 let full_bounds = (
4066 (1, 1),
4067 (
4068 metrics.metrics.column_count.max(1),
4069 metrics.metrics.row_count.max(1),
4070 ),
4071 );
4072
4073 let bounds = match ¶ms.scope {
4074 Some(SheetStylesScope::Range { range }) => {
4075 parse_range(range).ok_or_else(|| anyhow!("invalid range: {}", range))?
4076 }
4077 Some(SheetStylesScope::Region { region_id }) => {
4078 let region = workbook.detected_region(¶ms.sheet_name, *region_id)?;
4079 parse_range(®ion.bounds)
4080 .ok_or_else(|| anyhow!("invalid region bounds: {}", region.bounds))?
4081 }
4082 None => full_bounds,
4083 };
4084
4085 let granularity = params.granularity.unwrap_or_default();
4086
4087 let max_items = params
4088 .max_items
4089 .unwrap_or(STYLE_RANGE_LIMIT)
4090 .clamp(1, MAX_MAX_ITEMS);
4091
4092 let (mut styles, total_styles, mut styles_truncated) =
4093 workbook.with_sheet(¶ms.sheet_name, |sheet| {
4094 let mut acc: HashMap<String, StyleAccum> = HashMap::new();
4095
4096 for cell in sheet.get_cell_collection() {
4097 let address = cell.get_coordinate().get_coordinate().to_string();
4098 let Some((col, row)) = parse_address(&address) else {
4099 continue;
4100 };
4101 if col < bounds.0.0 || col > bounds.1.0 || row < bounds.0.1 || row > bounds.1.1 {
4102 continue;
4103 }
4104
4105 let descriptor = crate::styles::descriptor_from_style(cell.get_style());
4106 let style_id = crate::styles::stable_style_id(&descriptor);
4107
4108 let entry = acc
4109 .entry(style_id.clone())
4110 .or_insert_with(|| StyleAccum::new(descriptor.clone()));
4111 entry.occurrences += 1;
4112 if entry.example_cells.len() < STYLE_EXAMPLE_LIMIT {
4113 entry.example_cells.push(address.clone());
4114 }
4115
4116 if let Some((_, tagging)) = crate::analysis::style::tag_cell(cell) {
4117 for tag in tagging.tags {
4118 entry.tags.insert(tag);
4119 }
4120 }
4121
4122 entry.positions.push((row, col));
4123 }
4124
4125 let mut summaries: Vec<StyleSummary> = acc
4126 .into_iter()
4127 .map(|(style_id, mut entry)| {
4128 entry.positions.sort_unstable();
4129 entry.positions.dedup();
4130
4131 let (cell_ranges, ranges_truncated) = if include_ranges {
4132 if granularity == StyleGranularity::Cells {
4133 let mut out = Vec::new();
4134 for (row, col) in entry.positions.iter().take(max_items) {
4135 out.push(crate::utils::cell_address(*col, *row));
4136 }
4137 (out, entry.positions.len() > max_items)
4138 } else {
4139 crate::styles::compress_positions_to_ranges(&entry.positions, max_items)
4140 }
4141 } else {
4142 (Vec::new(), false)
4143 };
4144
4145 StyleSummary {
4146 style_id,
4147 occurrences: entry.occurrences,
4148 tags: entry.tags.into_iter().collect(),
4149 example_cells: if include_example_cells {
4150 entry.example_cells
4151 } else {
4152 Vec::new()
4153 },
4154 descriptor: if include_descriptor {
4155 Some(entry.descriptor)
4156 } else {
4157 None
4158 },
4159 cell_ranges,
4160 ranges_truncated,
4161 }
4162 })
4163 .collect();
4164
4165 summaries.sort_by(|a, b| {
4166 b.occurrences
4167 .cmp(&a.occurrences)
4168 .then_with(|| a.style_id.cmp(&b.style_id))
4169 });
4170
4171 let total = summaries.len() as u32;
4172 let truncated = if summaries.len() > style_limit {
4173 summaries.truncate(style_limit);
4174 true
4175 } else {
4176 false
4177 };
4178
4179 Ok::<_, anyhow::Error>((summaries, total, truncated))
4180 })??;
4181
4182 if let Some(max_bytes) = max_payload_bytes {
4183 let row_limit = cap_rows_by_payload_bytes(styles.len(), Some(max_bytes), |count| {
4184 let response = SheetStylesResponse {
4185 workbook_id: workbook.id.clone(),
4186 sheet_name: params.sheet_name.clone(),
4187 styles: styles[..count].to_vec(),
4188 conditional_rules: Vec::new(),
4189 total_styles,
4190 styles_truncated: false,
4191 };
4192 serde_json::to_vec(&response)
4193 .map(|payload| payload.len())
4194 .unwrap_or(usize::MAX)
4195 });
4196 if row_limit < styles.len() {
4197 styles.truncate(row_limit);
4198 styles_truncated = true;
4199 }
4200 }
4201
4202 Ok(SheetStylesResponse {
4203 workbook_id: workbook.id.clone(),
4204 sheet_name: params.sheet_name.clone(),
4205 styles,
4206 conditional_rules: Vec::new(),
4207 total_styles,
4208 styles_truncated,
4209 })
4210}
4211
4212pub async fn range_values(
4213 state: Arc<AppState>,
4214 params: RangeValuesParams,
4215) -> Result<RangeValuesResponse> {
4216 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
4217 let config = state.config();
4218 let output_profile = config.output_profile();
4219 let format = params.format.unwrap_or(match output_profile {
4220 OutputProfile::TokenDense => TableOutputFormat::Dense,
4221 OutputProfile::Verbose => TableOutputFormat::Json,
4222 });
4223 let include_headers = params.include_headers.unwrap_or(false);
4224 let include_formulas = params.include_formulas.unwrap_or(false);
4225 if let Some(page_size) = params.page_size
4226 && page_size == 0
4227 {
4228 return Err(anyhow!("page_size must be greater than zero"));
4229 }
4230 let max_cells = config.max_cells();
4231 let max_payload_bytes = config.max_payload_bytes();
4232 #[cfg(feature = "recalc")]
4233 let requested_bounds: Vec<((u32, u32), (u32, u32))> = params
4234 .ranges
4235 .iter()
4236 .filter_map(|r| parse_range(r))
4237 .collect();
4238
4239 #[cfg(feature = "recalc")]
4240 let (values, has_formula_in_target) = workbook.with_sheet(¶ms.sheet_name, |sheet| {
4241 let has_formula_in_target = sheet_has_formula_in_bounds(sheet, &requested_bounds);
4242 let values = params
4243 .ranges
4244 .iter()
4245 .filter_map(|range| {
4246 parse_range(range).map(|((start_col, start_row), (end_col, end_row))| {
4247 let total_rows = (end_row - start_row + 1) as usize;
4248 let total_cols = (end_col - start_col + 1) as usize;
4249 let mut row_limit = total_rows;
4250 if let Some(page_size) = params.page_size {
4251 row_limit = row_limit.min(page_size as usize);
4252 }
4253
4254 let include_formula_matrix = include_formulas
4255 && matches!(format, TableOutputFormat::Json | TableOutputFormat::Dense);
4256 let mut rows = Vec::new();
4257 let mut formula_rows = include_formula_matrix.then(Vec::new);
4258 for r in start_row..=end_row {
4259 if rows.len() >= row_limit {
4260 break;
4261 }
4262 let mut row_vals = Vec::new();
4263 let mut row_formulas = include_formula_matrix.then(Vec::new);
4264 for c in start_col..=end_col {
4265 let row_index = if include_headers && r == start_row && start_row == 1 {
4266 1u32
4267 } else {
4268 r
4269 };
4270 let cell = sheet.get_cell((c, row_index));
4271 row_vals.push(cell.and_then(cell_to_value));
4272 if let Some(formulas) = row_formulas.as_mut() {
4273 formulas.push(cell.and_then(|entry| {
4274 entry.is_formula().then(|| entry.get_formula().to_string())
4275 }));
4276 }
4277 }
4278 rows.push(row_vals);
4279 if let Some(formulas) = formula_rows.as_mut()
4280 && let Some(row) = row_formulas
4281 {
4282 formulas.push(row);
4283 }
4284 }
4285
4286 let mut row_limit = cap_rows_by_cells(rows.len(), total_cols, max_cells);
4287 if row_limit > 0 {
4288 row_limit =
4289 cap_rows_by_payload_bytes(row_limit, max_payload_bytes, |count| {
4290 let entry = build_range_values_entry(
4291 format,
4292 range,
4293 &rows[..count],
4294 formula_rows.as_ref().map(|matrix| &matrix[..count]),
4295 None,
4296 );
4297 serde_json::to_vec(&entry)
4298 .map(|payload| payload.len())
4299 .unwrap_or(usize::MAX)
4300 });
4301 }
4302
4303 if row_limit < rows.len() {
4304 rows.truncate(row_limit);
4305 if let Some(formulas) = formula_rows.as_mut() {
4306 formulas.truncate(row_limit);
4307 }
4308 }
4309
4310 let next_start_row = if rows.len() < total_rows {
4311 Some(start_row + rows.len() as u32)
4312 } else {
4313 None
4314 };
4315
4316 build_range_values_entry(
4317 format,
4318 range,
4319 &rows,
4320 formula_rows.as_deref(),
4321 next_start_row,
4322 )
4323 })
4324 })
4325 .collect();
4326
4327 Ok::<_, anyhow::Error>((values, has_formula_in_target))
4328 })??;
4329
4330 #[cfg(not(feature = "recalc"))]
4331 let values = workbook.with_sheet(¶ms.sheet_name, |sheet| {
4332 let values = params
4333 .ranges
4334 .iter()
4335 .filter_map(|range| {
4336 parse_range(range).map(|((start_col, start_row), (end_col, end_row))| {
4337 let total_rows = (end_row - start_row + 1) as usize;
4338 let total_cols = (end_col - start_col + 1) as usize;
4339 let mut row_limit = total_rows;
4340 if let Some(page_size) = params.page_size {
4341 row_limit = row_limit.min(page_size as usize);
4342 }
4343
4344 let include_formula_matrix = include_formulas
4345 && matches!(format, TableOutputFormat::Json | TableOutputFormat::Dense);
4346 let mut rows = Vec::new();
4347 let mut formula_rows = include_formula_matrix.then(Vec::new);
4348 for r in start_row..=end_row {
4349 if rows.len() >= row_limit {
4350 break;
4351 }
4352 let mut row_vals = Vec::new();
4353 let mut row_formulas = include_formula_matrix.then(Vec::new);
4354 for c in start_col..=end_col {
4355 let row_index = if include_headers && r == start_row && start_row == 1 {
4356 1u32
4357 } else {
4358 r
4359 };
4360 let cell = sheet.get_cell((c, row_index));
4361 row_vals.push(cell.and_then(cell_to_value));
4362 if let Some(formulas) = row_formulas.as_mut() {
4363 formulas.push(cell.and_then(|entry| {
4364 entry.is_formula().then(|| entry.get_formula().to_string())
4365 }));
4366 }
4367 }
4368 rows.push(row_vals);
4369 if let Some(formulas) = formula_rows.as_mut()
4370 && let Some(row) = row_formulas
4371 {
4372 formulas.push(row);
4373 }
4374 }
4375
4376 let mut row_limit = cap_rows_by_cells(rows.len(), total_cols, max_cells);
4377 if row_limit > 0 {
4378 row_limit =
4379 cap_rows_by_payload_bytes(row_limit, max_payload_bytes, |count| {
4380 let entry = build_range_values_entry(
4381 format,
4382 range,
4383 &rows[..count],
4384 formula_rows.as_ref().map(|matrix| &matrix[..count]),
4385 None,
4386 );
4387 serde_json::to_vec(&entry)
4388 .map(|payload| payload.len())
4389 .unwrap_or(usize::MAX)
4390 });
4391 }
4392
4393 if row_limit < rows.len() {
4394 rows.truncate(row_limit);
4395 if let Some(formulas) = formula_rows.as_mut() {
4396 formulas.truncate(row_limit);
4397 }
4398 }
4399
4400 let next_start_row = if rows.len() < total_rows {
4401 Some(start_row + rows.len() as u32)
4402 } else {
4403 None
4404 };
4405
4406 build_range_values_entry(
4407 format,
4408 range,
4409 &rows,
4410 formula_rows.as_deref(),
4411 next_start_row,
4412 )
4413 })
4414 })
4415 .collect();
4416
4417 Ok::<_, anyhow::Error>(values)
4418 })??;
4419
4420 #[cfg(feature = "recalc")]
4421 let warnings: Vec<Warning> = {
4422 if fork_recalc_needed(&state, ¶ms.workbook_or_fork_id) && has_formula_in_target {
4423 vec![Warning {
4424 code: "WARN_STALE_FORMULAS".to_string(),
4425 message: "Fork has pending edits and may contain stale formula results; call recalculate on the fork for fresh values.".to_string(),
4426 }]
4427 } else {
4428 Vec::new()
4429 }
4430 };
4431
4432 #[cfg(not(feature = "recalc"))]
4433 let warnings: Vec<Warning> = Vec::new();
4434
4435 Ok(RangeValuesResponse {
4436 workbook_id: workbook.id.clone(),
4437 sheet_name: params.sheet_name,
4438 warnings,
4439 values,
4440 })
4441}
4442
4443pub async fn inspect_cells(
4444 state: Arc<AppState>,
4445 params: InspectCellsParams,
4446) -> Result<InspectCellsResponse> {
4447 const DETAIL_LIMIT: usize = 25;
4448 const DETAIL_LIMIT_MAX: usize = 200;
4449
4450 if params.targets.is_empty() {
4451 return Err(anyhow!(
4452 "inspect-cells requires at least one A1 target (cell or range)"
4453 ));
4454 }
4455
4456 if let Some(b) = params.budget
4457 && (b < 1 || b as usize > DETAIL_LIMIT_MAX)
4458 {
4459 return Err(anyhow!(
4460 "budget must be between 1 and {} (got {})",
4461 DETAIL_LIMIT_MAX,
4462 b
4463 ));
4464 }
4465
4466 let effective_cap = params
4467 .budget
4468 .map(|b| (b as usize).min(DETAIL_LIMIT_MAX))
4469 .unwrap_or(DETAIL_LIMIT);
4470
4471 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
4472 let config = state.config();
4473 let detail_limit = config
4474 .max_cells()
4475 .map(|limit| limit.min(effective_cap))
4476 .unwrap_or(effective_cap)
4477 .max(1);
4478 let max_payload_bytes = config.max_payload_bytes();
4479 let include_empty = params.include_empty.unwrap_or(false);
4480
4481 let mut coords = Vec::new();
4482 let mut seen = HashSet::new();
4483 for target in ¶ms.targets {
4484 let ((start_col, start_row), (end_col, end_row)) =
4485 parse_range(target).ok_or_else(|| {
4486 anyhow!(
4487 "invalid target '{}'; expected A1 cell or range like A1:C10",
4488 target
4489 )
4490 })?;
4491
4492 for row in start_row..=end_row {
4493 for col in start_col..=end_col {
4494 if seen.insert((col, row)) {
4495 coords.push((col, row));
4496 }
4497 }
4498 }
4499 }
4500
4501 if coords.len() > detail_limit {
4502 return Err(anyhow!(
4503 "inspect-cells is a detail view and accepts up to {} cells per request (got {}). Narrow your selection or use sheet-page, range-values, or layout-page for broader discovery.",
4504 detail_limit,
4505 coords.len()
4506 ));
4507 }
4508
4509 let mut cells = workbook.with_sheet(¶ms.sheet_name, |sheet| {
4510 let mut out = Vec::new();
4511 for (col, row) in &coords {
4512 if let Some(cell) = sheet.get_cell((*col, *row)) {
4513 out.push(build_cell_snapshot(cell, true, true));
4514 } else if include_empty {
4515 out.push(CellSnapshot {
4516 address: format!("{}{}", column_number_to_name(*col), row),
4517 value: None,
4518 formula: None,
4519 cached_value: None,
4520 number_format: None,
4521 style_tags: Vec::new(),
4522 notes: Vec::new(),
4523 });
4524 }
4525 }
4526 Ok::<_, anyhow::Error>(out)
4527 })??;
4528
4529 let total_requested_rows = coords
4530 .iter()
4531 .map(|(_, row)| *row)
4532 .collect::<HashSet<u32>>()
4533 .len();
4534 let mut cell_rows: Vec<u32> = cells
4535 .iter()
4536 .filter_map(|cell| parse_address(&cell.address).map(|(_, row)| row))
4537 .collect();
4538 let mut truncated = false;
4539 let cell_limit = cap_rows_by_payload_bytes(cells.len(), max_payload_bytes, |count| {
4540 let response = InspectCellsResponse {
4541 workbook_id: workbook.id.clone(),
4542 sheet_name: params.sheet_name.clone(),
4543 range: params.targets.join(","),
4544 targets: if params.targets.len() > 1 {
4545 params.targets.clone()
4546 } else {
4547 Vec::new()
4548 },
4549 cells: cells[..count].to_vec(),
4550 truncated: false,
4551 budget: None,
4552 };
4553 serde_json::to_vec(&response)
4554 .map(|payload| payload.len())
4555 .unwrap_or(usize::MAX)
4556 });
4557 if cell_limit < cells.len() {
4558 cells.truncate(cell_limit);
4559 cell_rows.truncate(cell_limit);
4560 truncated = true;
4561 }
4562
4563 let cells_returned = cells.len();
4564 let rows_returned = cell_rows.into_iter().collect::<HashSet<u32>>().len();
4565 let budget = Some(ReadBudget {
4566 max_cells: Some(detail_limit),
4567 max_payload_bytes,
4568 rows_returned,
4569 cells_returned,
4570 total_rows_available: Some(total_requested_rows as u32),
4571 continuation: if truncated {
4572 Some(
4573 "inspect-cells is a strict detail-view tool; narrow your targets or use sheet-page / range-values for bulk reads"
4574 .to_string(),
4575 )
4576 } else {
4577 None
4578 },
4579 });
4580
4581 Ok(InspectCellsResponse {
4582 workbook_id: workbook.id.clone(),
4583 sheet_name: params.sheet_name,
4584 range: params.targets.join(","),
4585 targets: if params.targets.len() > 1 {
4586 params.targets
4587 } else {
4588 Vec::new()
4589 },
4590 cells,
4591 truncated,
4592 budget,
4593 })
4594}
4595
4596pub async fn find_value(
4597 state: Arc<AppState>,
4598 params: FindValueParams,
4599) -> Result<FindValueResponse> {
4600 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
4601 let mut matches = Vec::new();
4602 let mut truncated = false;
4603 let mut seen: u32 = 0;
4604 let offset = params.offset.unwrap_or(0);
4605 let limit = params.limit;
4606 let mode = params.mode.clone().unwrap_or_else(|| {
4607 if params.label.is_some() {
4608 FindMode::Label
4609 } else {
4610 FindMode::Value
4611 }
4612 });
4613 let match_mode = params.match_mode.unwrap_or_default();
4614 let direction = params.direction.clone().unwrap_or(LabelDirection::Any);
4615
4616 let target_sheets: Vec<String> = if let Some(sheet) = ¶ms.sheet_name {
4617 vec![sheet.clone()]
4618 } else {
4619 workbook.sheet_names()
4620 };
4621
4622 for sheet_name in target_sheets {
4623 let metrics_entry = workbook.get_sheet_metrics_fast(&sheet_name)?;
4624 let default_bounds = (
4625 (1, 1),
4626 (
4627 metrics_entry.metrics.column_count.max(1),
4628 metrics_entry.metrics.row_count.max(1),
4629 ),
4630 );
4631 let region_bounds = params
4632 .region_id
4633 .and_then(|id| workbook.detected_region(&sheet_name, id).ok());
4634 let (sheet_matches, sheet_seen, sheet_truncated) =
4635 workbook.with_sheet(&sheet_name, |sheet| {
4636 collect_value_matches(
4637 sheet,
4638 &sheet_name,
4639 &mode,
4640 match_mode,
4641 &direction,
4642 ¶ms,
4643 region_bounds.as_ref(),
4644 default_bounds,
4645 offset,
4646 limit,
4647 seen,
4648 )
4649 })??;
4650 seen = sheet_seen;
4651 matches.extend(sheet_matches);
4652 if sheet_truncated {
4653 truncated = true;
4654 break;
4655 }
4656 }
4657
4658 let next_offset = if truncated {
4659 Some(offset.saturating_add(matches.len() as u32))
4660 } else {
4661 None
4662 };
4663
4664 Ok(FindValueResponse {
4665 workbook_id: workbook.id.clone(),
4666 match_count: matches.len() as u32,
4667 matches,
4668 next_offset,
4669 })
4670}
4671
4672pub async fn read_table(
4673 state: Arc<AppState>,
4674 params: ReadTableParams,
4675) -> Result<ReadTableResponse> {
4676 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
4677 let config = state.config();
4678 let output_profile = config.output_profile();
4679 let format = params.format.unwrap_or(match output_profile {
4680 OutputProfile::TokenDense => TableOutputFormat::Csv,
4681 OutputProfile::Verbose => TableOutputFormat::Json,
4682 });
4683 let include_headers = params.include_headers.unwrap_or(true);
4684 let include_types = params.include_types.unwrap_or(false);
4685 let resolved = resolve_table_target(&workbook, ¶ms)?;
4686 let limit = params.limit.unwrap_or(100) as usize;
4687 let offset = params.offset.unwrap_or(0) as usize;
4688 let sample_mode = params.sample_mode.unwrap_or_default();
4689
4690 #[cfg(feature = "recalc")]
4691 let (headers, rows, total_rows, has_formula_in_target) =
4692 workbook.with_sheet(&resolved.sheet_name, |sheet| {
4693 let has_formula_in_target = sheet_has_formula_in_bounds(sheet, &[resolved.range]);
4694 let (headers, rows, total_rows) = extract_table_rows(
4695 sheet,
4696 &resolved,
4697 params.header_row,
4698 params.header_rows,
4699 params.columns.clone(),
4700 params.filters.clone(),
4701 limit,
4702 offset,
4703 sample_mode,
4704 )?;
4705 Ok::<_, anyhow::Error>((headers, rows, total_rows, has_formula_in_target))
4706 })??;
4707
4708 #[cfg(not(feature = "recalc"))]
4709 let (headers, rows, total_rows) = workbook.with_sheet(&resolved.sheet_name, |sheet| {
4710 let (headers, rows, total_rows) = extract_table_rows(
4711 sheet,
4712 &resolved,
4713 params.header_row,
4714 params.header_rows,
4715 params.columns.clone(),
4716 params.filters.clone(),
4717 limit,
4718 offset,
4719 sample_mode,
4720 )?;
4721 Ok::<_, anyhow::Error>((headers, rows, total_rows))
4722 })??;
4723
4724 #[cfg(feature = "recalc")]
4725 let warnings: Vec<Warning> = {
4726 if fork_recalc_needed(&state, ¶ms.workbook_or_fork_id) && has_formula_in_target {
4727 vec![Warning {
4728 code: "WARN_STALE_FORMULAS".to_string(),
4729 message: "Fork has pending edits and may contain stale formula results; call recalculate on the fork for fresh values.".to_string(),
4730 }]
4731 } else {
4732 Vec::new()
4733 }
4734 };
4735
4736 #[cfg(not(feature = "recalc"))]
4737 let warnings: Vec<Warning> = Vec::new();
4738
4739 let max_cells = config.max_cells();
4740 let max_payload_bytes = config.max_payload_bytes();
4741 let mut row_limit = cap_rows_by_cells(rows.len(), headers.len().max(1), max_cells);
4742 if row_limit > 0 {
4743 row_limit = cap_rows_by_payload_bytes(row_limit, max_payload_bytes, |count| {
4744 let (headers_out, rows_out, values_out, types_out, csv_out) = build_read_table_payload(
4745 format,
4746 &headers,
4747 &rows[..count],
4748 include_headers,
4749 include_types,
4750 );
4751 let response = ReadTableResponse {
4752 workbook_id: workbook.id.clone(),
4753 sheet_name: resolved.sheet_name.clone(),
4754 table_name: resolved.table_name.clone(),
4755 warnings: warnings.clone(),
4756 headers: headers_out,
4757 rows: rows_out,
4758 values: values_out,
4759 types: types_out,
4760 csv: csv_out,
4761 total_rows,
4762 next_offset: None,
4763 };
4764 serde_json::to_vec(&response)
4765 .map(|payload| payload.len())
4766 .unwrap_or(usize::MAX)
4767 });
4768 }
4769
4770 let rows = rows.into_iter().take(row_limit).collect::<Vec<_>>();
4771 let next_offset = if offset + rows.len() < total_rows as usize {
4772 Some((offset + rows.len()) as u32)
4773 } else {
4774 None
4775 };
4776 let (headers_out, rows_out, values_out, types_out, csv_out) =
4777 build_read_table_payload(format, &headers, &rows, include_headers, include_types);
4778
4779 Ok(ReadTableResponse {
4780 workbook_id: workbook.id.clone(),
4781 sheet_name: resolved.sheet_name,
4782 table_name: resolved.table_name,
4783 warnings,
4784 headers: headers_out,
4785 rows: rows_out,
4786 values: values_out,
4787 types: types_out,
4788 csv: csv_out,
4789 total_rows,
4790 next_offset,
4791 })
4792}
4793
4794pub async fn table_profile(
4795 state: Arc<AppState>,
4796 params: TableProfileParams,
4797) -> Result<TableProfileResponse> {
4798 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
4799 let config = state.config();
4800 let output_profile = config.output_profile();
4801 let summary_only = params
4802 .summary_only
4803 .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
4804 let resolved = resolve_table_target(
4805 &workbook,
4806 &ReadTableParams {
4807 workbook_or_fork_id: params.workbook_or_fork_id.clone(),
4808 sheet_name: params.sheet_name.clone(),
4809 table_name: params.table_name.clone(),
4810 region_id: params.region_id,
4811 range: None,
4812 header_row: None,
4813 header_rows: None,
4814 columns: None,
4815 filters: None,
4816 sample_mode: params.sample_mode,
4817 limit: params.sample_size,
4818 offset: Some(0),
4819 format: Some(TableOutputFormat::Json),
4820 include_headers: None,
4821 include_types: None,
4822 },
4823 )?;
4824
4825 let sample_size = params.sample_size.unwrap_or(10) as usize;
4826 let sample_mode = params.sample_mode.unwrap_or(SampleMode::Distributed);
4827
4828 let (mut headers, rows, total_rows) =
4829 workbook.with_sheet(&resolved.sheet_name, |sheet| {
4830 extract_table_rows(
4831 sheet,
4832 &resolved,
4833 None,
4834 None,
4835 None,
4836 None,
4837 sample_size,
4838 0,
4839 sample_mode,
4840 )
4841 })??;
4842
4843 let max_items = config.max_items();
4844 let max_payload_bytes = config.max_payload_bytes();
4845
4846 if let Some(max_items) = max_items
4847 && headers.len() > max_items
4848 {
4849 headers.truncate(max_items);
4850 }
4851
4852 let mut column_types = summarize_columns(&headers, &rows);
4853
4854 let mut samples: Vec<TableRow> = if summary_only {
4855 Vec::new()
4856 } else {
4857 rows.into_iter()
4858 .map(|row| filter_table_row(&row, &headers))
4859 .collect()
4860 };
4861
4862 if !summary_only {
4863 if let Some(max_items) = max_items
4864 && samples.len() > max_items
4865 {
4866 samples.truncate(max_items);
4867 }
4868
4869 if let Some(max_bytes) = max_payload_bytes {
4870 let sample_limit = cap_rows_by_payload_bytes(samples.len(), Some(max_bytes), |count| {
4871 let response = TableProfileResponse {
4872 workbook_id: workbook.id.clone(),
4873 sheet_name: resolved.sheet_name.clone(),
4874 table_name: resolved.table_name.clone(),
4875 headers: headers.clone(),
4876 column_types: column_types.clone(),
4877 row_count: total_rows,
4878 samples: samples[..count].to_vec(),
4879 notes: Vec::new(),
4880 };
4881 serde_json::to_vec(&response)
4882 .map(|payload| payload.len())
4883 .unwrap_or(usize::MAX)
4884 });
4885 if sample_limit < samples.len() {
4886 samples.truncate(sample_limit);
4887 }
4888
4889 let response = TableProfileResponse {
4890 workbook_id: workbook.id.clone(),
4891 sheet_name: resolved.sheet_name.clone(),
4892 table_name: resolved.table_name.clone(),
4893 headers: headers.clone(),
4894 column_types: column_types.clone(),
4895 row_count: total_rows,
4896 samples: samples.clone(),
4897 notes: Vec::new(),
4898 };
4899 if serde_json::to_vec(&response)
4900 .map(|payload| payload.len() > max_bytes)
4901 .unwrap_or(false)
4902 && !headers.is_empty()
4903 {
4904 let header_limit =
4905 cap_rows_by_payload_bytes(headers.len(), Some(max_bytes), |count| {
4906 let headers_slice = headers[..count].to_vec();
4907 let column_slice = column_types[..count.min(column_types.len())].to_vec();
4908 let samples_slice = samples
4909 .iter()
4910 .map(|row| filter_table_row(row, &headers_slice))
4911 .collect::<Vec<_>>();
4912 let response = TableProfileResponse {
4913 workbook_id: workbook.id.clone(),
4914 sheet_name: resolved.sheet_name.clone(),
4915 table_name: resolved.table_name.clone(),
4916 headers: headers_slice,
4917 column_types: column_slice,
4918 row_count: total_rows,
4919 samples: samples_slice,
4920 notes: Vec::new(),
4921 };
4922 serde_json::to_vec(&response)
4923 .map(|payload| payload.len())
4924 .unwrap_or(usize::MAX)
4925 });
4926
4927 if header_limit < headers.len() {
4928 headers.truncate(header_limit);
4929 column_types.truncate(header_limit.min(column_types.len()));
4930 samples = samples
4931 .into_iter()
4932 .map(|row| filter_table_row(&row, &headers))
4933 .collect();
4934 }
4935 }
4936 }
4937 }
4938
4939 Ok(TableProfileResponse {
4940 workbook_id: workbook.id.clone(),
4941 sheet_name: resolved.sheet_name,
4942 table_name: resolved.table_name,
4943 headers,
4944 column_types,
4945 row_count: total_rows,
4946 samples,
4947 notes: Vec::new(),
4948 })
4949}
4950
4951#[derive(Debug, Deserialize, JsonSchema)]
4952pub struct ManifestStubParams {
4953 #[serde(alias = "workbook_id")]
4954 pub workbook_or_fork_id: WorkbookId,
4955 pub sheet_filter: Option<String>,
4956}
4957
4958#[cfg(feature = "recalc-formualizer")]
4959pub async fn get_manifest_stub(
4960 state: Arc<AppState>,
4961 params: ManifestStubParams,
4962) -> Result<ManifestStubResponse> {
4963 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
4964 let mut summaries = workbook.list_summaries(true)?;
4965
4966 if let Some(filter) = ¶ms.sheet_filter {
4967 summaries.retain(|summary| summary.name.eq_ignore_ascii_case(filter));
4968 }
4969
4970 let mut ports = Vec::new();
4971 let sanitize_id_re = regex::Regex::new(r"[^a-z0-9_-]").expect("valid id regex");
4972 let sanitize_header_re = regex::Regex::new(r"[^a-zA-Z0-9_]").expect("valid header regex");
4973
4974 for summary in &summaries {
4975 let sheet_name = &summary.name;
4976 if let Ok(overview) = workbook.sheet_overview(sheet_name) {
4977 for (idx, region) in overview.detected_regions.iter().enumerate() {
4978 let classification_str = format!("{:?}", region.classification).to_lowercase();
4979 let id = format!(
4980 "{}_{}_{}",
4981 sheet_name.replace(" ", "_"),
4982 classification_str,
4983 idx
4984 )
4985 .to_lowercase()
4986 .replace(char::is_whitespace, "_");
4987 let id = sanitize_id_re.replace_all(&id, "").to_string();
4988
4989 let (dir, shape, schema, location) = match region.classification {
4990 crate::model::RegionKind::Parameters => {
4991 let shape = formualizer::sheetport_spec::Shape::Range;
4992 let schema = formualizer::sheetport_spec::Schema::Range(
4993 formualizer::sheetport_spec::RangeSchema {
4994 kind: formualizer::sheetport_spec::RangeKind::Range,
4995 cell_type: formualizer::sheetport_spec::ValueType::Number,
4996 format: None,
4997 },
4998 );
4999 let a1 = region.bounds.clone();
5000 let location = formualizer::sheetport_spec::Selector::A1(
5001 formualizer::sheetport_spec::SelectorA1 {
5002 a1: format!("'{}'!{}", sheet_name, a1),
5003 },
5004 );
5005 (
5006 formualizer::sheetport_spec::Direction::In,
5007 shape,
5008 schema,
5009 location,
5010 )
5011 }
5012 crate::model::RegionKind::Data => {
5013 let shape = formualizer::sheetport_spec::Shape::Table;
5014 let anchor_col = region
5015 .bounds
5016 .chars()
5017 .take_while(|c| c.is_alphabetic())
5018 .collect::<String>();
5019 let anchor_col = if anchor_col.is_empty() {
5020 "A".to_string()
5021 } else {
5022 anchor_col
5023 };
5024 let layout = formualizer::sheetport_spec::LayoutDescriptor {
5025 kind: formualizer::sheetport_spec::LayoutKind::HeaderContiguousV1,
5026 sheet: sheet_name.clone(),
5027 header_row: region.header_row.unwrap_or(1),
5028 anchor_col,
5029 terminate:
5030 formualizer::sheetport_spec::LayoutTermination::FirstBlankRow,
5031 marker_text: None,
5032 };
5033 let mut columns = Vec::new();
5034 for (c_idx, header) in region.headers.iter().enumerate() {
5035 let clean_name = sanitize_header_re
5036 .replace_all(header, "_")
5037 .to_string()
5038 .to_lowercase();
5039 let clean_name = if clean_name.is_empty() {
5040 format!("col_{}", c_idx)
5041 } else {
5042 clean_name
5043 };
5044 columns.push(formualizer::sheetport_spec::TableColumn {
5045 name: clean_name,
5046 value_type: formualizer::sheetport_spec::ValueType::String,
5047 col: None,
5048 format: None,
5049 units: None,
5050 });
5051 }
5052 let schema = formualizer::sheetport_spec::Schema::Table(
5053 formualizer::sheetport_spec::TableSchema {
5054 kind: formualizer::sheetport_spec::TableKind::Table,
5055 columns,
5056 keys: None,
5057 },
5058 );
5059 (
5060 formualizer::sheetport_spec::Direction::In,
5061 shape,
5062 schema,
5063 formualizer::sheetport_spec::Selector::Layout(
5064 formualizer::sheetport_spec::SelectorLayout { layout },
5065 ),
5066 )
5067 }
5068 crate::model::RegionKind::Calculator | crate::model::RegionKind::Outputs => {
5069 let shape = formualizer::sheetport_spec::Shape::Range;
5070 let schema = formualizer::sheetport_spec::Schema::Range(
5071 formualizer::sheetport_spec::RangeSchema {
5072 kind: formualizer::sheetport_spec::RangeKind::Range,
5073 cell_type: formualizer::sheetport_spec::ValueType::Number,
5074 format: None,
5075 },
5076 );
5077 let a1 = region.bounds.clone();
5078 let location = formualizer::sheetport_spec::Selector::A1(
5079 formualizer::sheetport_spec::SelectorA1 {
5080 a1: format!("'{}'!{}", sheet_name, a1),
5081 },
5082 );
5083 (
5084 formualizer::sheetport_spec::Direction::Out,
5085 shape,
5086 schema,
5087 location,
5088 )
5089 }
5090 _ => continue,
5091 };
5092
5093 ports.push(formualizer::sheetport_spec::Port {
5094 id,
5095 dir,
5096 shape,
5097 description: None,
5098 required: true,
5099 location,
5100 schema,
5101 constraints: None,
5102 units: None,
5103 default: None,
5104 partition_key: None,
5105 });
5106 }
5107 }
5108 }
5109
5110 let manifest_obj = formualizer::sheetport_spec::Manifest {
5111 spec: "fio".to_string(),
5112 spec_version: formualizer::sheetport_spec::SpecVersion("0.3.0".parse().unwrap()),
5113 capabilities: Some(formualizer::sheetport_spec::Capabilities {
5114 profile: formualizer::sheetport_spec::Profile::CoreV0,
5115 features: None,
5116 }),
5117 manifest: formualizer::sheetport_spec::ManifestMeta {
5118 id: workbook
5119 .slug
5120 .clone()
5121 .to_lowercase()
5122 .replace(char::is_whitespace, "-")
5123 .replace(|c: char| !c.is_alphanumeric() && c != '-', ""),
5124 name: workbook.slug.clone(),
5125 description: Some("Auto-generated manifest stub".to_string()),
5126 tags: None,
5127 workbook: Some(formualizer::sheetport_spec::WorkbookMeta {
5128 uri: Some(format!("file://{}", workbook.slug)),
5129 locale: None,
5130 date_system: None,
5131 timezone: None,
5132 }),
5133 metadata: None,
5134 },
5135 ports,
5136 };
5137
5138 let manifest_yaml = manifest_obj.to_yaml().unwrap_or_else(|_| "".to_string());
5139
5140 let sheets = summaries
5141 .into_iter()
5142 .map(|summary| ManifestSheetStub {
5143 sheet_name: summary.name.clone(),
5144 classification: summary.classification.clone(),
5145 candidate_expectations: vec![format!(
5146 "Review {} sheet for expectation candidates",
5147 format!("{:?}", summary.classification).to_ascii_lowercase()
5148 )],
5149 notes: summary.style_tags,
5150 })
5151 .collect();
5152
5153 let response = ManifestStubResponse {
5154 workbook_id: workbook.id.clone(),
5155 slug: workbook.slug.clone(),
5156 manifest_yaml,
5157 sheets,
5158 };
5159 Ok(response)
5160}
5161
5162#[cfg(not(feature = "recalc-formualizer"))]
5163pub async fn get_manifest_stub(
5164 _state: Arc<AppState>,
5165 _params: ManifestStubParams,
5166) -> Result<ManifestStubResponse> {
5167 Err(anyhow!(
5168 "sheetport operations require the 'recalc-formualizer' feature"
5169 ))
5170}
5171
5172#[derive(Debug, Deserialize, JsonSchema)]
5173pub struct CloseWorkbookParams {
5174 #[serde(alias = "workbook_id")]
5175 pub workbook_or_fork_id: WorkbookId,
5176}
5177
5178pub async fn close_workbook(
5179 state: Arc<AppState>,
5180 params: CloseWorkbookParams,
5181) -> Result<CloseWorkbookResponse> {
5182 state.close_workbook(¶ms.workbook_or_fork_id)?;
5183 Ok(CloseWorkbookResponse {
5184 workbook_id: params.workbook_or_fork_id.clone(),
5185 message: format!("workbook {} evicted", params.workbook_or_fork_id.as_str()),
5186 })
5187}
5188#[allow(clippy::too_many_arguments)]
5189fn collect_formula_matches(
5190 sheet: &umya_spreadsheet::Worksheet,
5191 sheet_name: &str,
5192 query: &str,
5193 case_sensitive: bool,
5194 include_context: bool,
5195 context_rows: u32,
5196 context_cols: u32,
5197 offset: u32,
5198 limit: u32,
5199 seen_so_far: u32,
5200) -> (Vec<FindFormulaMatch>, u32, bool) {
5201 use crate::workbook::cell_to_value;
5202
5203 let mut results = Vec::new();
5204 let mut seen = seen_so_far;
5205
5206 for cell in sheet.get_cell_collection() {
5207 if !cell.is_formula() {
5208 continue;
5209 }
5210 let formula = cell.get_formula();
5211 let haystack = if case_sensitive {
5212 formula.to_string()
5213 } else {
5214 formula.to_ascii_lowercase()
5215 };
5216 if !haystack.contains(query) {
5217 continue;
5218 }
5219
5220 if seen < offset {
5221 seen += 1;
5222 continue;
5223 }
5224
5225 if results.len() as u32 >= limit {
5226 return (results, seen, true);
5227 }
5228
5229 let coord = cell.get_coordinate();
5230 let column = *coord.get_col_num();
5231 let row = *coord.get_row_num();
5232
5233 let context = if include_context {
5234 let col_start = column.saturating_sub(context_cols / 2).max(1);
5235 let col_end = column + context_cols / 2;
5236 let columns: Vec<u32> = (col_start..=col_end).collect();
5237
5238 let mut context_rows_vec = Vec::new();
5239
5240 if context_rows > 0 {
5241 let header_row = build_row_snapshot(sheet, 1, &columns, false, false);
5242 context_rows_vec.push(header_row);
5243 }
5244
5245 let row_start = row.saturating_sub(context_rows / 2).max(1);
5246 let row_end = (row + context_rows / 2).min(sheet.get_highest_row());
5247
5248 for ctx_row in row_start..=row_end {
5249 let ctx_row_snapshot = build_row_snapshot(sheet, ctx_row, &columns, true, false);
5250 context_rows_vec.push(ctx_row_snapshot);
5251 }
5252
5253 context_rows_vec
5254 } else {
5255 Vec::new()
5256 };
5257
5258 results.push(FindFormulaMatch {
5259 address: coord.get_coordinate(),
5260 sheet_name: sheet_name.to_string(),
5261 formula: formula.to_string(),
5262 cached_value: cell_to_value(cell),
5263 context,
5264 });
5265
5266 seen += 1;
5267 }
5268
5269 (results, seen, false)
5270}
5271
5272#[derive(Clone)]
5273struct TraceFormulaInfo {
5274 fingerprint: String,
5275 formula: String,
5276}
5277
5278#[derive(Clone)]
5279struct TraceEdgeRaw {
5280 from: String,
5281 to: String,
5282 neighbor: String,
5283}
5284
5285#[derive(Clone)]
5286struct LayerLinks {
5287 depth: u32,
5288 edges: Vec<TraceEdgeRaw>,
5289 truncated_cells: Vec<String>,
5290}
5291
5292#[derive(Clone)]
5293struct NeighborDetail {
5294 address: String,
5295 column: Option<u32>,
5296 row: Option<u32>,
5297 kind: TraceCellKind,
5298 value: Option<CellValue>,
5299 formula: Option<String>,
5300 fingerprint: Option<String>,
5301 external: bool,
5302}
5303
5304fn build_formula_lookup(graph: &FormulaGraph) -> HashMap<String, TraceFormulaInfo> {
5305 let mut map = HashMap::new();
5306 for group in graph.groups() {
5307 for address in group.addresses.clone() {
5308 map.insert(
5309 address.to_ascii_uppercase(),
5310 TraceFormulaInfo {
5311 fingerprint: group.fingerprint.clone(),
5312 formula: group.formula.clone(),
5313 },
5314 );
5315 }
5316 }
5317 map
5318}
5319
5320struct TraceConfig<'a> {
5321 direction: &'a TraceDirection,
5322 origin: &'a str,
5323 sheet_name: &'a str,
5324 depth_limit: u32,
5325 page_size: usize,
5326}
5327
5328fn build_trace_layers(
5329 workbook: &WorkbookContext,
5330 graph: &FormulaGraph,
5331 formula_lookup: &HashMap<String, TraceFormulaInfo>,
5332 config: &TraceConfig<'_>,
5333 cursor: Option<TraceCursor>,
5334) -> Result<(Vec<TraceLayer>, Option<TraceCursor>, Vec<String>)> {
5335 let layer_links =
5336 collect_layer_links(graph, config.direction, config.origin, config.depth_limit);
5337 let mut layers = Vec::new();
5338 let mut next_cursor = None;
5339 let mut notes = Vec::new();
5340 let focus_depth = cursor.as_ref().map(|c| c.depth);
5341
5342 for layer in layer_links {
5343 let produce_edges = focus_depth.is_none_or(|depth| depth == layer.depth);
5344 let offset = cursor
5345 .as_ref()
5346 .filter(|c| c.depth == layer.depth)
5347 .map(|c| c.offset)
5348 .unwrap_or(0);
5349
5350 let mut node_set: HashSet<String> = HashSet::new();
5351 for edge in &layer.edges {
5352 node_set.insert(edge.neighbor.clone());
5353 }
5354 let mut nodes: Vec<String> = node_set.into_iter().collect();
5355 nodes.sort_by(|a, b| compare_addresses(a, b));
5356
5357 let details = workbook.with_sheet(config.sheet_name, |sheet| {
5358 collect_neighbor_details(sheet, config.sheet_name, &nodes, formula_lookup)
5359 })?;
5360 let total_nodes = details.len();
5361 let start = offset.min(total_nodes);
5362 let end = if produce_edges {
5363 (start + config.page_size).min(total_nodes)
5364 } else {
5365 start
5366 };
5367 let selected_slice = if produce_edges {
5368 &details[start..end]
5369 } else {
5370 &details[0..0]
5371 };
5372 let selected_addresses: HashSet<String> = selected_slice
5373 .iter()
5374 .map(|detail| detail.address.clone())
5375 .collect();
5376
5377 let summary = build_layer_summary(&details);
5378 let range_highlights = build_range_highlights(&details);
5379 let group_highlights = build_formula_group_highlights(&details);
5380 let notable_cells = build_notable_cells(&details, &range_highlights, &group_highlights);
5381
5382 let highlights = TraceLayerHighlights {
5383 top_ranges: range_highlights.clone(),
5384 top_formula_groups: group_highlights.clone(),
5385 notable_cells,
5386 };
5387
5388 let edges = if produce_edges {
5389 build_edges_for_layer(&layer.edges, &selected_addresses, formula_lookup)
5390 } else {
5391 Vec::new()
5392 };
5393
5394 let has_more = produce_edges && end < total_nodes;
5395 if has_more && next_cursor.is_none() {
5396 next_cursor = Some(TraceCursor {
5397 depth: layer.depth,
5398 offset: end,
5399 });
5400 }
5401 if has_more {
5402 notes.push(format!(
5403 "Layer {} truncated at {} of {} nodes; supply cursor.depth={} and cursor.offset={} to continue",
5404 layer.depth, end, total_nodes, layer.depth, end
5405 ));
5406 }
5407
5408 if !layer.truncated_cells.is_empty() {
5409 let cell_list = if layer.truncated_cells.len() <= 3 {
5410 layer.truncated_cells.join(", ")
5411 } else {
5412 format!(
5413 "{}, ... ({} more)",
5414 layer.truncated_cells[..3].join(", "),
5415 layer.truncated_cells.len() - 3
5416 )
5417 };
5418 notes.push(format!(
5419 "Layer {}: dependents truncated at {} per cell for: {}",
5420 layer.depth, TRACE_DEPENDENTS_PER_CELL_LIMIT, cell_list
5421 ));
5422 }
5423
5424 layers.push(TraceLayer {
5425 depth: layer.depth,
5426 summary,
5427 highlights,
5428 edges,
5429 has_more,
5430 });
5431 }
5432
5433 Ok((layers, next_cursor, notes))
5434}
5435
5436fn collect_layer_links(
5437 graph: &FormulaGraph,
5438 direction: &TraceDirection,
5439 origin: &str,
5440 depth_limit: u32,
5441) -> Vec<LayerLinks> {
5442 let mut visited: HashSet<String> = HashSet::new();
5443 visited.insert(origin.to_string());
5444 let mut frontier = vec![origin.to_string()];
5445 let mut layers = Vec::new();
5446
5447 for depth in 1..=depth_limit {
5448 let mut next_frontier_set: HashSet<String> = HashSet::new();
5449 let mut edges = Vec::new();
5450 let mut truncated_cells = Vec::new();
5451
5452 for cell in &frontier {
5453 let (neighbors, was_truncated) = match direction {
5454 TraceDirection::Precedents => (graph.precedents(cell), false),
5455 TraceDirection::Dependents => {
5456 graph.dependents_limited(cell, Some(TRACE_DEPENDENTS_PER_CELL_LIMIT))
5457 }
5458 };
5459
5460 if was_truncated {
5461 truncated_cells.push(cell.clone());
5462 }
5463
5464 for neighbor in neighbors {
5465 let neighbor_upper = neighbor.to_ascii_uppercase();
5466 let edge = match direction {
5467 TraceDirection::Precedents => TraceEdgeRaw {
5468 from: cell.clone(),
5469 to: neighbor_upper.clone(),
5470 neighbor: neighbor_upper.clone(),
5471 },
5472 TraceDirection::Dependents => TraceEdgeRaw {
5473 from: neighbor_upper.clone(),
5474 to: cell.clone(),
5475 neighbor: neighbor_upper.clone(),
5476 },
5477 };
5478 edges.push(edge);
5479 if visited.insert(neighbor_upper.clone()) {
5480 next_frontier_set.insert(neighbor_upper);
5481 }
5482 }
5483 }
5484
5485 if edges.is_empty() {
5486 break;
5487 }
5488
5489 layers.push(LayerLinks {
5490 depth,
5491 edges,
5492 truncated_cells,
5493 });
5494 if next_frontier_set.is_empty() {
5495 break;
5496 }
5497 let mut next_frontier: Vec<String> = next_frontier_set.into_iter().collect();
5498 next_frontier.sort();
5499 frontier = next_frontier;
5500 }
5501
5502 layers
5503}
5504
5505fn collect_neighbor_details(
5506 sheet: &umya_spreadsheet::Worksheet,
5507 current_sheet: &str,
5508 addresses: &[String],
5509 formula_lookup: &HashMap<String, TraceFormulaInfo>,
5510) -> Vec<NeighborDetail> {
5511 let mut details = Vec::new();
5512 for address in addresses {
5513 let (sheet_part, cell_part) = split_sheet_and_cell(address);
5514 let normalized_sheet = sheet_part
5515 .as_ref()
5516 .map(|s| clean_sheet_name(s).to_ascii_lowercase());
5517 let is_external = normalized_sheet
5518 .as_ref()
5519 .map(|s| !s.eq_ignore_ascii_case(current_sheet))
5520 .unwrap_or(false);
5521
5522 let Some(cell_ref) = cell_part else {
5523 details.push(NeighborDetail {
5524 address: address.clone(),
5525 column: None,
5526 row: None,
5527 kind: TraceCellKind::External,
5528 value: None,
5529 formula: None,
5530 fingerprint: None,
5531 external: true,
5532 });
5533 continue;
5534 };
5535
5536 let cell_ref_upper = cell_ref.to_ascii_uppercase();
5537
5538 if is_external {
5539 let formula_info = lookup_formula_info(formula_lookup, &cell_ref_upper, address);
5540 details.push(NeighborDetail {
5541 address: address.clone(),
5542 column: None,
5543 row: None,
5544 kind: TraceCellKind::External,
5545 value: None,
5546 formula: formula_info.map(|info| info.formula.clone()),
5547 fingerprint: formula_info.map(|info| info.fingerprint.clone()),
5548 external: true,
5549 });
5550 continue;
5551 }
5552
5553 let Some((col, row)) = parse_address(&cell_ref_upper) else {
5554 details.push(NeighborDetail {
5555 address: address.clone(),
5556 column: None,
5557 row: None,
5558 kind: TraceCellKind::External,
5559 value: None,
5560 formula: None,
5561 fingerprint: None,
5562 external: true,
5563 });
5564 continue;
5565 };
5566
5567 let cell_opt = sheet.get_cell((&col, &row));
5568 let formula_info = lookup_formula_info(formula_lookup, &cell_ref_upper, address);
5569 if let Some(cell) = cell_opt {
5570 let value = cell_to_value(cell);
5571 let kind = if cell.is_formula() {
5572 TraceCellKind::Formula
5573 } else if value.is_some() {
5574 TraceCellKind::Literal
5575 } else {
5576 TraceCellKind::Blank
5577 };
5578 details.push(NeighborDetail {
5579 address: address.clone(),
5580 column: Some(col),
5581 row: Some(row),
5582 kind,
5583 value,
5584 formula: formula_info.map(|info| info.formula.clone()),
5585 fingerprint: formula_info.map(|info| info.fingerprint.clone()),
5586 external: false,
5587 });
5588 } else {
5589 details.push(NeighborDetail {
5590 address: address.clone(),
5591 column: Some(col),
5592 row: Some(row),
5593 kind: TraceCellKind::Blank,
5594 value: None,
5595 formula: formula_info.map(|info| info.formula.clone()),
5596 fingerprint: formula_info.map(|info| info.fingerprint.clone()),
5597 external: false,
5598 });
5599 }
5600 }
5601 details
5602}
5603
5604fn build_layer_summary(details: &[NeighborDetail]) -> TraceLayerSummary {
5605 let mut summary = TraceLayerSummary {
5606 total_nodes: details.len(),
5607 formula_nodes: 0,
5608 value_nodes: 0,
5609 blank_nodes: 0,
5610 external_nodes: 0,
5611 unique_formula_groups: 0,
5612 };
5613
5614 let mut fingerprints: HashSet<String> = HashSet::new();
5615
5616 for detail in details {
5617 match detail.kind {
5618 TraceCellKind::Formula => {
5619 summary.formula_nodes += 1;
5620 if let Some(fp) = &detail.fingerprint {
5621 fingerprints.insert(fp.clone());
5622 }
5623 }
5624 TraceCellKind::Literal => summary.value_nodes += 1,
5625 TraceCellKind::Blank => summary.blank_nodes += 1,
5626 TraceCellKind::External => summary.external_nodes += 1,
5627 }
5628 }
5629
5630 summary.unique_formula_groups = fingerprints.len();
5631 summary
5632}
5633
5634fn build_formula_group_highlights(details: &[NeighborDetail]) -> Vec<TraceFormulaGroupHighlight> {
5635 let mut aggregates: HashMap<String, (String, usize, Vec<String>)> = HashMap::new();
5636 for detail in details {
5637 if let (Some(fingerprint), Some(formula)) = (&detail.fingerprint, &detail.formula) {
5638 let entry = aggregates
5639 .entry(fingerprint.clone())
5640 .or_insert_with(|| (formula.clone(), 0, Vec::new()));
5641 entry.1 += 1;
5642 if entry.2.len() < TRACE_GROUP_SAMPLE_LIMIT {
5643 entry.2.push(detail.address.clone());
5644 }
5645 }
5646 }
5647
5648 let mut highlights: Vec<TraceFormulaGroupHighlight> = aggregates
5649 .into_iter()
5650 .map(
5651 |(fingerprint, (formula, count, sample_addresses))| TraceFormulaGroupHighlight {
5652 fingerprint,
5653 formula,
5654 count,
5655 sample_addresses,
5656 },
5657 )
5658 .collect();
5659
5660 highlights.sort_by(|a, b| b.count.cmp(&a.count));
5661 highlights.truncate(TRACE_GROUP_HIGHLIGHT_LIMIT);
5662 highlights
5663}
5664
5665fn build_range_highlights(details: &[NeighborDetail]) -> Vec<TraceRangeHighlight> {
5666 let mut by_column: HashMap<u32, Vec<&NeighborDetail>> = HashMap::new();
5667 for detail in details {
5668 if let (Some(col), Some(_row)) = (detail.column, detail.row)
5669 && !detail.external
5670 {
5671 by_column.entry(col).or_default().push(detail);
5672 }
5673 }
5674
5675 for column_entries in by_column.values_mut() {
5676 column_entries.sort_by(|a, b| a.row.cmp(&b.row));
5677 }
5678
5679 let mut ranges = Vec::new();
5680 for entries in by_column.values() {
5681 let mut current: Vec<&NeighborDetail> = Vec::new();
5682 for detail in entries {
5683 if current.is_empty() {
5684 current.push(detail);
5685 continue;
5686 }
5687 let prev_row = current.last().and_then(|d| d.row).unwrap_or(0);
5688 if detail.row.unwrap_or(0) == prev_row + 1 {
5689 current.push(detail);
5690 } else {
5691 if current.len() >= TRACE_RANGE_THRESHOLD {
5692 ranges.push(make_range_highlight(¤t));
5693 }
5694 current.clear();
5695 current.push(detail);
5696 }
5697 }
5698 if current.len() >= TRACE_RANGE_THRESHOLD {
5699 ranges.push(make_range_highlight(¤t));
5700 }
5701 }
5702
5703 ranges.sort_by(|a, b| b.count.cmp(&a.count));
5704 ranges.truncate(TRACE_RANGE_HIGHLIGHT_LIMIT);
5705 ranges
5706}
5707
5708fn make_range_highlight(details: &[&NeighborDetail]) -> TraceRangeHighlight {
5709 let mut literals = 0usize;
5710 let mut formulas = 0usize;
5711 let mut blanks = 0usize;
5712 let mut sample_values = Vec::new();
5713 let mut sample_formulas = Vec::new();
5714 let mut sample_addresses = Vec::new();
5715
5716 for detail in details {
5717 match detail.kind {
5718 TraceCellKind::Formula => {
5719 formulas += 1;
5720 if let Some(formula) = &detail.formula
5721 && sample_formulas.len() < TRACE_RANGE_FORMULA_SAMPLES
5722 && !sample_formulas.contains(formula)
5723 {
5724 sample_formulas.push(formula.clone());
5725 }
5726 }
5727 TraceCellKind::Literal => {
5728 literals += 1;
5729 if let Some(value) = &detail.value
5730 && sample_values.len() < TRACE_RANGE_VALUE_SAMPLES
5731 {
5732 sample_values.push(value.clone());
5733 }
5734 }
5735 TraceCellKind::Blank => blanks += 1,
5736 TraceCellKind::External => {}
5737 }
5738 if sample_addresses.len() < TRACE_RANGE_VALUE_SAMPLES {
5739 sample_addresses.push(detail.address.clone());
5740 }
5741 }
5742
5743 TraceRangeHighlight {
5744 start: details
5745 .first()
5746 .map(|d| d.address.clone())
5747 .unwrap_or_default(),
5748 end: details
5749 .last()
5750 .map(|d| d.address.clone())
5751 .unwrap_or_default(),
5752 count: details.len(),
5753 literals,
5754 formulas,
5755 blanks,
5756 sample_values,
5757 sample_formulas,
5758 sample_addresses,
5759 }
5760}
5761
5762fn build_notable_cells(
5763 details: &[NeighborDetail],
5764 ranges: &[TraceRangeHighlight],
5765 groups: &[TraceFormulaGroupHighlight],
5766) -> Vec<TraceCellHighlight> {
5767 let mut exclude: HashSet<String> = HashSet::new();
5768 for range in ranges {
5769 exclude.insert(range.start.clone());
5770 exclude.insert(range.end.clone());
5771 for addr in &range.sample_addresses {
5772 exclude.insert(addr.clone());
5773 }
5774 }
5775 for group in groups {
5776 for addr in &group.sample_addresses {
5777 exclude.insert(addr.clone());
5778 }
5779 }
5780
5781 let mut highlights = Vec::new();
5782 let mut kind_counts: HashMap<TraceCellKind, usize> = HashMap::new();
5783
5784 for detail in details {
5785 if highlights.len() >= TRACE_CELL_HIGHLIGHT_LIMIT {
5786 break;
5787 }
5788 if exclude.contains(&detail.address) {
5789 continue;
5790 }
5791 let counter = kind_counts.entry(detail.kind.clone()).or_insert(0);
5792 if *counter >= 2 && detail.kind != TraceCellKind::External {
5793 continue;
5794 }
5795 highlights.push(TraceCellHighlight {
5796 address: detail.address.clone(),
5797 kind: detail.kind.clone(),
5798 value: detail.value.clone(),
5799 formula: detail.formula.clone(),
5800 });
5801 *counter += 1;
5802 }
5803
5804 highlights
5805}
5806
5807fn build_edges_for_layer(
5808 raw_edges: &[TraceEdgeRaw],
5809 selected: &HashSet<String>,
5810 formula_lookup: &HashMap<String, TraceFormulaInfo>,
5811) -> Vec<FormulaTraceEdge> {
5812 let mut edges = Vec::new();
5813 for edge in raw_edges {
5814 if selected.contains(&edge.neighbor) {
5815 let formula = lookup_formula_info(formula_lookup, &edge.neighbor, &edge.neighbor)
5816 .map(|info| info.formula.clone());
5817 edges.push(FormulaTraceEdge {
5818 from: edge.from.clone(),
5819 to: edge.to.clone(),
5820 formula,
5821 note: None,
5822 });
5823 }
5824 }
5825 edges.sort_by(|a, b| compare_addresses(&a.to, &b.to));
5826 edges
5827}
5828
5829fn lookup_formula_info<'a>(
5830 lookup: &'a HashMap<String, TraceFormulaInfo>,
5831 cell_ref: &str,
5832 original: &str,
5833) -> Option<&'a TraceFormulaInfo> {
5834 if let Some(info) = lookup.get(cell_ref) {
5835 return Some(info);
5836 }
5837 if let (Some(_sheet), Some(cell)) = split_sheet_and_cell(original) {
5838 let upper = cell.to_ascii_uppercase();
5839 return lookup.get(&upper);
5840 }
5841 None
5842}
5843
5844fn compare_addresses(left: &str, right: &str) -> Ordering {
5845 let (sheet_left, cell_left) = split_sheet_and_cell(left);
5846 let (sheet_right, cell_right) = split_sheet_and_cell(right);
5847
5848 let sheet_left_key = sheet_left
5849 .as_ref()
5850 .map(|s| clean_sheet_name(s).to_ascii_uppercase())
5851 .unwrap_or_default();
5852 let sheet_right_key = sheet_right
5853 .as_ref()
5854 .map(|s| clean_sheet_name(s).to_ascii_uppercase())
5855 .unwrap_or_default();
5856
5857 match sheet_left_key.cmp(&sheet_right_key) {
5858 Ordering::Equal => {
5859 let left_core = cell_left.unwrap_or_else(|| left.to_string());
5860 let right_core = cell_right.unwrap_or_else(|| right.to_string());
5861 let left_coords = parse_address(&left_core.to_ascii_uppercase());
5862 let right_coords = parse_address(&right_core.to_ascii_uppercase());
5863 match (left_coords, right_coords) {
5864 (Some((lc, lr)), Some((rc, rr))) => lc
5865 .cmp(&rc)
5866 .then_with(|| lr.cmp(&rr))
5867 .then_with(|| left_core.cmp(&right_core)),
5868 _ => left_core.cmp(&right_core),
5869 }
5870 }
5871 other => other,
5872 }
5873}
5874
5875fn split_sheet_and_cell(address: &str) -> (Option<String>, Option<String>) {
5876 if let Some(idx) = address.rfind('!') {
5877 let sheet = address[..idx].to_string();
5878 let cell = address[idx + 1..].to_string();
5879 (Some(sheet), Some(cell))
5880 } else {
5881 (None, Some(address.to_string()))
5882 }
5883}
5884
5885fn clean_sheet_name(sheet: &str) -> String {
5886 let trimmed = sheet.trim_matches(|c| c == '\'' || c == '"');
5887 let after_bracket = trimmed.rsplit(']').next().unwrap_or(trimmed);
5888 after_bracket
5889 .trim_matches(|c| c == '\'' || c == '"')
5890 .to_string()
5891}
5892
5893#[cfg(feature = "recalc-formualizer")]
5894fn json_to_literal(value: &serde_json::Value) -> formualizer::workbook::LiteralValue {
5895 match value {
5896 serde_json::Value::Null => formualizer::workbook::LiteralValue::Empty,
5897 serde_json::Value::Bool(b) => formualizer::workbook::LiteralValue::Boolean(*b),
5898 serde_json::Value::Number(n) => {
5899 if let Some(f) = n.as_f64() {
5900 formualizer::workbook::LiteralValue::Number(f)
5901 } else {
5902 formualizer::workbook::LiteralValue::Empty
5903 }
5904 }
5905 serde_json::Value::String(s) => formualizer::workbook::LiteralValue::Text(s.clone()),
5906 _ => formualizer::workbook::LiteralValue::Empty,
5907 }
5908}
5909
5910#[cfg(feature = "recalc-formualizer")]
5911fn json_to_port_value(value: &serde_json::Value) -> formualizer::sheetport::PortValue {
5912 match value {
5913 serde_json::Value::Object(map) => {
5914 let mut record = std::collections::BTreeMap::new();
5915 for (k, v) in map {
5916 record.insert(k.clone(), json_to_literal(v));
5917 }
5918 formualizer::sheetport::PortValue::Record(record)
5919 }
5920 serde_json::Value::Array(arr) => {
5921 if let Some(serde_json::Value::Object(_)) = arr.first() {
5923 let mut rows = Vec::new();
5924 for row_val in arr {
5925 if let serde_json::Value::Object(map) = row_val {
5926 let mut values = std::collections::BTreeMap::new();
5927 for (k, v) in map {
5928 values.insert(k.clone(), json_to_literal(v));
5929 }
5930 rows.push(formualizer::sheetport::TableRow::new(values));
5931 }
5932 }
5933 formualizer::sheetport::PortValue::Table(formualizer::sheetport::TableValue::new(
5934 rows,
5935 ))
5936 } else if let Some(serde_json::Value::Array(_)) = arr.first() {
5937 let mut rows = Vec::new();
5938 for row_val in arr {
5939 if let serde_json::Value::Array(inner) = row_val {
5940 rows.push(inner.iter().map(json_to_literal).collect());
5941 }
5942 }
5943 formualizer::sheetport::PortValue::Range(rows)
5944 } else {
5945 formualizer::sheetport::PortValue::Scalar(
5946 formualizer::workbook::LiteralValue::Empty,
5947 )
5948 }
5949 }
5950 _ => formualizer::sheetport::PortValue::Scalar(json_to_literal(value)),
5951 }
5952}
5953
5954#[cfg(feature = "recalc-formualizer")]
5955fn port_value_to_json(value: &formualizer::sheetport::PortValue) -> serde_json::Value {
5956 match value {
5957 formualizer::sheetport::PortValue::Scalar(lit) => literal_to_json(lit),
5958 formualizer::sheetport::PortValue::Record(map) => {
5959 let mut obj = serde_json::Map::new();
5960 for (k, v) in map {
5961 obj.insert(k.clone(), literal_to_json(v));
5962 }
5963 serde_json::Value::Object(obj)
5964 }
5965 formualizer::sheetport::PortValue::Range(rows) => {
5966 let arr: Vec<serde_json::Value> = rows
5967 .iter()
5968 .map(|row| serde_json::Value::Array(row.iter().map(literal_to_json).collect()))
5969 .collect();
5970 serde_json::Value::Array(arr)
5971 }
5972 formualizer::sheetport::PortValue::Table(table) => {
5973 let arr: Vec<serde_json::Value> = table
5974 .rows
5975 .iter()
5976 .map(|row| {
5977 let mut obj = serde_json::Map::new();
5978 for (k, v) in &row.values {
5979 obj.insert(k.clone(), literal_to_json(v));
5980 }
5981 serde_json::Value::Object(obj)
5982 })
5983 .collect();
5984 serde_json::Value::Array(arr)
5985 }
5986 }
5987}
5988
5989#[cfg(feature = "recalc-formualizer")]
5990fn literal_to_json(lit: &formualizer::workbook::LiteralValue) -> serde_json::Value {
5991 match lit {
5992 formualizer::workbook::LiteralValue::Empty => serde_json::Value::Null,
5993 formualizer::workbook::LiteralValue::Boolean(b) => serde_json::Value::Bool(*b),
5994 formualizer::workbook::LiteralValue::Number(n) => serde_json::json!(n),
5995 formualizer::workbook::LiteralValue::Int(i) => serde_json::json!(i),
5996 formualizer::workbook::LiteralValue::Text(t) => serde_json::Value::String(t.clone()),
5997 formualizer::workbook::LiteralValue::Error(e) => {
5998 serde_json::Value::String(format!("#ERROR: {:?}", e))
5999 }
6000 formualizer::workbook::LiteralValue::Date(d) => serde_json::Value::String(d.to_string()),
6001 formualizer::workbook::LiteralValue::DateTime(dt) => {
6002 serde_json::Value::String(dt.to_string())
6003 }
6004 formualizer::workbook::LiteralValue::Time(t) => serde_json::Value::String(t.to_string()),
6005 formualizer::workbook::LiteralValue::Duration(d) => {
6006 serde_json::Value::String(d.to_string())
6007 }
6008 formualizer::workbook::LiteralValue::Array(arr) => {
6009 let json_rows: Vec<serde_json::Value> = arr
6010 .iter()
6011 .map(|row| serde_json::Value::Array(row.iter().map(literal_to_json).collect()))
6012 .collect();
6013 serde_json::Value::Array(json_rows)
6014 }
6015 formualizer::workbook::LiteralValue::Pending => serde_json::Value::Null,
6016 }
6017}
6018
6019#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
6020pub struct ExecuteManifestParams {
6021 #[serde(alias = "workbook_id")]
6022 pub workbook_or_fork_id: WorkbookId,
6023 pub manifest_yaml: String,
6024 #[serde(default)]
6025 pub inputs: std::collections::BTreeMap<String, serde_json::Value>,
6026 #[serde(default)]
6027 pub rng_seed: Option<u64>,
6028 #[serde(default)]
6029 pub freeze_volatile: bool,
6030}
6031
6032#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
6033pub struct ExecuteManifestResponse {
6034 pub workbook_id: WorkbookId,
6035 pub outputs: serde_json::Value,
6036}
6037
6038#[cfg(feature = "recalc-formualizer")]
6039pub async fn execute_manifest(
6040 state: Arc<AppState>,
6041 params: ExecuteManifestParams,
6042) -> Result<ExecuteManifestResponse> {
6043 use formualizer::workbook::SpreadsheetReader;
6044 let workbook_ctx = state.open_workbook(¶ms.workbook_or_fork_id).await?;
6045 let path = &workbook_ctx.path;
6046
6047 let workbook_bytes = std::fs::read(path)?;
6048 let adapter = formualizer::workbook::UmyaAdapter::open_bytes(workbook_bytes)
6049 .or_else(|_| formualizer::workbook::UmyaAdapter::open_path(path))
6050 .map_err(|e| anyhow!("Failed to open adapter: {}", e))?;
6051
6052 let workbook = formualizer::workbook::Workbook::from_reader(
6053 adapter,
6054 formualizer::workbook::LoadStrategy::EagerAll,
6055 formualizer::workbook::WorkbookConfig::ephemeral(),
6056 )
6057 .map_err(|e| anyhow!("Failed to load workbook: {}", e))?;
6058
6059 let manifest = formualizer::sheetport_spec::Manifest::from_yaml_str(¶ms.manifest_yaml)
6060 .map_err(|e| anyhow!("Failed to parse manifest YAML: {}", e))?;
6061
6062 let mut session = formualizer::sheetport::SheetPortSession::new(workbook, manifest)
6063 .map_err(|e| anyhow!("Failed to create SheetPort session: {}", e))?;
6064
6065 let mut input_update = formualizer::sheetport::InputUpdate::new();
6066 for (key, val) in params.inputs {
6067 input_update.insert(key, json_to_port_value(&val));
6068 }
6069
6070 if !input_update.is_empty() {
6071 session
6072 .write_inputs(input_update)
6073 .map_err(|e| anyhow!("Failed to write inputs: {}", e))?;
6074 }
6075
6076 let options = formualizer::sheetport::EvalOptions {
6077 rng_seed: params.rng_seed,
6078 freeze_volatile: params.freeze_volatile,
6079 ..Default::default()
6080 };
6081
6082 let outputs = session
6083 .evaluate_once(options)
6084 .map_err(|e| anyhow!("Failed to evaluate: {}", e))?;
6085
6086 let mut out_map = serde_json::Map::new();
6087 for (k, v) in outputs.into_inner() {
6088 out_map.insert(k, port_value_to_json(&v));
6089 }
6090
6091 Ok(ExecuteManifestResponse {
6092 workbook_id: params.workbook_or_fork_id.clone(),
6093 outputs: serde_json::Value::Object(out_map),
6094 })
6095}
6096
6097#[cfg(not(feature = "recalc-formualizer"))]
6098pub async fn execute_manifest(
6099 _state: Arc<AppState>,
6100 _params: ExecuteManifestParams,
6101) -> Result<ExecuteManifestResponse> {
6102 Err(anyhow!(
6103 "sheetport operations require the 'recalc-formualizer' feature"
6104 ))
6105}
6106
6107#[derive(Debug, Deserialize, JsonSchema)]
6112pub struct GridExportParams {
6113 #[serde(alias = "workbook_id")]
6114 pub workbook_or_fork_id: WorkbookId,
6115 pub sheet_name: String,
6116 pub range: String,
6117}
6118
6119pub async fn grid_export(
6120 state: Arc<AppState>,
6121 params: GridExportParams,
6122) -> Result<crate::model::GridPayload> {
6123 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
6124 let ((min_col, min_row), (max_col, max_row)) =
6125 parse_range(¶ms.range).ok_or_else(|| anyhow!("invalid range: {}", params.range))?;
6126
6127 let payload = workbook.with_sheet(¶ms.sheet_name, |sheet| {
6128 let mut columns = Vec::new();
6129 for col_idx in min_col..=max_col {
6130 if let Some(dim) = sheet.get_column_dimension_by_number(&col_idx) {
6131 let w = *dim.get_width();
6132 if w > 0.0 {
6133 columns.push(crate::model::GridColumnHint {
6134 offset: col_idx - min_col,
6135 width_chars: w,
6136 });
6137 }
6138 }
6139 }
6140
6141 let mut merges = Vec::new();
6142 for mc in sheet.get_merge_cells() {
6143 let m_range = mc.get_range();
6144 if let Some(((c1, r1), (c2, r2))) = parse_range(&m_range)
6145 && c1 <= max_col
6146 && c2 >= min_col
6147 && r1 <= max_row
6148 && r2 >= min_row
6149 {
6150 merges.push(m_range.to_string());
6151 }
6152 }
6153
6154 let mut rows = Vec::new();
6155 for row in min_row..=max_row {
6156 let mut cells = Vec::new();
6157 for col in min_col..=max_col {
6158 if let Some(cell) = sheet.get_cell((&col, &row)) {
6159 let mut v = None;
6160 let mut f = None;
6161
6162 if cell.is_formula() {
6163 f = Some(format!("={}", cell.get_formula()));
6164 } else {
6165 let value = crate::workbook::cell_to_value(cell);
6166 if let Some(cv) = value {
6167 match cv {
6168 crate::model::CellValue::Text(s) => {
6169 v = Some(serde_json::Value::String(s))
6170 }
6171 crate::model::CellValue::Number(n) => {
6172 v = Some(serde_json::json!(n))
6173 }
6174 crate::model::CellValue::Bool(b) => {
6175 v = Some(serde_json::Value::Bool(b))
6176 }
6177 crate::model::CellValue::Error(e) => {
6178 v = Some(serde_json::Value::String(e))
6179 }
6180 crate::model::CellValue::Date(d) => {
6181 v = Some(serde_json::Value::String(d))
6182 }
6183 }
6184 }
6185 }
6186
6187 let style = cell.get_style();
6188 let desc = crate::styles::descriptor_from_style(style);
6189
6190 let fmt = desc.number_format.clone();
6191
6192 let mut style_patch = None;
6193 if desc.font.is_some()
6194 || desc.fill.is_some()
6195 || desc.borders.is_some()
6196 || desc.alignment.is_some()
6197 {
6198 style_patch = Some(crate::model::StylePatch {
6199 font: desc.font.map(|f| {
6200 Some(crate::model::FontPatch {
6201 name: f.name.map(Some),
6202 size: f.size.map(Some),
6203 bold: f.bold.map(Some),
6204 italic: f.italic.map(Some),
6205 underline: f.underline.map(Some),
6206 strikethrough: f.strikethrough.map(Some),
6207 color: f.color.map(Some),
6208 })
6209 }),
6210 fill: desc.fill.map(|f| {
6211 Some(match f {
6212 crate::model::FillDescriptor::Pattern(p) => {
6213 crate::model::FillPatch::Pattern(
6214 crate::model::PatternFillPatch {
6215 pattern_type: p.pattern_type.map(Some),
6216 foreground_color: p.foreground_color.map(Some),
6217 background_color: p.background_color.map(Some),
6218 },
6219 )
6220 }
6221 crate::model::FillDescriptor::Gradient(g) => {
6222 crate::model::FillPatch::Gradient(
6223 crate::model::GradientFillPatch {
6224 degree: g.degree.map(Some),
6225 stops: Some(
6226 g.stops
6227 .into_iter()
6228 .map(|s| crate::model::GradientStopPatch {
6229 position: s.position,
6230 color: s.color,
6231 })
6232 .collect(),
6233 ),
6234 },
6235 )
6236 }
6237 })
6238 }),
6239 borders: desc.borders.map(|b| {
6240 Some(crate::model::BordersPatch {
6241 left: b.left.map(|s| {
6242 Some(crate::model::BorderSidePatch {
6243 style: s.style.map(Some),
6244 color: s.color.map(Some),
6245 })
6246 }),
6247 right: b.right.map(|s| {
6248 Some(crate::model::BorderSidePatch {
6249 style: s.style.map(Some),
6250 color: s.color.map(Some),
6251 })
6252 }),
6253 top: b.top.map(|s| {
6254 Some(crate::model::BorderSidePatch {
6255 style: s.style.map(Some),
6256 color: s.color.map(Some),
6257 })
6258 }),
6259 bottom: b.bottom.map(|s| {
6260 Some(crate::model::BorderSidePatch {
6261 style: s.style.map(Some),
6262 color: s.color.map(Some),
6263 })
6264 }),
6265 diagonal: b.diagonal.map(|s| {
6266 Some(crate::model::BorderSidePatch {
6267 style: s.style.map(Some),
6268 color: s.color.map(Some),
6269 })
6270 }),
6271 vertical: b.vertical.map(|s| {
6272 Some(crate::model::BorderSidePatch {
6273 style: s.style.map(Some),
6274 color: s.color.map(Some),
6275 })
6276 }),
6277 horizontal: b.horizontal.map(|s| {
6278 Some(crate::model::BorderSidePatch {
6279 style: s.style.map(Some),
6280 color: s.color.map(Some),
6281 })
6282 }),
6283 diagonal_up: b.diagonal_up.map(Some),
6284 diagonal_down: b.diagonal_down.map(Some),
6285 })
6286 }),
6287 alignment: desc.alignment.map(|a| {
6288 Some(crate::model::AlignmentPatch {
6289 horizontal: a.horizontal.map(Some),
6290 vertical: a.vertical.map(Some),
6291 wrap_text: a.wrap_text.map(Some),
6292 text_rotation: a.text_rotation.map(Some),
6293 })
6294 }),
6295 number_format: None,
6296 });
6297 }
6298
6299 if v.is_some() || f.is_some() || fmt.is_some() || style_patch.is_some() {
6300 cells.push(crate::model::GridCell {
6301 offset: [row - min_row, col - min_col],
6302 v,
6303 f,
6304 fmt,
6305 style: style_patch,
6306 });
6307 }
6308 }
6309 }
6310 if !cells.is_empty() {
6311 rows.push(crate::model::GridRow { cells });
6312 }
6313 }
6314
6315 let anchor = format!(
6316 "{}{}",
6317 crate::utils::column_number_to_name(min_col),
6318 min_row
6319 );
6320
6321 Ok::<_, anyhow::Error>(crate::model::GridPayload {
6322 sheet: params.sheet_name.clone(),
6323 anchor,
6324 columns,
6325 merges,
6326 rows,
6327 })
6328 })??;
6329
6330 Ok(payload)
6331}
6332
6333#[derive(Debug, Deserialize, JsonSchema)]
6334pub struct LayoutPageParams {
6335 #[serde(alias = "workbook_id")]
6337 pub workbook_or_fork_id: WorkbookId,
6338 pub sheet_name: String,
6340 #[serde(default)]
6342 pub range: Option<String>,
6343 #[serde(default)]
6345 pub mode: Option<LayoutMode>,
6346 #[serde(default)]
6348 pub max_col_width: Option<u32>,
6349 #[serde(default)]
6351 pub fit_columns: Option<bool>,
6352 #[serde(default)]
6354 pub trim_empty_columns: Option<bool>,
6355 #[serde(default)]
6357 pub render: Option<LayoutRender>,
6358}
6359
6360const LAYOUT_MAX_ROWS: u32 = 80;
6361const LAYOUT_MAX_COLS: u32 = 25;
6362const LAYOUT_DEFAULT_COL_WIDTH: f64 = 8.43;
6363const LAYOUT_DEFAULT_MAX_COL_WIDTH: u32 = 20;
6364
6365pub async fn layout_page(
6366 state: Arc<AppState>,
6367 params: LayoutPageParams,
6368) -> Result<LayoutPageResponse> {
6369 let workbook = state.open_workbook(¶ms.workbook_or_fork_id).await?;
6370
6371 let range_str = params.range.as_deref().unwrap_or("A1:T50");
6372 let ((min_col, min_row), (raw_max_col, raw_max_row)) =
6373 parse_range(range_str).ok_or_else(|| anyhow!("invalid range: {}", range_str))?;
6374
6375 let requested_max_col_width = params
6376 .max_col_width
6377 .unwrap_or(LAYOUT_DEFAULT_MAX_COL_WIDTH)
6378 .max(3) as f64;
6379 let fit_columns = params.fit_columns.unwrap_or(false);
6380 let trim_empty_columns = params.trim_empty_columns.unwrap_or(true);
6381 let mode = params.mode.unwrap_or_default();
6382 let render = params.render.unwrap_or_default();
6383
6384 let max_col = raw_max_col.min(min_col + LAYOUT_MAX_COLS - 1);
6386 let max_row = raw_max_row.min(min_row + LAYOUT_MAX_ROWS - 1);
6387 let truncated = max_col < raw_max_col || max_row < raw_max_row;
6388
6389 let (mut columns, mut merged_cells, mut rows) =
6390 workbook.with_sheet(¶ms.sheet_name, |sheet| {
6391 let columns: Vec<LayoutPageColumnInfo> = (min_col..=max_col)
6393 .map(|col_idx| {
6394 let col_name = column_number_to_name(col_idx);
6395 let (raw_width, is_default) =
6396 match sheet.get_column_dimension_by_number(&col_idx) {
6397 Some(dim) => {
6398 let w = *dim.get_width();
6399 if w > 0.0 {
6400 (w, false)
6401 } else {
6402 (LAYOUT_DEFAULT_COL_WIDTH, true)
6403 }
6404 }
6405 None => (LAYOUT_DEFAULT_COL_WIDTH, true),
6406 };
6407 LayoutPageColumnInfo {
6408 col: col_name,
6409 index: col_idx,
6410 width_chars: raw_width,
6411 is_default_width: is_default,
6412 }
6413 })
6414 .collect();
6415
6416 let merged_strings: Vec<String> = sheet
6418 .get_merge_cells()
6419 .iter()
6420 .map(|m| m.get_range())
6421 .collect();
6422
6423 let merge_starts: std::collections::HashSet<(u32, u32)> = merged_strings
6425 .iter()
6426 .filter_map(|r| parse_range(r).map(|((c, ro), _)| (c, ro)))
6427 .collect();
6428
6429 let mut cell_map: HashMap<(u32, u32), LayoutCellInfo> = HashMap::new();
6431
6432 for cell in sheet.get_cell_collection() {
6433 let address = cell.get_coordinate().get_coordinate().to_string();
6434 let Some((col, row)) = parse_address(&address) else {
6435 continue;
6436 };
6437 if col < min_col || col > max_col || row < min_row || row > max_row {
6438 continue;
6439 }
6440
6441 let text: String = match mode {
6442 LayoutMode::Formulas => {
6443 let formula = cell.get_formula();
6444 if !formula.is_empty() {
6445 format!("={formula}")
6446 } else {
6447 cell_display_string(cell)
6448 }
6449 }
6450 LayoutMode::Values => cell_display_string(cell),
6451 };
6452
6453 let desc = crate::styles::descriptor_from_style(cell.get_style());
6454 let bold = desc.font.as_ref().and_then(|f| f.bold);
6455 let italic = desc.font.as_ref().and_then(|f| f.italic);
6456 let align_h = desc.alignment.as_ref().and_then(|a| a.horizontal.clone());
6457 let borders = desc.borders.as_ref().map(|b| LayoutCellBorders {
6458 top: b.top.as_ref().and_then(|s| s.style.clone()),
6459 bottom: b.bottom.as_ref().and_then(|s| s.style.clone()),
6460 left: b.left.as_ref().and_then(|s| s.style.clone()),
6461 right: b.right.as_ref().and_then(|s| s.style.clone()),
6462 });
6463 let borders = borders.and_then(|b| if b.is_empty() { None } else { Some(b) });
6464
6465 cell_map.insert(
6466 (col, row),
6467 LayoutCellInfo {
6468 address,
6469 value: if text.is_empty() { None } else { Some(text) },
6470 bold,
6471 italic,
6472 align_h,
6473 borders,
6474 merge_start: if merge_starts.contains(&(col, row)) {
6475 Some(true)
6476 } else {
6477 None
6478 },
6479 },
6480 );
6481 }
6482
6483 let rows: Vec<LayoutRowInfo> = (min_row..=max_row)
6485 .map(|row| {
6486 let cells = (min_col..=max_col)
6487 .map(|col| {
6488 cell_map
6489 .remove(&(col, row))
6490 .unwrap_or_else(|| LayoutCellInfo {
6491 address: format!("{}{}", column_number_to_name(col), row),
6492 value: None,
6493 bold: None,
6494 italic: None,
6495 align_h: None,
6496 borders: None,
6497 merge_start: None,
6498 })
6499 })
6500 .collect();
6501 LayoutRowInfo { row, cells }
6502 })
6503 .collect();
6504
6505 Ok::<_, anyhow::Error>((columns, merged_strings, rows))
6506 })??;
6507
6508 let mut notes = Vec::new();
6509 let mut render_min_col = min_col;
6510 let mut render_max_col = max_col;
6511
6512 if trim_empty_columns && !columns.is_empty() {
6513 let has_visible_content = |col_idx: usize| -> bool {
6514 rows.iter().any(|row| {
6515 let cell = &row.cells[col_idx];
6516 cell.value
6517 .as_deref()
6518 .map(|v| !v.is_empty())
6519 .unwrap_or(false)
6520 || cell.bold.unwrap_or(false)
6521 || cell.italic.unwrap_or(false)
6522 || cell.merge_start.unwrap_or(false)
6523 || cell.borders.is_some()
6524 })
6525 };
6526
6527 let mut start = 0usize;
6528 while start < columns.len() && !has_visible_content(start) {
6529 start += 1;
6530 }
6531
6532 let mut end = columns.len();
6533 while end > start && !has_visible_content(end - 1) {
6534 end -= 1;
6535 }
6536
6537 if start > 0 || end < columns.len() {
6538 if start < end {
6539 let trimmed_left = start;
6540 let trimmed_right = columns.len() - end;
6541 render_min_col = columns[start].index;
6542 render_max_col = columns[end - 1].index;
6543 columns = columns[start..end].to_vec();
6544 for row in &mut rows {
6545 row.cells = row.cells[start..end].to_vec();
6546 }
6547 notes.push(format!(
6548 "Trimmed empty edge columns (left: {trimmed_left}, right: {trimmed_right})"
6549 ));
6550 } else {
6551 render_min_col = columns[0].index;
6553 render_max_col = columns[0].index;
6554 columns = vec![columns[0].clone()];
6555 for row in &mut rows {
6556 row.cells = vec![row.cells[0].clone()];
6557 }
6558 notes.push(
6559 "All requested columns were empty; kept one placeholder column".to_string(),
6560 );
6561 }
6562 }
6563 }
6564
6565 merged_cells.retain(|mc| {
6567 parse_range(mc).is_some_and(|((c1, r1), (c2, r2))| {
6568 c1 <= render_max_col && c2 >= render_min_col && r1 <= max_row && r2 >= min_row
6569 })
6570 });
6571
6572 if fit_columns {
6573 for (ci, col) in columns.iter_mut().enumerate() {
6574 let mut max_len = 3usize;
6577 for row in &rows {
6578 if let Some(cell) = row.cells.get(ci) {
6579 let content_len = cell
6580 .value
6581 .as_deref()
6582 .map(|s| s.chars().count())
6583 .unwrap_or(0);
6584 let marker_len = usize::from(cell.bold.unwrap_or(false)) * 2
6585 + usize::from(cell.italic.unwrap_or(false)) * 2;
6586 max_len = max_len.max(content_len + marker_len);
6587 }
6588 }
6589 col.width_chars = max_len as f64;
6590 }
6591 } else {
6592 for col in &mut columns {
6593 col.width_chars = col.width_chars.min(requested_max_col_width);
6594 }
6595 }
6596
6597 let effective_range = format!(
6598 "{}{}:{}{}",
6599 column_number_to_name(render_min_col),
6600 min_row,
6601 column_number_to_name(render_max_col),
6602 max_row,
6603 );
6604
6605 let ascii_render = match render {
6606 LayoutRender::Ascii | LayoutRender::Both => Some(render_layout_ascii(
6607 &columns,
6608 &rows,
6609 if fit_columns {
6610 None
6611 } else {
6612 Some(requested_max_col_width as usize)
6613 },
6614 )),
6615 LayoutRender::Json => None,
6616 };
6617
6618 if truncated {
6619 notes.push(format!(
6620 "Range capped to {LAYOUT_MAX_ROWS} rows × {LAYOUT_MAX_COLS} columns"
6621 ));
6622 }
6623
6624 Ok(LayoutPageResponse {
6625 workbook_id: workbook.id.clone(),
6626 sheet_name: params.sheet_name,
6627 range: effective_range,
6628 columns,
6629 merged_cells,
6630 rows,
6631 ascii_render,
6632 truncated,
6633 notes,
6634 })
6635}
6636
6637fn cell_display_string(cell: &umya_spreadsheet::Cell) -> String {
6639 use crate::workbook::cell_to_value;
6640 match cell_to_value(cell) {
6641 Some(CellValue::Text(s)) => s,
6642 Some(CellValue::Number(n)) => {
6643 if n.fract() == 0.0 && n.abs() < 1e15 {
6644 format!("{}", n as i64)
6645 } else {
6646 format!("{n}")
6647 }
6648 }
6649 Some(CellValue::Bool(b)) => if b { "TRUE" } else { "FALSE" }.to_string(),
6650 Some(CellValue::Error(e)) => e,
6651 Some(CellValue::Date(d)) => d,
6652 None => String::new(),
6653 }
6654}
6655
6656fn border_weight(style: Option<&str>) -> u8 {
6658 match style {
6659 None => 0,
6660 Some(s) => match s.to_ascii_lowercase().as_str() {
6661 "none" => 0,
6662 "hair" | "dotted" | "dashed" | "dashDot" | "dashDotDot" => 1,
6663 "thin" | "slantDashDot" | "mediumDashDot" | "mediumDashDotDot" => 1,
6664 "medium" | "mediumDashed" => 2,
6665 "thick" => 2,
6666 "double" => 3,
6667 _ => 1,
6668 },
6669 }
6670}
6671
6672#[allow(
6674 clippy::unnecessary_map_or,
6675 clippy::if_same_then_else,
6676 clippy::needless_range_loop
6677)]
6678fn render_layout_ascii(
6679 columns: &[LayoutPageColumnInfo],
6680 rows: &[LayoutRowInfo],
6681 max_col_width: Option<usize>,
6682) -> String {
6683 use std::fmt::Write;
6684
6685 if rows.is_empty() || columns.is_empty() {
6686 return String::new();
6687 }
6688
6689 let col_widths: Vec<usize> = columns
6691 .iter()
6692 .map(|c| {
6693 let raw = c.width_chars.ceil() as usize;
6694 let capped = max_col_width.map_or(raw, |cap| raw.min(cap));
6695 capped.max(3)
6696 })
6697 .collect();
6698
6699 let n_cols = columns.len();
6700 let n_rows = rows.len();
6701
6702 let cell_borders = |ri: usize, ci: usize| -> (u8, u8, u8, u8) {
6705 if ri >= n_rows || ci >= n_cols {
6707 return (0, 0, 0, 0);
6708 }
6709 let cell = &rows[ri].cells[ci];
6710 let b = cell.borders.as_ref();
6711 (
6712 border_weight(b.and_then(|b| b.top.as_deref())),
6713 border_weight(b.and_then(|b| b.bottom.as_deref())),
6714 border_weight(b.and_then(|b| b.left.as_deref())),
6715 border_weight(b.and_then(|b| b.right.as_deref())),
6716 )
6717 };
6718
6719 let h_sep_weight = |above: usize, ci: usize| -> u8 {
6723 let bottom_of_above = if above < n_rows {
6724 cell_borders(above, ci).1
6725 } else {
6726 0
6727 };
6728 let top_of_below = if above.checked_add(1).map_or(false, |r| r < n_rows) {
6729 cell_borders(above + 1, ci).0
6730 } else {
6731 0
6732 };
6733 bottom_of_above.max(top_of_below)
6734 };
6735
6736 let v_sep_weight = |ri: usize, left: usize| -> u8 {
6738 let right_of_left = if left < n_cols {
6739 cell_borders(ri, left).3
6740 } else {
6741 0
6742 };
6743 let left_of_right = if left + 1 < n_cols {
6744 cell_borders(ri, left + 1).2
6745 } else {
6746 0
6747 };
6748 right_of_left.max(left_of_right)
6749 };
6750
6751 let h_char = |w: u8| match w {
6753 0 => ' ',
6754 1 => '─',
6755 2 => '━',
6756 _ => '═',
6757 };
6758 let v_char = |w: u8| match w {
6759 0 => ' ',
6760 1 => '│',
6761 2 => '┃',
6762 _ => '║',
6763 };
6764
6765 let junction = |hw: u8, vw: u8| -> char {
6767 match (hw, vw) {
6768 (0, 0) => ' ',
6769 (0, _) => v_char(vw),
6770 (_, 0) => h_char(hw),
6771 (1, 1) => '┼',
6772 (1, 2) | (1, 3) => '╂',
6773 (2, 1) | (3, 1) => '┿',
6774 _ => '╋',
6775 }
6776 };
6777
6778 let mut out = String::new();
6779
6780 let _ = writeln!(
6782 out,
6783 "[*=bold /=italic border weight: ─thin ━medium ═double]"
6784 );
6785
6786 let draw_h_line = |out: &mut String, above: usize, is_top: bool, is_bottom: bool| {
6789 let left_v = if is_top || is_bottom { 0u8 } else { 0u8 }; let _ = write!(
6792 out,
6793 "{}",
6794 junction(
6795 h_sep_weight(above, 0).max(1)
6796 * (is_top || is_bottom || h_sep_weight(above, 0) > 0) as u8,
6797 left_v
6798 )
6799 );
6800 for ci in 0..n_cols {
6801 let hw = h_sep_weight(above, ci).max(if is_top || is_bottom { 1 } else { 0 });
6802 for _ in 0..col_widths[ci] + 2 {
6803 let _ = write!(out, "{}", h_char(hw));
6804 }
6805 if ci + 1 < n_cols {
6807 let vw = v_sep_weight(if above < n_rows { above } else { n_rows - 1 }, ci);
6808 let _ = write!(out, "{}", junction(hw, vw));
6809 } else {
6810 let _ = write!(out, "{}", junction(hw, 0));
6811 }
6812 }
6813 let _ = writeln!(out);
6814 };
6815
6816 draw_h_line(&mut out, usize::MAX, true, false);
6818
6819 for ri in 0..n_rows {
6820 let _ = write!(out, "{}", v_char(1));
6823 for ci in 0..n_cols {
6824 let cell = &rows[ri].cells[ci];
6825 let w = col_widths[ci];
6826
6827 let content = cell.value.as_deref().unwrap_or("");
6828 let bold = cell.bold.unwrap_or(false);
6830 let italic = cell.italic.unwrap_or(false);
6831 let marker_chars = if bold && italic {
6832 4
6833 } else if bold || italic {
6834 2
6835 } else {
6836 0
6837 };
6838 let content_width = w.saturating_sub(marker_chars);
6839 let truncated_content: String = if content.chars().count() > content_width {
6840 let mut s: String = content
6841 .chars()
6842 .take(content_width.saturating_sub(1))
6843 .collect();
6844 s.push('…');
6845 s
6846 } else {
6847 content.to_string()
6848 };
6849
6850 let is_numeric = content
6852 .trim_start_matches(['-', '$', '('])
6853 .chars()
6854 .next()
6855 .map(|c| c.is_ascii_digit())
6856 .unwrap_or(false);
6857 let right_align = cell.align_h.as_deref() == Some("right")
6858 || (is_numeric && cell.align_h.as_deref().map(|a| a != "left").unwrap_or(true));
6859
6860 let decorated = {
6862 let mut s = String::new();
6863 if bold {
6864 s.push('*');
6865 }
6866 if italic {
6867 s.push('/');
6868 }
6869 s.push_str(&truncated_content);
6870 if italic {
6871 s.push('/');
6872 }
6873 if bold {
6874 s.push('*');
6875 }
6876 s
6877 };
6878
6879 let decorated_len = decorated.chars().count();
6881 let padded = if right_align {
6882 format!(
6883 " {:>width$} ",
6884 decorated,
6885 width = w.saturating_sub(decorated_len) + decorated_len
6886 )
6887 } else {
6888 format!(
6889 " {:<width$} ",
6890 decorated,
6891 width = w.saturating_sub(decorated_len) + decorated_len
6892 )
6893 };
6894 let _ = write!(out, "{}", padded);
6895
6896 if ci + 1 < n_cols {
6898 let vw = v_sep_weight(ri, ci).max(1); let _ = write!(out, "{}", v_char(vw));
6900 } else {
6901 let _ = write!(out, "{}", v_char(1));
6902 }
6903 }
6904 let _ = writeln!(out);
6905
6906 if ri + 1 < n_rows {
6908 let max_w: u8 = (0..n_cols)
6910 .map(|ci| h_sep_weight(ri, ci))
6911 .max()
6912 .unwrap_or(0);
6913 if max_w > 0 {
6914 draw_h_line(&mut out, ri, false, false);
6915 }
6916 }
6917 }
6918
6919 draw_h_line(&mut out, n_rows - 1, false, true);
6921
6922 out
6923}