Finish exporting process for multiple types
This commit is contained in:
parent
b0c2e4a44d
commit
dcfc25091b
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user