You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
140 lines
5.9 KiB
140 lines
5.9 KiB
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<Void, Never>?
|
|
|
|
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<Person>? {
|
|
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 } })
|
|
}
|
|
}
|