669 lines
24 KiB
Swift
669 lines
24 KiB
Swift
/// SQLite persistence via GRDB.
|
|
/// Schema: Session -> Measurement -> DataPoint
|
|
|
|
import Foundation
|
|
import GRDB
|
|
|
|
// MARK: - Records
|
|
|
|
struct Session: Codable, FetchableRecord, MutablePersistableRecord {
|
|
var id: Int64?
|
|
var startedAt: Date
|
|
var label: String?
|
|
var notes: String?
|
|
|
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
|
id = inserted.rowID
|
|
}
|
|
}
|
|
|
|
struct Measurement: Codable, FetchableRecord, MutablePersistableRecord {
|
|
var id: Int64?
|
|
var sessionId: Int64
|
|
var type: String
|
|
var startedAt: Date
|
|
var config: Data?
|
|
var resultSummary: Data?
|
|
|
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
|
id = inserted.rowID
|
|
}
|
|
}
|
|
|
|
struct DataPoint: Codable, FetchableRecord, MutablePersistableRecord {
|
|
var id: Int64?
|
|
var measurementId: Int64
|
|
var index: Int
|
|
var payload: Data
|
|
|
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
|
id = inserted.rowID
|
|
}
|
|
}
|
|
|
|
// MARK: - Database manager
|
|
|
|
final class Storage: @unchecked Sendable {
|
|
static let shared = Storage()
|
|
|
|
private let dbQueue: DatabaseQueue
|
|
|
|
private init() {
|
|
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
let path = dir.appendingPathComponent("eis4.sqlite").path
|
|
do {
|
|
dbQueue = try DatabaseQueue(path: path)
|
|
try migrate()
|
|
} catch {
|
|
fatalError("Database init failed: \(error)")
|
|
}
|
|
}
|
|
|
|
private func migrate() throws {
|
|
var migrator = DatabaseMigrator()
|
|
|
|
migrator.registerMigration("v1") { db in
|
|
try db.create(table: "session") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("startedAt", .datetime).notNull()
|
|
t.column("label", .text)
|
|
t.column("notes", .text)
|
|
}
|
|
|
|
try db.create(table: "measurement") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("sessionId", .integer).notNull()
|
|
.references("session", onDelete: .cascade)
|
|
t.column("type", .text).notNull()
|
|
t.column("startedAt", .datetime).notNull()
|
|
t.column("config", .blob)
|
|
t.column("resultSummary", .blob)
|
|
}
|
|
|
|
try db.create(table: "dataPoint") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("measurementId", .integer).notNull()
|
|
.references("measurement", onDelete: .cascade)
|
|
t.column("index", .integer).notNull()
|
|
t.column("payload", .blob).notNull()
|
|
}
|
|
}
|
|
|
|
try migrator.migrate(dbQueue)
|
|
}
|
|
|
|
// MARK: - Sessions
|
|
|
|
func createSession(label: String? = nil) throws -> Session {
|
|
try dbQueue.write { db in
|
|
var s = Session(startedAt: Date(), label: label)
|
|
try s.insert(db)
|
|
return s
|
|
}
|
|
}
|
|
|
|
func fetchSessions() throws -> [Session] {
|
|
try dbQueue.read { db in
|
|
try Session.order(Column("startedAt").desc).fetchAll(db)
|
|
}
|
|
}
|
|
|
|
func deleteSession(_ id: Int64) throws {
|
|
try dbQueue.write { db in
|
|
try db.execute(sql: "DELETE FROM session WHERE id = ?", arguments: [id])
|
|
}
|
|
}
|
|
|
|
func updateSession(_ id: Int64, label: String?, notes: String?) throws {
|
|
try dbQueue.write { db in
|
|
try db.execute(
|
|
sql: "UPDATE session SET label = ?, notes = ? WHERE id = ?",
|
|
arguments: [label, notes, id]
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Measurements
|
|
|
|
func addMeasurement(
|
|
sessionId: Int64,
|
|
type: MeasurementType,
|
|
config: (any Encodable)? = nil
|
|
) throws -> Measurement {
|
|
let configData: Data? = if let config {
|
|
try JSONEncoder().encode(config)
|
|
} else {
|
|
nil
|
|
}
|
|
return try dbQueue.write { db in
|
|
var m = Measurement(
|
|
sessionId: sessionId,
|
|
type: type.rawValue,
|
|
startedAt: Date(),
|
|
config: configData
|
|
)
|
|
try m.insert(db)
|
|
return m
|
|
}
|
|
}
|
|
|
|
func setMeasurementResult(_ measurementId: Int64, result: any Encodable) throws {
|
|
try dbQueue.write { db in
|
|
let data = try JSONEncoder().encode(result)
|
|
try db.execute(
|
|
sql: "UPDATE measurement SET resultSummary = ? WHERE id = ?",
|
|
arguments: [data, measurementId]
|
|
)
|
|
}
|
|
}
|
|
|
|
func fetchMeasurements(sessionId: Int64) throws -> [Measurement] {
|
|
try dbQueue.read { db in
|
|
try Measurement
|
|
.filter(Column("sessionId") == sessionId)
|
|
.order(Column("startedAt").asc)
|
|
.fetchAll(db)
|
|
}
|
|
}
|
|
|
|
func deleteMeasurement(_ id: Int64) throws {
|
|
try dbQueue.write { db in
|
|
try db.execute(sql: "DELETE FROM measurement WHERE id = ?", arguments: [id])
|
|
}
|
|
}
|
|
|
|
func dataPointCount(measurementId: Int64) throws -> Int {
|
|
try dbQueue.read { db in
|
|
try DataPoint
|
|
.filter(Column("measurementId") == measurementId)
|
|
.fetchCount(db)
|
|
}
|
|
}
|
|
|
|
func measurementCount(sessionId: Int64) throws -> Int {
|
|
try dbQueue.read { db in
|
|
try Measurement
|
|
.filter(Column("sessionId") == sessionId)
|
|
.fetchCount(db)
|
|
}
|
|
}
|
|
|
|
// MARK: - Data points
|
|
|
|
func addDataPoint<T: Encodable>(measurementId: Int64, index: Int, point: T) throws {
|
|
let payload = try JSONEncoder().encode(point)
|
|
try dbQueue.write { db in
|
|
var dp = DataPoint(
|
|
measurementId: measurementId,
|
|
index: index,
|
|
payload: payload
|
|
)
|
|
try dp.insert(db)
|
|
}
|
|
}
|
|
|
|
func addDataPoints<T: Encodable>(measurementId: Int64, points: [(index: Int, value: T)]) throws {
|
|
let encoder = JSONEncoder()
|
|
try dbQueue.write { db in
|
|
for (idx, val) in points {
|
|
let payload = try encoder.encode(val)
|
|
var dp = DataPoint(measurementId: measurementId, index: idx, payload: payload)
|
|
try dp.insert(db)
|
|
}
|
|
}
|
|
}
|
|
|
|
func fetchDataPoints(measurementId: Int64) throws -> [DataPoint] {
|
|
try dbQueue.read { db in
|
|
try DataPoint
|
|
.filter(Column("measurementId") == measurementId)
|
|
.order(Column("index").asc)
|
|
.fetchAll(db)
|
|
}
|
|
}
|
|
|
|
/// Decode stored data points into typed values.
|
|
func fetchTypedPoints<T: Decodable>(measurementId: Int64, as type: T.Type) throws -> [T] {
|
|
let rows = try fetchDataPoints(measurementId: measurementId)
|
|
let decoder = JSONDecoder()
|
|
return try rows.map { try decoder.decode(T.self, from: $0.payload) }
|
|
}
|
|
|
|
// MARK: - Observation (for SwiftUI live updates)
|
|
|
|
func observeDataPoints(
|
|
measurementId: Int64,
|
|
onChange: @escaping ([DataPoint]) -> Void
|
|
) -> DatabaseCancellable {
|
|
let observation = ValueObservation.tracking { db in
|
|
try DataPoint
|
|
.filter(Column("measurementId") == measurementId)
|
|
.order(Column("index").asc)
|
|
.fetchAll(db)
|
|
}
|
|
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
|
|
}
|
|
|
|
func observeSessions(onChange: @escaping ([Session]) -> Void) -> DatabaseCancellable {
|
|
let observation = ValueObservation.tracking { db in
|
|
try Session.order(Column("startedAt").desc).fetchAll(db)
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|