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::{
    constants::SortDirection,
    models::{GridSavedPaginationState, GridSavedState, SortState},
};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildGridSavedStateContext {
    pub column_order: Vec<String>,
    pub active_filters: BTreeMap<String, String>,
    pub sort_state: SortState,
    pub group_by_columns: Vec<String>,
    pub current_page: usize,
    pub page_size: usize,
    pub total_items: usize,
    pub expanded_rows: BTreeMap<String, bool>,
    pub expanded_tree_rows: BTreeMap<String, bool>,
    #[serde(default)]
    pub pinned_columns: BTreeMap<String, String>,
    /// Per-column width overrides. Mirrors the TS
    /// `columnWidthOverrides: Record<string, string>` which the vanilla
    /// host serializes when `saveWidths` is on. Hosts that don't track
    /// resize state can leave this empty.
    #[serde(default)]
    pub column_width_overrides: BTreeMap<String, String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridRestoreMutationPlan {
    #[serde(default)]
    pub column_order: Option<Vec<String>>,
    #[serde(default)]
    pub filters: Option<BTreeMap<String, String>>,
    #[serde(default)]
    pub sort: Option<SortState>,
    #[serde(default)]
    pub grouping: Option<Vec<String>>,
    #[serde(default)]
    pub pagination: Option<GridSavedPaginationState>,
    #[serde(default)]
    pub expandable: Option<BTreeMap<String, bool>>,
    #[serde(default)]
    pub tree_view: Option<BTreeMap<String, bool>>,
    #[serde(default)]
    pub pinning: Option<BTreeMap<String, String>>,
    #[serde(default)]
    pub column_width_overrides: Option<BTreeMap<String, String>>,
}

pub fn build_grid_saved_state(context: BuildGridSavedStateContext) -> GridSavedState {
    GridSavedState {
        column_order: context.column_order,
        filters: context.active_filters,
        sort: Some(context.sort_state),
        grouping: context.group_by_columns,
        pagination: Some(GridSavedPaginationState {
            pagination_current_page: current_page_value(context.current_page),
            pagination_page_size: effective_page_size(context.page_size, context.total_items),
        }),
        expandable: context.expanded_rows,
        tree_view: context.expanded_tree_rows,
        pinning: context.pinned_columns,
        column_width_overrides: context.column_width_overrides,
    }
}

pub fn normalize_grid_saved_state(value: &Value) -> GridSavedState {
    let mut normalized = GridSavedState::default();
    let Some(state) = value.as_object() else {
        return normalized;
    };

    if let Some(Value::Array(column_order)) = state.get("columnOrder") {
        normalized.column_order = column_order
            .iter()
            .filter_map(|value| value.as_str())
            .filter(|value| is_safe_state_key(value))
            .map(ToOwned::to_owned)
            .collect();
    }

    if let Some(Value::Object(filters)) = state.get("filters") {
        normalized.filters = filters
            .iter()
            .filter_map(|(key, value)| {
                let string_value = value.as_str()?;
                is_safe_state_key(key).then(|| (key.clone(), string_value.to_string()))
            })
            .collect();
    }

    if let Some(Value::Object(sort)) = state.get("sort") {
        normalized.sort = Some(SortState {
            column_name: sort
                .get("columnName")
                .and_then(Value::as_str)
                .filter(|value| is_safe_state_key(value))
                .map(ToOwned::to_owned),
            direction: match sort.get("direction").and_then(Value::as_str) {
                Some("asc") => SortDirection::Asc,
                Some("desc") => SortDirection::Desc,
                _ => SortDirection::None,
            },
        });
    }

    if let Some(Value::Array(grouping)) = state.get("grouping") {
        normalized.grouping = grouping
            .iter()
            .filter_map(Value::as_str)
            .filter(|value| is_safe_state_key(value))
            .map(ToOwned::to_owned)
            .collect();
    }

    if let Some(Value::Object(pagination)) = state.get("pagination") {
        normalized.pagination = Some(GridSavedPaginationState {
            pagination_current_page: pagination
                .get("paginationCurrentPage")
                .and_then(Value::as_u64)
                .map(|value| value.max(1) as usize)
                .unwrap_or(1),
            pagination_page_size: pagination
                .get("paginationPageSize")
                .and_then(Value::as_u64)
                .map(|value| value as usize)
                .unwrap_or(0),
        });
    }

    if let Some(Value::Object(expandable)) = state.get("expandable") {
        normalized.expandable = normalize_boolean_map(expandable);
    }

    if let Some(Value::Object(tree_view)) = state.get("treeView") {
        normalized.tree_view = normalize_boolean_map(tree_view);
    }

    if let Some(Value::Object(pinning)) = state.get("pinning") {
        normalized.pinning = pinning
            .iter()
            .filter_map(|(key, value)| match value.as_str() {
                Some("left") | Some("right") if is_safe_state_key(key) => {
                    Some((key.clone(), value.as_str().unwrap().to_string()))
                }
                _ => None,
            })
            .collect();
    }

    // Column width overrides — keyed by safe column name, valued as
    // arbitrary CSS-style width strings (`"180px"`, `"30%"`, etc.).
    // We don't validate the value beyond stripping non-string entries
    // because the column-width parser already tolerates arbitrary
    // suffixes (and rejects unknown values at apply time).
    if let Some(Value::Object(widths)) = state.get("columnWidthOverrides") {
        normalized.column_width_overrides = widths
            .iter()
            .filter_map(|(key, value)| {
                if !is_safe_state_key(key) {
                    return None;
                }
                value.as_str().map(|width| (key.clone(), width.to_string()))
            })
            .collect();
    }

    normalized
}

pub fn create_grid_restore_mutation_plan(state: &GridSavedState) -> GridRestoreMutationPlan {
    let normalized =
        normalize_grid_saved_state(&serde_json::to_value(state).unwrap_or(Value::Null));

    GridRestoreMutationPlan {
        column_order: (!normalized.column_order.is_empty()).then_some(normalized.column_order),
        filters: (!normalized.filters.is_empty()).then_some(normalized.filters),
        sort: normalized.sort,
        grouping: (!normalized.grouping.is_empty()).then_some(normalized.grouping),
        pagination: normalized.pagination,
        expandable: (!normalized.expandable.is_empty()).then_some(normalized.expandable),
        tree_view: (!normalized.tree_view.is_empty()).then_some(normalized.tree_view),
        pinning: (!normalized.pinning.is_empty()).then_some(normalized.pinning),
        column_width_overrides: (!normalized.column_width_overrides.is_empty())
            .then_some(normalized.column_width_overrides),
    }
}

pub fn serialize_grid_saved_state(state: &GridSavedState) -> Result<String, serde_json::Error> {
    serde_json::to_string(state)
}

pub fn deserialize_grid_saved_state(value: &str) -> Result<GridSavedState, serde_json::Error> {
    let parsed: Value = serde_json::from_str(value)?;
    Ok(normalize_grid_saved_state(&parsed))
}

pub fn serialize_grid_saved_state_with<T>(
    state: &GridSavedState,
    serializer: impl FnOnce(&GridSavedState) -> T,
) -> T {
    serializer(state)
}

pub fn deserialize_grid_saved_state_with<T, E>(
    value: T,
    deserializer: impl FnOnce(T) -> Result<GridSavedState, E>,
) -> Result<GridSavedState, E> {
    let state = deserializer(value)?;
    let normalized =
        normalize_grid_saved_state(&serde_json::to_value(state).unwrap_or(Value::Null));
    Ok(normalized)
}

fn current_page_value(current_page: usize) -> usize {
    current_page.max(1)
}

fn effective_page_size(page_size: usize, total_items: usize) -> usize {
    if page_size > 0 {
        page_size
    } else {
        total_items
    }
}

pub fn sanitize_download_filename(value: &str) -> String {
    let sanitized = value
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>();
    let trimmed = sanitized.trim_matches('_').to_string();
    if trimmed.is_empty() {
        "ui-grid".to_string()
    } else {
        trimmed
    }
}

pub fn normalize_boolean_map(value: &serde_json::Map<String, Value>) -> BTreeMap<String, bool> {
    value
        .iter()
        .filter_map(|(key, entry)| {
            let boolean = entry.as_bool()?;
            is_safe_state_key(key).then(|| (key.clone(), boolean))
        })
        .collect()
}

pub fn is_safe_state_key(value: &str) -> bool {
    !matches!(value, "__proto__" | "constructor" | "prototype")
}