838 lines
27 KiB
Swift
838 lines
27 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
var body: some View {
|
|
TabView {
|
|
ConversionToolView()
|
|
.tabItem {
|
|
Label("Datenumwandlung", systemImage: "arrow.triangle.2.circlepath")
|
|
}
|
|
|
|
SDCardToolView()
|
|
.tabItem {
|
|
Label("SD-Karte", systemImage: "sdcard")
|
|
}
|
|
|
|
MP3GainToolView()
|
|
.tabItem {
|
|
Label("MP3Gain", systemImage: "waveform")
|
|
}
|
|
|
|
ID3TagToolView()
|
|
.tabItem {
|
|
Label("ID3-Tags", systemImage: "tag")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ConversionToolView: 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 writeReport = true
|
|
@State private var cleanAfterExport = true
|
|
@State private var validateAfterExport = true
|
|
@State private var trackMode: TonUinoExportOptions.TrackMode = .merged
|
|
@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()
|
|
|
|
Picker("Modus", selection: $trackMode) {
|
|
Text("Zusammenführen").tag(TonUinoExportOptions.TrackMode.merged)
|
|
Text("Getrennt").tag(TonUinoExportOptions.TrackMode.separate)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.frame(width: 230)
|
|
|
|
HStack(spacing: 12) {
|
|
Toggle("Überschreiben", isOn: $overwriteExisting)
|
|
Toggle("Bericht", isOn: $writeReport)
|
|
Toggle("dot_clean", isOn: $cleanAfterExport)
|
|
Toggle("Prüfen", isOn: $validateAfterExport)
|
|
}
|
|
.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") { episode in
|
|
Text(targetFileDescription(for: episode))
|
|
.monospacedDigit()
|
|
}
|
|
.width(108)
|
|
|
|
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 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)
|
|
var result = exporter.export(
|
|
episodes: episodes,
|
|
outputURL: outputURL,
|
|
options: TonUinoExportOptions(
|
|
overwriteExisting: overwriteExisting,
|
|
writeReport: writeReport,
|
|
trackMode: trackMode
|
|
)
|
|
)
|
|
|
|
if cleanAfterExport {
|
|
result.append(contentsOf: SDCardCleaner().clean(url: outputURL))
|
|
}
|
|
|
|
if validateAfterExport {
|
|
result.append(contentsOf: SDCardValidator(fileManager: .default).validate(rootURL: outputURL))
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
messages = result
|
|
isExporting = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func targetFileDescription(for episode: EpisodePlan) -> String {
|
|
switch trackMode {
|
|
case .merged:
|
|
"001.mp3"
|
|
case .separate:
|
|
episode.tracks.count <= 1 ? "001.mp3" : "001...\(String(format: "%03d", episode.tracks.count)).mp3"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SDCardToolView: View {
|
|
@State private var sdCardURL: URL?
|
|
@State private var messages: [ExportMessage] = []
|
|
@State private var isWorking = false
|
|
|
|
private var canRun: Bool {
|
|
sdCardURL != nil && !isWorking
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 10) {
|
|
Button {
|
|
chooseFolder(title: "TonUINO SD-Karte wählen") { url in
|
|
sdCardURL = url
|
|
messages = []
|
|
}
|
|
} label: {
|
|
Label("SD-Karte", systemImage: "sdcard")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
validateSDCard()
|
|
} label: {
|
|
Label("Prüfen", systemImage: "checklist")
|
|
}
|
|
.disabled(!canRun)
|
|
|
|
Button {
|
|
cleanSDCard()
|
|
} label: {
|
|
Label("dot_clean ausführen", systemImage: "sparkles")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!canRun)
|
|
}
|
|
.padding(12)
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("SD-Karte bereinigen")
|
|
.font(.title2.weight(.semibold))
|
|
|
|
PathRow(label: "Karte", url: sdCardURL)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Protokoll")
|
|
.font(.headline)
|
|
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 8) {
|
|
if messages.isEmpty {
|
|
Text("Noch keine SD-Karte geprüft oder bereinigt.")
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|
|
|
|
private func cleanSDCard() {
|
|
guard let sdCardURL else { return }
|
|
|
|
isWorking = true
|
|
messages = [.info("dot_clean gestartet.")]
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
var result = SDCardCleaner().clean(url: sdCardURL)
|
|
result.append(contentsOf: SDCardValidator(fileManager: .default).validate(rootURL: sdCardURL))
|
|
|
|
DispatchQueue.main.async {
|
|
messages = result
|
|
isWorking = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func validateSDCard() {
|
|
guard let sdCardURL else { return }
|
|
|
|
isWorking = true
|
|
messages = [.info("Prüfung gestartet.")]
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let result = SDCardValidator(fileManager: .default).validate(rootURL: sdCardURL)
|
|
|
|
DispatchQueue.main.async {
|
|
messages = result
|
|
isWorking = false
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private struct MP3GainToolView: View {
|
|
@State private var targetURL: URL?
|
|
@State private var messages: [ExportMessage] = []
|
|
@State private var isWorking = false
|
|
|
|
private var canRun: Bool {
|
|
targetURL != nil && !isWorking
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 10) {
|
|
Button {
|
|
chooseFolder(title: "Ordner für MP3Gain wählen") { url in
|
|
targetURL = url
|
|
messages = []
|
|
}
|
|
} label: {
|
|
Label("Ordner", systemImage: "folder")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
normalizeMP3Gain()
|
|
} label: {
|
|
Label("MP3Gain ausführen", systemImage: "waveform")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!canRun)
|
|
}
|
|
.padding(12)
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("MP3Gain Normalisierung")
|
|
.font(.title2.weight(.semibold))
|
|
PathRow(label: "Ordner", url: targetURL)
|
|
Text("Verwendet rekursiv: mp3gain -r -k -d 4")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Protokoll")
|
|
.font(.headline)
|
|
MessageList(messages: messages, emptyText: "Noch keine MP3-Dateien normalisiert.")
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|
|
|
|
private func normalizeMP3Gain() {
|
|
guard let targetURL else { return }
|
|
|
|
isWorking = true
|
|
messages = [.info("MP3Gain gestartet: -r -k -d 4")]
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let result = MP3GainNormalizer(fileManager: .default).normalize(rootURL: targetURL)
|
|
|
|
DispatchQueue.main.async {
|
|
messages = result
|
|
isWorking = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ID3TagToolView: View {
|
|
@State private var targetURL: URL?
|
|
@State private var messages: [ExportMessage] = []
|
|
@State private var isWorking = false
|
|
@State private var removeAllTags = false
|
|
@State private var removeTitle = true
|
|
@State private var removeArtist = true
|
|
@State private var removeAlbum = true
|
|
@State private var removeTrack = true
|
|
@State private var removeYear = true
|
|
@State private var removeGenre = true
|
|
@State private var removeComment = true
|
|
@State private var removeArtwork = true
|
|
@State private var writeComment = false
|
|
@State private var commentText = ""
|
|
@State private var tagSearchText = ""
|
|
|
|
private var canApply: Bool {
|
|
targetURL != nil && !isWorking && (removeAllTags || selectedFields.isEmpty == false || writeComment)
|
|
}
|
|
|
|
private var canSearchTags: Bool {
|
|
targetURL != nil && !isWorking && !tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
private var selectedFields: Set<ID3TagField> {
|
|
var fields = Set<ID3TagField>()
|
|
if removeTitle { fields.insert(.title) }
|
|
if removeArtist { fields.insert(.artist) }
|
|
if removeAlbum { fields.insert(.album) }
|
|
if removeTrack { fields.insert(.track) }
|
|
if removeYear { fields.insert(.year) }
|
|
if removeGenre { fields.insert(.genre) }
|
|
if removeComment && !writeComment { fields.insert(.comment) }
|
|
if removeArtwork { fields.insert(.artwork) }
|
|
return fields
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 10) {
|
|
Button {
|
|
chooseFolder(title: "SD-Karte oder Ordner wählen") { url in
|
|
targetURL = url
|
|
scanTags(for: url)
|
|
}
|
|
} label: {
|
|
Label("Ordner wählen", systemImage: "folder")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
if let targetURL {
|
|
scanTags(for: targetURL)
|
|
}
|
|
} label: {
|
|
Label("Tags anzeigen", systemImage: "list.bullet.rectangle")
|
|
}
|
|
.disabled(targetURL == nil || isWorking)
|
|
|
|
Button {
|
|
applyChanges()
|
|
} label: {
|
|
Label("Tags anwenden", systemImage: "tag.slash")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!canApply)
|
|
}
|
|
.padding(12)
|
|
|
|
Divider()
|
|
|
|
HSplitView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("ID3-Tags bearbeiten")
|
|
.font(.title2.weight(.semibold))
|
|
PathRow(label: "Ordner", url: targetURL)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Toggle("Alle ID3-Tags löschen", isOn: $removeAllTags)
|
|
.toggleStyle(.checkbox)
|
|
|
|
Divider()
|
|
|
|
Text("Felder leeren/löschen")
|
|
.font(.headline)
|
|
|
|
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) {
|
|
GridRow {
|
|
Toggle("Titel", isOn: $removeTitle)
|
|
Toggle("Interpret", isOn: $removeArtist)
|
|
}
|
|
GridRow {
|
|
Toggle("Album", isOn: $removeAlbum)
|
|
Toggle("Tracknummer", isOn: $removeTrack)
|
|
}
|
|
GridRow {
|
|
Toggle("Jahr", isOn: $removeYear)
|
|
Toggle("Genre", isOn: $removeGenre)
|
|
}
|
|
GridRow {
|
|
Toggle("Kommentar", isOn: $removeComment)
|
|
.disabled(writeComment)
|
|
Toggle("Cover/Bilder", isOn: $removeArtwork)
|
|
}
|
|
}
|
|
.toggleStyle(.checkbox)
|
|
.disabled(removeAllTags)
|
|
|
|
Divider()
|
|
|
|
Toggle("Kommentar eintragen", isOn: $writeComment)
|
|
.toggleStyle(.checkbox)
|
|
.onChange(of: writeComment) { newValue in
|
|
if newValue {
|
|
removeComment = false
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
TextField("", text: $commentText)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(width: 240)
|
|
|
|
Button {
|
|
useClipboardAsComment()
|
|
} label: {
|
|
Label("Aus Zwischenablage", systemImage: "doc.on.clipboard")
|
|
}
|
|
|
|
Button {
|
|
useSelectedFolderNameAsComment()
|
|
} label: {
|
|
Label("Ordnername", systemImage: "folder")
|
|
}
|
|
|
|
Button {
|
|
clearComment()
|
|
} label: {
|
|
Label("Leeren", systemImage: "xmark.circle")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Text("Tag-Suche")
|
|
.font(.headline)
|
|
|
|
HStack(spacing: 8) {
|
|
TextField("", text: $tagSearchText)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(width: 240)
|
|
|
|
Button {
|
|
searchTags()
|
|
} label: {
|
|
Label("Tags suchen", systemImage: "magnifyingglass")
|
|
}
|
|
.disabled(!canSearchTags)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(minWidth: 420, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Protokoll")
|
|
.font(.headline)
|
|
MessageList(messages: messages, emptyText: "Noch keine ID3-Tags angezeigt oder bearbeitet.")
|
|
}
|
|
.frame(minWidth: 300)
|
|
.padding(16)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyChanges() {
|
|
guard let targetURL else { return }
|
|
|
|
isWorking = true
|
|
messages = [.info("ID3-Bearbeitung gestartet.")]
|
|
|
|
let options = ID3TagEditOptions(
|
|
removeAllTags: removeAllTags,
|
|
fieldsToRemove: selectedFields,
|
|
commentToWrite: writeComment ? commentText.trimmingCharacters(in: .whitespacesAndNewlines) : nil
|
|
)
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
var result = ID3TagEditor(fileManager: .default).apply(to: targetURL, options: options)
|
|
result.append(contentsOf: ID3TagInspector(fileManager: .default).inspect(rootURL: targetURL))
|
|
|
|
DispatchQueue.main.async {
|
|
messages = result
|
|
isWorking = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scanTags(for url: URL) {
|
|
isWorking = true
|
|
messages = [.info("ID3-Tags werden gelesen.")]
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let result = ID3TagInspector(fileManager: .default).inspect(rootURL: url)
|
|
|
|
DispatchQueue.main.async {
|
|
messages = result
|
|
isWorking = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func searchTags() {
|
|
guard let targetURL else { return }
|
|
|
|
isWorking = true
|
|
messages = [.info("Tag-Suche gestartet.")]
|
|
|
|
let query = tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let result = ID3TagSearcher(fileManager: .default).search(rootURL: targetURL, query: query)
|
|
|
|
DispatchQueue.main.async {
|
|
messages = result
|
|
isWorking = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func useClipboardAsComment() {
|
|
guard let text = NSPasteboard.general.string(forType: .string)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!text.isEmpty else {
|
|
messages = [.warning("Zwischenablage enthält keinen Text.")]
|
|
return
|
|
}
|
|
|
|
setComment(text)
|
|
}
|
|
|
|
private func useSelectedFolderNameAsComment() {
|
|
guard let targetURL else {
|
|
messages = [.warning("Kein Ordner gewählt.")]
|
|
return
|
|
}
|
|
|
|
setComment(targetURL.lastPathComponent)
|
|
}
|
|
|
|
private func clearComment() {
|
|
commentText = ""
|
|
writeComment = false
|
|
}
|
|
|
|
private func setComment(_ text: String) {
|
|
commentText = text
|
|
writeComment = true
|
|
removeComment = false
|
|
}
|
|
}
|
|
|
|
private struct MessageList: View {
|
|
let messages: [ExportMessage]
|
|
let emptyText: String
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 8) {
|
|
if messages.isEmpty {
|
|
Text(emptyText)
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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 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)
|
|
}
|
|
}
|