export/import: TOML format with human-readable keys, cross-platform compatible

This commit is contained in:
jess 2026-03-31 21:45:15 -07:00
parent 34b298dfe2
commit 06f4fa8e71
4 changed files with 714 additions and 43 deletions

View File

@ -250,4 +250,419 @@ final class Storage: @unchecked Sendable {
} }
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange) 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..<eqIdx]).trimmingCharacters(in: .whitespaces)
let valStr = String(line[line.index(after: eqIdx)...]).trimmingCharacters(in: .whitespaces)
if key.hasPrefix("\"") && key.hasSuffix("\"") {
key = String(key.dropFirst(1).dropLast(1))
}
return (key, parseValue(valStr))
}
private static func parseValue(_ s: String) -> 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
}
}
} }

58
cue/Cargo.lock generated
View File

@ -770,6 +770,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"toml 0.8.23",
"winres", "winres",
] ]
@ -2764,7 +2765,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"toml_edit", "toml_edit 0.25.4+spec-1.1.0",
] ]
[[package]] [[package]]
@ -3134,6 +3135,15 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@ -3540,6 +3550,28 @@ dependencies = [
"serde", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.0.0+spec-1.1.0" version = "1.0.0+spec-1.1.0"
@ -3549,6 +3581,20 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.25.4+spec-1.1.0" version = "0.25.4+spec-1.1.0"
@ -3556,7 +3602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime", "toml_datetime 1.0.0+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow", "winnow",
] ]
@ -3570,6 +3616,12 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@ -4549,7 +4601,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [ dependencies = [
"toml", "toml 0.5.11",
] ]
[[package]] [[package]]

View File

@ -12,6 +12,7 @@ muda = { version = "0.17", default-features = false }
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = { version = "0.8", features = ["preserve_order"] }
dirs-next = "2" dirs-next = "2"
[target.'cfg(windows)'.build-dependencies] [target.'cfg(windows)'.build-dependencies]

View File

@ -1,5 +1,5 @@
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use serde::{Serialize, Deserialize}; use toml::value::Table;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
@ -170,58 +170,261 @@ impl Storage {
notes: row.get(2)?, created_at: row.get(3)?, 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 measurements = self.get_measurements(session_id)?;
let mut export_measurements = Vec::new(); let mut meas_array = Vec::new();
for m in &measurements { 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(&params)));
let points = self.get_data_points(m.id)?; let points = self.get_data_points(m.id)?;
let data: Vec<serde_json::Value> = points.iter() let mut data_array = Vec::new();
.map(|p| serde_json::from_str(&p.data_json).unwrap_or(serde_json::Value::Null)) let mut cl_result: Option<serde_json::Value> = None;
.collect();
export_measurements.push(ExportMeasurement { for p in &points {
mtype: m.mtype.clone(), let jv: serde_json::Value = serde_json::from_str(&p.data_json)
params: serde_json::from_str(&m.params_json).unwrap_or_default(), .unwrap_or(serde_json::Value::Null);
created_at: m.created_at.clone(), if let Some(obj) = jv.as_object() {
data, 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, root.insert("measurement".into(), toml::Value::Array(meas_array));
notes: sess.notes, Ok(toml::to_string_pretty(&toml::Value::Table(root))?)
created_at: sess.created_at,
measurements: export_measurements,
};
Ok(serde_json::to_string_pretty(&export)?)
} }
pub fn import_session(&self, json: &str) -> Result<i64, Box<dyn std::error::Error>> { pub fn import_session(&self, toml_str: &str) -> Result<i64, Box<dyn std::error::Error>> {
let export: ExportSession = serde_json::from_str(json)?; let doc: toml::Value = toml::from_str(toml_str)?;
let session_id = self.create_session(&export.name, &export.notes)?; let root = doc.as_table().ok_or("invalid TOML root")?;
for m in &export.measurements {
let params_json = serde_json::to_string(&m.params)?; let sess = root.get("session")
let mid = self.create_measurement(session_id, &m.mtype, &params_json)?; .and_then(|v| v.as_table())
let points: Vec<(i32, String)> = m.data.iter().enumerate() .ok_or("missing [session]")?;
.map(|(i, v)| (i as i32, serde_json::to_string(v).unwrap_or_default()))
.collect(); let name = sess.get("name").and_then(|v| v.as_str()).unwrap_or("");
self.add_data_points_batch(mid, &points)?; 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, &params_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) Ok(session_id)
} }
} }
#[derive(Serialize, Deserialize)] // MARK: - TOML ↔ JSON conversion helpers
struct ExportSession {
name: String, fn json_to_toml_table(jv: &serde_json::Value) -> Table {
notes: String, let mut t = Table::new();
created_at: String, if let Some(obj) = jv.as_object() {
measurements: Vec<ExportMeasurement>, for (k, v) in obj {
if let Some(tv) = json_val_to_toml(v) {
t.insert(k.clone(), tv);
}
}
}
t
} }
#[derive(Serialize, Deserialize)] fn json_val_to_toml(jv: &serde_json::Value) -> Option<toml::Value> {
struct ExportMeasurement { match jv {
mtype: String, serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
params: serde_json::Value, serde_json::Value::Number(n) => {
created_at: String, if let Some(i) = n.as_i64() { Some(toml::Value::Integer(i)) }
data: Vec<serde_json::Value>, 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<Table> {
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<String, serde_json::Value>, key: &str) -> Option<toml::Value> {
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<String, serde_json::Value>,
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 { fn dirs() -> std::path::PathBuf {