From b0c2e4a44d5c13660f7d86ca78a7d2112c7dded8 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Mon, 25 May 2026 15:02:51 -0500 Subject: [PATCH] UI enhancements and exporting --- .../project.pbxproj | 4 +- World Manager for Minecraft/ContentView.swift | 425 ++++++++++++++---- .../Models/MinecraftSource.swift | 33 ++ .../Services/SourceLibrary.swift | 156 +++++++ .../Services/WorldExporter.swift | 97 ++++ .../Services/WorldScanner.swift | 114 +---- 6 files changed, 640 insertions(+), 189 deletions(-) create mode 100644 World Manager for Minecraft/Models/MinecraftSource.swift create mode 100644 World Manager for Minecraft/Services/SourceLibrary.swift create mode 100644 World Manager for Minecraft/Services/WorldExporter.swift diff --git a/World Manager for Minecraft.xcodeproj/project.pbxproj b/World Manager for Minecraft.xcodeproj/project.pbxproj index d32b280..81656d2 100644 --- a/World Manager for Minecraft.xcodeproj/project.pbxproj +++ b/World Manager for Minecraft.xcodeproj/project.pbxproj @@ -397,7 +397,7 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -427,7 +427,7 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 6285b52..8911aec 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -7,84 +7,76 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers struct ContentView: View { - @StateObject private var scanner = WorldScanner() - @State private var folderURL: URL? + @StateObject private var library = SourceLibrary() @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 { NavigationSplitView { - VStack(alignment: .leading, spacing: 12) { - Button("Choose Minecraft Folder...") { - pickFolder() - } - - if let folderURL { - 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) - } + List(selection: $selectedSidebarSelection) { + ForEach(library.sources) { source in + Section(source.displayName) { + ForEach(sidebarFilters(for: source)) { filter in + SidebarFilterRow(filter: filter) + .tag(filter.selection as SidebarSelection?) } } } - .listStyle(.sidebar) } - .padding() - .navigationTitle("Source") + .listStyle(.sidebar) + .navigationTitle("Sources") } content: { - List(filteredItems, selection: $selectedItem) { item in - HStack(alignment: .top, spacing: 10) { - ItemThumbnailView(iconURL: item.iconURL) + if library.sources.isEmpty { + EmptySourcesView( + 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) { - Text(item.displayName) - .lineLimit(1) + VStack(alignment: .leading, spacing: 4) { + Text(item.displayName) + .lineLimit(1) - Text(item.contentType.rawValue) - .font(.caption) - .foregroundStyle(.secondary) + Text(item.contentType.rawValue) + .font(.caption) + .foregroundStyle(.secondary) - Text(item.folderName) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - } - - Spacer() - - if !item.metadataLoaded { - ProgressView() - .controlSize(.small) + Text(item.folderName) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + + Spacer() + + if !item.metadataLoaded { + ProgressView() + .controlSize(.small) + } } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .tag(item) } - .padding(.vertical, 2) - .contentShape(Rectangle()) - .tag(item) + .navigationTitle(contentListTitle) + .searchable(text: $searchText, placement: .toolbar, prompt: "Search Content") } - .navigationTitle(contentListTitle) } 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 { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .top, spacing: 16) { @@ -102,6 +94,22 @@ struct ContentView: View { .font(.subheadline) .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) @@ -135,18 +143,52 @@ struct ContentView: View { } .navigationTitle("Minecraft World Manager") .toolbar { - ToolbarItem(placement: .primaryAction) { - if scanner.isScanning { + ToolbarItemGroup(placement: .primaryAction) { + 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) { ProgressView() .controlSize(.small) - Text(scanner.scanStatus) + Text(activeScanSummary) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(1) } - .frame(maxWidth: 280, alignment: .trailing) + .frame(maxWidth: 320, alignment: .trailing) } } } @@ -157,15 +199,50 @@ struct ContentView: View { 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] { - switch selectedSidebarSelection { - case .all: - return scanner.items - case .contentType(let contentType): - return scanner.items.filter { $0.contentType == contentType } + guard let selectedSidebarSelection else { + return [] } + + 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? { @@ -173,31 +250,45 @@ struct ContentView: View { 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 { - switch selectedSidebarSelection { - case .all: + guard let selectedSidebarSelection else { 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 = [ SidebarFilter( title: "All Content", iconName: "square.grid.2x2", - count: scanner.items.count, - selection: .all + count: source.items.count, + selection: .allContent(sourceID: source.id) ) ] filters.append( 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 { return nil } @@ -206,7 +297,7 @@ struct ContentView: View { title: sidebarTitle(for: contentType), iconName: sidebarIcon(for: contentType), count: count, - selection: .contentType(contentType) + selection: .contentType(sourceID: source.id, contentType: contentType) ) } ) @@ -258,32 +349,145 @@ struct ContentView: View { private func pickFolder() { let panel = NSOpenPanel() - panel.allowsMultipleSelection = false + panel.allowsMultipleSelection = true panel.canChooseDirectories = true 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 } - folderURL = pickedURL - selectedItem = nil - selectedSidebarSelection = .all + for url in panel.urls { + let sourceID = library.addSource(at: url) + 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 { - 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 { - case all - case contentType(MinecraftContentType) + case allContent(sourceID: URL) + 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 { - let id = UUID() + var id: SidebarSelection { selection } let title: String let iconName: String 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 { let iconURL: URL? diff --git a/World Manager for Minecraft/Models/MinecraftSource.swift b/World Manager for Minecraft/Models/MinecraftSource.swift new file mode 100644 index 0000000..4f23322 --- /dev/null +++ b/World Manager for Minecraft/Models/MinecraftSource.swift @@ -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 + } +} diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift new file mode 100644 index 0000000..523bf38 --- /dev/null +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -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] = [:] + + 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]) + } +} diff --git a/World Manager for Minecraft/Services/WorldExporter.swift b/World Manager for Minecraft/Services/WorldExporter.swift new file mode 100644 index 0000000..9d1b2c2 --- /dev/null +++ b/World Manager for Minecraft/Services/WorldExporter.swift @@ -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 + } +} diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift index 65708e4..afc317c 100644 --- a/World Manager for Minecraft/Services/WorldScanner.swift +++ b/World Manager for Minecraft/Services/WorldScanner.swift @@ -5,92 +5,10 @@ // Created by John Burwell on 2026-05-25. // -import Combine import Foundation -@MainActor -final class WorldScanner: ObservableObject { - @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] { +enum WorldScanner { + nonisolated static func discoverItems(in searchRootURL: URL) throws -> [MinecraftContentItem] { let fileManager = FileManager.default let resourceKeys: [URLResourceKey] = [.isDirectoryKey] @@ -139,7 +57,7 @@ final class WorldScanner: ObservableObject { return discoveredItems } - nonisolated private static func enrich(item: MinecraftContentItem) -> MinecraftContentItem { + nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem { let fileManager = FileManager.default var enrichedItem = item @@ -152,6 +70,19 @@ final class WorldScanner: ObservableObject { 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? { let normalizedFolderName = folderName.lowercased() @@ -273,17 +204,4 @@ final class WorldScanner: ObservableObject { 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 - } }