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 {
|
||||
@StateObject private var scanner = WorldScanner()
|
||||
@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 {
|
||||
NavigationSplitView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Button("Choose Minecraft Worlds Folder...") {
|
||||
Button("Choose Minecraft Folder...") {
|
||||
pickFolder()
|
||||
}
|
||||
|
||||
@ -31,61 +32,228 @@ struct ContentView: View {
|
||||
.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()
|
||||
List(selection: $selectedSidebarSelection) {
|
||||
if let folderURL {
|
||||
Section(folderURL.lastPathComponent) {
|
||||
ForEach(sidebarFilters) { filter in
|
||||
SidebarFilterRow(filter: filter)
|
||||
.tag(filter.selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Folder")
|
||||
.navigationTitle("Source")
|
||||
} content: {
|
||||
List(scanner.worlds, selection: $selectedWorld) { world in
|
||||
List(filteredItems, selection: $selectedItem) { item in
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ItemThumbnailView(iconURL: item.iconURL)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(world.displayName)
|
||||
Text(world.folderName)
|
||||
Text(item.displayName)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(item.contentType.rawValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(item.folderName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !item.metadataLoaded {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Worlds")
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(Rectangle())
|
||||
.tag(item)
|
||||
}
|
||||
.navigationTitle(contentListTitle)
|
||||
} detail: {
|
||||
if let selectedWorld {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(selectedWorld.displayName)
|
||||
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(selectedWorld.folderName)
|
||||
Text(selectedItem.contentType.rawValue)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let modifiedDate = selectedWorld.modifiedDate {
|
||||
Text("Modified: \(modifiedDate.formatted(date: .abbreviated, time: .shortened))")
|
||||
Text(selectedItem.folderName)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let sizeBytes = selectedWorld.sizeBytes {
|
||||
Text("Size: \(ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file))")
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
if let sizeBytes = selectedItem.sizeBytes {
|
||||
detailRow(
|
||||
title: "Size",
|
||||
value: ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||
)
|
||||
}
|
||||
|
||||
detailRow(
|
||||
title: "Metadata",
|
||||
value: selectedItem.metadataLoaded ? "Loaded" : "Loading..."
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
} else {
|
||||
Text("Select a world to see details")
|
||||
Text("Select a world or pack to see details")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.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() {
|
||||
@ -93,14 +261,15 @@ struct ContentView: View {
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = true
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
folderURL = pickedURL
|
||||
selectedWorld = nil
|
||||
selectedItem = nil
|
||||
selectedSidebarSelection = .all
|
||||
|
||||
Task {
|
||||
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 {
|
||||
static var previews: some View {
|
||||
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
|
||||
final class WorldScanner: ObservableObject {
|
||||
@Published var worlds: [MinecraftWorld] = []
|
||||
@Published var items: [MinecraftContentItem] = []
|
||||
@Published var isScanning = false
|
||||
@Published var scanStatus = ""
|
||||
@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
|
||||
scanError = nil
|
||||
scanStatus = "Scanning worlds..."
|
||||
worlds = []
|
||||
scanStatus = "Searching for Minecraft content..."
|
||||
items = []
|
||||
|
||||
do {
|
||||
let worlds = try await Task.detached(priority: .userInitiated) {
|
||||
try Self.scanWorlds(at: parentFolderURL)
|
||||
let discoveredItems = try await Task.detached(priority: .userInitiated) {
|
||||
try Self.discoverItems(in: searchRootURL)
|
||||
}.value
|
||||
|
||||
self.worlds = worlds
|
||||
self.scanStatus = worlds.isEmpty ? "No worlds found." : "Found \(worlds.count) worlds."
|
||||
} catch {
|
||||
scanError = "Failed to scan folder: \(error.localizedDescription)"
|
||||
scanStatus = ""
|
||||
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
|
||||
}
|
||||
|
||||
nonisolated private static func scanWorlds(at parentFolderURL: URL) throws -> [MinecraftWorld] {
|
||||
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 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(
|
||||
at: parentFolderURL,
|
||||
includingPropertiesForKeys: Array(resourceKeys),
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
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
|
||||
return children.filter {
|
||||
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
||||
}
|
||||
}
|
||||
|
||||
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 isCandidateItem(at directoryURL: URL, type: MinecraftContentType, fileManager: FileManager) -> Bool {
|
||||
switch type {
|
||||
case .world:
|
||||
return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("level.dat").path)
|
||||
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("db", isDirectory: true).path)
|
||||
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("levelname.txt").path)
|
||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||
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 {
|
||||
let levelNameURL = worldURL.appendingPathComponent("levelname.txt")
|
||||
|
||||
nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String {
|
||||
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 fallback
|
||||
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
|
||||
fileManager.fileExists(atPath: manifestURL.path),
|
||||
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
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
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 iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? {
|
||||
let candidateNames: [String]
|
||||
|
||||
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? {
|
||||
@ -130,4 +273,17 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user