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