ui-grid-core 1.0.6

Rust engine for ui-grid
use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::models::{GridColumnDef, GridOptions, GridRecord};

pub type GridImporterHeaderMapping = Vec<Option<String>>;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridImporterOptions {
    pub enable: Option<bool>,
    pub show_menu: Option<bool>,
}

pub fn resolve_grid_importer_options(options: &GridOptions) -> GridImporterOptions {
    GridImporterOptions {
        enable: options.enable_importer,
        show_menu: options.importer_show_menu,
    }
}

pub fn flatten_grid_column_defs_for_import(
    column_defs: &[GridColumnDef],
    header_filter: Option<&dyn Fn(&str) -> String>,
) -> BTreeMap<String, String> {
    let mut flattened = BTreeMap::new();
    for column_def in column_defs {
        let target = column_def
            .field
            .clone()
            .unwrap_or_else(|| column_def.name.clone());
        if !column_def.name.is_empty() {
            flattened.insert(column_def.name.clone(), target.clone());
            flattened.insert(column_def.name.to_lowercase(), target.clone());
        }
        if let Some(field) = &column_def.field {
            flattened.insert(field.clone(), target.clone());
            flattened.insert(field.to_lowercase(), target.clone());
        }
        if let Some(display_name) = &column_def.display_name {
            flattened.insert(display_name.clone(), target.clone());
            flattened.insert(display_name.to_lowercase(), target.clone());
            if let Some(filter) = header_filter {
                let filtered = filter(display_name);
                flattened.insert(filtered.clone(), target.clone());
                flattened.insert(filtered.to_lowercase(), target.clone());
            }
        }
    }
    flattened
}

pub fn default_grid_importer_process_headers(
    column_defs: Option<&[GridColumnDef]>,
    header_row: &[String],
    header_filter: Option<&dyn Fn(&str) -> String>,
) -> GridImporterHeaderMapping {
    if is_none_or(column_defs, |column_defs| column_defs.is_empty()) {
        return header_row
            .iter()
            .map(|value| Some(sanitize_header(value)))
            .collect();
    }

    let lookup =
        flatten_grid_column_defs_for_import(column_defs.unwrap_or_default(), header_filter);
    header_row
        .iter()
        .map(|value| {
            lookup
                .get(value)
                .cloned()
                .or_else(|| lookup.get(&value.to_lowercase()).cloned())
        })
        .collect()
}

pub fn create_grid_importer_new_object(new_object: Option<&dyn Fn() -> GridRecord>) -> GridRecord {
    new_object
        .map(|factory| factory())
        .unwrap_or_else(|| Value::Object(Default::default()))
}

pub fn apply_grid_importer_object_callback(
    object_callback: Option<&dyn Fn(GridRecord) -> GridRecord>,
    obj: GridRecord,
) -> GridRecord {
    object_callback
        .map(|callback| callback(obj.clone()))
        .unwrap_or(obj)
}

pub fn parse_grid_importer_json(source: &str) -> Result<Vec<GridRecord>, GridImporterJsonError> {
    let loaded: Value =
        serde_json::from_str(source).map_err(|_| GridImporterJsonError::InvalidJson)?;
    match loaded {
        Value::Array(values) => Ok(values),
        _ => Err(GridImporterJsonError::JsonNotArray),
    }
}

pub fn parse_grid_importer_csv(source: &str) -> Option<Vec<Vec<String>>> {
    if source.is_empty() {
        return None;
    }

    let mut rows = Vec::new();
    let mut current_row = Vec::new();
    let mut field = String::new();
    let mut in_quotes = false;
    let chars = source.chars().collect::<Vec<_>>();
    let mut index = 0;

    while index < chars.len() {
        let ch = chars[index];
        if in_quotes {
            if ch == '"' {
                if chars.get(index + 1) == Some(&'"') {
                    field.push('"');
                    index += 1;
                } else {
                    in_quotes = false;
                }
            } else {
                field.push(ch);
            }
        } else if ch == '"' {
            in_quotes = true;
        } else if ch == ',' {
            current_row.push(std::mem::take(&mut field));
        } else if ch == '\n' {
            current_row.push(std::mem::take(&mut field));
            rows.push(std::mem::take(&mut current_row));
        } else if ch == '\r' {
            if chars.get(index + 1) != Some(&'\n') {
                current_row.push(std::mem::take(&mut field));
                rows.push(std::mem::take(&mut current_row));
            }
        } else {
            field.push(ch);
        }
        index += 1;
    }

    if !field.is_empty() || !current_row.is_empty() {
        current_row.push(field);
        rows.push(current_row);
    }

    (!rows.is_empty()).then_some(rows)
}

