diff --git a/cue-ios/CueIOS/Models/Storage.swift b/cue-ios/CueIOS/Models/Storage.swift index 9474086..4ee146f 100644 --- a/cue-ios/CueIOS/Models/Storage.swift +++ b/cue-ios/CueIOS/Models/Storage.swift @@ -250,4 +250,419 @@ final class Storage: @unchecked Sendable { } return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange) } + + // MARK: - TOML export/import + + private static let tomlDateFmt: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone.current + return f + }() + + func exportSession(_ id: Int64) throws -> String { + let session = try dbQueue.read { db in + try Session.fetchOne(db, key: id) + } + guard let session else { throw StorageError.notFound } + + var out = "[session]\n" + out += "name = \(tomlQuote(session.label ?? ""))\n" + out += "notes = \(tomlQuote(session.notes ?? ""))\n" + out += "created_at = \(tomlQuote(Self.tomlDateFmt.string(from: session.startedAt)))\n" + + let measurements = try fetchMeasurements(sessionId: id) + for m in measurements { + out += "\n[[measurement]]\n" + out += "type = \(tomlQuote(m.type))\n" + out += "created_at = \(tomlQuote(Self.tomlDateFmt.string(from: m.startedAt)))\n" + + if let configData = m.config, + let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] { + out += "\n[measurement.params]\n" + for (k, v) in config.sorted(by: { $0.key < $1.key }) { + out += "\(k) = \(tomlValue(v))\n" + } + } + + guard let mid = m.id else { continue } + let mtype = MeasurementType(rawValue: m.type) + let points = try fetchDataPoints(measurementId: mid) + let decoder = JSONDecoder() + + switch mtype { + case .eis: + for dp in points { + if let p = try? decoder.decode(EisPoint.self, from: dp.payload) { + out += "\n[[measurement.data]]\n" + out += "\"Frequency (Hz)\" = \(tomlFloat(p.freqHz))\n" + out += "\"|Z| (Ohm)\" = \(tomlFloat(p.magOhms))\n" + out += "\"Phase (deg)\" = \(tomlFloat(p.phaseDeg))\n" + out += "\"Re (Ohm)\" = \(tomlFloat(p.zReal))\n" + out += "\"Im (Ohm)\" = \(tomlFloat(p.zImag))\n" + } + } + case .lsv: + for dp in points { + if let p = try? decoder.decode(LsvPoint.self, from: dp.payload) { + out += "\n[[measurement.data]]\n" + out += "\"Voltage (mV)\" = \(tomlFloat(p.vMv))\n" + out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n" + } + } + case .amp: + for dp in points { + if let p = try? decoder.decode(AmpPoint.self, from: dp.payload) { + out += "\n[[measurement.data]]\n" + out += "\"Time (ms)\" = \(tomlFloat(p.tMs))\n" + out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n" + } + } + case .chlorine: + for dp in points { + if let p = try? decoder.decode(ClPoint.self, from: dp.payload) { + out += "\n[[measurement.data]]\n" + out += "\"Time (ms)\" = \(tomlFloat(p.tMs))\n" + out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n" + out += "\"Phase\" = \(p.phase)\n" + } + } + if let resultData = m.resultSummary, + let r = try? decoder.decode(ClResult.self, from: resultData) { + out += "\n[measurement.result]\n" + out += "\"Free Cl (uA)\" = \(tomlFloat(r.iFreeUa))\n" + out += "\"Total Cl (uA)\" = \(tomlFloat(r.iTotalUa))\n" + } + case .ph: + for dp in points { + if let p = try? decoder.decode(PhResult.self, from: dp.payload) { + out += "\n[[measurement.data]]\n" + out += "\"OCP (mV)\" = \(tomlFloat(p.vOcpMv))\n" + out += "\"pH\" = \(tomlFloat(p.ph))\n" + out += "\"Temperature (C)\" = \(tomlFloat(p.tempC))\n" + } + } + case nil: + break + } + } + return out + } + + func importSession(from toml: String) throws -> Int64 { + let parsed = TOMLParser.parse(toml) + + guard let sessionDict = parsed["session"] as? [String: Any] else { + throw StorageError.parseError("missing [session]") + } + let name = sessionDict["name"] as? String ?? "" + let notes = sessionDict["notes"] as? String ?? "" + + var session = Session( + startedAt: Self.tomlDateFmt.date(from: sessionDict["created_at"] as? String ?? "") ?? Date(), + label: name.isEmpty ? nil : name, + notes: notes.isEmpty ? nil : notes + ) + try dbQueue.write { db in try session.insert(db) } + guard let sid = session.id else { throw StorageError.parseError("insert failed") } + + guard let measurements = parsed["measurement"] as? [[String: Any]] else { return sid } + let encoder = JSONEncoder() + + for mDict in measurements { + let typeStr = mDict["type"] as? String ?? "eis" + let createdStr = mDict["created_at"] as? String ?? "" + let created = Self.tomlDateFmt.date(from: createdStr) ?? Date() + + var configData: Data? = nil + if let params = mDict["params"] as? [String: Any] { + configData = try? JSONSerialization.data(withJSONObject: params) + } + + var meas = Measurement( + sessionId: sid, + type: typeStr, + startedAt: created, + config: configData + ) + try dbQueue.write { db in try meas.insert(db) } + guard let mid = meas.id else { continue } + + let mtype = MeasurementType(rawValue: typeStr) + guard let dataRows = mDict["data"] as? [[String: Any]] else { continue } + + try dbQueue.write { db in + for (idx, row) in dataRows.enumerated() { + let payload: Data + switch mtype { + case .eis: + payload = try encoder.encode(EisPoint( + freqHz: floatVal(row, "Frequency (Hz)"), + magOhms: floatVal(row, "|Z| (Ohm)"), + phaseDeg: floatVal(row, "Phase (deg)"), + zReal: floatVal(row, "Re (Ohm)"), + zImag: floatVal(row, "Im (Ohm)"), + rtiaMagBefore: 0, rtiaMagAfter: 0, + revMag: 0, revPhase: 0, pctErr: 0 + )) + case .lsv: + payload = try encoder.encode(LsvPoint( + vMv: floatVal(row, "Voltage (mV)"), + iUa: floatVal(row, "Current (uA)") + )) + case .amp: + payload = try encoder.encode(AmpPoint( + tMs: floatVal(row, "Time (ms)"), + iUa: floatVal(row, "Current (uA)") + )) + case .chlorine: + payload = try encoder.encode(ClPoint( + tMs: floatVal(row, "Time (ms)"), + iUa: floatVal(row, "Current (uA)"), + phase: UInt8(intVal(row, "Phase")) + )) + case .ph: + payload = try encoder.encode(PhResult( + vOcpMv: floatVal(row, "OCP (mV)"), + ph: floatVal(row, "pH"), + tempC: floatVal(row, "Temperature (C)") + )) + case nil: + continue + } + var dp = DataPoint(measurementId: mid, index: idx, payload: payload) + try dp.insert(db) + } + } + + if mtype == .chlorine, let resultDict = mDict["result"] as? [String: Any] { + let r = ClResult( + iFreeUa: floatVal(resultDict, "Free Cl (uA)"), + iTotalUa: floatVal(resultDict, "Total Cl (uA)") + ) + try setMeasurementResult(mid, result: r) + } + if mtype == .ph, let dataRows = mDict["data"] as? [[String: Any]], let first = dataRows.first { + let r = PhResult( + vOcpMv: floatVal(first, "OCP (mV)"), + ph: floatVal(first, "pH"), + tempC: floatVal(first, "Temperature (C)") + ) + try setMeasurementResult(mid, result: r) + } + } + return sid + } +} + +enum StorageError: Error { + case notFound + case parseError(String) +} + +// MARK: - TOML helpers + +private func tomlQuote(_ s: String) -> String { + let escaped = s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + return "\"\(escaped)\"" +} + +private func tomlFloat(_ v: Float) -> String { + if v == v.rounded() && abs(v) < 1e15 { + return String(format: "%.1f", v) + } + return String(v) +} + +private func tomlValue(_ v: Any) -> String { + switch v { + case let s as String: return tomlQuote(s) + case let n as NSNumber: + if CFGetTypeID(n) == CFBooleanGetTypeID() { return n.boolValue ? "true" : "false" } + let d = n.doubleValue + if d == d.rounded() && abs(d) < 1e15 && !"\(n)".contains(".") { + return "\(n)" + } + return String(format: "%g", d) + default: return tomlQuote("\(v)") + } +} + +private func floatVal(_ dict: [String: Any], _ key: String) -> Float { + if let n = dict[key] as? NSNumber { return n.floatValue } + return 0 +} + +private func intVal(_ dict: [String: Any], _ key: String) -> Int { + if let n = dict[key] as? NSNumber { return n.intValue } + return 0 +} + +// MARK: - Minimal TOML parser + +enum TOMLParser { + static func parse(_ input: String) -> [String: Any] { + var root: [String: Any] = [:] + var currentSection: [String] = [] + var arrayCounters: [String: Int] = [:] + let lines = input.components(separatedBy: .newlines) + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + + if let match = parseArrayTable(trimmed) { + let parts = match.components(separatedBy: ".") + let rootKey = parts[0] + let counterKey = match + + if arrayCounters[counterKey] == nil { + setNested(&root, path: [rootKey], value: [[String: Any]]()) + arrayCounters[counterKey] = 0 + } else { + arrayCounters[counterKey]! += 1 + } + + if var arr = getNested(root, path: [rootKey]) as? [[String: Any]] { + if arr.count <= arrayCounters[counterKey]! { + arr.append([:]) + setNested(&root, path: [rootKey], value: arr) + } + } + + currentSection = parts + continue + } + + if let match = parseTable(trimmed) { + currentSection = match.components(separatedBy: ".") + continue + } + + if let (key, value) = parseKeyValue(trimmed) { + let path = resolvePath(currentSection, arrayCounters: arrayCounters) + var target = (getNested(root, path: path) as? [String: Any]) ?? [:] + target[key] = value + if path.isEmpty { + for (k, v) in target { root[k] = v } + } else { + setNested(&root, path: path, value: target) + } + } + } + return root + } + + private static func parseArrayTable(_ line: String) -> String? { + guard line.hasPrefix("[[") && line.hasSuffix("]]") else { return nil } + let inner = line.dropFirst(2).dropLast(2).trimmingCharacters(in: .whitespaces) + return inner.isEmpty ? nil : inner + } + + private static func parseTable(_ line: String) -> String? { + guard line.hasPrefix("[") && line.hasSuffix("]") && !line.hasPrefix("[[") else { return nil } + let inner = line.dropFirst(1).dropLast(1).trimmingCharacters(in: .whitespaces) + return inner.isEmpty ? nil : inner + } + + private static func parseKeyValue(_ line: String) -> (String, Any)? { + guard let eqIdx = line.firstIndex(of: "=") else { return nil } + var key = String(line[line.startIndex.. Any { + if s.hasPrefix("\"") && s.hasSuffix("\"") { + var inner = String(s.dropFirst(1).dropLast(1)) + inner = inner.replacingOccurrences(of: "\\n", with: "\n") + inner = inner.replacingOccurrences(of: "\\\"", with: "\"") + inner = inner.replacingOccurrences(of: "\\\\", with: "\\") + return inner + } + if s == "true" { return true } + if s == "false" { return false } + if s.contains(".") || s.contains("e") || s.contains("E") { + if let d = Double(s) { return NSNumber(value: d) } + } + if let i = Int(s) { return NSNumber(value: i) } + return s + } + + private static func resolvePath( + _ section: [String], arrayCounters: [String: Int] + ) -> [String] { + guard !section.isEmpty else { return [] } + + let rootKey = section[0] + let fullKey = section.joined(separator: ".") + var path: [String] = [rootKey] + + if let idx = arrayCounters[rootKey] { + path.append("\(idx)") + } + + if section.count > 1 { + let subKey = section[1...].joined(separator: ".") + if let idx = arrayCounters[fullKey] { + path.append(subKey) + path.append("\(idx)") + } else { + for part in section[1...] { + path.append(part) + } + } + } + + return path + } + + private static func getNested(_ dict: [String: Any], path: [String]) -> Any? { + var current: Any = dict + for component in path { + if let idx = Int(component), let arr = current as? [Any], idx < arr.count { + current = arr[idx] + } else if let d = current as? [String: Any], let val = d[component] { + current = val + } else { + return nil + } + } + return current + } + + private static func setNested(_ dict: inout [String: Any], path: [String], value: Any) { + guard !path.isEmpty else { return } + if path.count == 1 { + dict[path[0]] = value + return + } + + let key = path[0] + let rest = Array(path[1...]) + + if let idxStr = rest.first, let idx = Int(idxStr), var arr = dict[key] as? [Any] { + while arr.count <= idx { arr.append([String: Any]()) } + if rest.count == 1 { + arr[idx] = value + } else { + var sub = (arr[idx] as? [String: Any]) ?? [:] + setNested(&sub, path: Array(rest[1...]), value: value) + arr[idx] = sub + } + dict[key] = arr + } else { + var sub = (dict[key] as? [String: Any]) ?? [:] + setNested(&sub, path: rest, value: value) + dict[key] = sub + } + } } diff --git a/cue/Cargo.lock b/cue/Cargo.lock index d04ac29..f6eed4f 100644 --- a/cue/Cargo.lock +++ b/cue/Cargo.lock @@ -770,6 +770,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "toml 0.8.23", "winres", ] @@ -2764,7 +2765,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.4+spec-1.1.0", ] [[package]] @@ -3134,6 +3135,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3540,6 +3550,28 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -3549,6 +3581,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_edit" version = "0.25.4+spec-1.1.0" @@ -3556,7 +3602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "winnow", ] @@ -3570,6 +3616,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tracing" version = "0.1.44" @@ -4549,7 +4601,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" dependencies = [ - "toml", + "toml 0.5.11", ] [[package]] diff --git a/cue/Cargo.toml b/cue/Cargo.toml index 97c0faa..bb7185f 100644 --- a/cue/Cargo.toml +++ b/cue/Cargo.toml @@ -12,6 +12,7 @@ muda = { version = "0.17", default-features = false } rusqlite = { version = "0.31", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = { version = "0.8", features = ["preserve_order"] } dirs-next = "2" [target.'cfg(windows)'.build-dependencies] diff --git a/cue/src/storage.rs b/cue/src/storage.rs index dc33e37..4ae206e 100644 --- a/cue/src/storage.rs +++ b/cue/src/storage.rs @@ -1,5 +1,5 @@ use rusqlite::{Connection, params}; -use serde::{Serialize, Deserialize}; +use toml::value::Table; #[derive(Debug, Clone)] #[allow(dead_code)] @@ -170,58 +170,261 @@ impl Storage { 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 export_measurements = Vec::new(); + 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 data: Vec = points.iter() - .map(|p| serde_json::from_str(&p.data_json).unwrap_or(serde_json::Value::Null)) - .collect(); - export_measurements.push(ExportMeasurement { - mtype: m.mtype.clone(), - params: serde_json::from_str(&m.params_json).unwrap_or_default(), - created_at: m.created_at.clone(), - data, - }); + 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)); } - let export = ExportSession { - name: sess.name, - notes: sess.notes, - created_at: sess.created_at, - measurements: export_measurements, - }; - Ok(serde_json::to_string_pretty(&export)?) + + root.insert("measurement".into(), toml::Value::Array(meas_array)); + Ok(toml::to_string_pretty(&toml::Value::Table(root))?) } - pub fn import_session(&self, json: &str) -> Result> { - let export: ExportSession = serde_json::from_str(json)?; - let session_id = self.create_session(&export.name, &export.notes)?; - for m in &export.measurements { - let params_json = serde_json::to_string(&m.params)?; - let mid = self.create_measurement(session_id, &m.mtype, ¶ms_json)?; - let points: Vec<(i32, String)> = m.data.iter().enumerate() - .map(|(i, v)| (i as i32, serde_json::to_string(v).unwrap_or_default())) - .collect(); - self.add_data_points_batch(mid, &points)?; + 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)?; + + 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) } } -#[derive(Serialize, Deserialize)] -struct ExportSession { - name: String, - notes: String, - created_at: String, - measurements: Vec, +// 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 } -#[derive(Serialize, Deserialize)] -struct ExportMeasurement { - mtype: String, - params: serde_json::Value, - created_at: String, - data: Vec, +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 {