import Foundation
import PhysarumEngine
/// Persists custom presets as JSON files in Application Support, with an order
/// manifest (`_order.json`). Web-schema-compatible; later CloudKit-migratable.
final class PresetStore {
private let dir: URL
private let orderURL: URL
private let fm = FileManager.default
init(baseDir: URL? = nil) {
if let base = baseDir {
dir = base
} else if let appSupport = try? FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) {
dir = appSupport.appendingPathComponent("Physarum/CustomPresets", isDirectory: true)
} else {
dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("PhysarumCustomPresets", isDirectory: true)
}
orderURL = dir.appendingPathComponent("_order.json")
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
/// Create or overwrite a custom preset; append to the order if new.
func save(_ preset: Preset) throws {
var p = preset
p.builtIn = false
let data = try JSONEncoder().encode(p)
try data.write(to: fileURL(p.id))
var order = loadOrder()
if !order.contains(p.id) {
order.append(p.id)
writeOrder(order)
}
}
/// All custom presets, in saved order (unknown-order files appended).
func loadCustom() -> [Preset] {
let order = loadOrder()
let decoder = JSONDecoder()
let files = ((try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? [])
.filter { $0.pathExtension == "json" && $0.lastPathComponent != "_order.json" }
let byId = Dictionary(
files.compactMap { try? decoder.decode(Preset.self, from: Data(contentsOf: $0)) }.map { ($0.id, $0) },
uniquingKeysWith: { a, _ in a }
)
var result = order.compactMap { byId[$0] }
for (id, p) in byId where !order.contains(id) { result.append(p) }
return result
}
func delete(_ id: String) {
try? fm.removeItem(at: fileURL(id))
writeOrder(loadOrder().filter { $0 != id })
deleteThumbnail(for: id)
}
// MARK: - thumbnail storage
private var thumbnailsDir: URL { dir.appendingPathComponent("Thumbnails", isDirectory: true) }
/// Write a thumbnail PNG for a custom preset id (creates the Thumbnails dir on first use).
func writeThumbnail(_ png: Data, for id: String) throws {
try fm.createDirectory(at: thumbnailsDir, withIntermediateDirectories: true)
try png.write(to: thumbnailsDir.appendingPathComponent("\(id).png"))
}
/// URL of a custom preset's thumbnail PNG, if present.
func thumbnailURL(for id: String) -> URL? {
let u = thumbnailsDir.appendingPathComponent("\(id).png")
return fm.fileExists(atPath: u.path) ? u : nil
}
/// Of the given ids, those without a thumbnail PNG (backfill candidates).
func presetsMissingThumbnail(ids: [String]) -> [String] {
ids.filter { thumbnailURL(for: $0) == nil }
}
/// Delete a custom preset's thumbnail.
func deleteThumbnail(for id: String) {
try? fm.removeItem(at: thumbnailsDir.appendingPathComponent("\(id).png"))
}
/// Delete thumbnail PNGs whose key isn't in `keys` — orphans left behind when a slot's content
/// changed (content-addressed thumbnails) or a slot was cleared. Keeps the dir bounded.
func pruneThumbnails(keeping keys: Set<String>) {
guard let files = try? fm.contentsOfDirectory(at: thumbnailsDir, includingPropertiesForKeys: nil) else { return }
for f in files where f.pathExtension == "png" {
if !keys.contains(f.deletingPathExtension().lastPathComponent) { try? fm.removeItem(at: f) }
}
}
func reorder(_ ids: [String]) { writeOrder(ids) }
/// One-off export of legacy custom presets that didn't fit into the slot grid during the
/// slot-model migration — kept here rather than dropped silently.
var legacyExportURL: URL { dir.appendingPathComponent("LegacyPresets-export.json") }
// MARK: - global audio config (preset-independent)
private var audioURL: URL { dir.appendingPathComponent("_audio.json") }
func saveAudio(_ a: AudioReactive) { try? JSONEncoder().encode(a).write(to: audioURL) }
func loadAudio() -> AudioReactive? {
guard let d = try? Data(contentsOf: audioURL) else { return nil }
return try? JSONDecoder().decode(AudioReactive.self, from: d)
}
// Loudness-response tuning constants (device-calibration), persisted independently of
// the audio routes so a device-tuning session survives app restarts.
private var tuningURL: URL { dir.appendingPathComponent("_tuning.json") }
func saveTuning(_ t: AudioTuning) { try? JSONEncoder().encode(t).write(to: tuningURL) }
func loadTuning() -> AudioTuning? {
guard let d = try? Data(contentsOf: tuningURL) else { return nil }
return try? JSONDecoder().decode(AudioTuning.self, from: d)
}
private func fileURL(_ id: String) -> URL { dir.appendingPathComponent("\(id).json") }
private func loadOrder() -> [String] {
guard let d = try? Data(contentsOf: orderURL),
let a = try? JSONDecoder().decode([String].self, from: d) else { return [] }
return a
}
private func writeOrder(_ ids: [String]) {
try? JSONEncoder().encode(ids).write(to: orderURL)
}
}