use rusqlite::{Connection, params}; use toml::value::Table; #[derive(Debug, Clone)] #[allow(dead_code)] pub struct Session { pub id: i64, pub name: String, pub notes: String, pub created_at: String, } #[derive(Debug, Clone)] #[allow(dead_code)] pub struct Measurement { pub id: i64, pub session_id: i64, pub mtype: String, pub params_json: String, pub created_at: String, pub esp_timestamp: Option, } #[derive(Debug, Clone)] #[allow(dead_code)] pub struct DataPoint { pub id: i64, pub measurement_id: i64, pub idx: i32, pub data_json: String, } pub struct Storage { conn: Connection, } impl Storage { pub fn open() -> Result { let dir = dirs(); std::fs::create_dir_all(&dir).ok(); let path = dir.join("measurements.db"); let conn = Connection::open(path)?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; conn.execute_batch(SCHEMA)?; Self::migrate_v2(&conn)?; Ok(Self { conn }) } fn migrate_v2(conn: &Connection) -> Result<(), rusqlite::Error> { let has_col: bool = conn.prepare("SELECT esp_timestamp FROM measurements LIMIT 0") .is_ok(); if !has_col { conn.execute_batch( "ALTER TABLE measurements ADD COLUMN esp_timestamp INTEGER;" )?; } Ok(()) } pub fn create_session(&self, name: &str, notes: &str) -> Result { self.conn.execute( "INSERT INTO sessions (name, notes) VALUES (?1, ?2)", params![name, notes], )?; Ok(self.conn.last_insert_rowid()) } pub fn list_sessions(&self) -> Result, rusqlite::Error> { let mut stmt = self.conn.prepare( "SELECT id, name, notes, created_at FROM sessions ORDER BY created_at DESC", )?; let rows = stmt.query_map([], |row| { Ok(Session { id: row.get(0)?, name: row.get(1)?, notes: row.get(2)?, created_at: row.get(3)?, }) })?; rows.collect() } pub fn delete_session(&self, id: i64) -> Result<(), rusqlite::Error> { self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])?; Ok(()) } pub fn create_measurement( &self, session_id: i64, mtype: &str, params_json: &str, esp_timestamp: Option, ) -> Result { if let Some(ts) = esp_timestamp { let exists: bool = self.conn.query_row( "SELECT EXISTS(SELECT 1 FROM measurements WHERE session_id = ?1 AND esp_timestamp = ?2)", params![session_id, ts as i64], |row| row.get(0), )?; if exists { return Err(rusqlite::Error::StatementChangedRows(0)); } } self.conn.execute( "INSERT INTO measurements (session_id, type, params_json, esp_timestamp) VALUES (?1, ?2, ?3, ?4)", params![session_id, mtype, params_json, esp_timestamp.map(|t| t as i64)], )?; Ok(self.conn.last_insert_rowid()) } pub fn add_data_point( &self, measurement_id: i64, idx: i32, data_json: &str, ) -> Result<(), rusqlite::Error> { self.conn.execute( "INSERT INTO data_points (measurement_id, idx, data_json) VALUES (?1, ?2, ?3)", params![measurement_id, idx, data_json], )?; Ok(()) } pub fn add_data_points_batch( &self, measurement_id: i64, points: &[(i32, String)], ) -> Result<(), rusqlite::Error> { let tx = self.conn.unchecked_transaction()?; { let mut stmt = tx.prepare( "INSERT INTO data_points (measurement_id, idx, data_json) VALUES (?1, ?2, ?3)", )?; for (idx, json) in points { stmt.execute(params![measurement_id, idx, json])?; } } tx.commit() } pub fn get_measurements(&self, session_id: i64) -> Result, rusqlite::Error> { let mut stmt = self.conn.prepare( "SELECT id, session_id, type, params_json, created_at, esp_timestamp \ FROM measurements WHERE session_id = ?1 ORDER BY created_at DESC", )?; let rows = stmt.query_map(params![session_id], |row| { Ok(Measurement { id: row.get(0)?, session_id: row.get(1)?, mtype: row.get(2)?, params_json: row.get(3)?, created_at: row.get(4)?, esp_timestamp: row.get(5)?, }) })?; rows.collect() } pub fn measurement_count(&self, session_id: i64) -> Result { self.conn.query_row( "SELECT COUNT(*) FROM measurements WHERE session_id = ?1", params![session_id], |row| row.get(0), ) } pub fn data_point_count(&self, measurement_id: i64) -> Result { self.conn.query_row( "SELECT COUNT(*) FROM data_points WHERE measurement_id = ?1", params![measurement_id], |row| row.get(0), ) } pub fn delete_measurement(&self, id: i64) -> Result<(), rusqlite::Error> { self.conn.execute("DELETE FROM measurements WHERE id = ?1", params![id])?; Ok(()) } pub fn get_data_points(&self, measurement_id: i64) -> Result, rusqlite::Error> { let mut stmt = self.conn.prepare( "SELECT id, measurement_id, idx, data_json \ FROM data_points WHERE measurement_id = ?1 ORDER BY idx", )?; let rows = stmt.query_map(params![measurement_id], |row| { Ok(DataPoint { id: row.get(0)?, measurement_id: row.get(1)?, idx: row.get(2)?, data_json: row.get(3)?, }) })?; rows.collect() } pub fn export_session(&self, session_id: i64) -> Result> { let sess = self.conn.query_row( "SELECT id, name, notes, created_at FROM sessions WHERE id = ?1", params![session_id], |row| Ok(Session { id: row.get(0)?, name: row.get(1)?, notes: row.get(2)?, created_at: row.get(3)?, }), )?; let mut root = Table::new(); let mut session_table = Table::new(); session_table.insert("name".into(), toml::Value::String(sess.name)); session_table.insert("notes".into(), toml::Value::String(sess.notes)); session_table.insert("created_at".into(), toml::Value::String(sess.created_at)); root.insert("session".into(), toml::Value::Table(session_table)); let measurements = self.get_measurements(session_id)?; let mut meas_array = Vec::new(); for m in &measurements { let mut mt = Table::new(); mt.insert("type".into(), toml::Value::String(m.mtype.clone())); mt.insert("created_at".into(), toml::Value::String(m.created_at.clone())); let params: serde_json::Value = serde_json::from_str(&m.params_json) .unwrap_or(serde_json::Value::Object(Default::default())); mt.insert("params".into(), toml::Value::Table(json_to_toml_table(¶ms))); let points = self.get_data_points(m.id)?; let mut data_array = Vec::new(); let mut cl_result: Option = None; for p in &points { let jv: serde_json::Value = serde_json::from_str(&p.data_json) .unwrap_or(serde_json::Value::Null); if let Some(obj) = jv.as_object() { if obj.contains_key("result") { cl_result = obj.get("result").cloned(); continue; } } if let Some(row) = data_point_to_toml(&m.mtype, &jv) { data_array.push(toml::Value::Table(row)); } } if !data_array.is_empty() { mt.insert("data".into(), toml::Value::Array(data_array)); } if let Some(r) = cl_result { mt.insert("result".into(), toml::Value::Table(cl_result_to_toml(&r))); } meas_array.push(toml::Value::Table(mt)); } root.insert("measurement".into(), toml::Value::Array(meas_array)); Ok(toml::to_string_pretty(&toml::Value::Table(root))?) } pub fn import_session(&self, toml_str: &str) -> Result> { let doc: toml::Value = toml::from_str(toml_str)?; let root = doc.as_table().ok_or("invalid TOML root")?; let sess = root.get("session") .and_then(|v| v.as_table()) .ok_or("missing [session]")?; let name = sess.get("name").and_then(|v| v.as_str()).unwrap_or(""); let notes = sess.get("notes").and_then(|v| v.as_str()).unwrap_or(""); let session_id = self.create_session(name, notes)?; if let Some(toml::Value::Array(measurements)) = root.get("measurement") { for mt in measurements { let mt = mt.as_table().ok_or("invalid measurement")?; let mtype = mt.get("type").and_then(|v| v.as_str()).unwrap_or("eis"); let params_table = mt.get("params").and_then(|v| v.as_table()); let params_json = match params_table { Some(t) => serde_json::to_string(&toml_table_to_json(t))?, None => "{}".to_string(), }; let mid = self.create_measurement(session_id, mtype, ¶ms_json, None)?; if let Some(toml::Value::Array(data)) = mt.get("data") { let pts: Vec<(i32, String)> = data.iter().enumerate() .filter_map(|(i, row)| { let row = row.as_table()?; let jv = toml_data_row_to_json(mtype, row); serde_json::to_string(&jv).ok().map(|s| (i as i32, s)) }) .collect(); self.add_data_points_batch(mid, &pts)?; } if mtype == "chlorine" { if let Some(result_table) = mt.get("result").and_then(|v| v.as_table()) { let rj = toml_cl_result_to_json(result_table); let wrapper = format!("{{\"result\":{}}}", serde_json::to_string(&rj)?); let idx = self.conn.query_row( "SELECT COALESCE(MAX(idx), -1) + 1 FROM data_points WHERE measurement_id = ?1", params![mid], |row| row.get::<_, i32>(0), )?; self.add_data_point(mid, idx, &wrapper)?; } } } } Ok(session_id) } } // MARK: - TOML ↔ JSON conversion helpers fn json_to_toml_table(jv: &serde_json::Value) -> Table { let mut t = Table::new(); if let Some(obj) = jv.as_object() { for (k, v) in obj { if let Some(tv) = json_val_to_toml(v) { t.insert(k.clone(), tv); } } } t } fn json_val_to_toml(jv: &serde_json::Value) -> Option { match jv { serde_json::Value::String(s) => Some(toml::Value::String(s.clone())), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { Some(toml::Value::Integer(i)) } else { n.as_f64().map(toml::Value::Float) } } serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)), _ => None, } } fn data_point_to_toml(mtype: &str, jv: &serde_json::Value) -> Option { let obj = jv.as_object()?; let mut t = Table::new(); match mtype { "eis" => { t.insert("Frequency (Hz)".into(), toml_f(obj, "freq_hz")?); t.insert("|Z| (Ohm)".into(), toml_f(obj, "mag_ohms")?); t.insert("Phase (deg)".into(), toml_f(obj, "phase_deg")?); t.insert("Re (Ohm)".into(), toml_f(obj, "z_real")?); t.insert("Im (Ohm)".into(), toml_f(obj, "z_imag")?); } "lsv" => { t.insert("Voltage (mV)".into(), toml_f(obj, "v_mv")?); t.insert("Current (uA)".into(), toml_f(obj, "i_ua")?); } "amp" => { t.insert("Time (ms)".into(), toml_f(obj, "t_ms")?); t.insert("Current (uA)".into(), toml_f(obj, "i_ua")?); } "chlorine" => { t.insert("Time (ms)".into(), toml_f(obj, "t_ms")?); t.insert("Current (uA)".into(), toml_f(obj, "i_ua")?); if let Some(p) = obj.get("phase").and_then(|v| v.as_u64()) { t.insert("Phase".into(), toml::Value::Integer(p as i64)); } } "ph" => { t.insert("OCP (mV)".into(), toml_f(obj, "v_ocp_mv")?); t.insert("pH".into(), toml_f(obj, "ph")?); t.insert("Temperature (C)".into(), toml_f(obj, "temp_c")?); } _ => return None, } Some(t) } fn toml_f(obj: &serde_json::Map, key: &str) -> Option { obj.get(key)?.as_f64().map(toml::Value::Float) } fn cl_result_to_toml(jv: &serde_json::Value) -> Table { let mut t = Table::new(); if let Some(obj) = jv.as_object() { if let Some(v) = obj.get("i_free_ua").and_then(|v| v.as_f64()) { t.insert("Free Cl (uA)".into(), toml::Value::Float(v)); } if let Some(v) = obj.get("i_total_ua").and_then(|v| v.as_f64()) { t.insert("Total Cl (uA)".into(), toml::Value::Float(v)); } } t } fn toml_table_to_json(t: &Table) -> serde_json::Value { let mut obj = serde_json::Map::new(); for (k, v) in t { obj.insert(k.clone(), toml_val_to_json(v)); } serde_json::Value::Object(obj) } fn toml_val_to_json(v: &toml::Value) -> serde_json::Value { match v { toml::Value::String(s) => serde_json::Value::String(s.clone()), toml::Value::Integer(i) => serde_json::json!(*i), toml::Value::Float(f) => serde_json::json!(*f), toml::Value::Boolean(b) => serde_json::Value::Bool(*b), toml::Value::Table(t) => toml_table_to_json(t), _ => serde_json::Value::Null, } } fn toml_data_row_to_json(mtype: &str, row: &Table) -> serde_json::Value { let mut obj = serde_json::Map::new(); match mtype { "eis" => { set_f(&mut obj, "freq_hz", row, "Frequency (Hz)"); set_f(&mut obj, "mag_ohms", row, "|Z| (Ohm)"); set_f(&mut obj, "phase_deg", row, "Phase (deg)"); set_f(&mut obj, "z_real", row, "Re (Ohm)"); set_f(&mut obj, "z_imag", row, "Im (Ohm)"); } "lsv" => { set_f(&mut obj, "v_mv", row, "Voltage (mV)"); set_f(&mut obj, "i_ua", row, "Current (uA)"); } "amp" => { set_f(&mut obj, "t_ms", row, "Time (ms)"); set_f(&mut obj, "i_ua", row, "Current (uA)"); } "chlorine" => { set_f(&mut obj, "t_ms", row, "Time (ms)"); set_f(&mut obj, "i_ua", row, "Current (uA)"); if let Some(v) = row.get("Phase").and_then(|v| v.as_integer()) { obj.insert("phase".into(), serde_json::json!(v)); } } "ph" => { set_f(&mut obj, "v_ocp_mv", row, "OCP (mV)"); set_f(&mut obj, "ph", row, "pH"); set_f(&mut obj, "temp_c", row, "Temperature (C)"); } _ => {} } serde_json::Value::Object(obj) } fn set_f( obj: &mut serde_json::Map, json_key: &str, row: &Table, toml_key: &str, ) { if let Some(v) = row.get(toml_key).and_then(|v| v.as_float()) { obj.insert(json_key.into(), serde_json::json!(v)); } } fn toml_cl_result_to_json(t: &Table) -> serde_json::Value { let mut obj = serde_json::Map::new(); if let Some(v) = t.get("Free Cl (uA)").and_then(|v| v.as_float()) { obj.insert("i_free_ua".into(), serde_json::json!(v)); } if let Some(v) = t.get("Total Cl (uA)").and_then(|v| v.as_float()) { obj.insert("i_total_ua".into(), serde_json::json!(v)); } serde_json::Value::Object(obj) } fn dirs() -> std::path::PathBuf { dirs_home().join(".eis4") } fn dirs_home() -> std::path::PathBuf { std::env::var("HOME") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::path::PathBuf::from(".")) } const SCHEMA: &str = " CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS measurements ( id INTEGER PRIMARY KEY, session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, type TEXT NOT NULL, params_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS data_points ( id INTEGER PRIMARY KEY, measurement_id INTEGER NOT NULL REFERENCES measurements(id) ON DELETE CASCADE, idx INTEGER NOT NULL, data_json TEXT NOT NULL ); ";