Zum Inhalt

apple/PhysarumApp/Sources/Model/PresetStore.swift

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)
    }
}