pub fn build_grid_importer_objects_from_csv(
    import_array: &[Vec<String>],
    column_defs: Option<&[GridColumnDef]>,
) -> Option<Vec<GridRecord>> {
    let (header_row, data_rows) = import_array.split_first()?;
    let header_mapping = default_grid_importer_process_headers(column_defs, header_row, None);

    let mut result = Vec::new();
    for row in data_rows {
        let mut obj = create_grid_importer_new_object(None);
        let Value::Object(map) = &mut obj else {
            continue;
        };
        for (index, value) in row.iter().enumerate() {
            if let Some(Some(key)) = header_mapping.get(index) {
                map.insert(key.clone(), Value::String(value.clone()));
            }
        }
        result.push(obj);
    }
    Some(result)
}

pub fn build_grid_importer_objects_from_json(parsed: &[GridRecord]) -> Vec<GridRecord> {
    parsed
        .iter()
        .map(|value| {
            let mut obj = create_grid_importer_new_object(None);
            if let (Value::Object(target), Value::Object(source)) = (&mut obj, value) {
                for (key, value) in source {
                    target.insert(key.clone(), value.clone());
                }
            }
            obj
        })
        .collect()
}

fn sanitize_header(value: &str) -> String {
    value
        .chars()
        .map(|ch| match ch {
            '0'..='9' | 'a'..='z' | 'A'..='Z' | '-' | '_' => ch,
            _ => '_',
        })
        .collect()
}

fn is_none_or<T>(value: Option<T>, predicate: impl FnOnce(T) -> bool) -> bool {
    match value {
        None => true,
        Some(value) => predicate(value),
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GridImporterJsonError {
    InvalidJson,
    JsonNotArray,
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    fn column_defs() -> Vec<GridColumnDef> {
        vec![
            GridColumnDef {
                name: "id".to_string(),
                field: Some("id".to_string()),
                ..GridColumnDef::default()
            },
            GridColumnDef {
                name: "name".to_string(),
                display_name: Some("Full Name".to_string()),
                ..GridColumnDef::default()
            },
            GridColumnDef {
                name: "status".to_string(),
                ..GridColumnDef::default()
            },
        ]
    }

    #[test]
    fn flattens_headers_and_processes_defaults() {
        let lookup = flatten_grid_column_defs_for_import(&column_defs(), None);
        assert_eq!(lookup.get("id"), Some(&"id".to_string()));
        assert_eq!(lookup.get("Full Name"), Some(&"name".to_string()));
        assert_eq!(lookup.get("full name"), Some(&"name".to_string()));

        let filtered =
            flatten_grid_column_defs_for_import(&column_defs(), Some(&|name| format!("*{name}*")));
        assert_eq!(filtered.get("*Full Name*"), Some(&"name".to_string()));

        assert_eq!(
            default_grid_importer_process_headers(
                None,
                &["First Name".to_string(), "e/mail".to_string()],
                None
            ),
            vec![Some("First_Name".to_string()), Some("e_mail".to_string())]
        );
    }

    #[test]
    fn parses_json_and_csv_inputs() {
        assert_eq!(
            parse_grid_importer_json("[{\"id\":1}]").unwrap(),
            vec![json!({ "id": 1 })]
        );
        assert_eq!(
            parse_grid_importer_json("not json"),
            Err(GridImporterJsonError::InvalidJson)
        );
        assert_eq!(
            parse_grid_importer_json("{\"id\":1}"),
            Err(GridImporterJsonError::JsonNotArray)
        );

        assert_eq!(
            parse_grid_importer_csv("name,notes\n\"Alpha, Inc\",\"He said \"\"hi\"\"\"").unwrap(),
            vec![
                vec!["name".to_string(), "notes".to_string()],
                vec!["Alpha, Inc".to_string(), "He said \"hi\"".to_string()],
            ]
        );
    }

    #[test]
    fn builds_objects_from_csv_and_json() {
        let csv = vec![
            vec![
                "id".to_string(),
                "Full Name".to_string(),
                "status".to_string(),
            ],
            vec!["1".to_string(), "Alpha".to_string(), "Active".to_string()],
        ];
        assert_eq!(
            build_grid_importer_objects_from_csv(&csv, Some(&column_defs())).unwrap(),
            vec![json!({ "id": "1", "name": "Alpha", "status": "Active" })]
        );
        assert_eq!(
            build_grid_importer_objects_from_json(&[json!({ "id": 1 }), json!({ "id": 2 })]),
            vec![json!({ "id": 1 }), json!({ "id": 2 })]
        );
    }
}