import AppKit
import UniformTypeIdentifiers
/// Export für Numbers (.xlsx), Sicherung speichern und laden.
/// Eine echte .numbers-Datei lässt sich nicht erzeugen (Apple-eigenes Format);
/// die .xlsx öffnet Numbers direkt und wird nach dem Speichern dort geöffnet.
@MainActor
enum Exporter {
static func exportXlsx(store: Store, ui: UIState) {
let panel = NSSavePanel()
panel.allowedContentTypes = [UTType(filenameExtension: "xlsx") ?? .data]
panel.nameFieldStringValue = "Leben-und-Dienst-\(Day.today).xlsx"
panel.message = "Der aktuelle Stand wird als Tabelle gespeichert und in Numbers geöffnet."
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
try XLSXWriter.workbook(sheets: sheets(store: store)).write(to: url, options: .atomic)
openInNumbers(url)
ui.show("Export gespeichert.")
} catch {
ui.show("Export fehlgeschlagen: \(error.localizedDescription)")
}
}
static func saveBackup(store: Store, ui: UIState) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "mawari-sicherung-\(Day.today).json"
guard panel.runModal() == .OK, let url = panel.url else { return }
do { try store.encoded().write(to: url, options: .atomic); ui.show("Sicherung gespeichert.") }
catch { ui.show("Sicherung fehlgeschlagen: \(error.localizedDescription)") }
}
static func importBackup(store: Store, ui: UIState) {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.message = "Sicherung oder Datei aus dem HTML-Tool (leben-und-dienst.json) wählen. Der aktuelle Stand wird dabei ersetzt (eine Kopie bleibt im Datenordner)."
guard panel.runModal() == .OK, let url = panel.url else { return }
if !store.db.persons.isEmpty || !store.db.entries.isEmpty {
let alert = NSAlert()
alert.messageText = "Aktuelle Daten ersetzen?"
alert.informativeText = "Alle Personen und Einträge werden durch die Datei ersetzt. Der bisherige Stand wird vorher im Datenordner gesichert."
alert.addButton(withTitle: "Ersetzen")
alert.addButton(withTitle: "Abbrechen")
guard alert.runModal() == .alertFirstButtonReturn else { return }
}
do {
try store.importFile(url)
ui.tab = .overview
ui.show("Geladen: \(store.db.persons.count) Personen, \(store.db.entries.count) Einträge.")
} catch {
ui.show("Datei konnte nicht gelesen werden.")
}
}
private static func openInNumbers(_ url: URL) {
if let numbers = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.iWork.Numbers") {
NSWorkspace.shared.open([url], withApplicationAt: numbers, configuration: NSWorkspace.OpenConfiguration())
} else {
NSWorkspace.shared.open(url)
}
}
// MARK: Inhalt
private static func sheets(store: Store) -> [XLSXWriter.Sheet] {
typealias C = XLSXWriter.Cell
var ov: [[C]] = [
[.text("Leben und Dienst – Übersicht", .title)],
[.text("Stand \(Day.display(Day.today)) · Datum = letzte Einteilung · nie = freigegeben, aber noch nie eingeteilt · – = nicht freigegeben")],
[],
]
var maxCols = 2
for group in TaskGroup.allCases {
let d = UserDefaults.standard
let sort = OverviewSort(column: d.string(forKey: "sort.\(group.rawValue).column") ?? "name",
ascending: d.object(forKey: "sort.\(group.rawValue).ascending") as? Bool ?? true)
let (tasks, rows) = overviewRows(store: store, group: group, sort: sort)
maxCols = max(maxCols, tasks.count + 2)
let band: XLSXWriter.Style = group == .diener ? .bandDiener : .bandSchulung
ov.append([.text(group.title, band)] + Array(repeating: .text(" ", band), count: tasks.count + 1))
ov.append([.text("Name", .header), .text("Zuletzt (irgendeine)", .header)] + tasks.map { .text($0.name, .header) })
for r in rows {
ov.append([.text(r.person.name), r.any.map { .date($0) } ?? .text("nie", .center)] + r.cells.map {
switch $0 {
case .notAllowed: return .text("–", .center)
case .never: return .text("nie", .center)
case .date(let s): return .date(s)
}
})
}
ov.append([])
}
let hist: [[C]] = [["Datum", "Aufgabe", "Person", "Partner", "Thema / Notiz"].map { .text($0, .header) }]
+ store.db.entries.sorted { $0.date > $1.date }.map { e in
[.date(e.date), .text(store.task(e.task)?.name ?? e.task), .text(store.name(e.person)),
.text(e.partner.isEmpty ? "" : store.name(e.partner)), .text(e.note)]
}
let pers: [[C]] = [(["Name", "Bruder/Schwester", "Notiz"] + store.db.tasks.map(\.name)).map { .text($0, .header) }]
+ store.personsSorted.map { p in
[.text(p.name), .text(p.gender), .text(p.note)] + store.db.tasks.map { p.may($0.id) ? .text("x", .center) : .text("") }
}
return [
.init(name: "Übersicht", rows: ov, widths: [26, 13] + Array(repeating: 14, count: maxCols - 2)),
.init(name: "Verlauf", rows: hist, widths: [11, 26, 24, 24, 40]),
.init(name: "Personen", rows: pers, widths: [24, 14, 24] + store.db.tasks.map { _ in 9 }),
]
}
}
/// Minimaler .xlsx-Schreiber (ZIP ohne Kompression), ohne Fremdbibliothek.
enum XLSXWriter {
enum Style: Int { case normal = 0, date = 1, header = 2, title = 3, bandDiener = 4, bandSchulung = 5, center = 6 }
enum Cell { case text(String, Style = .normal), date(String) }
struct Sheet { let name: String; let rows: [[Cell]]; let widths: [Double] }
static func workbook(sheets: [Sheet]) -> Data {
var files: [(String, String)] = [
("[Content_Types].xml", #"