query-forge 0.9.0

Run SQL queries and dataset diffs on XLSX/XML/CSV/JSON/JSONL/Markdown/HTML/Feather/Parquet inputs and export results as text, CSV, JSONL, Markdown, XML, HTML, XLSX, Feather, or Parquet
Documentation
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,
    })
}