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", #""# + sheets.indices.map { #""# }.joined() + ""), ("_rels/.rels", #""#), ("xl/workbook.xml", #""# + sheets.enumerated().map { #""# }.joined() + ""), ("xl/_rels/workbook.xml.rels", #""# + sheets.indices.map { #""# }.joined() + #""#), ("xl/styles.xml", #""#), ] for (i, s) in sheets.enumerated() { files.append(("xl/worksheets/sheet\(i + 1).xml", sheetXML(s))) } return zip(files.map { ($0.0, Data($0.1.utf8)) }) } private static func sheetXML(_ s: Sheet) -> String { let cols = s.widths.enumerated().map { #""# }.joined() var out = #"\#(cols)"# for (r, row) in s.rows.enumerated() { out += #""# for (c, cell) in row.enumerated() { let ref = column(c) + "\(r + 1)" switch cell { case .text(let t, let st): guard !t.isEmpty else { continue } out += #"\#(esc(t))"# case .date(let d): out += #"\#(serial(d))"# } } out += "" } return out + "" } private static func serial(_ iso: String) -> Int { let p = iso.split(separator: "-").compactMap { Int($0) } guard p.count == 3 else { return 0 } var cal = Calendar(identifier: .gregorian); cal.timeZone = TimeZone(identifier: "UTC")! let d = cal.date(from: DateComponents(year: p[0], month: p[1], day: p[2]))! let base = cal.date(from: DateComponents(year: 1899, month: 12, day: 30))! return cal.dateComponents([.day], from: base, to: d).day ?? 0 } private static func column(_ i: Int) -> String { var n = i + 1, s = "" while n > 0 { let m = (n - 1) % 26; s = String(UnicodeScalar(65 + m)!) + s; n = (n - 1) / 26 } return s } private static func esc(_ s: String) -> String { s.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "\"", with: """) } // MARK: ZIP (stored) private static let crcTable: [UInt32] = (0..<256).map { n -> UInt32 in var c = UInt32(n) for _ in 0..<8 { c = c & 1 != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1 } return c } private static func crc32(_ d: Data) -> UInt32 { var c: UInt32 = 0xFFFFFFFF for b in d { c = crcTable[Int((c ^ UInt32(b)) & 0xFF)] ^ (c >> 8) } return c ^ 0xFFFFFFFF } private static func zip(_ files: [(String, Data)]) -> Data { var out = Data(), central = Data() func u16(_ v: Int, _ d: inout Data) { withUnsafeBytes(of: UInt16(v).littleEndian) { d.append(contentsOf: $0) } } func u32(_ v: UInt32, _ d: inout Data) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } for (name, body) in files { let n = Data(name.utf8), crc = crc32(body), offset = UInt32(out.count) u32(0x04034b50, &out); u16(20, &out); u16(0x0800, &out); u16(0, &out); u16(0, &out); u16(0, &out) u32(crc, &out); u32(UInt32(body.count), &out); u32(UInt32(body.count), &out); u16(n.count, &out); u16(0, &out) out.append(n); out.append(body) u32(0x02014b50, ¢ral); u16(20, ¢ral); u16(20, ¢ral); u16(0x0800, ¢ral); u16(0, ¢ral) u16(0, ¢ral); u16(0, ¢ral); u32(crc, ¢ral); u32(UInt32(body.count), ¢ral); u32(UInt32(body.count), ¢ral) u16(n.count, ¢ral); u16(0, ¢ral); u16(0, ¢ral); u16(0, ¢ral); u16(0, ¢ral); u32(0, ¢ral) u32(offset, ¢ral); central.append(n) } let cdOffset = UInt32(out.count) out.append(central) u32(0x06054b50, &out); u16(0, &out); u16(0, &out); u16(files.count, &out); u16(files.count, &out) u32(UInt32(central.count), &out); u32(cdOffset, &out); u16(0, &out) return out } }