world-manager/World Manager for Minecraft/Services/WorldScanner.swift

208 lines
8.1 KiB
Swift

//
// WorldScanner.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import Foundation
enum WorldScanner {
nonisolated static func discoverItems(in searchRootURL: URL) throws -> [MinecraftContentItem] {
let fileManager = FileManager.default
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 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 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()
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: directoryURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)
return children.filter {
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
}
}
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 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 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(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? {
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
}
}