feat: Add support for tabular/csv data input.
This commit is contained in:
155
crates/pikl-core/src/model/column.rs
Normal file
155
crates/pikl-core/src/model/column.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! Column configuration for table mode. Defines which fields
|
||||
//! to display as columns and how to label them.
|
||||
|
||||
use crate::item::resolve_field_path;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A single column definition: which field to extract and
|
||||
/// what to call it in the header.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnDef {
|
||||
/// Dotted path into the item's JSON value (e.g. "meta.res").
|
||||
pub field: String,
|
||||
/// Display name for the column header. Defaults to the
|
||||
/// field path if no alias is given.
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// Column layout for table mode. Controls which fields are
|
||||
/// shown and in what order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnConfig {
|
||||
pub columns: Vec<ColumnDef>,
|
||||
}
|
||||
|
||||
impl ColumnConfig {
|
||||
/// Parse a column spec string like "label,meta.res:Resolution,meta.size".
|
||||
/// Columns are comma-separated. Each column can have an alias
|
||||
/// after the first colon: "field:Alias".
|
||||
pub fn parse(spec: &str) -> Self {
|
||||
let columns = spec
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|part| {
|
||||
let part = part.trim();
|
||||
if let Some((field, alias)) = part.split_once(':') {
|
||||
ColumnDef {
|
||||
field: field.trim().to_string(),
|
||||
display_name: alias.trim().to_string(),
|
||||
}
|
||||
} else {
|
||||
ColumnDef {
|
||||
field: part.to_string(),
|
||||
display_name: part.to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self { columns }
|
||||
}
|
||||
|
||||
/// Build a ColumnConfig from raw header names. Used when
|
||||
/// auto-generating columns from CSV headers.
|
||||
pub fn from_headers(headers: &[String]) -> Self {
|
||||
let columns = headers
|
||||
.iter()
|
||||
.map(|h| ColumnDef {
|
||||
field: h.clone(),
|
||||
display_name: h.clone(),
|
||||
})
|
||||
.collect();
|
||||
Self { columns }
|
||||
}
|
||||
|
||||
/// Resolve each column's value from an item's JSON value.
|
||||
/// Missing fields produce an empty string.
|
||||
pub fn resolve_values(&self, value: &Value) -> Vec<String> {
|
||||
self.columns
|
||||
.iter()
|
||||
.map(|col| {
|
||||
resolve_field_path(value, &col.field)
|
||||
.map(value_to_cell)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a JSON value to a cell string for display.
|
||||
fn value_to_cell(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_columns() {
|
||||
let config = ColumnConfig::parse("label,age,city");
|
||||
assert_eq!(config.columns.len(), 3);
|
||||
assert_eq!(config.columns[0].field, "label");
|
||||
assert_eq!(config.columns[0].display_name, "label");
|
||||
assert_eq!(config.columns[2].field, "city");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_alias() {
|
||||
let config = ColumnConfig::parse("label,meta.res:Resolution");
|
||||
assert_eq!(config.columns.len(), 2);
|
||||
assert_eq!(config.columns[1].field, "meta.res");
|
||||
assert_eq!(config.columns[1].display_name, "Resolution");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_string() {
|
||||
let config = ColumnConfig::parse("");
|
||||
assert!(config.columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_trailing_comma() {
|
||||
let config = ColumnConfig::parse("label,age,");
|
||||
assert_eq!(config.columns.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_flat_values() {
|
||||
let config = ColumnConfig::parse("label,age");
|
||||
let value = json!({"label": "alice", "age": 30});
|
||||
let cells = config.resolve_values(&value);
|
||||
assert_eq!(cells, vec!["alice", "30"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_nested_values() {
|
||||
let config = ColumnConfig::parse("label,meta.res");
|
||||
let value = json!({"label": "test", "meta": {"res": "1080p"}});
|
||||
let cells = config.resolve_values(&value);
|
||||
assert_eq!(cells, vec!["test", "1080p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing_field() {
|
||||
let config = ColumnConfig::parse("label,nope");
|
||||
let value = json!({"label": "test"});
|
||||
let cells = config.resolve_values(&value);
|
||||
assert_eq!(cells, vec!["test", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_headers_builds_identity() {
|
||||
let headers = vec!["name".to_string(), "age".to_string()];
|
||||
let config = ColumnConfig::from_headers(&headers);
|
||||
assert_eq!(config.columns.len(), 2);
|
||||
assert_eq!(config.columns[0].field, "name");
|
||||
assert_eq!(config.columns[0].display_name, "name");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user