export/import: TOML format with human-readable keys, cross-platform compatible
This commit is contained in:
parent
34b298dfe2
commit
06f4fa8e71
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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(¶ms)));
|
||||||
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
let export = ExportSession {
|
|
||||||
name: sess.name,
|
|
||||||
notes: sess.notes,
|
|
||||||
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>> {
|
if !data_array.is_empty() {
|
||||||
let export: ExportSession = serde_json::from_str(json)?;
|
mt.insert("data".into(), toml::Value::Array(data_array));
|
||||||
let session_id = self.create_session(&export.name, &export.notes)?;
|
}
|
||||||
for m in &export.measurements {
|
|
||||||
let params_json = serde_json::to_string(&m.params)?;
|
if let Some(r) = cl_result {
|
||||||
let mid = self.create_measurement(session_id, &m.mtype, ¶ms_json)?;
|
mt.insert("result".into(), toml::Value::Table(cl_result_to_toml(&r)));
|
||||||
let points: Vec<(i32, String)> = m.data.iter().enumerate()
|
}
|
||||||
.map(|(i, v)| (i as i32, serde_json::to_string(v).unwrap_or_default()))
|
|
||||||
|
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<i64, Box<dyn std::error::Error>> {
|
||||||
|
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();
|
.collect();
|
||||||
self.add_data_points_batch(mid, &points)?;
|
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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue