use anyhow::{Context, Result, anyhow, bail};
use scraper::{Html, Selector};
use crate::{QueryValue, TypeInferenceOptions};
use super::{SheetData, normalize_text_headers, parse_scalar_value};
pub(super) fn load_html_sheet(
html_path: &std::path::Path,
requested_sheet: Option<&str>,
inference_options: &TypeInferenceOptions,
has_headers: bool,
) -> Result<SheetData> {
let content = std::fs::read_to_string(html_path)
.with_context(|| format!("failed to read {}", html_path.display()))?;
load_html_sheet_from_str(
html_path,
&content,
requested_sheet,
inference_options,
has_headers,
)
}
pub(super) fn load_html_sheet_from_str(
html_path: &std::path::Path,
content: &str,
requested_sheet: Option<&str>,
inference_options: &TypeInferenceOptions,
has_headers: bool,
) -> Result<SheetData> {
let document = Html::parse_document(&content);
let table_selector = Selector::parse("table").expect("valid CSS selector");
let tables: Vec<_> = document.select(&table_selector).collect();
if tables.is_empty() {
bail!(
"HTML input {} does not contain any <table> element",
html_path.display()
);
}
let table_index = if let Some(key) = requested_sheet {
let index = key.trim().parse::<usize>().map_err(|_| {
anyhow!(
"invalid HTML table selector '{key}' for {}. Use a 1-based numeric index such as ':1' or ':2'.",
html_path.display()
)
})?;
if index == 0 {
bail!(
"invalid HTML table selector '{key}' for {}. Table indexes are 1-based (:1, :2, ...).",
html_path.display()
);
}
index
} else {
1
};
let table_count = tables.len();
let Some(table) = tables.into_iter().nth(table_index - 1) else {
bail!(
"HTML table {} not found in {}. Found {} table(s); choose an index between 1 and {}.",
table_index,
html_path.display(),
table_count,
table_count
);
};
let tr_selector = Selector::parse("tr").expect("valid CSS selector");
let th_selector = Selector::parse("th").expect("valid CSS selector");
let td_selector = Selector::parse("td").expect("valid CSS selector");
let all_rows: Vec<Vec<String>> = table
.select(&tr_selector)
.map(|tr| {
let th_cells: Vec<String> = tr
.select(&th_selector)
.map(|cell| cell.text().collect::<String>().trim().to_owned())
.collect();
if !th_cells.is_empty() {
return th_cells;
}
tr.select(&td_selector)
.map(|cell| cell.text().collect::<String>().trim().to_owned())
.collect()
})
.filter(|row| !row.is_empty())
.collect();
if all_rows.is_empty() {
bail!(
"HTML table {} in {} is empty",
table_index,
html_path.display()
);
}
let (raw_headers, data_text_rows) = if has_headers {
let headers = all_rows[0].clone();
(headers, all_rows[1..].to_vec())
} else {
let width = all_rows.iter().map(Vec::len).max().unwrap_or(0);
let generated: Vec<String> = (1..=width).map(|i| format!("column{i}")).collect();
(generated, all_rows)
};
let columns = normalize_text_headers(&raw_headers);
if columns.is_empty() {
bail!("HTML table {} has no columns", table_index);
}
let data_rows: Vec<Vec<QueryValue>> = data_text_rows
.into_iter()
.map(|row| {
(0..columns.len())
.map(|index| {
let value = row.get(index).map(String::as_str).unwrap_or("");
parse_scalar_value(value, inference_options)
})
.collect()
})
.filter(|row: &Vec<QueryValue>| row.iter().any(|value| !matches!(value, QueryValue::Null)))
.collect();
if data_rows.is_empty() {
bail!(
"HTML table {} in {} is empty (no data rows)",
table_index,
html_path.display()
);
}
Ok(SheetData {
original_name: format!("table{table_index}"),
columns,
rows: data_rows,
})
}