1use crate::analysis::{
2 classification,
3 formula::{FormulaAtlas, FormulaGraph},
4 style,
5};
6use crate::caps::BackendCaps;
7use crate::config::ServerConfig;
8use crate::model::{
9 FormulaParseDiagnostics, FormulaParseDiagnosticsBuilder, FormulaParsePolicy, NamedItemKind,
10 NamedRangeDescriptor, NamedRangeScope, SheetClassification, SheetOverviewResponse,
11 SheetSummary, WorkbookDescription, WorkbookId, WorkbookListResponse,
12};
13use crate::tools::filters::WorkbookFilter;
14use crate::utils::{
15 hash_bytes_sha256_hex, hash_file_sha256_hex, hash_path_identity, make_short_workbook_id,
16 path_to_forward_slashes, system_time_to_rfc3339,
17};
18use anyhow::{Context, Result, anyhow};
19use chrono::{DateTime, Utc};
20use parking_lot::RwLock;
21use std::cmp::Ordering;
22use std::collections::{HashMap, HashSet};
23use std::fs;
24use std::io::Cursor;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27use std::time::Instant;
28use umya_spreadsheet::reader::xlsx;
29use umya_spreadsheet::{DefinedName, Spreadsheet, Worksheet};
30
31const KV_MAX_WIDTH_FOR_DENSITY_CHECK: u32 = 6;
32const KV_SAMPLE_ROWS: u32 = 20;
33const KV_DENSITY_THRESHOLD: f32 = 0.4;
34const KV_CHECK_ROWS: u32 = 15;
35const KV_MAX_LABEL_LEN: usize = 25;
36const KV_MIN_TEXT_VALUE_LEN: usize = 2;
37const KV_MIN_PAIRS: u32 = 3;
38const KV_MIN_PAIR_RATIO: f32 = 0.3;
39
40const HEADER_MAX_SCAN_ROWS: u32 = 2;
41const HEADER_LONG_STRING_PENALTY_THRESHOLD: usize = 40;
42const HEADER_LONG_STRING_PENALTY: f32 = 1.5;
43const HEADER_PROPER_NOUN_MIN_LEN: usize = 5;
44const HEADER_PROPER_NOUN_PENALTY: f32 = 1.0;
45const HEADER_DIGIT_STRING_MIN_LEN: usize = 3;
46const HEADER_DIGIT_STRING_PENALTY: f32 = 0.5;
47const HEADER_DATE_PENALTY: f32 = 1.0;
48const HEADER_YEAR_LIKE_BONUS: f32 = 0.5;
49const HEADER_YEAR_MIN: f64 = 1900.0;
50const HEADER_YEAR_MAX: f64 = 2100.0;
51const HEADER_UNIQUE_BONUS: f32 = 0.2;
52const HEADER_NUMBER_PENALTY: f32 = 0.3;
53const HEADER_SINGLE_COL_MIN_SCORE: f32 = 1.5;
54const HEADER_SCORE_TIE_THRESHOLD: f32 = 0.3;
55const HEADER_SECOND_ROW_MIN_SCORE_RATIO: f32 = 0.6;
56const HEADER_MAX_COLUMNS: u32 = 200;
57
58const DETECT_MAX_ROWS: u32 = 10_000;
59const DETECT_MAX_COLS: u32 = 500;
60const DETECT_MAX_AREA: u64 = 5_000_000;
61const DETECT_MAX_CELLS: usize = 200_000;
62const DETECT_MAX_LEAVES: usize = 200;
63const DETECT_MAX_DEPTH: u32 = 12;
64const DETECT_MAX_MS: u64 = 200;
65const DETECT_OUTLIER_FRACTION: f32 = 0.01;
66const DETECT_OUTLIER_MIN_CELLS: usize = 50;
67
68pub struct WorkbookContext {
69 pub id: WorkbookId,
70 pub short_id: String,
71 pub revision_id: String,
72 pub slug: String,
73 pub path: PathBuf,
74 pub caps: BackendCaps,
75 pub bytes: u64,
76 pub last_modified: Option<DateTime<Utc>>,
77 spreadsheet: Arc<RwLock<Spreadsheet>>,
78 sheet_cache: RwLock<HashMap<String, Arc<SheetCacheEntry>>>,
79 formula_atlas: Arc<FormulaAtlas>,
80}
81
82pub struct SheetCacheEntry {
83 pub metrics: SheetMetrics,
84 pub style_tags: Vec<String>,
85 pub named_ranges: Vec<NamedRangeDescriptor>,
86 detected_regions: RwLock<Option<Vec<crate::model::DetectedRegion>>>,
87 region_notes: RwLock<Vec<String>>,
88}
89
90#[derive(Debug, Clone)]
91pub struct SheetMetrics {
92 pub row_count: u32,
93 pub column_count: u32,
94 pub non_empty_cells: u32,
95 pub formula_cells: u32,
96 pub cached_values: u32,
97 pub comments: u32,
98 pub style_map: HashMap<String, StyleUsage>,
99 pub classification: SheetClassification,
100}
101
102#[derive(Debug, Clone)]
103pub struct StyleUsage {
104 pub occurrences: u32,
105 pub tags: Vec<String>,
106 pub example_cells: Vec<String>,
107}
108
109impl SheetCacheEntry {
110 pub fn detected_regions(&self) -> Vec<crate::model::DetectedRegion> {
111 self.detected_regions
112 .read()
113 .as_ref()
114 .cloned()
115 .unwrap_or_default()
116 }
117
118 pub fn region_notes(&self) -> Vec<String> {
119 self.region_notes.read().clone()
120 }
121
122 pub fn has_detected_regions(&self) -> bool {
123 self.detected_regions.read().is_some()
124 }
125
126 pub fn set_detected_regions(&self, regions: Vec<crate::model::DetectedRegion>) {
127 let mut guard = self.detected_regions.write();
128 if guard.is_none() {
129 *guard = Some(regions);
130 }
131 }
132
133 pub fn set_region_notes(&self, notes: Vec<String>) {
134 if notes.is_empty() {
135 return;
136 }
137 let mut guard = self.region_notes.write();
138 if guard.is_empty() {
139 *guard = notes;
140 }
141 }
142}
143
144impl WorkbookContext {
145 pub fn load(_config: &Arc<ServerConfig>, path: &Path) -> Result<Self> {
146 fs::metadata(path).with_context(|| format!("unable to read metadata for {:?}", path))?;
147 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
148 let slug = path
149 .file_stem()
150 .map(|s| s.to_string_lossy().to_string())
151 .unwrap_or_else(|| "workbook".to_string());
152 let id = WorkbookId(hash_path_identity(&canonical));
153 let short_id = make_short_workbook_id(&slug, id.as_str());
154 let revision_id = hash_file_sha256_hex(path)
155 .with_context(|| format!("unable to hash workbook {:?}", path))?;
156
157 Self::load_from_path(_config, path, id, short_id, Some(revision_id))
158 }
159
160 pub fn load_from_path(
161 _config: &Arc<ServerConfig>,
162 path: &Path,
163 stable_id: WorkbookId,
164 short_id: String,
165 revision_id: Option<String>,
166 ) -> Result<Self> {
167 let metadata = fs::metadata(path)
168 .with_context(|| format!("unable to read metadata for {:?}", path))?;
169 let slug = path
170 .file_stem()
171 .map(|s| s.to_string_lossy().to_string())
172 .unwrap_or_else(|| "workbook".to_string());
173 let bytes = metadata.len();
174 let last_modified = metadata.modified().ok().and_then(system_time_to_rfc3339);
175 let revision_id = match revision_id {
176 Some(id) => id,
177 None => hash_file_sha256_hex(path)
178 .with_context(|| format!("unable to hash workbook {:?}", path))?,
179 };
180 let spreadsheet =
181 xlsx::read(path).with_context(|| format!("failed to parse workbook {:?}", path))?;
182
183 Ok(Self {
184 id: stable_id,
185 short_id,
186 revision_id,
187 slug,
188 path: path.to_path_buf(),
189 caps: BackendCaps::xlsx(),
190 bytes,
191 last_modified,
192 spreadsheet: Arc::new(RwLock::new(spreadsheet)),
193 sheet_cache: RwLock::new(HashMap::new()),
194 formula_atlas: Arc::new(FormulaAtlas::default()),
195 })
196 }
197
198 pub fn load_from_bytes(
199 _config: &Arc<ServerConfig>,
200 display_name: &str,
201 bytes: &[u8],
202 stable_id: WorkbookId,
203 short_id: String,
204 revision_id: Option<String>,
205 ) -> Result<Self> {
206 let slug = Path::new(display_name)
207 .file_stem()
208 .map(|s| s.to_string_lossy().to_string())
209 .unwrap_or_else(|| "workbook".to_string());
210 let cursor = Cursor::new(bytes);
211 let spreadsheet = xlsx::read_reader(cursor, true)
212 .with_context(|| format!("failed to parse workbook bytes for {display_name}"))?;
213 let revision_id = revision_id.unwrap_or_else(|| hash_bytes_sha256_hex(bytes));
214
215 Ok(Self {
216 id: stable_id,
217 short_id,
218 revision_id,
219 slug,
220 path: PathBuf::from(format!("virtual/{display_name}")),
221 caps: BackendCaps::xlsx(),
222 bytes: bytes.len() as u64,
223 last_modified: None,
224 spreadsheet: Arc::new(RwLock::new(spreadsheet)),
225 sheet_cache: RwLock::new(HashMap::new()),
226 formula_atlas: Arc::new(FormulaAtlas::default()),
227 })
228 }
229
230 pub fn sheet_names(&self) -> Vec<String> {
231 let book = self.spreadsheet.read();
232 book.get_sheet_collection()
233 .iter()
234 .map(|sheet| sheet.get_name().to_string())
235 .collect()
236 }
237
238 pub fn describe(&self) -> WorkbookDescription {
239 let book = self.spreadsheet.read();
240 let defined_names_count = book.get_defined_names().len();
241 let table_count: usize = book
242 .get_sheet_collection()
243 .iter()
244 .map(|sheet| sheet.get_tables().len())
245 .sum();
246 let macros_present = false;
247
248 WorkbookDescription {
249 workbook_id: self.id.clone(),
250 short_id: self.short_id.clone(),
251 slug: self.slug.clone(),
252 path: path_to_forward_slashes(&self.path),
253 client_path: None,
254 bytes: self.bytes,
255 sheet_count: book.get_sheet_collection().len(),
256 defined_names: defined_names_count,
257 tables: table_count,
258 macros_present,
259 last_modified: self
260 .last_modified
261 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
262 revision_id: Some(self.revision_id.clone()),
263 caps: self.caps.clone(),
264 }
265 }
266
267 pub fn get_sheet_metrics_fast(&self, sheet_name: &str) -> Result<Arc<SheetCacheEntry>> {
268 if let Some(entry) = self.sheet_cache.read().get(sheet_name) {
269 return Ok(entry.clone());
270 }
271
272 let mut writer = self.sheet_cache.write();
273 if let Some(entry) = writer.get(sheet_name) {
274 return Ok(entry.clone());
275 }
276
277 let book = self.spreadsheet.read();
278 let sheet = book
279 .get_sheet_by_name(sheet_name)
280 .ok_or_else(|| anyhow!("sheet {} not found", sheet_name))?;
281 let (metrics, style_tags) = compute_sheet_metrics(sheet);
282 let named_ranges = gather_named_ranges(sheet, book.get_defined_names());
283
284 let entry = Arc::new(SheetCacheEntry {
285 metrics,
286 style_tags,
287 named_ranges,
288 detected_regions: RwLock::new(None),
289 region_notes: RwLock::new(Vec::new()),
290 });
291
292 writer.insert(sheet_name.to_string(), entry.clone());
293 Ok(entry)
294 }
295
296 pub fn get_sheet_metrics(&self, sheet_name: &str) -> Result<Arc<SheetCacheEntry>> {
297 let entry = self.get_sheet_metrics_fast(sheet_name)?;
298 if entry.has_detected_regions() {
299 return Ok(entry);
300 }
301
302 let book = self.spreadsheet.read();
303 let sheet = book
304 .get_sheet_by_name(sheet_name)
305 .ok_or_else(|| anyhow!("sheet {} not found", sheet_name))?;
306 let detected = detect_regions(sheet, &entry.metrics);
307 entry.set_detected_regions(detected.regions);
308 entry.set_region_notes(detected.notes);
309 Ok(entry)
310 }
311
312 pub fn list_summaries(&self, include_bounds: bool) -> Result<Vec<SheetSummary>> {
313 let book = self.spreadsheet.read();
314 let mut summaries = Vec::new();
315 for sheet in book.get_sheet_collection() {
316 let name = sheet.get_name().to_string();
317 let entry = self.get_sheet_metrics_fast(&name)?;
318 summaries.push(SheetSummary {
319 name: name.clone(),
320 visible: sheet.get_sheet_state() != "hidden",
321 row_count: include_bounds.then_some(entry.metrics.row_count),
322 column_count: include_bounds.then_some(entry.metrics.column_count),
323 non_empty_cells: include_bounds.then_some(entry.metrics.non_empty_cells),
324 formula_cells: include_bounds.then_some(entry.metrics.formula_cells),
325 cached_values: include_bounds.then_some(entry.metrics.cached_values),
326 classification: entry.metrics.classification.clone(),
327 style_tags: if include_bounds {
328 entry.style_tags.clone()
329 } else {
330 Vec::new()
331 },
332 });
333 }
334 Ok(summaries)
335 }
336
337 pub fn with_sheet<T, F>(&self, sheet_name: &str, func: F) -> Result<T>
338 where
339 F: FnOnce(&Worksheet) -> T,
340 {
341 let book = self.spreadsheet.read();
342 let sheet = book
343 .get_sheet_by_name(sheet_name)
344 .ok_or_else(|| anyhow!("sheet {} not found", sheet_name))?;
345 Ok(func(sheet))
346 }
347
348 pub fn with_spreadsheet<T, F>(&self, func: F) -> Result<T>
349 where
350 F: FnOnce(&Spreadsheet) -> T,
351 {
352 let book = self.spreadsheet.read();
353 Ok(func(&book))
354 }
355
356 pub fn formula_graph(&self, sheet_name: &str) -> Result<FormulaGraph> {
357 self.with_sheet(sheet_name, |sheet| {
358 FormulaGraph::build(sheet, &self.formula_atlas, FormulaParsePolicy::Warn, None)
359 })?
360 }
361
362 pub fn formula_graph_with_diagnostics(
363 &self,
364 sheet_name: &str,
365 policy: FormulaParsePolicy,
366 ) -> Result<(FormulaGraph, FormulaParseDiagnostics)> {
367 let mut builder = FormulaParseDiagnosticsBuilder::new(policy);
368 let graph = self.with_sheet(sheet_name, |sheet| {
369 FormulaGraph::build(sheet, &self.formula_atlas, policy, Some(&mut builder))
370 })??;
371 Ok((graph, builder.build()))
372 }
373
374 pub(crate) fn formula_graph_with_diagnostics_builder(
375 &self,
376 sheet_name: &str,
377 policy: FormulaParsePolicy,
378 builder: &mut FormulaParseDiagnosticsBuilder,
379 ) -> Result<FormulaGraph> {
380 self.with_sheet(sheet_name, |sheet| {
381 FormulaGraph::build(sheet, &self.formula_atlas, policy, Some(builder))
382 })?
383 }
384
385 pub fn named_items(&self) -> Result<Vec<NamedRangeDescriptor>> {
386 let book = self.spreadsheet.read();
387 let sheet_names: Vec<String> = book
388 .get_sheet_collection()
389 .iter()
390 .map(|sheet| sheet.get_name().to_string())
391 .collect();
392 let mut items = Vec::new();
393 for defined in book.get_defined_names() {
394 let refers_to = defined.get_address();
395 let scope = if defined.has_local_sheet_id() {
396 let idx = *defined.get_local_sheet_id() as usize;
397 sheet_names.get(idx).cloned()
398 } else {
399 None
400 };
401 let kind = if refers_to.starts_with('=') {
402 NamedItemKind::Formula
403 } else {
404 NamedItemKind::NamedRange
405 };
406
407 let (scope_kind, scope_sheet_name) = if defined.has_local_sheet_id() {
408 let idx = *defined.get_local_sheet_id() as usize;
409 (Some(NamedRangeScope::Sheet), sheet_names.get(idx).cloned())
410 } else {
411 (Some(NamedRangeScope::Workbook), None)
412 };
413
414 items.push(NamedRangeDescriptor {
415 name: defined.get_name().to_string(),
416 scope: scope.clone(),
417 scope_kind,
418 scope_sheet_name: scope_sheet_name.clone(),
419 refers_to: refers_to.clone(),
420 kind,
421 sheet_name: scope,
422 comment: None,
423 });
424 }
425
426 for sheet in book.get_sheet_collection() {
428 for defined in sheet.get_defined_names() {
429 let refers_to = defined.get_address();
430 let kind = if refers_to.starts_with('=') {
431 NamedItemKind::Formula
432 } else {
433 NamedItemKind::NamedRange
434 };
435 let sheet_name_str = sheet.get_name().to_string();
436 let already_present = items.iter().any(|item| {
438 item.name == defined.get_name()
439 && item.scope_kind == Some(NamedRangeScope::Sheet)
440 && item.scope_sheet_name.as_deref() == Some(sheet_name_str.as_str())
441 });
442 if already_present {
443 continue;
444 }
445 items.push(NamedRangeDescriptor {
446 name: defined.get_name().to_string(),
447 scope: Some(sheet_name_str.clone()),
448 scope_kind: Some(NamedRangeScope::Sheet),
449 scope_sheet_name: Some(sheet_name_str.clone()),
450 refers_to,
451 kind,
452 sheet_name: Some(sheet_name_str),
453 comment: None,
454 });
455 }
456
457 for table in sheet.get_tables() {
458 let start = table.get_area().0.get_coordinate();
459 let end = table.get_area().1.get_coordinate();
460 items.push(NamedRangeDescriptor {
461 name: table.get_name().to_string(),
462 scope: Some(sheet.get_name().to_string()),
463 scope_kind: Some(NamedRangeScope::Sheet),
464 scope_sheet_name: Some(sheet.get_name().to_string()),
465 refers_to: format!("{}:{}", start, end),
466 kind: NamedItemKind::Table,
467 sheet_name: Some(sheet.get_name().to_string()),
468 comment: None,
469 });
470 }
471 }
472
473 Ok(items)
474 }
475
476 pub fn sheet_overview(&self, sheet_name: &str) -> Result<SheetOverviewResponse> {
477 let entry = self.get_sheet_metrics(sheet_name)?;
478 let narrative = classification::narrative(&entry.metrics);
479 let regions = classification::regions(&entry.metrics);
480 let key_ranges = classification::key_ranges(&entry.metrics);
481 let detected_regions = entry.detected_regions();
482
483 Ok(SheetOverviewResponse {
484 workbook_id: self.id.clone(),
485 sheet_name: sheet_name.to_string(),
486 narrative,
487 regions,
488 detected_regions: detected_regions.clone(),
489 detected_region_count: detected_regions.len() as u32,
490 detected_regions_truncated: false,
491 key_ranges,
492 formula_ratio: if entry.metrics.non_empty_cells == 0 {
493 0.0
494 } else {
495 entry.metrics.formula_cells as f32 / entry.metrics.non_empty_cells as f32
496 },
497 notable_features: entry.style_tags.clone(),
498 notes: entry.region_notes(),
499 })
500 }
501
502 pub fn detected_region(
503 &self,
504 sheet_name: &str,
505 id: u32,
506 ) -> Result<crate::model::DetectedRegion> {
507 let entry = self.get_sheet_metrics(sheet_name)?;
508 entry
509 .detected_regions()
510 .iter()
511 .find(|r| r.id == id)
512 .cloned()
513 .ok_or_else(|| anyhow!("region {} not found on sheet {}", id, sheet_name))
514 }
515}
516
517fn contains_date_time_token(format_code: &str) -> bool {
518 let mut in_quote = false;
519 let mut in_bracket = false;
520 let chars: Vec<char> = format_code.chars().collect();
521
522 for (i, &ch) in chars.iter().enumerate() {
523 match ch {
524 '"' => in_quote = !in_quote,
525 '[' if !in_quote => in_bracket = true,
526 ']' if !in_quote => in_bracket = false,
527 'y' | 'd' | 'h' | 's' | 'm' if !in_quote && !in_bracket => {
528 if ch == 'm' {
529 let prev = if i > 0 { chars.get(i - 1) } else { None };
530 let next = chars.get(i + 1);
531 let after_time_sep = prev == Some(&':') || prev == Some(&'h');
532 let before_time_sep = next == Some(&':') || next == Some(&'s');
533 if after_time_sep || before_time_sep {
534 return true;
535 }
536 if prev == Some(&'m') || next == Some(&'m') {
537 return true;
538 }
539 if matches!(prev, Some(&'/') | Some(&'-') | Some(&'.'))
540 || matches!(next, Some(&'/') | Some(&'-') | Some(&'.'))
541 {
542 return true;
543 }
544 } else {
545 return true;
546 }
547 }
548 _ => {}
549 }
550 }
551 false
552}
553
554const DATE_FORMAT_IDS: &[u32] = &[
555 14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 45, 46, 47, 50, 51,
556 52, 53, 54, 55, 56, 57, 58,
557];
558
559const EXCEL_LEAP_YEAR_BUG_SERIAL: i64 = 60;
560
561fn is_date_formatted(cell: &umya_spreadsheet::Cell) -> bool {
562 let Some(nf) = cell.get_style().get_number_format() else {
563 return false;
564 };
565
566 let format_id = nf.get_number_format_id();
567 if DATE_FORMAT_IDS.contains(format_id) {
568 return true;
569 }
570
571 let code = nf.get_format_code();
572 if code == "General" || code == "@" || code == "0" || code == "0.00" {
573 return false;
574 }
575
576 contains_date_time_token(code)
577}
578
579pub fn excel_serial_to_iso(serial: f64, use_1904_system: bool) -> String {
580 excel_serial_to_iso_with_leap_bug(serial, use_1904_system, true)
581}
582
583pub fn excel_serial_to_iso_with_leap_bug(
584 serial: f64,
585 use_1904_system: bool,
586 compensate_leap_bug: bool,
587) -> String {
588 use chrono::NaiveDate;
589
590 let days = serial.trunc() as i64;
591
592 if use_1904_system {
593 let epoch_1904 = NaiveDate::from_ymd_opt(1904, 1, 1).unwrap();
594 return epoch_1904
595 .checked_add_signed(chrono::Duration::days(days))
596 .map(|d| d.format("%Y-%m-%d").to_string())
597 .unwrap_or_else(|| serial.to_string());
598 }
599
600 let epoch = if compensate_leap_bug && days >= EXCEL_LEAP_YEAR_BUG_SERIAL {
601 NaiveDate::from_ymd_opt(1899, 12, 30).unwrap()
602 } else {
603 NaiveDate::from_ymd_opt(1899, 12, 31).unwrap()
604 };
605
606 epoch
607 .checked_add_signed(chrono::Duration::days(days))
608 .map(|d| d.format("%Y-%m-%d").to_string())
609 .unwrap_or_else(|| serial.to_string())
610}
611
612pub fn cell_to_value(cell: &umya_spreadsheet::Cell) -> Option<crate::model::CellValue> {
613 cell_to_value_with_date_system(cell, false)
614}
615
616pub fn cell_to_value_with_date_system(
617 cell: &umya_spreadsheet::Cell,
618 use_1904_system: bool,
619) -> Option<crate::model::CellValue> {
620 let raw = cell.get_value();
621 if raw.is_empty() {
622 return None;
623 }
624 if let Ok(number) = raw.parse::<f64>() {
625 if is_date_formatted(cell) {
626 return Some(crate::model::CellValue::Date(excel_serial_to_iso(
627 number,
628 use_1904_system,
629 )));
630 }
631 return Some(crate::model::CellValue::Number(number));
632 }
633
634 let lower = raw.to_ascii_lowercase();
635 if lower == "true" {
636 return Some(crate::model::CellValue::Bool(true));
637 }
638 if lower == "false" {
639 return Some(crate::model::CellValue::Bool(false));
640 }
641
642 Some(crate::model::CellValue::Text(raw.to_string()))
643}
644
645pub fn compute_sheet_metrics(sheet: &Worksheet) -> (SheetMetrics, Vec<String>) {
646 use std::collections::HashMap as StdHashMap;
647 let mut non_empty = 0u32;
648 let mut formulas = 0u32;
649 let mut cached = 0u32;
650 let comments = sheet.get_comments().len() as u32;
651 let mut style_usage: StdHashMap<String, StyleUsage> = StdHashMap::new();
652
653 for cell in sheet.get_cell_collection() {
654 let value = cell.get_value();
655 if !value.is_empty() {
656 non_empty += 1;
657 }
658 if cell.is_formula() {
659 formulas += 1;
660 if !cell.get_value().is_empty() {
661 cached += 1;
662 }
663 }
664
665 if let Some((style_key, usage)) = style::tag_cell(cell) {
666 let entry = style_usage.entry(style_key).or_insert_with(|| StyleUsage {
667 occurrences: 0,
668 tags: usage.tags.clone(),
669 example_cells: Vec::new(),
670 });
671 entry.occurrences += 1;
672 if entry.example_cells.len() < 5 {
673 entry.example_cells.push(usage.example_cell.clone());
674 }
675 }
676 }
677
678 let (max_col, max_row) = sheet.get_highest_column_and_row();
679
680 let classification = classification::classify(
681 non_empty,
682 formulas,
683 max_row,
684 max_col,
685 comments,
686 &style_usage,
687 );
688
689 let style_tags: Vec<String> = style_usage
690 .values()
691 .flat_map(|usage| usage.tags.clone())
692 .collect();
693
694 let metrics = SheetMetrics {
695 row_count: max_row,
696 column_count: max_col,
697 non_empty_cells: non_empty,
698 formula_cells: formulas,
699 cached_values: cached,
700 comments,
701 style_map: style_usage,
702 classification,
703 };
704 (metrics, style_tags)
705}
706
707#[derive(Debug, Clone, Copy)]
708struct Rect {
709 start_row: u32,
710 end_row: u32,
711 start_col: u32,
712 end_col: u32,
713}
714
715#[derive(Debug, Clone)]
716struct CellInfo {
717 value: Option<crate::model::CellValue>,
718 is_formula: bool,
719}
720
721#[derive(Debug)]
722struct Occupancy {
723 cells: HashMap<(u32, u32), CellInfo>,
724 rows: HashMap<u32, Vec<u32>>,
725 cols: HashMap<u32, Vec<u32>>,
726 min_row: u32,
727 max_row: u32,
728 min_col: u32,
729 max_col: u32,
730}
731
732impl Occupancy {
733 fn bounds_rect(&self) -> Option<Rect> {
734 if self.cells.is_empty() {
735 None
736 } else {
737 Some(Rect {
738 start_row: self.min_row,
739 end_row: self.max_row,
740 start_col: self.min_col,
741 end_col: self.max_col,
742 })
743 }
744 }
745
746 fn dense_bounds(&self) -> Option<Rect> {
747 let bounds = self.bounds_rect()?;
748 let total_cells = self.cells.len();
749 if total_cells < DETECT_OUTLIER_MIN_CELLS {
750 return Some(bounds);
751 }
752 let trim_cells = ((total_cells as f32) * DETECT_OUTLIER_FRACTION).round() as usize;
753 if trim_cells == 0 || trim_cells * 2 >= total_cells {
754 return Some(bounds);
755 }
756
757 let mut row_counts: Vec<(u32, usize)> = self
758 .rows
759 .iter()
760 .map(|(row, cols)| (*row, cols.len()))
761 .collect();
762 row_counts.sort_by_key(|(row, _)| *row);
763
764 let mut col_counts: Vec<(u32, usize)> = self
765 .cols
766 .iter()
767 .map(|(col, rows)| (*col, rows.len()))
768 .collect();
769 col_counts.sort_by_key(|(col, _)| *col);
770
771 let (start_row, end_row) =
772 trim_bounds_by_cells(&row_counts, trim_cells, bounds.start_row, bounds.end_row);
773 let (start_col, end_col) =
774 trim_bounds_by_cells(&col_counts, trim_cells, bounds.start_col, bounds.end_col);
775
776 if start_row > end_row || start_col > end_col {
777 return Some(bounds);
778 }
779
780 Some(Rect {
781 start_row,
782 end_row,
783 start_col,
784 end_col,
785 })
786 }
787
788 fn row_col_counts(&self, rect: &Rect) -> (Vec<u32>, Vec<u32>) {
789 let height = (rect.end_row - rect.start_row + 1) as usize;
790 let width = (rect.end_col - rect.start_col + 1) as usize;
791 let mut row_counts = vec![0u32; height];
792 let mut col_counts = vec![0u32; width];
793
794 for (row, cols) in &self.rows {
795 if *row < rect.start_row || *row > rect.end_row {
796 continue;
797 }
798 let count = count_in_sorted_range(cols, rect.start_col, rect.end_col);
799 row_counts[(row - rect.start_row) as usize] = count;
800 }
801 for (col, rows) in &self.cols {
802 if *col < rect.start_col || *col > rect.end_col {
803 continue;
804 }
805 let count = count_in_sorted_range(rows, rect.start_row, rect.end_row);
806 col_counts[(col - rect.start_col) as usize] = count;
807 }
808 (row_counts, col_counts)
809 }
810
811 fn stats_in_rect(&self, rect: &Rect) -> RegionStats {
812 let mut stats = RegionStats::default();
813 for (row, cols) in &self.rows {
814 if *row < rect.start_row || *row > rect.end_row {
815 continue;
816 }
817 let start_idx = lower_bound(cols, rect.start_col);
818 let end_idx = upper_bound(cols, rect.end_col);
819 for col in &cols[start_idx..end_idx] {
820 if let Some(info) = self.cells.get(&(*row, *col)) {
821 stats.non_empty += 1;
822 if info.is_formula {
823 stats.formulas += 1;
824 }
825 if let Some(val) = &info.value {
826 match val {
827 crate::model::CellValue::Text(_) => stats.text += 1,
828 crate::model::CellValue::Number(_) => stats.numbers += 1,
829 crate::model::CellValue::Bool(_) => stats.bools += 1,
830 crate::model::CellValue::Date(_) => stats.dates += 1,
831 crate::model::CellValue::Error(_) => stats.errors += 1,
832 }
833 }
834 }
835 }
836 }
837 stats
838 }
839
840 fn value_at(&self, row: u32, col: u32) -> Option<&crate::model::CellValue> {
841 self.cells.get(&(row, col)).and_then(|c| c.value.as_ref())
842 }
843}
844
845fn lower_bound(values: &[u32], target: u32) -> usize {
846 let mut left = 0;
847 let mut right = values.len();
848 while left < right {
849 let mid = (left + right) / 2;
850 if values[mid] < target {
851 left = mid + 1;
852 } else {
853 right = mid;
854 }
855 }
856 left
857}
858
859fn upper_bound(values: &[u32], target: u32) -> usize {
860 let mut left = 0;
861 let mut right = values.len();
862 while left < right {
863 let mid = (left + right) / 2;
864 if values[mid] <= target {
865 left = mid + 1;
866 } else {
867 right = mid;
868 }
869 }
870 left
871}
872
873fn count_in_sorted_range(values: &[u32], start: u32, end: u32) -> u32 {
874 if values.is_empty() {
875 return 0;
876 }
877 let start_idx = lower_bound(values, start);
878 let end_idx = upper_bound(values, end);
879 end_idx.saturating_sub(start_idx) as u32
880}
881
882fn trim_bounds_by_cells(
883 entries: &[(u32, usize)],
884 trim_cells: usize,
885 default_start: u32,
886 default_end: u32,
887) -> (u32, u32) {
888 if entries.is_empty() {
889 return (default_start, default_end);
890 }
891
892 let mut remaining = trim_cells;
893 let mut start_idx = 0usize;
894 while start_idx < entries.len() {
895 let count = entries[start_idx].1;
896 if remaining < count {
897 break;
898 }
899 remaining -= count;
900 start_idx += 1;
901 }
902
903 let mut remaining = trim_cells;
904 let mut end_idx = entries.len();
905 while end_idx > 0 {
906 let count = entries[end_idx - 1].1;
907 if remaining < count {
908 break;
909 }
910 remaining -= count;
911 end_idx -= 1;
912 }
913
914 let start = entries
915 .get(start_idx)
916 .map(|(idx, _)| *idx)
917 .unwrap_or(default_start);
918 let end = if end_idx == 0 {
919 default_end
920 } else {
921 entries
922 .get(end_idx - 1)
923 .map(|(idx, _)| *idx)
924 .unwrap_or(default_end)
925 };
926 (start, end)
927}
928
929#[derive(Debug, Default, Clone)]
930struct RegionStats {
931 non_empty: u32,
932 formulas: u32,
933 text: u32,
934 numbers: u32,
935 bools: u32,
936 dates: u32,
937 errors: u32,
938}
939
940#[derive(Debug, Clone, Copy, PartialEq, Eq)]
941enum Gutter {
942 Row { start: u32, end: u32 },
943 Col { start: u32, end: u32 },
944}
945
946#[derive(Debug, Default)]
947struct DetectRegionsResult {
948 regions: Vec<crate::model::DetectedRegion>,
949 notes: Vec<String>,
950}
951
952#[derive(Debug)]
953struct DetectLimits {
954 start: Instant,
955 max_ms: u64,
956 max_leaves: usize,
957 max_depth: u32,
958 leaves: usize,
959 exceeded_time: bool,
960 exceeded_leaves: bool,
961}
962
963impl DetectLimits {
964 fn new() -> Self {
965 Self {
966 start: Instant::now(),
967 max_ms: DETECT_MAX_MS,
968 max_leaves: DETECT_MAX_LEAVES,
969 max_depth: DETECT_MAX_DEPTH,
970 leaves: 0,
971 exceeded_time: false,
972 exceeded_leaves: false,
973 }
974 }
975
976 fn should_stop(&mut self) -> bool {
977 if !self.exceeded_time && self.start.elapsed().as_millis() as u64 >= self.max_ms {
978 self.exceeded_time = true;
979 }
980 self.exceeded_time || self.exceeded_leaves
981 }
982
983 fn note_leaf(&mut self) {
984 self.leaves += 1;
985 if self.leaves >= self.max_leaves {
986 self.exceeded_leaves = true;
987 }
988 }
989}
990
991fn detect_regions(sheet: &Worksheet, metrics: &SheetMetrics) -> DetectRegionsResult {
992 if metrics.row_count == 0 || metrics.column_count == 0 {
993 return DetectRegionsResult::default();
994 }
995
996 let occupancy = build_occupancy(sheet);
997 if occupancy.cells.is_empty() {
998 return DetectRegionsResult::default();
999 }
1000
1001 let area = (metrics.row_count as u64) * (metrics.column_count as u64);
1002 let exceeds_caps = metrics.row_count > DETECT_MAX_ROWS
1003 || metrics.column_count > DETECT_MAX_COLS
1004 || area > DETECT_MAX_AREA
1005 || occupancy.cells.len() > DETECT_MAX_CELLS;
1006
1007 if exceeds_caps {
1008 let mut result = DetectRegionsResult::default();
1009 if let Some(bounds) = occupancy.dense_bounds() {
1010 result.regions.push(build_fallback_region(&bounds, metrics));
1011 }
1012 result.notes.push(format!(
1013 "Region detection capped: rows {}, cols {}, occupied {}.",
1014 metrics.row_count,
1015 metrics.column_count,
1016 occupancy.cells.len()
1017 ));
1018 return result;
1019 }
1020
1021 let root = occupancy.bounds_rect().unwrap_or(Rect {
1022 start_row: 1,
1023 end_row: metrics.row_count.max(1),
1024 start_col: 1,
1025 end_col: metrics.column_count.max(1),
1026 });
1027
1028 let mut leaves = Vec::new();
1029 let mut limits = DetectLimits::new();
1030 split_rect(&occupancy, &root, 0, &mut limits, &mut leaves);
1031
1032 let mut regions = Vec::new();
1033 for (idx, rect) in leaves.into_iter().enumerate() {
1034 if limits.should_stop() {
1035 break;
1036 }
1037 if let Some(trimmed) = trim_rect(&occupancy, rect, &mut limits) {
1038 let region = build_region(&occupancy, &trimmed, metrics, idx as u32);
1039 regions.push(region);
1040 }
1041 }
1042
1043 let mut notes = Vec::new();
1044 if limits.exceeded_time || limits.exceeded_leaves {
1045 notes.push("Region detection truncated due to time/complexity caps.".to_string());
1046 }
1047 if regions.is_empty()
1048 && let Some(bounds) = occupancy.dense_bounds()
1049 {
1050 regions.push(build_fallback_region(&bounds, metrics));
1051 notes.push("Region detection returned no regions; fallback bounds used.".to_string());
1052 }
1053
1054 DetectRegionsResult { regions, notes }
1055}
1056
1057fn build_fallback_region(rect: &Rect, metrics: &SheetMetrics) -> crate::model::DetectedRegion {
1058 let kind = match metrics.classification {
1059 SheetClassification::Calculator => crate::model::RegionKind::Calculator,
1060 SheetClassification::Metadata => crate::model::RegionKind::Metadata,
1061 _ => crate::model::RegionKind::Data,
1062 };
1063 let end_col = crate::utils::column_number_to_name(rect.end_col.max(1));
1064 let end_cell = format!("{}{}", end_col, rect.end_row.max(1));
1065 let header_count = rect.end_col - rect.start_col + 1;
1066 crate::model::DetectedRegion {
1067 id: 0,
1068 bounds: format!(
1069 "{}{}:{}",
1070 crate::utils::column_number_to_name(rect.start_col),
1071 rect.start_row,
1072 end_cell
1073 ),
1074 header_row: None,
1075 headers: Vec::new(),
1076 header_count,
1077 headers_truncated: header_count > 0,
1078 row_count: rect.end_row - rect.start_row + 1,
1079 classification: kind.clone(),
1080 region_kind: Some(kind),
1081 confidence: 0.2,
1082 }
1083}
1084
1085fn build_occupancy(sheet: &Worksheet) -> Occupancy {
1086 let mut cells = HashMap::new();
1087 let mut rows: HashMap<u32, Vec<u32>> = HashMap::new();
1088 let mut cols: HashMap<u32, Vec<u32>> = HashMap::new();
1089 let mut min_row = u32::MAX;
1090 let mut max_row = 0u32;
1091 let mut min_col = u32::MAX;
1092 let mut max_col = 0u32;
1093
1094 for cell in sheet.get_cell_collection() {
1095 let coord = cell.get_coordinate();
1096 let row = *coord.get_row_num();
1097 let col = *coord.get_col_num();
1098 let value = cell_to_value(cell);
1099 let is_formula = cell.is_formula();
1100 cells.insert((row, col), CellInfo { value, is_formula });
1101 rows.entry(row).or_default().push(col);
1102 cols.entry(col).or_default().push(row);
1103 min_row = min_row.min(row);
1104 max_row = max_row.max(row);
1105 min_col = min_col.min(col);
1106 max_col = max_col.max(col);
1107 }
1108
1109 for cols in rows.values_mut() {
1110 cols.sort_unstable();
1111 }
1112 for rows in cols.values_mut() {
1113 rows.sort_unstable();
1114 }
1115
1116 if cells.is_empty() {
1117 min_row = 0;
1118 min_col = 0;
1119 }
1120
1121 Occupancy {
1122 cells,
1123 rows,
1124 cols,
1125 min_row,
1126 max_row,
1127 min_col,
1128 max_col,
1129 }
1130}
1131
1132fn split_rect(
1133 occupancy: &Occupancy,
1134 rect: &Rect,
1135 depth: u32,
1136 limits: &mut DetectLimits,
1137 leaves: &mut Vec<Rect>,
1138) {
1139 if limits.should_stop() || depth >= limits.max_depth {
1140 limits.note_leaf();
1141 leaves.push(*rect);
1142 return;
1143 }
1144 if rect.start_row >= rect.end_row && rect.start_col >= rect.end_col {
1145 limits.note_leaf();
1146 leaves.push(*rect);
1147 return;
1148 }
1149 if let Some(gutter) = find_best_gutter(occupancy, rect, limits) {
1150 match gutter {
1151 Gutter::Row { start, end } => {
1152 if start > rect.start_row {
1153 let upper = Rect {
1154 start_row: rect.start_row,
1155 end_row: start - 1,
1156 start_col: rect.start_col,
1157 end_col: rect.end_col,
1158 };
1159 split_rect(occupancy, &upper, depth + 1, limits, leaves);
1160 }
1161 if end < rect.end_row {
1162 let lower = Rect {
1163 start_row: end + 1,
1164 end_row: rect.end_row,
1165 start_col: rect.start_col,
1166 end_col: rect.end_col,
1167 };
1168 split_rect(occupancy, &lower, depth + 1, limits, leaves);
1169 }
1170 }
1171 Gutter::Col { start, end } => {
1172 if start > rect.start_col {
1173 let left = Rect {
1174 start_row: rect.start_row,
1175 end_row: rect.end_row,
1176 start_col: rect.start_col,
1177 end_col: start - 1,
1178 };
1179 split_rect(occupancy, &left, depth + 1, limits, leaves);
1180 }
1181 if end < rect.end_col {
1182 let right = Rect {
1183 start_row: rect.start_row,
1184 end_row: rect.end_row,
1185 start_col: end + 1,
1186 end_col: rect.end_col,
1187 };
1188 split_rect(occupancy, &right, depth + 1, limits, leaves);
1189 }
1190 }
1191 }
1192 return;
1193 }
1194 limits.note_leaf();
1195 leaves.push(*rect);
1196}
1197
1198fn find_best_gutter(
1199 occupancy: &Occupancy,
1200 rect: &Rect,
1201 limits: &mut DetectLimits,
1202) -> Option<Gutter> {
1203 if limits.should_stop() {
1204 return None;
1205 }
1206 let (row_counts, col_counts) = occupancy.row_col_counts(rect);
1207 let width = rect.end_col - rect.start_col + 1;
1208 let height = rect.end_row - rect.start_row + 1;
1209
1210 let row_blank_runs = find_blank_runs(&row_counts, width);
1211 let col_blank_runs = find_blank_runs(&col_counts, height);
1212
1213 let mut best: Option<(Gutter, u32)> = None;
1214
1215 if let Some((start, end, len)) = row_blank_runs {
1216 let gutter = Gutter::Row {
1217 start: rect.start_row + start,
1218 end: rect.start_row + end,
1219 };
1220 best = Some((gutter, len));
1221 }
1222 if let Some((start, end, len)) = col_blank_runs {
1223 let gutter = Gutter::Col {
1224 start: rect.start_col + start,
1225 end: rect.start_col + end,
1226 };
1227 if best.map(|(_, l)| len > l).unwrap_or(true) {
1228 best = Some((gutter, len));
1229 }
1230 }
1231
1232 best.map(|(g, _)| g)
1233}
1234
1235fn find_blank_runs(counts: &[u32], span: u32) -> Option<(u32, u32, u32)> {
1236 if counts.is_empty() {
1237 return None;
1238 }
1239 let mut best_start = 0;
1240 let mut best_end = 0;
1241 let mut best_len = 0;
1242 let mut current_start = None;
1243 for (idx, count) in counts.iter().enumerate() {
1244 let is_blank = *count == 0 || (*count as f32 / span as f32) < 0.05;
1245 if is_blank {
1246 if current_start.is_none() {
1247 current_start = Some(idx as u32);
1248 }
1249 } else if let Some(start) = current_start.take() {
1250 let end = idx as u32 - 1;
1251 let len = end - start + 1;
1252 if len > best_len && start > 0 && end + 1 < counts.len() as u32 {
1253 best_len = len;
1254 best_start = start;
1255 best_end = end;
1256 }
1257 }
1258 }
1259 if let Some(start) = current_start {
1260 let end = counts.len() as u32 - 1;
1261 let len = end - start + 1;
1262 if len > best_len && start > 0 && end + 1 < counts.len() as u32 {
1263 best_len = len;
1264 best_start = start;
1265 best_end = end;
1266 }
1267 }
1268 if best_len >= 2 {
1269 Some((best_start, best_end, best_len))
1270 } else {
1271 None
1272 }
1273}
1274
1275fn trim_rect(occupancy: &Occupancy, rect: Rect, limits: &mut DetectLimits) -> Option<Rect> {
1276 let mut r = rect;
1277 loop {
1278 if limits.should_stop() {
1279 return Some(r);
1280 }
1281 let (row_counts, col_counts) = occupancy.row_col_counts(&r);
1282 let width = r.end_col - r.start_col + 1;
1283 let height = r.end_row - r.start_row + 1;
1284 let top_blank = row_counts
1285 .first()
1286 .map(|c| *c == 0 || (*c as f32 / width as f32) < 0.1)
1287 .unwrap_or(false);
1288 let bottom_blank = row_counts
1289 .last()
1290 .map(|c| *c == 0 || (*c as f32 / width as f32) < 0.1)
1291 .unwrap_or(false);
1292 let left_blank = col_counts
1293 .first()
1294 .map(|c| *c == 0 || (*c as f32 / height as f32) < 0.1)
1295 .unwrap_or(false);
1296 let right_blank = col_counts
1297 .last()
1298 .map(|c| *c == 0 || (*c as f32 / height as f32) < 0.1)
1299 .unwrap_or(false);
1300
1301 let mut changed = false;
1302 if top_blank && r.start_row < r.end_row {
1303 r.start_row += 1;
1304 changed = true;
1305 }
1306 if bottom_blank && r.end_row > r.start_row {
1307 r.end_row -= 1;
1308 changed = true;
1309 }
1310 if left_blank && r.start_col < r.end_col {
1311 r.start_col += 1;
1312 changed = true;
1313 }
1314 if right_blank && r.end_col > r.start_col {
1315 r.end_col -= 1;
1316 changed = true;
1317 }
1318
1319 if !changed {
1320 break;
1321 }
1322 if r.start_row > r.end_row || r.start_col > r.end_col {
1323 return None;
1324 }
1325 }
1326 Some(r)
1327}
1328
1329fn build_region(
1330 occupancy: &Occupancy,
1331 rect: &Rect,
1332 metrics: &SheetMetrics,
1333 id: u32,
1334) -> crate::model::DetectedRegion {
1335 let header_info = detect_headers(occupancy, rect);
1336 let stats = occupancy.stats_in_rect(rect);
1337 let (kind, confidence) = classify_region(rect, &stats, &header_info, metrics);
1338 let header_len = header_info.headers.len() as u32;
1339 let header_count = rect.end_col - rect.start_col + 1;
1340 let headers_truncated = header_len != header_count;
1341 crate::model::DetectedRegion {
1342 id,
1343 bounds: format!(
1344 "{}{}:{}{}",
1345 crate::utils::column_number_to_name(rect.start_col),
1346 rect.start_row,
1347 crate::utils::column_number_to_name(rect.end_col),
1348 rect.end_row
1349 ),
1350 header_row: header_info.header_row,
1351 headers: header_info.headers,
1352 header_count,
1353 headers_truncated,
1354 row_count: rect.end_row - rect.start_row + 1,
1355 classification: kind.clone(),
1356 region_kind: Some(kind),
1357 confidence,
1358 }
1359}
1360
1361#[derive(Debug, Default)]
1362struct HeaderInfo {
1363 header_row: Option<u32>,
1364 headers: Vec<String>,
1365 is_key_value: bool,
1366}
1367
1368fn is_key_value_layout(occupancy: &Occupancy, rect: &Rect) -> bool {
1369 let width = rect.end_col - rect.start_col + 1;
1370
1371 if width == 2 {
1372 return check_key_value_columns(occupancy, rect, rect.start_col, rect.start_col + 1);
1373 }
1374
1375 if width <= KV_MAX_WIDTH_FOR_DENSITY_CHECK {
1376 let rows_to_sample = (rect.end_row - rect.start_row + 1).min(KV_SAMPLE_ROWS);
1377 let density_threshold = (rows_to_sample as f32 * KV_DENSITY_THRESHOLD) as u32;
1378
1379 let mut col_densities: Vec<(u32, u32)> = Vec::new();
1380 for col in rect.start_col..=rect.end_col {
1381 let count = (rect.start_row..rect.start_row + rows_to_sample)
1382 .filter(|&row| occupancy.value_at(row, col).is_some())
1383 .count() as u32;
1384 if count >= density_threshold {
1385 col_densities.push((col, count));
1386 }
1387 }
1388
1389 if col_densities.len() == 2 {
1390 let label_col = col_densities[0].0;
1391 let value_col = col_densities[1].0;
1392 return check_key_value_columns(occupancy, rect, label_col, value_col);
1393 } else if col_densities.len() == 4 && width >= 4 {
1394 let pair1 =
1395 check_key_value_columns(occupancy, rect, col_densities[0].0, col_densities[1].0);
1396 let pair2 =
1397 check_key_value_columns(occupancy, rect, col_densities[2].0, col_densities[3].0);
1398 return pair1 && pair2;
1399 }
1400 }
1401
1402 false
1403}
1404
1405fn check_key_value_columns(
1406 occupancy: &Occupancy,
1407 rect: &Rect,
1408 label_col: u32,
1409 value_col: u32,
1410) -> bool {
1411 let mut label_value_pairs = 0u32;
1412 let rows_to_check = (rect.end_row - rect.start_row + 1).min(KV_CHECK_ROWS);
1413
1414 for row in rect.start_row..rect.start_row + rows_to_check {
1415 let first_col = occupancy.value_at(row, label_col);
1416 let second_col = occupancy.value_at(row, value_col);
1417
1418 if let (Some(crate::model::CellValue::Text(label)), Some(val)) = (first_col, second_col) {
1419 let label_looks_like_key = label.len() <= KV_MAX_LABEL_LEN
1420 && !label.chars().any(|c| c.is_ascii_digit())
1421 && label.contains(|c: char| c.is_alphabetic());
1422
1423 let value_is_data = matches!(
1424 val,
1425 crate::model::CellValue::Number(_) | crate::model::CellValue::Date(_)
1426 ) || matches!(val, crate::model::CellValue::Text(s) if s.len() > KV_MIN_TEXT_VALUE_LEN);
1427
1428 if label_looks_like_key && value_is_data {
1429 label_value_pairs += 1;
1430 }
1431 }
1432 }
1433
1434 label_value_pairs >= KV_MIN_PAIRS
1435 && label_value_pairs as f32 / rows_to_check as f32 >= KV_MIN_PAIR_RATIO
1436}
1437
1438fn header_data_penalty(s: &str) -> f32 {
1439 if s.is_empty() {
1440 return 0.0;
1441 }
1442 if s.len() > HEADER_LONG_STRING_PENALTY_THRESHOLD {
1443 return HEADER_LONG_STRING_PENALTY;
1444 }
1445 let first_char = s.chars().next().unwrap();
1446 let is_capitalized = first_char.is_uppercase();
1447 let has_lowercase = s.chars().skip(1).any(|c| c.is_lowercase());
1448 let is_all_caps = s.chars().all(|c| !c.is_alphabetic() || c.is_uppercase());
1449 let has_digits = s.chars().any(|c| c.is_ascii_digit());
1450 let is_proper_noun =
1451 is_capitalized && has_lowercase && !is_all_caps && s.len() > HEADER_PROPER_NOUN_MIN_LEN;
1452
1453 let mut penalty = 0.0;
1454 if is_proper_noun {
1455 penalty += HEADER_PROPER_NOUN_PENALTY;
1456 }
1457 if has_digits && s.len() > HEADER_DIGIT_STRING_MIN_LEN {
1458 penalty += HEADER_DIGIT_STRING_PENALTY;
1459 }
1460 penalty
1461}
1462
1463fn detect_headers(occupancy: &Occupancy, rect: &Rect) -> HeaderInfo {
1464 if is_key_value_layout(occupancy, rect) {
1465 let mut headers = Vec::new();
1466 for col in rect.start_col..=rect.end_col {
1467 headers.push(crate::utils::column_number_to_name(col));
1468 }
1469 return HeaderInfo {
1470 header_row: None,
1471 headers,
1472 is_key_value: true,
1473 };
1474 }
1475
1476 let width = rect.end_col - rect.start_col + 1;
1477 if width > HEADER_MAX_COLUMNS {
1478 return HeaderInfo {
1479 header_row: None,
1480 headers: Vec::new(),
1481 is_key_value: false,
1482 };
1483 }
1484
1485 let mut candidates = Vec::new();
1486 let max_row = rect
1487 .start_row
1488 .saturating_add(HEADER_MAX_SCAN_ROWS)
1489 .min(rect.end_row);
1490 for row in rect.start_row..=max_row {
1491 let mut text = 0;
1492 let mut numbers = 0;
1493 let mut non_empty = 0;
1494 let mut unique = HashSet::new();
1495 let mut data_like_penalty: f32 = 0.0;
1496 let mut year_like_bonus: f32 = 0.0;
1497
1498 for col in rect.start_col..=rect.end_col {
1499 if let Some(val) = occupancy.value_at(row, col) {
1500 non_empty += 1;
1501 match val {
1502 crate::model::CellValue::Text(s) => {
1503 text += 1;
1504 unique.insert(s.clone());
1505 data_like_penalty += header_data_penalty(s);
1506 }
1507 crate::model::CellValue::Number(n) => {
1508 if *n >= HEADER_YEAR_MIN && *n <= HEADER_YEAR_MAX && n.fract() == 0.0 {
1509 year_like_bonus += HEADER_YEAR_LIKE_BONUS;
1510 text += 1;
1511 } else {
1512 numbers += 1;
1513 }
1514 }
1515 crate::model::CellValue::Bool(_) => text += 1,
1516 crate::model::CellValue::Date(_) => {
1517 data_like_penalty += HEADER_DATE_PENALTY;
1518 }
1519 crate::model::CellValue::Error(_) => {}
1520 }
1521 }
1522 }
1523 if non_empty == 0 {
1524 continue;
1525 }
1526 let score = text as f32 + unique.len() as f32 * HEADER_UNIQUE_BONUS
1527 - numbers as f32 * HEADER_NUMBER_PENALTY
1528 - data_like_penalty
1529 + year_like_bonus;
1530 candidates.push((row, score, text, non_empty));
1531 }
1532
1533 let is_single_col = rect.start_col == rect.end_col;
1534
1535 let header_candidates: Vec<&(u32, f32, u32, u32)> = candidates
1536 .iter()
1537 .filter(|(_, score, text, non_empty)| {
1538 *text >= 1
1539 && *text * 2 >= *non_empty
1540 && (!is_single_col || *score > HEADER_SINGLE_COL_MIN_SCORE)
1541 })
1542 .collect();
1543
1544 let best = header_candidates.iter().copied().max_by(|a, b| {
1545 a.1.partial_cmp(&b.1)
1546 .unwrap_or(Ordering::Equal)
1547 .then_with(|| b.0.cmp(&a.0))
1548 });
1549 let earliest = header_candidates
1550 .iter()
1551 .copied()
1552 .min_by(|a, b| a.0.cmp(&b.0));
1553
1554 let maybe_header = match (best, earliest) {
1555 (Some(best_row), Some(early_row)) => {
1556 if (best_row.1 - early_row.1).abs() <= HEADER_SCORE_TIE_THRESHOLD {
1557 Some(early_row.0)
1558 } else {
1559 Some(best_row.0)
1560 }
1561 }
1562 (Some(best_row), None) => Some(best_row.0),
1563 _ => None,
1564 };
1565
1566 let mut header_rows = Vec::new();
1567 if let Some(hr) = maybe_header {
1568 header_rows.push(hr);
1569 if hr < rect.end_row
1570 && let Some((_, score_next, text_next, non_empty_next)) =
1571 candidates.iter().find(|(r, _, _, _)| *r == hr + 1)
1572 && *text_next >= 1
1573 && *text_next * 2 >= *non_empty_next
1574 && *score_next
1575 >= HEADER_SECOND_ROW_MIN_SCORE_RATIO
1576 * candidates
1577 .iter()
1578 .find(|(r, _, _, _)| *r == hr)
1579 .map(|c| c.1)
1580 .unwrap_or(0.0)
1581 {
1582 header_rows.push(hr + 1);
1583 }
1584 }
1585
1586 let mut headers = Vec::new();
1587 for col in rect.start_col..=rect.end_col {
1588 let mut parts = Vec::new();
1589 for hr in &header_rows {
1590 if let Some(val) = occupancy.value_at(*hr, col) {
1591 match val {
1592 crate::model::CellValue::Text(s) if !s.trim().is_empty() => {
1593 parts.push(s.trim().to_string())
1594 }
1595 crate::model::CellValue::Number(n) => parts.push(n.to_string()),
1596 crate::model::CellValue::Bool(b) => parts.push(b.to_string()),
1597 crate::model::CellValue::Date(d) => parts.push(d.clone()),
1598 crate::model::CellValue::Error(e) => parts.push(e.clone()),
1599 _ => {}
1600 }
1601 }
1602 }
1603 if parts.is_empty() {
1604 headers.push(crate::utils::column_number_to_name(col));
1605 } else {
1606 headers.push(parts.join(" / "));
1607 }
1608 }
1609
1610 HeaderInfo {
1611 header_row: header_rows.first().copied(),
1612 headers,
1613 is_key_value: false,
1614 }
1615}
1616
1617fn classify_region(
1618 rect: &Rect,
1619 stats: &RegionStats,
1620 header_info: &HeaderInfo,
1621 metrics: &SheetMetrics,
1622) -> (crate::model::RegionKind, f32) {
1623 let width = rect.end_col - rect.start_col + 1;
1624 let height = rect.end_row - rect.start_row + 1;
1625 let area = width.max(1) * height.max(1);
1626 let density = if area == 0 {
1627 0.0
1628 } else {
1629 stats.non_empty as f32 / area as f32
1630 };
1631 let formula_ratio = if stats.non_empty == 0 {
1632 0.0
1633 } else {
1634 stats.formulas as f32 / stats.non_empty as f32
1635 };
1636 let text_ratio = if stats.non_empty == 0 {
1637 0.0
1638 } else {
1639 stats.text as f32 / stats.non_empty as f32
1640 };
1641
1642 let mut kind = crate::model::RegionKind::Data;
1643 if formula_ratio > 0.25 && is_outputs_band(rect, metrics, height, width) {
1644 kind = crate::model::RegionKind::Outputs;
1645 } else if formula_ratio > 0.55 {
1646 kind = crate::model::RegionKind::Calculator;
1647 } else if height <= 3
1648 && width <= 4
1649 && text_ratio > 0.5
1650 && rect.end_row >= metrics.row_count.saturating_sub(3)
1651 {
1652 kind = crate::model::RegionKind::Metadata;
1653 } else if header_info.is_key_value
1654 || (formula_ratio < 0.25
1655 && stats.numbers > 0
1656 && stats.text > 0
1657 && text_ratio >= 0.3
1658 && (width <= 2 || (width <= 3 && header_info.header_row.is_none())))
1659 {
1660 kind = crate::model::RegionKind::Parameters;
1661 } else if height <= 4 && width <= 6 && formula_ratio < 0.2 && text_ratio > 0.4 && density < 0.5
1662 {
1663 kind = crate::model::RegionKind::Metadata;
1664 }
1665
1666 let mut confidence: f32 = 0.4;
1667 if header_info.header_row.is_some() {
1668 confidence += 0.2;
1669 }
1670 confidence += (density * 0.2).min(0.2);
1671 confidence += (formula_ratio * 0.2).min(0.2);
1672 if matches!(
1673 kind,
1674 crate::model::RegionKind::Parameters | crate::model::RegionKind::Metadata
1675 ) && width <= 4
1676 {
1677 confidence += 0.1;
1678 }
1679 if confidence > 1.0 {
1680 confidence = 1.0;
1681 }
1682
1683 (kind, confidence)
1684}
1685
1686fn is_outputs_band(rect: &Rect, metrics: &SheetMetrics, height: u32, width: u32) -> bool {
1687 let near_bottom = rect.end_row >= metrics.row_count.saturating_sub(6);
1688 let near_right = rect.end_col >= metrics.column_count.saturating_sub(3);
1689 let is_shallow = height <= 6;
1690 let is_narrow_at_edge = width <= 6 && near_right;
1691 let not_at_top_left = rect.start_row > 1 || rect.start_col > 1;
1692 let sheet_has_depth = metrics.row_count > 10 || metrics.column_count > 6;
1693 let is_band = (is_shallow && near_bottom) || is_narrow_at_edge;
1694 is_band && not_at_top_left && sheet_has_depth
1695}
1696
1697fn gather_named_ranges(
1698 sheet: &Worksheet,
1699 defined_names: &[DefinedName],
1700) -> Vec<NamedRangeDescriptor> {
1701 let name_str = sheet.get_name();
1702 defined_names
1703 .iter()
1704 .filter(|name| name.get_address().contains(name_str))
1705 .map(|name| {
1706 let (scope_kind, scope_sheet_name) = if name.has_local_sheet_id() {
1707 (Some(NamedRangeScope::Sheet), Some(name_str.to_string()))
1708 } else {
1709 (Some(NamedRangeScope::Workbook), None)
1710 };
1711 NamedRangeDescriptor {
1712 name: name.get_name().to_string(),
1713 scope: if name.has_local_sheet_id() {
1714 Some(name_str.to_string())
1715 } else {
1716 None
1717 },
1718 scope_kind,
1719 scope_sheet_name,
1720 refers_to: name.get_address(),
1721 kind: NamedItemKind::NamedRange,
1722 sheet_name: Some(name_str.to_string()),
1723 comment: None,
1724 }
1725 })
1726 .collect()
1727}
1728
1729pub fn build_workbook_list(
1730 config: &Arc<ServerConfig>,
1731 filter: &WorkbookFilter,
1732) -> Result<WorkbookListResponse> {
1733 let mut descriptors = Vec::new();
1734
1735 if let Some(single) = config.single_workbook() {
1736 let metadata = fs::metadata(single)
1737 .with_context(|| format!("unable to read metadata for {:?}", single))?;
1738 let canonical = fs::canonicalize(single).unwrap_or_else(|_| single.to_path_buf());
1739 let id = WorkbookId(hash_path_identity(&canonical));
1740 let slug = single
1741 .file_stem()
1742 .map(|s| s.to_string_lossy().to_string())
1743 .unwrap_or_else(|| "workbook".to_string());
1744 let folder = derive_folder(config, single);
1745 let short_id = make_short_workbook_id(&slug, id.as_str());
1746 let caps = BackendCaps::xlsx();
1747
1748 if filter.matches(&slug, folder.as_deref(), single) {
1749 let relative = single
1750 .strip_prefix(&config.workspace_root)
1751 .unwrap_or(single);
1752 let descriptor = crate::model::WorkbookDescriptor {
1753 workbook_id: id,
1754 short_id,
1755 slug,
1756 folder,
1757 path: Some(path_to_forward_slashes(relative)),
1758 client_path: None,
1759 bytes: metadata.len(),
1760 last_modified: metadata
1761 .modified()
1762 .ok()
1763 .and_then(system_time_to_rfc3339)
1764 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
1765 revision_id: Some(hash_file_sha256_hex(single)?),
1766 caps: Some(caps),
1767 };
1768 descriptors.push(descriptor);
1769 }
1770
1771 return Ok(WorkbookListResponse {
1772 workbooks: descriptors,
1773 next_offset: None,
1774 });
1775 }
1776
1777 use walkdir::WalkDir;
1778
1779 for entry in WalkDir::new(&config.workspace_root) {
1780 let entry = entry?;
1781 if !entry.file_type().is_file() {
1782 continue;
1783 }
1784 let path = entry.path();
1785 if !has_supported_extension(&config.supported_extensions, path) {
1786 continue;
1787 }
1788 let metadata = entry.metadata()?;
1789 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1790 let id = WorkbookId(hash_path_identity(&canonical));
1791 let slug = path
1792 .file_stem()
1793 .map(|s| s.to_string_lossy().to_string())
1794 .unwrap_or_else(|| "workbook".to_string());
1795 let folder = derive_folder(config, path);
1796 let short_id = make_short_workbook_id(&slug, id.as_str());
1797 let caps = BackendCaps::xlsx();
1798
1799 if !filter.matches(&slug, folder.as_deref(), path) {
1800 continue;
1801 }
1802
1803 let relative = path.strip_prefix(&config.workspace_root).unwrap_or(path);
1804 let descriptor = crate::model::WorkbookDescriptor {
1805 workbook_id: id,
1806 short_id,
1807 slug,
1808 folder,
1809 path: Some(path_to_forward_slashes(relative)),
1810 client_path: None,
1811 bytes: metadata.len(),
1812 last_modified: metadata
1813 .modified()
1814 .ok()
1815 .and_then(system_time_to_rfc3339)
1816 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
1817 revision_id: Some(hash_file_sha256_hex(path)?),
1818 caps: Some(caps),
1819 };
1820 descriptors.push(descriptor);
1821 }
1822
1823 descriptors.sort_by(|a, b| a.slug.cmp(&b.slug));
1824
1825 Ok(WorkbookListResponse {
1826 workbooks: descriptors,
1827 next_offset: None,
1828 })
1829}
1830
1831fn derive_folder(config: &Arc<ServerConfig>, path: &Path) -> Option<String> {
1832 path.strip_prefix(&config.workspace_root)
1833 .ok()
1834 .and_then(|relative| relative.parent())
1835 .and_then(|parent| parent.file_name())
1836 .map(|os| os.to_string_lossy().to_string())
1837}
1838
1839fn has_supported_extension(allowed: &[String], path: &Path) -> bool {
1840 path.extension()
1841 .and_then(|ext| ext.to_str())
1842 .map(|ext| {
1843 let lower = ext.to_ascii_lowercase();
1844 allowed.iter().any(|candidate| candidate == &lower)
1845 })
1846 .unwrap_or(false)
1847}