import AppKit import SwiftUI struct ContentView: View { @State private var inputURL: URL? @State private var outputURL: URL? @State private var episodes: [EpisodePlan] = [] @State private var messages: [ExportMessage] = [] @State private var overwriteExisting = false @State private var isExporting = false private var canExport: Bool { inputURL != nil && outputURL != nil && !episodes.isEmpty && !isExporting } var body: some View { VStack(spacing: 0) { toolbar Divider() HSplitView { VStack(alignment: .leading, spacing: 12) { header episodeTable } .frame(minWidth: 520) VStack(alignment: .leading, spacing: 12) { Text("Protokoll") .font(.headline) messageList } .frame(minWidth: 280) .padding(16) } } .onChange(of: inputURL) { _ in reloadPreview() } .onAppear(perform: reloadPreview) } private var toolbar: some View { HStack(spacing: 10) { Button { chooseFolder(title: "Input-Ordner wählen") { url in inputURL = url } } label: { Label("Input", systemImage: "folder") } Button { chooseFolder(title: "Output-Ordner wählen") { url in outputURL = url } } label: { Label("Output", systemImage: "externaldrive") } Button { reloadPreview() } label: { Label("Aktualisieren", systemImage: "arrow.clockwise") } .disabled(inputURL == nil) Spacer() Toggle("Überschreiben", isOn: $overwriteExisting) .toggleStyle(.checkbox) Button { export() } label: { Label("Exportieren", systemImage: "square.and.arrow.down") } .buttonStyle(.borderedProminent) .disabled(!canExport) } .padding(12) } private var header: some View { VStack(alignment: .leading, spacing: 8) { Text("TonUINO Export") .font(.title2.weight(.semibold)) VStack(alignment: .leading, spacing: 4) { PathRow(label: "Input", url: inputURL) PathRow(label: "Output", url: outputURL) } Text("\(episodes.count) Folgen, \(episodes.reduce(0) { $0 + $1.tracks.count }) Quelldateien") .foregroundStyle(.secondary) } .padding([.horizontal, .top], 16) } private var episodeTable: some View { Table(episodes) { TableColumn("Ziel") { episode in Text(episode.targetFolderName) .monospacedDigit() } .width(48) TableColumn("Folge") { episode in VStack(alignment: .leading, spacing: 2) { Text(episode.sourceName) .lineLimit(1) if !episode.warnings.isEmpty { Text(episode.warnings.joined(separator: " · ")) .font(.caption) .foregroundStyle(.orange) .lineLimit(2) } } } TableColumn("Quellen") { episode in Text("\(episode.tracks.count)") .monospacedDigit() } .width(64) TableColumn("Zieldatei") { _ in Text("001.mp3") .monospacedDigit() } .width(82) TableColumn("Erste Quelle") { episode in Text(episode.tracks.first?.source.lastPathComponent ?? "-") .foregroundStyle(.secondary) .lineLimit(1) } } .padding(.horizontal, 16) .padding(.bottom, 16) } private var messageList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { if messages.isEmpty { Text("Noch kein Export ausgeführt.") .foregroundStyle(.secondary) } else { ForEach(messages) { message in HStack(alignment: .top, spacing: 8) { Image(systemName: message.iconName) .foregroundStyle(message.color) .frame(width: 18) Text(message.text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } } } } } private func chooseFolder(title: String, completion: (URL) -> Void) { let panel = NSOpenPanel() panel.title = title panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.canCreateDirectories = true if panel.runModal() == .OK, let url = panel.url { completion(url) } } private func reloadPreview() { guard let inputURL else { episodes = [] return } do { let scanner = TonUinoScanner(fileManager: .default) episodes = try scanner.scan(inputURL: inputURL) messages = scannerMessages(for: episodes) } catch { episodes = [] messages = [.error("Vorschau fehlgeschlagen: \(error.localizedDescription)")] } } private func scannerMessages(for episodes: [EpisodePlan]) -> [ExportMessage] { var result: [ExportMessage] = [] if episodes.isEmpty { result.append(.warning("Keine Unterordner mit Audiodateien gefunden.")) } for episode in episodes where !episode.warnings.isEmpty { result.append(.warning("\(episode.sourceName): \(episode.warnings.joined(separator: ", "))")) } return result } private func export() { guard let outputURL else { return } isExporting = true messages = [.info("Export gestartet.")] DispatchQueue.global(qos: .userInitiated).async { let exporter = TonUinoExporter(fileManager: .default) let result = exporter.export( episodes: episodes, outputURL: outputURL, overwriteExisting: overwriteExisting ) DispatchQueue.main.async { messages = result isExporting = false } } } } private struct PathRow: View { let label: String let url: URL? var body: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(label) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 48, alignment: .leading) Text(url?.path(percentEncoded: false) ?? "Nicht gewählt") .font(.caption) .lineLimit(1) .truncationMode(.middle) .textSelection(.enabled) } } } struct ExportMessage: Identifiable { enum Kind { case info case warning case error } let id = UUID() let kind: Kind let text: String var iconName: String { switch kind { case .info: "checkmark.circle" case .warning: "exclamationmark.triangle" case .error: "xmark.octagon" } } var color: Color { switch kind { case .info: .green case .warning: .orange case .error: .red } } static func info(_ text: String) -> ExportMessage { ExportMessage(kind: .info, text: text) } static func warning(_ text: String) -> ExportMessage { ExportMessage(kind: .warning, text: text) } static func error(_ text: String) -> ExportMessage { ExportMessage(kind: .error, text: text) } }