From 7d51075631cbfd13b2e74bc96967af5b2e313a01 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Mon, 25 May 2026 14:07:35 -0500 Subject: [PATCH] Fix refactor --- World Manager for Minecraft/ContentView.swift | 122 +++++++++++----- World Manager for Minecraft/Item.swift | 18 --- .../Models/MinecraftWorld.swift | 19 +++ .../Services/WorldScanner.swift | 133 ++++++++++++++++++ .../World_Manager_for_MinecraftApp.swift | 15 -- 5 files changed, 241 insertions(+), 66 deletions(-) delete mode 100644 World Manager for Minecraft/Item.swift create mode 100644 World Manager for Minecraft/Models/MinecraftWorld.swift create mode 100644 World Manager for Minecraft/Services/WorldScanner.swift diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 1afe35e..e2c3f15 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -5,55 +5,111 @@ // Created by John Burwell on 2026-05-25. // +import AppKit import SwiftUI -import SwiftData struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] + @StateObject private var scanner = WorldScanner() + @State private var folderURL: URL? + @State private var selectedWorld: MinecraftWorld? var body: some View { NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } + VStack(alignment: .leading, spacing: 12) { + Button("Choose Minecraft Worlds Folder...") { + pickFolder() } - .onDelete(perform: deleteItems) + + if let folderURL { + Text(folderURL.path) + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } else { + Text("No folder selected") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if scanner.isScanning { + ProgressView(scanner.scanStatus) + } else if !scanner.scanStatus.isEmpty { + Text(scanner.scanStatus) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let scanError = scanner.scanError { + Text(scanError) + .font(.footnote) + .foregroundStyle(.red) + } + + Spacer() } - .navigationSplitViewColumnWidth(min: 180, ideal: 200) - .toolbar { - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } + .padding() + .navigationTitle("Folder") + } content: { + List(scanner.worlds, selection: $selectedWorld) { world in + VStack(alignment: .leading, spacing: 4) { + Text(world.displayName) + Text(world.folderName) + .font(.caption) + .foregroundStyle(.secondary) } } + .navigationTitle("Worlds") } detail: { - Text("Select an item") - } - } + if let selectedWorld { + VStack(alignment: .leading, spacing: 12) { + Text(selectedWorld.displayName) + .font(.title2) - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } + Text(selectedWorld.folderName) + .font(.headline) + .foregroundStyle(.secondary) - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) + if let modifiedDate = selectedWorld.modifiedDate { + Text("Modified: \(modifiedDate.formatted(date: .abbreviated, time: .shortened))") + } + + if let sizeBytes = selectedWorld.sizeBytes { + Text("Size: \(ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file))") + } + + Spacer() + } + .padding() + } else { + Text("Select a world to see details") + .foregroundStyle(.secondary) } } + .navigationTitle("Minecraft World Manager") + } + + private func pickFolder() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.title = "Choose Minecraft Worlds Folder" + + guard panel.runModal() == .OK, let pickedURL = panel.url else { + return + } + + folderURL = pickedURL + selectedWorld = nil + + Task { + await scanner.scan(at: pickedURL) + } } } -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } } diff --git a/World Manager for Minecraft/Item.swift b/World Manager for Minecraft/Item.swift deleted file mode 100644 index 7ec1a8f..0000000 --- a/World Manager for Minecraft/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// World Manager for Minecraft -// -// Created by John Burwell on 2026-05-25. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/World Manager for Minecraft/Models/MinecraftWorld.swift b/World Manager for Minecraft/Models/MinecraftWorld.swift new file mode 100644 index 0000000..a40ec72 --- /dev/null +++ b/World Manager for Minecraft/Models/MinecraftWorld.swift @@ -0,0 +1,19 @@ +// +// MinecraftWorld.swift +// World Manager for Minecraft +// +// Created by John Burwell on 2026-05-25. +// + +import Foundation + +struct MinecraftWorld: Identifiable, Hashable { + let id = UUID() + let folderURL: URL + let folderName: String + let displayName: String + let iconURL: URL? + let modifiedDate: Date? + let sizeBytes: Int64? + let isValidWorld: Bool +} diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift new file mode 100644 index 0000000..c224d09 --- /dev/null +++ b/World Manager for Minecraft/Services/WorldScanner.swift @@ -0,0 +1,133 @@ +// +// WorldScanner.swift +// World Manager for Minecraft +// +// Created by John Burwell on 2026-05-25. +// + +import Combine +import Foundation + +@MainActor +final class WorldScanner: ObservableObject { + @Published var worlds: [MinecraftWorld] = [] + @Published var isScanning = false + @Published var scanStatus = "" + @Published var scanError: String? + + func scan(at parentFolderURL: URL) async { + isScanning = true + scanError = nil + scanStatus = "Scanning worlds..." + worlds = [] + + do { + let worlds = try await Task.detached(priority: .userInitiated) { + try Self.scanWorlds(at: parentFolderURL) + }.value + + self.worlds = worlds + self.scanStatus = worlds.isEmpty ? "No worlds found." : "Found \(worlds.count) worlds." + } catch { + scanError = "Failed to scan folder: \(error.localizedDescription)" + scanStatus = "" + } + + isScanning = false + } + + nonisolated private static func scanWorlds(at parentFolderURL: URL) throws -> [MinecraftWorld] { + let fileManager = FileManager.default + let resourceKeys: Set = [.isDirectoryKey, .contentModificationDateKey] + let children = try fileManager.contentsOfDirectory( + at: parentFolderURL, + includingPropertiesForKeys: Array(resourceKeys), + options: [.skipsHiddenFiles] + ) + + return try children.compactMap { childURL in + let values = try childURL.resourceValues(forKeys: resourceKeys) + guard values.isDirectory == true else { + return nil + } + + let isValidWorld = isLikelyWorldFolder(childURL, fileManager: fileManager) + guard isValidWorld else { + return nil + } + + let folderName = childURL.lastPathComponent + let displayName = readDisplayName(in: childURL, fallback: folderName) + let iconURL = iconURL(in: childURL, fileManager: fileManager) + let sizeBytes = folderSize(at: childURL, fileManager: fileManager) + + return MinecraftWorld( + folderURL: childURL, + folderName: folderName, + displayName: displayName, + iconURL: iconURL, + modifiedDate: values.contentModificationDate, + sizeBytes: sizeBytes, + isValidWorld: true + ) + } + .sorted { lhs, rhs in + lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + + nonisolated private static func isLikelyWorldFolder(_ url: URL, fileManager: FileManager) -> Bool { + let levelDatURL = url.appendingPathComponent("level.dat") + let dbURL = url.appendingPathComponent("db", isDirectory: true) + let levelNameURL = url.appendingPathComponent("levelname.txt") + + return fileManager.fileExists(atPath: levelDatURL.path) + || fileManager.fileExists(atPath: dbURL.path) + || fileManager.fileExists(atPath: levelNameURL.path) + } + + nonisolated private static func readDisplayName(in worldURL: URL, fallback: String) -> String { + let levelNameURL = worldURL.appendingPathComponent("levelname.txt") + + guard + let name = try? String(contentsOf: levelNameURL, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + else { + return fallback + } + + return name + } + + nonisolated private static func iconURL(in worldURL: URL, fileManager: FileManager) -> URL? { + let iconURL = worldURL.appendingPathComponent("world_icon.jpeg") + return fileManager.fileExists(atPath: iconURL.path) ? iconURL : nil + } + + nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? { + guard let enumerator = fileManager.enumerator( + at: folderURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + var totalSize: Int64 = 0 + + for case let fileURL as URL in enumerator { + guard + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, + let fileSize = values.fileSize + else { + continue + } + + totalSize += Int64(fileSize) + } + + return totalSize + } +} diff --git a/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift b/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift index cc63824..ea40390 100644 --- a/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift +++ b/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift @@ -6,27 +6,12 @@ // import SwiftUI -import SwiftData @main struct World_Manager_for_MinecraftApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() - var body: some Scene { WindowGroup { ContentView() } - .modelContainer(sharedModelContainer) } }