Tidy up the sidebar and implement scanning
This commit is contained in:
parent
7d51075631
commit
1c06e4f67b
@ -11,12 +11,13 @@ import SwiftUI
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var scanner = WorldScanner()
|
@StateObject private var scanner = WorldScanner()
|
||||||
@State private var folderURL: URL?
|
@State private var folderURL: URL?
|
||||||
@State private var selectedWorld: MinecraftWorld?
|
@State private var selectedItem: MinecraftContentItem?
|
||||||
|
@State private var selectedSidebarSelection: SidebarSelection = .all
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Button("Choose Minecraft Worlds Folder...") {
|
Button("Choose Minecraft Folder...") {
|
||||||
pickFolder()
|
pickFolder()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,61 +32,228 @@ struct ContentView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.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 {
|
if let scanError = scanner.scanError {
|
||||||
Text(scanError)
|
Text(scanError)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
List(selection: $selectedSidebarSelection) {
|
||||||
|
if let folderURL {
|
||||||
|
Section(folderURL.lastPathComponent) {
|
||||||
|
ForEach(sidebarFilters) { filter in
|
||||||
|
SidebarFilterRow(filter: filter)
|
||||||
|
.tag(filter.selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.navigationTitle("Folder")
|
.navigationTitle("Source")
|
||||||
} content: {
|
} content: {
|
||||||
List(scanner.worlds, selection: $selectedWorld) { world in
|
List(filteredItems, selection: $selectedItem) { item in
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack(alignment: .top, spacing: 10) {
|
||||||
Text(world.displayName)
|
ItemThumbnailView(iconURL: item.iconURL)
|
||||||
Text(world.folderName)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Worlds")
|
|
||||||
} detail: {
|
|
||||||
if let selectedWorld {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text(selectedWorld.displayName)
|
|
||||||
.font(.title2)
|
|
||||||
|
|
||||||
Text(selectedWorld.folderName)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.font(.headline)
|
Text(item.displayName)
|
||||||
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
||||||
|
|
||||||
if let modifiedDate = selectedWorld.modifiedDate {
|
Text(item.contentType.rawValue)
|
||||||
Text("Modified: \(modifiedDate.formatted(date: .abbreviated, time: .shortened))")
|
.font(.caption)
|
||||||
}
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
if let sizeBytes = selectedWorld.sizeBytes {
|
Text(item.folderName)
|
||||||
Text("Size: \(ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file))")
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
if !item.metadataLoaded {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.tag(item)
|
||||||
|
}
|
||||||
|
.navigationTitle(contentListTitle)
|
||||||
|
} detail: {
|
||||||
|
if let selectedItem = currentSelectedItem {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
LargeItemThumbnailView(iconURL: selectedItem.iconURL)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(selectedItem.displayName)
|
||||||
|
.font(.title2)
|
||||||
|
|
||||||
|
Text(selectedItem.contentType.rawValue)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(selectedItem.folderName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRow(title: "Folder Path", value: selectedItem.folderURL.path)
|
||||||
|
detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path)
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRow(
|
||||||
|
title: "Metadata",
|
||||||
|
value: selectedItem.metadataLoaded ? "Loaded" : "Loading..."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
} else {
|
} else {
|
||||||
Text("Select a world to see details")
|
Text("Select a world or pack to see details")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Minecraft World Manager")
|
.navigationTitle("Minecraft World Manager")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
if scanner.isScanning {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Text(scanner.scanStatus)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 280, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: filteredItems.map(\.id)) { _, filteredIDs in
|
||||||
|
guard let selectedItem, !filteredIDs.contains(selectedItem.id) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.selectedItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredItems: [MinecraftContentItem] {
|
||||||
|
switch selectedSidebarSelection {
|
||||||
|
case .all:
|
||||||
|
return scanner.items
|
||||||
|
case .contentType(let contentType):
|
||||||
|
return scanner.items.filter { $0.contentType == contentType }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentSelectedItem: MinecraftContentItem? {
|
||||||
|
guard let selectedItem else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return scanner.items.first(where: { $0.id == selectedItem.id }) ?? selectedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contentListTitle: String {
|
||||||
|
switch selectedSidebarSelection {
|
||||||
|
case .all:
|
||||||
|
return "Minecraft Content"
|
||||||
|
case .contentType(let contentType):
|
||||||
|
return contentType.rawValue + "s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarFilters: [SidebarFilter] {
|
||||||
|
var filters = [
|
||||||
|
SidebarFilter(
|
||||||
|
title: "All Content",
|
||||||
|
iconName: "square.grid.2x2",
|
||||||
|
count: scanner.items.count,
|
||||||
|
selection: .all
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
filters.append(
|
||||||
|
contentsOf: MinecraftContentType.allCases.compactMap { contentType in
|
||||||
|
let count = scanner.items.filter { $0.contentType == contentType }.count
|
||||||
|
guard count > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return SidebarFilter(
|
||||||
|
title: sidebarTitle(for: contentType),
|
||||||
|
iconName: sidebarIcon(for: contentType),
|
||||||
|
count: count,
|
||||||
|
selection: .contentType(contentType)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sidebarTitle(for contentType: MinecraftContentType) -> String {
|
||||||
|
switch contentType {
|
||||||
|
case .world:
|
||||||
|
return "Worlds"
|
||||||
|
case .behaviorPack:
|
||||||
|
return "Behavior Packs"
|
||||||
|
case .resourcePack:
|
||||||
|
return "Resource Packs"
|
||||||
|
case .skinPack:
|
||||||
|
return "Skin Packs"
|
||||||
|
case .worldTemplate:
|
||||||
|
return "World Templates"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sidebarIcon(for contentType: MinecraftContentType) -> String {
|
||||||
|
switch contentType {
|
||||||
|
case .world:
|
||||||
|
return "globe.europe.africa"
|
||||||
|
case .behaviorPack:
|
||||||
|
return "shippingbox"
|
||||||
|
case .resourcePack:
|
||||||
|
return "paintpalette"
|
||||||
|
case .skinPack:
|
||||||
|
return "person.crop.square"
|
||||||
|
case .worldTemplate:
|
||||||
|
return "doc.on.doc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func detailRow(title: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pickFolder() {
|
private func pickFolder() {
|
||||||
@ -93,14 +261,15 @@ struct ContentView: View {
|
|||||||
panel.allowsMultipleSelection = false
|
panel.allowsMultipleSelection = false
|
||||||
panel.canChooseDirectories = true
|
panel.canChooseDirectories = true
|
||||||
panel.canChooseFiles = false
|
panel.canChooseFiles = false
|
||||||
panel.title = "Choose Minecraft Worlds Folder"
|
panel.title = "Choose a Folder to Search"
|
||||||
|
|
||||||
guard panel.runModal() == .OK, let pickedURL = panel.url else {
|
guard panel.runModal() == .OK, let pickedURL = panel.url else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
folderURL = pickedURL
|
folderURL = pickedURL
|
||||||
selectedWorld = nil
|
selectedItem = nil
|
||||||
|
selectedSidebarSelection = .all
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await scanner.scan(at: pickedURL)
|
await scanner.scan(at: pickedURL)
|
||||||
@ -108,6 +277,91 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum SidebarSelection: Hashable {
|
||||||
|
case all
|
||||||
|
case contentType(MinecraftContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SidebarFilter: Identifiable, Hashable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let iconName: String
|
||||||
|
let count: Int
|
||||||
|
let selection: SidebarSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SidebarFilterRow: View {
|
||||||
|
let filter: SidebarFilter
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: filter.iconName)
|
||||||
|
.frame(width: 16)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(filter.title)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(filter.count, format: .number)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ItemThumbnailView: View {
|
||||||
|
let iconURL: URL?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let image = loadImage(from: iconURL) {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(.quaternary)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "shippingbox")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LargeItemThumbnailView: View {
|
||||||
|
let iconURL: URL?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let image = loadImage(from: iconURL) {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 128, height: 128)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(.quaternary)
|
||||||
|
.frame(width: 128, height: 128)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "shippingbox")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImage(from url: URL?) -> NSImage? {
|
||||||
|
guard let url else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return NSImage(contentsOf: url)
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
|||||||
@ -0,0 +1,75 @@
|
|||||||
|
//
|
||||||
|
// MinecraftContentItem.swift
|
||||||
|
// World Manager for Minecraft
|
||||||
|
//
|
||||||
|
// Created by John Burwell on 2026-05-25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MinecraftContentType: String, CaseIterable, Hashable, Sendable {
|
||||||
|
case world = "World"
|
||||||
|
case behaviorPack = "Behavior Pack"
|
||||||
|
case resourcePack = "Resource Pack"
|
||||||
|
case skinPack = "Skin Pack"
|
||||||
|
case worldTemplate = "World Template"
|
||||||
|
|
||||||
|
nonisolated var collectionFolderName: String {
|
||||||
|
switch self {
|
||||||
|
case .world:
|
||||||
|
return "minecraftWorlds"
|
||||||
|
case .behaviorPack:
|
||||||
|
return "behavior_packs"
|
||||||
|
case .resourcePack:
|
||||||
|
return "resource_packs"
|
||||||
|
case .skinPack:
|
||||||
|
return "skin_packs"
|
||||||
|
case .worldTemplate:
|
||||||
|
return "world_templates"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
||||||
|
let id: URL
|
||||||
|
let folderURL: URL
|
||||||
|
let folderName: String
|
||||||
|
let contentType: MinecraftContentType
|
||||||
|
let collectionRootURL: URL
|
||||||
|
var displayName: String
|
||||||
|
var iconURL: URL?
|
||||||
|
var modifiedDate: Date?
|
||||||
|
var sizeBytes: Int64?
|
||||||
|
var metadataLoaded: Bool
|
||||||
|
|
||||||
|
nonisolated init(
|
||||||
|
folderURL: URL,
|
||||||
|
folderName: String,
|
||||||
|
contentType: MinecraftContentType,
|
||||||
|
collectionRootURL: URL,
|
||||||
|
displayName: String? = nil,
|
||||||
|
iconURL: URL? = nil,
|
||||||
|
modifiedDate: Date? = nil,
|
||||||
|
sizeBytes: Int64? = nil,
|
||||||
|
metadataLoaded: Bool = false
|
||||||
|
) {
|
||||||
|
self.id = folderURL.standardizedFileURL
|
||||||
|
self.folderURL = folderURL
|
||||||
|
self.folderName = folderName
|
||||||
|
self.contentType = contentType
|
||||||
|
self.collectionRootURL = collectionRootURL
|
||||||
|
self.displayName = displayName ?? folderName
|
||||||
|
self.iconURL = iconURL
|
||||||
|
self.modifiedDate = modifiedDate
|
||||||
|
self.sizeBytes = sizeBytes
|
||||||
|
self.metadataLoaded = metadataLoaded
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func == (lhs: MinecraftContentItem, rhs: MinecraftContentItem) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,19 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@ -10,99 +10,242 @@ import Foundation
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class WorldScanner: ObservableObject {
|
final class WorldScanner: ObservableObject {
|
||||||
@Published var worlds: [MinecraftWorld] = []
|
@Published var items: [MinecraftContentItem] = []
|
||||||
@Published var isScanning = false
|
@Published var isScanning = false
|
||||||
@Published var scanStatus = ""
|
@Published var scanStatus = ""
|
||||||
@Published var scanError: String?
|
@Published var scanError: String?
|
||||||
|
|
||||||
func scan(at parentFolderURL: URL) async {
|
private var activeScanID = UUID()
|
||||||
|
|
||||||
|
func scan(at searchRootURL: URL) async {
|
||||||
|
let scanID = UUID()
|
||||||
|
activeScanID = scanID
|
||||||
isScanning = true
|
isScanning = true
|
||||||
scanError = nil
|
scanError = nil
|
||||||
scanStatus = "Scanning worlds..."
|
scanStatus = "Searching for Minecraft content..."
|
||||||
worlds = []
|
items = []
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let worlds = try await Task.detached(priority: .userInitiated) {
|
let discoveredItems = try await Task.detached(priority: .userInitiated) {
|
||||||
try Self.scanWorlds(at: parentFolderURL)
|
try Self.discoverItems(in: searchRootURL)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
self.worlds = worlds
|
guard activeScanID == scanID else {
|
||||||
self.scanStatus = worlds.isEmpty ? "No worlds found." : "Found \(worlds.count) worlds."
|
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 {
|
} catch {
|
||||||
|
guard activeScanID == scanID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
scanError = "Failed to scan folder: \(error.localizedDescription)"
|
scanError = "Failed to scan folder: \(error.localizedDescription)"
|
||||||
scanStatus = ""
|
scanStatus = ""
|
||||||
|
isScanning = false
|
||||||
}
|
}
|
||||||
|
|
||||||
isScanning = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func scanWorlds(at parentFolderURL: URL) throws -> [MinecraftWorld] {
|
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: Set<URLResourceKey> = [.isDirectoryKey, .contentModificationDateKey]
|
let resourceKeys: [URLResourceKey] = [.isDirectoryKey]
|
||||||
|
|
||||||
|
guard let enumerator = fileManager.enumerator(
|
||||||
|
at: searchRootURL,
|
||||||
|
includingPropertiesForKeys: resourceKeys,
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var discoveredItems: [MinecraftContentItem] = []
|
||||||
|
var seenItemURLs = Set<URL>()
|
||||||
|
|
||||||
|
for case let directoryURL as URL in enumerator {
|
||||||
|
guard (try? directoryURL.resourceValues(forKeys: Set(resourceKeys)).isDirectory) == true else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let contentType = contentType(forCollectionFolderName: directoryURL.lastPathComponent) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let childDirectories = try immediateChildDirectories(of: directoryURL, fileManager: fileManager)
|
||||||
|
for childDirectory in childDirectories {
|
||||||
|
let itemURL = childDirectory.standardizedFileURL
|
||||||
|
guard !seenItemURLs.contains(itemURL) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) {
|
||||||
|
seenItemURLs.insert(itemURL)
|
||||||
|
discoveredItems.append(
|
||||||
|
MinecraftContentItem(
|
||||||
|
folderURL: childDirectory,
|
||||||
|
folderName: childDirectory.lastPathComponent,
|
||||||
|
contentType: contentType,
|
||||||
|
collectionRootURL: directoryURL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredItems.sort(by: sortItems)
|
||||||
|
return discoveredItems
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func enrich(item: MinecraftContentItem) -> MinecraftContentItem {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
var enrichedItem = item
|
||||||
|
|
||||||
|
enrichedItem.displayName = displayName(for: item, fileManager: fileManager)
|
||||||
|
enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager)
|
||||||
|
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
|
||||||
|
enrichedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager)
|
||||||
|
enrichedItem.metadataLoaded = true
|
||||||
|
|
||||||
|
return enrichedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? {
|
||||||
|
let normalizedFolderName = folderName.lowercased()
|
||||||
|
|
||||||
|
return MinecraftContentType.allCases.first { type in
|
||||||
|
type.collectionFolderName.lowercased() == normalizedFolderName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func immediateChildDirectories(of directoryURL: URL, fileManager: FileManager) throws -> [URL] {
|
||||||
let children = try fileManager.contentsOfDirectory(
|
let children = try fileManager.contentsOfDirectory(
|
||||||
at: parentFolderURL,
|
at: directoryURL,
|
||||||
includingPropertiesForKeys: Array(resourceKeys),
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
options: [.skipsHiddenFiles]
|
options: [.skipsHiddenFiles]
|
||||||
)
|
)
|
||||||
|
|
||||||
return try children.compactMap { childURL in
|
return children.filter {
|
||||||
let values = try childURL.resourceValues(forKeys: resourceKeys)
|
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
||||||
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 {
|
nonisolated private static func isCandidateItem(at directoryURL: URL, type: MinecraftContentType, fileManager: FileManager) -> Bool {
|
||||||
let levelDatURL = url.appendingPathComponent("level.dat")
|
switch type {
|
||||||
let dbURL = url.appendingPathComponent("db", isDirectory: true)
|
case .world:
|
||||||
let levelNameURL = url.appendingPathComponent("levelname.txt")
|
return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("level.dat").path)
|
||||||
|
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("db", isDirectory: true).path)
|
||||||
return fileManager.fileExists(atPath: levelDatURL.path)
|
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("levelname.txt").path)
|
||||||
|| fileManager.fileExists(atPath: dbURL.path)
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
|| fileManager.fileExists(atPath: levelNameURL.path)
|
return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("manifest.json").path)
|
||||||
|
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("pack_icon.png").path)
|
||||||
|
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("pack_icon.jpeg").path)
|
||||||
|
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("pack_icon.jpg").path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func readDisplayName(in worldURL: URL, fallback: String) -> String {
|
nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String {
|
||||||
let levelNameURL = worldURL.appendingPathComponent("levelname.txt")
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
let levelNameURL = item.folderURL.appendingPathComponent("levelname.txt")
|
||||||
|
guard
|
||||||
|
let name = try? String(contentsOf: levelNameURL, encoding: .utf8)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!name.isEmpty
|
||||||
|
else {
|
||||||
|
return item.folderName
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
|
if let manifestName = manifestName(in: item.folderURL, fileManager: fileManager) {
|
||||||
|
return manifestName
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.folderName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func manifestName(in directoryURL: URL, fileManager: FileManager) -> String? {
|
||||||
|
let manifestURL = directoryURL.appendingPathComponent("manifest.json")
|
||||||
guard
|
guard
|
||||||
let name = try? String(contentsOf: levelNameURL, encoding: .utf8)
|
fileManager.fileExists(atPath: manifestURL.path),
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
let data = try? Data(contentsOf: manifestURL),
|
||||||
|
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let header = jsonObject["header"] as? [String: Any],
|
||||||
|
let name = (header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!name.isEmpty
|
!name.isEmpty
|
||||||
else {
|
else {
|
||||||
return fallback
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func iconURL(in worldURL: URL, fileManager: FileManager) -> URL? {
|
nonisolated private static func iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? {
|
||||||
let iconURL = worldURL.appendingPathComponent("world_icon.jpeg")
|
let candidateNames: [String]
|
||||||
return fileManager.fileExists(atPath: iconURL.path) ? iconURL : nil
|
|
||||||
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
candidateNames = ["world_icon.jpeg", "world_icon.jpg", "world_icon.png"]
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
|
candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidateName in candidateNames {
|
||||||
|
let candidateURL = item.folderURL.appendingPathComponent(candidateName)
|
||||||
|
if fileManager.fileExists(atPath: candidateURL.path) {
|
||||||
|
return candidateURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? {
|
||||||
|
try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
|
nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
|
||||||
@ -130,4 +273,17 @@ 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