UI enhancements and exporting
This commit is contained in:
parent
1c06e4f67b
commit
b0c2e4a44d
@ -397,7 +397,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@ -427,7 +427,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
@ -7,84 +7,76 @@
|
|||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var scanner = WorldScanner()
|
@StateObject private var library = SourceLibrary()
|
||||||
@State private var folderURL: URL?
|
|
||||||
@State private var selectedItem: MinecraftContentItem?
|
@State private var selectedItem: MinecraftContentItem?
|
||||||
@State private var selectedSidebarSelection: SidebarSelection = .all
|
@State private var selectedSidebarSelection: SidebarSelection?
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var isDropTargeted = false
|
||||||
|
@State private var exportAlert: ExportAlert?
|
||||||
|
@State private var isExportingSelectedWorld = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
List(selection: $selectedSidebarSelection) {
|
||||||
Button("Choose Minecraft Folder...") {
|
ForEach(library.sources) { source in
|
||||||
pickFolder()
|
Section(source.displayName) {
|
||||||
}
|
ForEach(sidebarFilters(for: source)) { filter in
|
||||||
|
SidebarFilterRow(filter: filter)
|
||||||
if let folderURL {
|
.tag(filter.selection as SidebarSelection?)
|
||||||
Text(folderURL.path)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
} else {
|
|
||||||
Text("No folder selected")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let scanError = scanner.scanError {
|
|
||||||
Text(scanError)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
|
|
||||||
List(selection: $selectedSidebarSelection) {
|
|
||||||
if let folderURL {
|
|
||||||
Section(folderURL.lastPathComponent) {
|
|
||||||
ForEach(sidebarFilters) { filter in
|
|
||||||
SidebarFilterRow(filter: filter)
|
|
||||||
.tag(filter.selection)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
|
||||||
}
|
}
|
||||||
.padding()
|
.listStyle(.sidebar)
|
||||||
.navigationTitle("Source")
|
.navigationTitle("Sources")
|
||||||
} content: {
|
} content: {
|
||||||
List(filteredItems, selection: $selectedItem) { item in
|
if library.sources.isEmpty {
|
||||||
HStack(alignment: .top, spacing: 10) {
|
EmptySourcesView(
|
||||||
ItemThumbnailView(iconURL: item.iconURL)
|
isDropTargeted: isDropTargeted,
|
||||||
|
chooseFolder: pickFolder
|
||||||
|
)
|
||||||
|
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: handleDroppedProviders)
|
||||||
|
} else {
|
||||||
|
List(filteredItems, selection: $selectedItem) { item in
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
ItemThumbnailView(iconURL: item.iconURL)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(item.displayName)
|
Text(item.displayName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Text(item.contentType.rawValue)
|
Text(item.contentType.rawValue)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
Text(item.folderName)
|
Text(item.folderName)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if !item.metadataLoaded {
|
if !item.metadataLoaded {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.tag(item)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.navigationTitle(contentListTitle)
|
||||||
.contentShape(Rectangle())
|
.searchable(text: $searchText, placement: .toolbar, prompt: "Search Content")
|
||||||
.tag(item)
|
|
||||||
}
|
}
|
||||||
.navigationTitle(contentListTitle)
|
|
||||||
} detail: {
|
} detail: {
|
||||||
if let selectedItem = currentSelectedItem {
|
if library.sources.isEmpty {
|
||||||
|
Text("Add a source folder to start scanning Minecraft content")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if let selectedItem = currentSelectedItem {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack(alignment: .top, spacing: 16) {
|
HStack(alignment: .top, spacing: 16) {
|
||||||
@ -102,6 +94,22 @@ struct ContentView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if selectedItem.contentType == .world {
|
||||||
|
Button {
|
||||||
|
exportSelectedWorld()
|
||||||
|
} label: {
|
||||||
|
if isExportingSelectedWorld {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Label("Export .mcworld", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isExportingSelectedWorld)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
detailRow(title: "Folder Path", value: selectedItem.folderURL.path)
|
detailRow(title: "Folder Path", value: selectedItem.folderURL.path)
|
||||||
@ -135,18 +143,52 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Minecraft World Manager")
|
.navigationTitle("Minecraft World Manager")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
if scanner.isScanning {
|
if selectedWorld != nil {
|
||||||
|
Button {
|
||||||
|
exportSelectedWorld()
|
||||||
|
} label: {
|
||||||
|
Label("Export .mcworld", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
.disabled(isExportingSelectedWorld)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
pickFolder()
|
||||||
|
} label: {
|
||||||
|
Label("Add Source", systemImage: "plus")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentSource = currentSource {
|
||||||
|
Menu {
|
||||||
|
Button("Rescan \"\(currentSource.displayName)\"") {
|
||||||
|
library.rescanSource(withID: currentSource.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Remove \"\(currentSource.displayName)\"", role: .destructive) {
|
||||||
|
removeSource(currentSource.id)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
.help("Source actions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .secondaryAction) {
|
||||||
|
if let activeScanSummary = library.activeScanSummary {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
|
|
||||||
Text(scanner.scanStatus)
|
Text(activeScanSummary)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 280, alignment: .trailing)
|
.frame(maxWidth: 320, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -157,15 +199,50 @@ struct ContentView: View {
|
|||||||
|
|
||||||
self.selectedItem = nil
|
self.selectedItem = nil
|
||||||
}
|
}
|
||||||
|
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in
|
||||||
|
syncSelection(with: sourceIDs)
|
||||||
|
}
|
||||||
|
.alert(item: $exportAlert) { alert in
|
||||||
|
Alert(
|
||||||
|
title: Text(alert.title),
|
||||||
|
message: Text(alert.message),
|
||||||
|
dismissButton: .default(Text("OK"))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var filteredItems: [MinecraftContentItem] {
|
private var filteredItems: [MinecraftContentItem] {
|
||||||
switch selectedSidebarSelection {
|
guard let selectedSidebarSelection else {
|
||||||
case .all:
|
return []
|
||||||
return scanner.items
|
|
||||||
case .contentType(let contentType):
|
|
||||||
return scanner.items.filter { $0.contentType == contentType }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scopedItems: [MinecraftContentItem]
|
||||||
|
|
||||||
|
switch selectedSidebarSelection {
|
||||||
|
case .allContent(let sourceID):
|
||||||
|
scopedItems = library.source(withID: sourceID)?.items ?? []
|
||||||
|
case .contentType(let sourceID, let contentType):
|
||||||
|
scopedItems = library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedSearchText.isEmpty else {
|
||||||
|
return scopedItems
|
||||||
|
}
|
||||||
|
|
||||||
|
return scopedItems.filter { item in
|
||||||
|
item.displayName.localizedCaseInsensitiveContains(trimmedSearchText)
|
||||||
|
|| item.folderName.localizedCaseInsensitiveContains(trimmedSearchText)
|
||||||
|
|| item.contentType.rawValue.localizedCaseInsensitiveContains(trimmedSearchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentSource: MinecraftSource? {
|
||||||
|
guard let sourceID = selectedSidebarSelection?.sourceID else {
|
||||||
|
return library.sources.first
|
||||||
|
}
|
||||||
|
|
||||||
|
return library.source(withID: sourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentSelectedItem: MinecraftContentItem? {
|
private var currentSelectedItem: MinecraftContentItem? {
|
||||||
@ -173,31 +250,45 @@ struct ContentView: View {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanner.items.first(where: { $0.id == selectedItem.id }) ?? selectedItem
|
return library.sources
|
||||||
|
.flatMap(\.items)
|
||||||
|
.first(where: { $0.id == selectedItem.id }) ?? selectedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedWorld: MinecraftContentItem? {
|
||||||
|
guard let currentSelectedItem, currentSelectedItem.contentType == .world else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSelectedItem
|
||||||
}
|
}
|
||||||
|
|
||||||
private var contentListTitle: String {
|
private var contentListTitle: String {
|
||||||
switch selectedSidebarSelection {
|
guard let selectedSidebarSelection else {
|
||||||
case .all:
|
|
||||||
return "Minecraft Content"
|
return "Minecraft Content"
|
||||||
case .contentType(let contentType):
|
}
|
||||||
return contentType.rawValue + "s"
|
|
||||||
|
switch selectedSidebarSelection {
|
||||||
|
case .allContent(let sourceID):
|
||||||
|
return library.source(withID: sourceID)?.displayName ?? "Minecraft Content"
|
||||||
|
case .contentType(_, let contentType):
|
||||||
|
return sidebarTitle(for: contentType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sidebarFilters: [SidebarFilter] {
|
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
||||||
var filters = [
|
var filters = [
|
||||||
SidebarFilter(
|
SidebarFilter(
|
||||||
title: "All Content",
|
title: "All Content",
|
||||||
iconName: "square.grid.2x2",
|
iconName: "square.grid.2x2",
|
||||||
count: scanner.items.count,
|
count: source.items.count,
|
||||||
selection: .all
|
selection: .allContent(sourceID: source.id)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
filters.append(
|
filters.append(
|
||||||
contentsOf: MinecraftContentType.allCases.compactMap { contentType in
|
contentsOf: MinecraftContentType.allCases.compactMap { contentType in
|
||||||
let count = scanner.items.filter { $0.contentType == contentType }.count
|
let count = source.items.filter { $0.contentType == contentType }.count
|
||||||
guard count > 0 else {
|
guard count > 0 else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -206,7 +297,7 @@ struct ContentView: View {
|
|||||||
title: sidebarTitle(for: contentType),
|
title: sidebarTitle(for: contentType),
|
||||||
iconName: sidebarIcon(for: contentType),
|
iconName: sidebarIcon(for: contentType),
|
||||||
count: count,
|
count: count,
|
||||||
selection: .contentType(contentType)
|
selection: .contentType(sourceID: source.id, contentType: contentType)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -258,32 +349,145 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private func pickFolder() {
|
private func pickFolder() {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.allowsMultipleSelection = false
|
panel.allowsMultipleSelection = true
|
||||||
panel.canChooseDirectories = true
|
panel.canChooseDirectories = true
|
||||||
panel.canChooseFiles = false
|
panel.canChooseFiles = false
|
||||||
panel.title = "Choose a Folder to Search"
|
panel.title = "Add Minecraft Source Folders"
|
||||||
|
|
||||||
guard panel.runModal() == .OK, let pickedURL = panel.url else {
|
guard panel.runModal() == .OK else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
folderURL = pickedURL
|
for url in panel.urls {
|
||||||
selectedItem = nil
|
let sourceID = library.addSource(at: url)
|
||||||
selectedSidebarSelection = .all
|
selectSourceIfNeeded(sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
|
||||||
|
let fileURLType = UTType.fileURL.identifier
|
||||||
|
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
|
||||||
|
guard !supportedProviders.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for provider in supportedProviders {
|
||||||
|
provider.loadDataRepresentation(forTypeIdentifier: fileURLType) { data, _ in
|
||||||
|
guard
|
||||||
|
let data,
|
||||||
|
let url = NSURL(absoluteURLWithDataRepresentation: data, relativeTo: nil) as URL?
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
let sourceID = library.addSource(at: url)
|
||||||
|
selectSourceIfNeeded(sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectSourceIfNeeded(_ sourceID: URL) {
|
||||||
|
guard selectedSidebarSelection == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSource(_ sourceID: URL) {
|
||||||
|
let fallbackSourceID = library.sources.first(where: { $0.id != sourceID })?.id
|
||||||
|
library.removeSource(withID: sourceID)
|
||||||
|
|
||||||
|
if selectedSidebarSelection?.sourceID == sourceID {
|
||||||
|
selectedSidebarSelection = fallbackSourceID.map { .allContent(sourceID: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let selectedItem, currentSelectedItem?.id != selectedItem.id {
|
||||||
|
self.selectedItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncSelection(with sourceIDs: [URL]) {
|
||||||
|
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
|
||||||
|
self.selectedSidebarSelection = sourceIDs.first.map { .allContent(sourceID: $0) }
|
||||||
|
} else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
|
||||||
|
self.selectedSidebarSelection = .allContent(sourceID: firstSourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let selectedItem {
|
||||||
|
let itemStillExists = library.sources
|
||||||
|
.flatMap(\.items)
|
||||||
|
.contains(where: { $0.id == selectedItem.id })
|
||||||
|
|
||||||
|
if !itemStillExists {
|
||||||
|
self.selectedItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportSelectedWorld() {
|
||||||
|
guard let world = selectedWorld, !isExportingSelectedWorld 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]
|
||||||
|
|
||||||
|
guard panel.runModal() == .OK, let destinationURL = panel.url else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isExportingSelectedWorld = true
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await scanner.scan(at: pickedURL)
|
do {
|
||||||
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
try WorldExporter.exportWorld(world, to: destinationURL)
|
||||||
|
}.value
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
isExportingSelectedWorld = false
|
||||||
|
exportAlert = ExportAlert(
|
||||||
|
title: "Export Complete",
|
||||||
|
message: "\"\(world.displayName)\" was exported as a .mcworld file."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isExportingSelectedWorld = false
|
||||||
|
exportAlert = ExportAlert(
|
||||||
|
title: "Export Failed",
|
||||||
|
message: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SidebarSelection: Hashable {
|
private enum SidebarSelection: Hashable {
|
||||||
case all
|
case allContent(sourceID: URL)
|
||||||
case contentType(MinecraftContentType)
|
case contentType(sourceID: URL, contentType: MinecraftContentType)
|
||||||
|
|
||||||
|
var sourceID: URL {
|
||||||
|
switch self {
|
||||||
|
case .allContent(let sourceID), .contentType(let sourceID, _):
|
||||||
|
return sourceID
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SidebarFilter: Identifiable, Hashable {
|
private struct SidebarFilter: Identifiable, Hashable {
|
||||||
let id = UUID()
|
var id: SidebarSelection { selection }
|
||||||
let title: String
|
let title: String
|
||||||
let iconName: String
|
let iconName: String
|
||||||
let count: Int
|
let count: Int
|
||||||
@ -309,6 +513,49 @@ private struct SidebarFilterRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ExportAlert: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EmptySourcesView: View {
|
||||||
|
let isDropTargeted: Bool
|
||||||
|
let chooseFolder: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 24)
|
||||||
|
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10]))
|
||||||
|
.foregroundStyle(isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.25))
|
||||||
|
.frame(width: 220, height: 160)
|
||||||
|
|
||||||
|
Image(systemName: "folder.badge.plus")
|
||||||
|
.font(.system(size: 56, weight: .regular))
|
||||||
|
.foregroundStyle(isDropTargeted ? Color.accentColor : Color.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Add a Minecraft Source")
|
||||||
|
.font(.title2)
|
||||||
|
|
||||||
|
Text("Choose a copied Minecraft folder or drop one here to start scanning worlds, packs, and templates.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Choose Minecraft Folder...") {
|
||||||
|
chooseFolder()
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct ItemThumbnailView: View {
|
private struct ItemThumbnailView: View {
|
||||||
let iconURL: URL?
|
let iconURL: URL?
|
||||||
|
|
||||||
|
|||||||
33
World Manager for Minecraft/Models/MinecraftSource.swift
Normal file
33
World Manager for Minecraft/Models/MinecraftSource.swift
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// MinecraftSource.swift
|
||||||
|
// World Manager for Minecraft
|
||||||
|
//
|
||||||
|
// Created by John Burwell on 2026-05-25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||||
|
let id: URL
|
||||||
|
let folderURL: URL
|
||||||
|
var displayName: String
|
||||||
|
var items: [MinecraftContentItem]
|
||||||
|
var isScanning: Bool
|
||||||
|
var scanStatus: String
|
||||||
|
var scanError: String?
|
||||||
|
|
||||||
|
init(folderURL: URL) {
|
||||||
|
let normalizedURL = folderURL.standardizedFileURL
|
||||||
|
self.id = normalizedURL
|
||||||
|
self.folderURL = normalizedURL
|
||||||
|
self.displayName = normalizedURL.lastPathComponent
|
||||||
|
self.items = []
|
||||||
|
self.isScanning = false
|
||||||
|
self.scanStatus = ""
|
||||||
|
self.scanError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemCount: Int {
|
||||||
|
items.count
|
||||||
|
}
|
||||||
|
}
|
||||||
156
World Manager for Minecraft/Services/SourceLibrary.swift
Normal file
156
World Manager for Minecraft/Services/SourceLibrary.swift
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
//
|
||||||
|
// SourceLibrary.swift
|
||||||
|
// World Manager for Minecraft
|
||||||
|
//
|
||||||
|
// Created by John Burwell on 2026-05-25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SourceLibrary: ObservableObject {
|
||||||
|
@Published var sources: [MinecraftSource] = []
|
||||||
|
|
||||||
|
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||||
|
|
||||||
|
func addSource(at url: URL) -> URL {
|
||||||
|
let normalizedURL = url.standardizedFileURL
|
||||||
|
|
||||||
|
if sources.contains(where: { $0.id == normalizedURL }) {
|
||||||
|
startScan(for: normalizedURL)
|
||||||
|
return normalizedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
sources.append(MinecraftSource(folderURL: normalizedURL))
|
||||||
|
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
||||||
|
startScan(for: normalizedURL)
|
||||||
|
return normalizedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func source(withID sourceID: URL) -> MinecraftSource? {
|
||||||
|
sources.first(where: { $0.id == sourceID })
|
||||||
|
}
|
||||||
|
|
||||||
|
func rescanSource(withID sourceID: URL) {
|
||||||
|
startScan(for: sourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSource(withID sourceID: URL) {
|
||||||
|
scanTasks[sourceID]?.cancel()
|
||||||
|
scanTasks[sourceID] = nil
|
||||||
|
sources.removeAll { $0.id == sourceID }
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeScanSummary: String? {
|
||||||
|
let scanningSources = sources.filter(\.isScanning)
|
||||||
|
guard !scanningSources.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if scanningSources.count == 1, let source = scanningSources.first {
|
||||||
|
return "\(source.displayName): \(source.scanStatus)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Scanning \(scanningSources.count) sources..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startScan(for sourceID: URL) {
|
||||||
|
scanTasks[sourceID]?.cancel()
|
||||||
|
|
||||||
|
let task = Task { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.scanSource(withID: sourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanTasks[sourceID] = task
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scanSource(withID sourceID: URL) async {
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
source.isScanning = true
|
||||||
|
source.scanError = nil
|
||||||
|
source.scanStatus = "Searching for Minecraft content..."
|
||||||
|
source.items = []
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let discoveredItems = try await Task.detached(priority: .userInitiated) {
|
||||||
|
try WorldScanner.discoverItems(in: sourceID)
|
||||||
|
}.value
|
||||||
|
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
source.items = discoveredItems
|
||||||
|
source.scanStatus = discoveredItems.isEmpty
|
||||||
|
? "No Minecraft content found."
|
||||||
|
: "Found \(discoveredItems.count) items. Loading details..."
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadedCount = 0
|
||||||
|
|
||||||
|
await withTaskGroup(of: MinecraftContentItem.self) { group in
|
||||||
|
for item in discoveredItems {
|
||||||
|
group.addTask {
|
||||||
|
WorldScanner.enrich(item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await enrichedItem in group {
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedCount += 1
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
guard let index = source.items.firstIndex(where: { $0.id == enrichedItem.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
source.items[index] = enrichedItem
|
||||||
|
source.items.sort(by: WorldScanner.sortItems)
|
||||||
|
|
||||||
|
if loadedCount == discoveredItems.count {
|
||||||
|
source.scanStatus = "Loaded \(loadedCount) items."
|
||||||
|
source.isScanning = false
|
||||||
|
} else {
|
||||||
|
source.scanStatus = "Loaded details for \(loadedCount) of \(discoveredItems.count) items..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if discoveredItems.isEmpty {
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
source.isScanning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
source.scanError = "Failed to scan folder: \(error.localizedDescription)"
|
||||||
|
source.scanStatus = ""
|
||||||
|
source.isScanning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanTasks[sourceID] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) {
|
||||||
|
guard let index = sources.firstIndex(where: { $0.id == sourceID }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate(&sources[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
97
World Manager for Minecraft/Services/WorldExporter.swift
Normal file
97
World Manager for Minecraft/Services/WorldExporter.swift
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,92 +5,10 @@
|
|||||||
// Created by John Burwell on 2026-05-25.
|
// Created by John Burwell on 2026-05-25.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor
|
enum WorldScanner {
|
||||||
final class WorldScanner: ObservableObject {
|
nonisolated static func discoverItems(in searchRootURL: URL) throws -> [MinecraftContentItem] {
|
||||||
@Published var items: [MinecraftContentItem] = []
|
|
||||||
@Published var isScanning = false
|
|
||||||
@Published var scanStatus = ""
|
|
||||||
@Published var scanError: String?
|
|
||||||
|
|
||||||
private var activeScanID = UUID()
|
|
||||||
|
|
||||||
func scan(at searchRootURL: URL) async {
|
|
||||||
let scanID = UUID()
|
|
||||||
activeScanID = scanID
|
|
||||||
isScanning = true
|
|
||||||
scanError = nil
|
|
||||||
scanStatus = "Searching for Minecraft content..."
|
|
||||||
items = []
|
|
||||||
|
|
||||||
do {
|
|
||||||
let discoveredItems = try await Task.detached(priority: .userInitiated) {
|
|
||||||
try Self.discoverItems(in: searchRootURL)
|
|
||||||
}.value
|
|
||||||
|
|
||||||
guard activeScanID == scanID else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items = discoveredItems
|
|
||||||
scanStatus = discoveredItems.isEmpty
|
|
||||||
? "No Minecraft content found."
|
|
||||||
: "Found \(discoveredItems.count) items. Loading details..."
|
|
||||||
|
|
||||||
var loadedCount = 0
|
|
||||||
|
|
||||||
await withTaskGroup(of: MinecraftContentItem.self) { group in
|
|
||||||
for item in discoveredItems {
|
|
||||||
group.addTask {
|
|
||||||
Self.enrich(item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for await enrichedItem in group {
|
|
||||||
await MainActor.run {
|
|
||||||
guard self.activeScanID == scanID else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.replaceItem(with: enrichedItem)
|
|
||||||
loadedCount += 1
|
|
||||||
|
|
||||||
if loadedCount == discoveredItems.count {
|
|
||||||
self.scanStatus = "Loaded \(loadedCount) items."
|
|
||||||
self.isScanning = false
|
|
||||||
} else {
|
|
||||||
self.scanStatus = "Loaded details for \(loadedCount) of \(discoveredItems.count) items..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if discoveredItems.isEmpty {
|
|
||||||
isScanning = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
guard activeScanID == scanID else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scanError = "Failed to scan folder: \(error.localizedDescription)"
|
|
||||||
scanStatus = ""
|
|
||||||
isScanning = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func replaceItem(with updatedItem: MinecraftContentItem) {
|
|
||||||
guard let index = items.firstIndex(where: { $0.id == updatedItem.id }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items[index] = updatedItem
|
|
||||||
items.sort(by: Self.sortItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private static func discoverItems(in searchRootURL: URL) throws -> [MinecraftContentItem] {
|
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let resourceKeys: [URLResourceKey] = [.isDirectoryKey]
|
let resourceKeys: [URLResourceKey] = [.isDirectoryKey]
|
||||||
|
|
||||||
@ -139,7 +57,7 @@ final class WorldScanner: ObservableObject {
|
|||||||
return discoveredItems
|
return discoveredItems
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func enrich(item: MinecraftContentItem) -> MinecraftContentItem {
|
nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
var enrichedItem = item
|
var enrichedItem = item
|
||||||
|
|
||||||
@ -152,6 +70,19 @@ final class WorldScanner: ObservableObject {
|
|||||||
return enrichedItem
|
return enrichedItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated static func sortItems(_ lhs: MinecraftContentItem, _ rhs: MinecraftContentItem) -> Bool {
|
||||||
|
if lhs.contentType != rhs.contentType {
|
||||||
|
return lhs.contentType.rawValue.localizedStandardCompare(rhs.contentType.rawValue) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayNameOrder = lhs.displayName.localizedStandardCompare(rhs.displayName)
|
||||||
|
if displayNameOrder != .orderedSame {
|
||||||
|
return displayNameOrder == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
return lhs.folderName.localizedStandardCompare(rhs.folderName) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? {
|
nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? {
|
||||||
let normalizedFolderName = folderName.lowercased()
|
let normalizedFolderName = folderName.lowercased()
|
||||||
|
|
||||||
@ -273,17 +204,4 @@ final class WorldScanner: ObservableObject {
|
|||||||
|
|
||||||
return totalSize
|
return totalSize
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func sortItems(_ lhs: MinecraftContentItem, _ rhs: MinecraftContentItem) -> Bool {
|
|
||||||
if lhs.contentType != rhs.contentType {
|
|
||||||
return lhs.contentType.rawValue.localizedStandardCompare(rhs.contentType.rawValue) == .orderedAscending
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayNameOrder = lhs.displayName.localizedStandardCompare(rhs.displayName)
|
|
||||||
if displayNameOrder != .orderedSame {
|
|
||||||
return displayNameOrder == .orderedAscending
|
|
||||||
}
|
|
||||||
|
|
||||||
return lhs.folderName.localizedStandardCompare(rhs.folderName) == .orderedAscending
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user