Finish exporting process for multiple types

This commit is contained in:
John Burwell 2026-05-25 15:37:18 -05:00
parent b0c2e4a44d
commit dcfc25091b
4 changed files with 440 additions and 153 deletions

View File

@ -15,8 +15,8 @@ struct ContentView: View {
@State private var selectedSidebarSelection: SidebarSelection?
@State private var searchText = ""
@State private var isDropTargeted = false
@State private var exportAlert: ExportAlert?
@State private var isExportingSelectedWorld = false
@State private var itemActionAlert: ItemActionAlert?
@State private var isPerformingItemAction = false
var body: some View {
NavigationSplitView {
@ -68,6 +68,9 @@ struct ContentView: View {
.padding(.vertical, 2)
.contentShape(Rectangle())
.tag(item)
.contextMenu {
itemContextMenu(for: item)
}
}
.navigationTitle(contentListTitle)
.searchable(text: $searchText, placement: .toolbar, prompt: "Search Content")
@ -78,7 +81,7 @@ struct ContentView: View {
.foregroundStyle(.secondary)
} else if let selectedItem = currentSelectedItem {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 16) {
LargeItemThumbnailView(iconURL: selectedItem.iconURL)
@ -97,42 +100,81 @@ struct ContentView: View {
Spacer(minLength: 0)
if selectedItem.contentType == .world {
Button {
exportSelectedWorld()
} label: {
if isExportingSelectedWorld {
ProgressView()
.controlSize(.small)
} else {
Label("Export .mcworld", systemImage: "square.and.arrow.up")
}
VStack(alignment: .trailing, spacing: 8) {
SharingPickerButton(
title: "Share",
systemImage: "square.and.arrow.up",
isEnabled: !isPerformingItemAction
) { anchorView in
shareItem(selectedItem, from: anchorView)
}
.disabled(isExportingSelectedWorld)
Button {
saveItem(selectedItem)
} label: {
Label("Export .\(selectedItem.contentType.archiveExtension)", systemImage: "square.and.arrow.down")
}
.disabled(isPerformingItemAction)
Button {
revealInFinder(selectedItem)
} label: {
Label("Reveal in Finder", systemImage: "folder")
}
.disabled(isPerformingItemAction)
}
}
detailRow(title: "Folder Path", value: selectedItem.folderURL.path)
detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path)
detailSection("Location") {
detailRow(title: "Folder Path", value: selectedItem.folderURL.path)
detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path)
}
detailSection("Details") {
if let modifiedDate = selectedItem.modifiedDate {
detailRow(
title: "Modified",
value: modifiedDate.formatted(date: .abbreviated, time: .shortened)
)
}
if let sizeBytes = selectedItem.sizeBytes {
detailRow(
title: "Size",
value: ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
)
}
if let modifiedDate = selectedItem.modifiedDate {
detailRow(
title: "Modified",
value: modifiedDate.formatted(date: .abbreviated, time: .shortened)
title: "Metadata",
value: selectedItem.metadataLoaded ? "Loaded" : "Loading..."
)
}
if let sizeBytes = selectedItem.sizeBytes {
detailRow(
title: "Size",
value: ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
)
}
detailSection("Contents") {
let previewEntries = directoryPreviewEntries(for: selectedItem)
detailRow(
title: "Metadata",
value: selectedItem.metadataLoaded ? "Loaded" : "Loading..."
)
if previewEntries.isEmpty {
Text("No visible files or folders")
.foregroundStyle(.secondary)
} else {
ForEach(previewEntries) { entry in
HStack(spacing: 10) {
Image(systemName: entry.isDirectory ? "folder" : "doc")
.foregroundStyle(.secondary)
Text(entry.name)
.lineLimit(1)
Spacer()
}
}
if previewEntries.count == directoryPreviewLimit {
Text("Showing the first \(directoryPreviewLimit) items")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.padding()
}
@ -144,13 +186,28 @@ struct ContentView: View {
.navigationTitle("Minecraft World Manager")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
if selectedWorld != nil {
Button {
exportSelectedWorld()
} label: {
Label("Export .mcworld", systemImage: "square.and.arrow.up")
if let selectedExportableItem = selectedExportableItem {
SharingPickerButton(
title: nil,
systemImage: "square.and.arrow.up",
isEnabled: !isPerformingItemAction
) { anchorView in
shareItem(selectedExportableItem, from: anchorView)
}
.disabled(isExportingSelectedWorld)
Button {
saveItem(selectedExportableItem)
} label: {
Label("Export", systemImage: "square.and.arrow.down")
}
.disabled(isPerformingItemAction)
Button {
revealInFinder(selectedExportableItem)
} label: {
Label("Reveal in Finder", systemImage: "folder")
}
.disabled(isPerformingItemAction)
}
Button {
@ -202,7 +259,7 @@ struct ContentView: View {
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in
syncSelection(with: sourceIDs)
}
.alert(item: $exportAlert) { alert in
.alert(item: $itemActionAlert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
@ -211,6 +268,8 @@ struct ContentView: View {
}
}
private let directoryPreviewLimit = 12
private var filteredItems: [MinecraftContentItem] {
guard let selectedSidebarSelection else {
return []
@ -255,12 +314,8 @@ struct ContentView: View {
.first(where: { $0.id == selectedItem.id }) ?? selectedItem
}
private var selectedWorld: MinecraftContentItem? {
guard let currentSelectedItem, currentSelectedItem.contentType == .world else {
return nil
}
return currentSelectedItem
private var selectedExportableItem: MinecraftContentItem? {
currentSelectedItem
}
private var contentListTitle: String {
@ -335,6 +390,16 @@ struct ContentView: View {
}
}
@ViewBuilder
private func detailSection<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.headline)
content()
}
}
@ViewBuilder
private func detailRow(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
@ -347,6 +412,50 @@ struct ContentView: View {
}
}
@ViewBuilder
private func itemContextMenu(for item: MinecraftContentItem) -> some View {
Button("Share...") {
shareItem(item, from: nil)
}
Button("Export .\(item.contentType.archiveExtension)") {
saveItem(item)
}
Divider()
Button("Reveal in Finder") {
revealInFinder(item)
}
}
private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] {
let fileManager = FileManager.default
guard let urls = try? fileManager.contentsOfDirectory(
at: item.folderURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return urls
.map { url in
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
return DirectoryPreviewEntry(name: url.lastPathComponent, isDirectory: isDirectory)
}
.sorted { lhs, rhs in
if lhs.isDirectory != rhs.isDirectory {
return lhs.isDirectory && !rhs.isDirectory
}
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
.prefix(directoryPreviewLimit)
.map { $0 }
}
private func pickFolder() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
@ -429,42 +538,42 @@ struct ContentView: View {
}
}
private func exportSelectedWorld() {
guard let world = selectedWorld, !isExportingSelectedWorld else {
private func saveItem(_ item: MinecraftContentItem) {
guard !isPerformingItemAction else {
return
}
let panel = NSSavePanel()
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.title = "Export Minecraft World"
panel.message = "Choose where to save the .mcworld file."
panel.nameFieldStringValue = WorldExporter.suggestedFilename(for: world)
panel.allowedContentTypes = [UTType(filenameExtension: "mcworld") ?? .data]
panel.title = "Export \(item.contentType.exportTitle)"
panel.message = "Choose where to save the .\(item.contentType.archiveExtension) file."
panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item)
panel.allowedContentTypes = [archiveType(for: item)]
guard panel.runModal() == .OK, let destinationURL = panel.url else {
return
}
isExportingSelectedWorld = true
isPerformingItemAction = true
Task {
do {
try await Task.detached(priority: .userInitiated) {
try WorldExporter.exportWorld(world, to: destinationURL)
try ContentPackageExporter.exportItem(item, to: destinationURL)
}.value
await MainActor.run {
isExportingSelectedWorld = false
exportAlert = ExportAlert(
isPerformingItemAction = false
itemActionAlert = ItemActionAlert(
title: "Export Complete",
message: "\"\(world.displayName)\" was exported as a .mcworld file."
message: "\"\(item.displayName)\" was exported as \(ContentPackageExporter.suggestedFilename(for: item))."
)
}
} catch {
await MainActor.run {
isExportingSelectedWorld = false
exportAlert = ExportAlert(
isPerformingItemAction = false
itemActionAlert = ItemActionAlert(
title: "Export Failed",
message: error.localizedDescription
)
@ -472,6 +581,55 @@ struct ContentView: View {
}
}
}
private func shareItem(_ item: MinecraftContentItem, from anchorView: NSView?) {
guard !isPerformingItemAction else {
return
}
isPerformingItemAction = true
Task {
do {
let shareURL = try await Task.detached(priority: .userInitiated) {
try ContentPackageExporter.prepareShareFile(for: item)
}.value
await MainActor.run {
isPerformingItemAction = false
let presentationView = anchorView ?? NSApp.keyWindow?.contentView
guard let presentationView else {
itemActionAlert = ItemActionAlert(
title: "Share Failed",
message: "Could not find a view to present the sharing menu."
)
return
}
let picker = NSSharingServicePicker(items: [shareURL])
let targetRect = anchorView?.bounds ?? presentationView.bounds.insetBy(dx: presentationView.bounds.width / 2, dy: presentationView.bounds.height / 2)
picker.show(relativeTo: targetRect, of: presentationView, preferredEdge: .minY)
}
} catch {
await MainActor.run {
isPerformingItemAction = false
itemActionAlert = ItemActionAlert(
title: "Share Failed",
message: error.localizedDescription
)
}
}
}
}
private func revealInFinder(_ item: MinecraftContentItem) {
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
}
private func archiveType(for item: MinecraftContentItem) -> UTType {
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data
}
}
private enum SidebarSelection: Hashable {
@ -513,12 +671,65 @@ private struct SidebarFilterRow: View {
}
}
private struct ExportAlert: Identifiable {
private struct ItemActionAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
private struct DirectoryPreviewEntry: Identifiable {
let id = UUID()
let name: String
let isDirectory: Bool
}
private struct SharingPickerButton: NSViewRepresentable {
let title: String?
let systemImage: String
let isEnabled: Bool
let action: (NSView) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(action: action)
}
func makeNSView(context: Context) -> NSButton {
let button = NSButton()
button.target = context.coordinator
button.action = #selector(Coordinator.didPressButton(_:))
button.bezelStyle = .texturedRounded
update(button)
return button
}
func updateNSView(_ nsView: NSButton, context: Context) {
context.coordinator.action = action
update(nsView)
}
private func update(_ button: NSButton) {
button.image = NSImage(
systemSymbolName: systemImage,
accessibilityDescription: title ?? "Share"
)
button.imagePosition = title == nil ? .imageOnly : .imageLeading
button.title = title ?? ""
button.isEnabled = isEnabled
}
final class Coordinator: NSObject {
var action: (NSView) -> Void
init(action: @escaping (NSView) -> Void) {
self.action = action
}
@objc func didPressButton(_ sender: NSButton) {
action(sender)
}
}
}
private struct EmptySourcesView: View {
let isDropTargeted: Bool
let chooseFolder: () -> Void

View File

@ -28,6 +28,32 @@ enum MinecraftContentType: String, CaseIterable, Hashable, Sendable {
return "world_templates"
}
}
nonisolated var archiveExtension: String {
switch self {
case .world:
return "mcworld"
case .behaviorPack, .resourcePack, .skinPack:
return "mcpack"
case .worldTemplate:
return "mctemplate"
}
}
nonisolated var exportTitle: String {
switch self {
case .world:
return "Minecraft World"
case .behaviorPack:
return "Behavior Pack"
case .resourcePack:
return "Resource Pack"
case .skinPack:
return "Skin Pack"
case .worldTemplate:
return "World Template"
}
}
}
struct MinecraftContentItem: Identifiable, Hashable, Sendable {

View File

@ -0,0 +1,147 @@
//
// ContentPackageExporter.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import Foundation
enum ContentPackageExporter {
enum ExportError: LocalizedError {
case failedToCreateArchive(String)
var errorDescription: String? {
switch self {
case .failedToCreateArchive(let output):
return output.isEmpty ? "Failed to create the archive file." : output
}
}
}
nonisolated static func exportItem(_ item: MinecraftContentItem, to destinationURL: URL) throws {
let fileManager = FileManager.default
let archiveURL = normalizedArchiveURL(for: item, destinationURL: destinationURL)
let temporaryArchiveURL = temporaryArchiveURL(for: item, fileManager: fileManager)
defer {
try? fileManager.removeItem(at: temporaryArchiveURL)
}
try createArchive(for: item, at: temporaryArchiveURL)
if fileManager.fileExists(atPath: archiveURL.path) {
try fileManager.removeItem(at: archiveURL)
}
try fileManager.moveItem(at: temporaryArchiveURL, to: archiveURL)
}
nonisolated static func prepareShareFile(for item: MinecraftContentItem) throws -> URL {
let fileManager = FileManager.default
let shareDirectoryURL = fileManager.temporaryDirectory
.appendingPathComponent("MinecraftContentShares", isDirectory: true)
try fileManager.createDirectory(at: shareDirectoryURL, withIntermediateDirectories: true)
let archiveURL = uniqueArchiveURL(
in: shareDirectoryURL,
baseName: suggestedBaseFilename(for: item),
pathExtension: item.contentType.archiveExtension,
fileManager: fileManager
)
try createArchive(for: item, at: archiveURL)
return archiveURL
}
nonisolated static func suggestedBaseFilename(for item: MinecraftContentItem) -> String {
sanitizedFilename(item.displayName.isEmpty ? item.folderName : item.displayName)
}
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String {
"\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)"
}
nonisolated private static func createArchive(for item: MinecraftContentItem, at archiveURL: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.currentDirectoryURL = item.folderURL
process.arguments = [
"-c",
"-k",
"--norsrc",
".",
archiveURL.path
]
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = outputPipe
try process.run()
process.waitUntilExit()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard process.terminationStatus == 0 else {
throw ExportError.failedToCreateArchive(output)
}
}
nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
let normalizedDestinationURL = destinationURL.standardizedFileURL
let requiredExtension = item.contentType.archiveExtension
if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension {
return normalizedDestinationURL
}
return normalizedDestinationURL.appendingPathExtension(requiredExtension)
}
nonisolated private static func temporaryArchiveURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL {
fileManager.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(item.contentType.archiveExtension)
}
nonisolated private static func uniqueArchiveURL(
in directoryURL: URL,
baseName: String,
pathExtension: String,
fileManager: FileManager
) -> URL {
var candidateURL = directoryURL
.appendingPathComponent(baseName)
.appendingPathExtension(pathExtension)
var suffix = 2
while fileManager.fileExists(atPath: candidateURL.path) {
candidateURL = directoryURL
.appendingPathComponent("\(baseName) \(suffix)")
.appendingPathExtension(pathExtension)
suffix += 1
}
return candidateURL
}
nonisolated private static func sanitizedFilename(_ value: String) -> String {
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
let components = value.components(separatedBy: invalidCharacters)
let collapsed = components.joined(separator: " ")
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedWhitespace = collapsed.replacingOccurrences(
of: "\\s+",
with: " ",
options: .regularExpression
)
return normalizedWhitespace.isEmpty ? "Minecraft Content" : normalizedWhitespace
}
}

View File

@ -1,97 +0,0 @@
//
// WorldExporter.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import Foundation
enum WorldExporter {
enum ExportError: LocalizedError {
case unsupportedContentType
case failedToCreateArchive(String)
var errorDescription: String? {
switch self {
case .unsupportedContentType:
return "Only Minecraft worlds can be exported as .mcworld files."
case .failedToCreateArchive(let output):
return output.isEmpty ? "Failed to create the .mcworld archive." : output
}
}
}
nonisolated static func exportWorld(_ item: MinecraftContentItem, to destinationURL: URL) throws {
guard item.contentType == .world else {
throw ExportError.unsupportedContentType
}
let fileManager = FileManager.default
let normalizedDestinationURL = destinationURL.standardizedFileURL
let archiveURL = normalizedDestinationURL.pathExtension.lowercased() == "mcworld"
? normalizedDestinationURL
: normalizedDestinationURL.appendingPathExtension("mcworld")
let temporaryArchiveURL = fileManager.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mcworld")
defer {
try? fileManager.removeItem(at: temporaryArchiveURL)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.currentDirectoryURL = item.folderURL
process.arguments = [
"-c",
"-k",
"--norsrc",
".",
temporaryArchiveURL.path
]
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = outputPipe
try process.run()
process.waitUntilExit()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard process.terminationStatus == 0 else {
throw ExportError.failedToCreateArchive(output)
}
if fileManager.fileExists(atPath: archiveURL.path) {
try fileManager.removeItem(at: archiveURL)
}
try fileManager.moveItem(at: temporaryArchiveURL, to: archiveURL)
}
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String {
let baseName = sanitizedFilename(item.displayName.isEmpty ? item.folderName : item.displayName)
return "\(baseName).mcworld"
}
nonisolated private static func sanitizedFilename(_ value: String) -> String {
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
let components = value.components(separatedBy: invalidCharacters)
let collapsed = components.joined(separator: " ")
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedWhitespace = collapsed.replacingOccurrences(
of: "\\s+",
with: " ",
options: .regularExpression
)
return normalizedWhitespace.isEmpty ? "Minecraft World" : normalizedWhitespace
}
}