import Foundation import SwiftUI /// Hält alle Daten und speichert jede Änderung sofort in /// ~/Library/Application Support/Mawari/daten.json. Einmal am Tag wird eine /// Sicherungskopie in "Sicherungen" abgelegt (die letzten 60 bleiben). @MainActor final class Store: ObservableObject { @Published var db: Database { didSet { scheduleSave() } } @Published var lastError: String? let folder: URL private var file: URL { folder.appendingPathComponent("daten.json") } private var backups: URL { folder.appendingPathComponent("Sicherungen") } private var saveTask: Task? init() { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] // MAWARI_DATA_DIR: nur zum Testen, damit echte Daten unberührt bleiben. if let dir = ProcessInfo.processInfo.environment["MAWARI_DATA_DIR"] { folder = URL(fileURLWithPath: dir, isDirectory: true) } else { folder = base.appendingPathComponent("Mawari", isDirectory: true) } try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) if let data = try? Data(contentsOf: folder.appendingPathComponent("daten.json")), let loaded = try? JSONDecoder().decode(Database.self, from: data) { db = loaded } else { db = .empty } dailyBackup() } // MARK: Speichern private func scheduleSave() { saveTask?.cancel() saveTask = Task { [weak self] in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } self?.saveNow() } } func saveNow() { do { try encoded().write(to: file, options: .atomic) lastError = nil } catch { lastError = "Speichern fehlgeschlagen: \(error.localizedDescription)" } } func encoded() throws -> Data { var copy = db copy.updated = ISO8601DateFormatter().string(from: Date()) let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] return try enc.encode(copy) } private func dailyBackup() { guard FileManager.default.fileExists(atPath: file.path) else { return } try? FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true) let target = backups.appendingPathComponent("daten-\(Day.today).json") if !FileManager.default.fileExists(atPath: target.path) { try? FileManager.default.copyItem(at: file, to: target) } let all = (try? FileManager.default.contentsOfDirectory(at: backups, includingPropertiesForKeys: nil)) ?? [] for old in all.filter({ $0.pathExtension == "json" }).sorted(by: { $0.lastPathComponent > $1.lastPathComponent }).dropFirst(60) { try? FileManager.default.removeItem(at: old) } } // MARK: Import / Sicherung func importFile(_ url: URL) throws { let access = url.startAccessingSecurityScopedResource() defer { if access { url.stopAccessingSecurityScopedResource() } } let data = try Data(contentsOf: url) let loaded = try JSONDecoder().decode(Database.self, from: data) saveNow() // aktuellen Stand vorher sichern let safety = backups.appendingPathComponent("vor-import-\(Int(Date().timeIntervalSince1970)).json") try? FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true) try? FileManager.default.copyItem(at: file, to: safety) db = loaded } // MARK: Abfragen func task(_ id: String) -> DutyTask? { db.tasks.first { $0.id == id } } func person(_ id: String) -> Person? { db.persons.first { $0.id == id } } func name(_ id: String) -> String { person(id)?.name ?? "(gelöscht)" } var personsSorted: [Person] { db.persons.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } } func lastDate(person: String, task: DutyTask) -> String? { var best: String? for e in db.entries { let hit = task.partnerColumn ? e.partner == person : (e.task == task.id && e.person == person) if hit, e.date > (best ?? "") { best = e.date } } return best } func lastAny(person: String) -> String? { var best: String? for e in db.entries where (e.person == person || e.partner == person) && e.date > (best ?? "") { best = e.date } return best } // MARK: Ändern func save(entry: Entry) { if let i = db.entries.firstIndex(where: { $0.id == entry.id }) { db.entries[i] = entry } else { db.entries.append(entry) } } func delete(entry id: String) { db.entries.removeAll { $0.id == id } } func addPerson(named name: String) -> Bool { let n = name.trimmingCharacters(in: .whitespaces) guard !n.isEmpty, !db.persons.contains(where: { $0.name.lowercased() == n.lowercased() }) else { return false } db.persons.append(Person(id: newID("p"), name: n)) return true } func deletePerson(_ id: String) { db.entries.removeAll { $0.person == id } for i in db.entries.indices where db.entries[i].partner == id { db.entries[i].partner = "" } db.persons.removeAll { $0.id == id } } func entryCount(person id: String) -> Int { db.entries.filter { $0.person == id || $0.partner == id }.count } func entryCount(task: DutyTask) -> Int { task.partnerColumn ? db.entries.filter { !$0.partner.isEmpty }.count : db.entries.filter { $0.task == task.id }.count } func binding(person id: String) -> Binding? { guard db.persons.contains(where: { $0.id == id }) else { return nil } return Binding( get: { self.db.persons.first { $0.id == id } ?? Person(id: id, name: "") }, set: { new in if let i = self.db.persons.firstIndex(where: { $0.id == id }) { self.db.persons[i] = new } }) } }