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 selectedSidebarSelection: SidebarSelection?
@State private var searchText = "" @State private var searchText = ""
@State private var isDropTargeted = false @State private var isDropTargeted = false
@State private var exportAlert: ExportAlert? @State private var itemActionAlert: ItemActionAlert?
@State private var isExportingSelectedWorld = false @State private var isPerformingItemAction = false
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
@ -68,6 +68,9 @@ struct ContentView: View {
.padding(.vertical, 2) .padding(.vertical, 2)
.contentShape(Rectangle()) .contentShape(Rectangle())
.tag(item) .tag(item)
.contextMenu {
itemContextMenu(for: item)
}
} }
.navigationTitle(contentListTitle) .navigationTitle(contentListTitle)
.searchable(text: $searchText, placement: .toolbar, prompt: "Search Content") .searchable(text: $searchText, placement: .toolbar, prompt: "Search Content")
@ -78,7 +81,7 @@ struct ContentView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let selectedItem = currentSelectedItem { } else if let selectedItem = currentSelectedItem {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 16) { HStack(alignment: .top, spacing: 16) {
LargeItemThumbnailView(iconURL: selectedItem.iconURL) LargeItemThumbnailView(iconURL: selectedItem.iconURL)
@ -97,42 +100,81 @@ struct ContentView: View {
Spacer(minLength: 0) Spacer(minLength: 0)
if selectedItem.contentType == .world { VStack(alignment: .trailing, spacing: 8) {
Button { SharingPickerButton(
exportSelectedWorld() title: "Share",
} label: { systemImage: "square.and.arrow.up",
if isExportingSelectedWorld { isEnabled: !isPerformingItemAction
ProgressView() ) { anchorView in
.controlSize(.small) shareItem(selectedItem, from: anchorView)
} else {
Label("Export .mcworld", systemImage: "square.and.arrow.up")
}
} }
.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) detailSection("Location") {
detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path) 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( detailRow(
title: "Modified", title: "Metadata",
value: modifiedDate.formatted(date: .abbreviated, time: .shortened) value: selectedItem.metadataLoaded ? "Loaded" : "Loading..."
) )
} }
if let sizeBytes = selectedItem.sizeBytes { detailSection("Contents") {
detailRow( let previewEntries = directoryPreviewEntries(for: selectedItem)
title: "Size",
value: ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
)
}
detailRow( if previewEntries.isEmpty {
title: "Metadata", Text("No visible files or folders")
value: selectedItem.metadataLoaded ? "Loaded" : "Loading..." .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() .padding()
} }
@ -144,13 +186,28 @@ struct ContentView: View {
.navigationTitle("Minecraft World Manager") .navigationTitle("Minecraft World Manager")
.toolbar { .toolbar {
ToolbarItemGroup(placement: .primaryAction) { ToolbarItemGroup(placement: .primaryAction) {
if selectedWorld != nil { if let selectedExportableItem = selectedExportableItem {
Button { SharingPickerButton(
exportSelectedWorld() title: nil,
} label: { systemImage: "square.and.arrow.up",
Label("Export .mcworld", 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 { Button {
@ -202,7 +259,7 @@ struct ContentView: View {
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in .onChange(of: library.sources.map(\.id)) { _, sourceIDs in
syncSelection(with: sourceIDs) syncSelection(with: sourceIDs)
} }
.alert(item: $exportAlert) { alert in .alert(item: $itemActionAlert) { alert in
Alert( Alert(
title: Text(alert.title), title: Text(alert.title),
message: Text(alert.message), message: Text(alert.message),
@ -211,6 +268,8 @@ struct ContentView: View {
} }
} }
private let directoryPreviewLimit = 12
private var filteredItems: [MinecraftContentItem] { private var filteredItems: [MinecraftContentItem] {
guard let selectedSidebarSelection else { guard let selectedSidebarSelection else {
return [] return []
@ -255,12 +314,8 @@ struct ContentView: View {
.first(where: { $0.id == selectedItem.id }) ?? selectedItem .first(where: { $0.id == selectedItem.id }) ?? selectedItem
} }
private var selectedWorld: MinecraftContentItem? { private var selectedExportableItem: MinecraftContentItem? {
guard let currentSelectedItem, currentSelectedItem.contentType == .world else { currentSelectedItem
return nil
}
return currentSelectedItem
} }
private var contentListTitle: String { 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 @ViewBuilder
private func detailRow(title: String, value: String) -> some View { private func detailRow(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) { 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() { private func pickFolder() {
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.allowsMultipleSelection = true panel.allowsMultipleSelection = true
@ -429,42 +538,42 @@ struct ContentView: View {
} }
} }
private func exportSelectedWorld() { private func saveItem(_ item: MinecraftContentItem) {
guard let world = selectedWorld, !isExportingSelectedWorld else { guard !isPerformingItemAction else {
return return
} }
let panel = NSSavePanel() let panel = NSSavePanel()
panel.canCreateDirectories = true panel.canCreateDirectories = true
panel.isExtensionHidden = false panel.isExtensionHidden = false
panel.title = "Export Minecraft World" panel.title = "Export \(item.contentType.exportTitle)"
panel.message = "Choose where to save the .mcworld file." panel.message = "Choose where to save the .\(item.contentType.archiveExtension) file."
panel.nameFieldStringValue = WorldExporter.suggestedFilename(for: world) panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item)
panel.allowedContentTypes = [UTType(filenameExtension: "mcworld") ?? .data] panel.allowedContentTypes = [archiveType(for: item)]
guard panel.runModal() == .OK, let destinationURL = panel.url else { guard panel.runModal() == .OK, let destinationURL = panel.url else {
return return
} }
isExportingSelectedWorld = true isPerformingItemAction = true
Task { Task {
do { do {
try await Task.detached(priority: .userInitiated) { try await Task.detached(priority: .userInitiated) {
try WorldExporter.exportWorld(world, to: destinationURL) try ContentPackageExporter.exportItem(item, to: destinationURL)
}.value }.value
await MainActor.run { await MainActor.run {
isExportingSelectedWorld = false isPerformingItemAction = false
exportAlert = ExportAlert( itemActionAlert = ItemActionAlert(
title: "Export Complete", title: "Export Complete",
message: "\"\(world.displayName)\" was exported as a .mcworld file." message: "\"\(item.displayName)\" was exported as \(ContentPackageExporter.suggestedFilename(for: item))."
) )
} }
} catch { } catch {
await MainActor.run { await MainActor.run {
isExportingSelectedWorld = false isPerformingItemAction = false
exportAlert = ExportAlert( itemActionAlert = ItemActionAlert(
title: "Export Failed", title: "Export Failed",
message: error.localizedDescription 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 { private enum SidebarSelection: Hashable {
@ -513,12 +671,65 @@ private struct SidebarFilterRow: View {
} }
} }
private struct ExportAlert: Identifiable { private struct ItemActionAlert: Identifiable {
let id = UUID() let id = UUID()
let title: String let title: String
let message: 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 { private struct EmptySourcesView: View {
let isDropTargeted: Bool let isDropTargeted: Bool
let chooseFolder: () -> Void let chooseFolder: () -> Void

View File

@ -28,6 +28,32 @@ enum MinecraftContentType: String, CaseIterable, Hashable, Sendable {
return "world_templates" 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 { 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
}
}