Add SD card and ID3 tag tools
This commit is contained in:
+470
-17
@@ -2,11 +2,35 @@ 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 {
|
||||
@@ -66,8 +90,13 @@ struct ContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("Überschreiben", isOn: $overwriteExisting)
|
||||
.toggleStyle(.checkbox)
|
||||
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()
|
||||
@@ -161,19 +190,6 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 = []
|
||||
@@ -212,12 +228,23 @@ struct ContentView: View {
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let exporter = TonUinoExporter(fileManager: .default)
|
||||
let result = exporter.export(
|
||||
var result = exporter.export(
|
||||
episodes: episodes,
|
||||
outputURL: outputURL,
|
||||
overwriteExisting: overwriteExisting
|
||||
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
|
||||
@@ -226,6 +253,432 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
|
||||
Reference in New Issue
Block a user