Files
TonUninoNator/Sources/ContentView.swift
T

740 lines
24 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")
}
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 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()
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") { _ 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 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
)
)
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 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 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)
}
}