concurrency
This commit is contained in:
parent
08574cb259
commit
56f7ea7055
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
DerivedData/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Xcode user data
|
||||
*.xcuserstate
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata/
|
||||
|
||||
# Swift Package Manager local state
|
||||
.swiftpm/
|
||||
@ -36,6 +36,7 @@ struct ContentView: View {
|
||||
revealFooterURLAction: revealURLInFinder(_:),
|
||||
filters: sidebarFilters(for:)
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
||||
} content: {
|
||||
ItemListColumnView(
|
||||
isEmpty: library.sources.isEmpty,
|
||||
@ -52,11 +53,15 @@ struct ContentView: View {
|
||||
refreshAction: rescanCurrentSource,
|
||||
itemContextMenu: itemContextMenu(for:)
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
|
||||
} detail: {
|
||||
ItemDetailColumnView(
|
||||
item: currentSelectedItem,
|
||||
behaviorPacks: currentSelectedItem.map { packReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||
resourcePacks: currentSelectedItem.map { packReferences(for: $0, type: .resourcePack) } ?? [],
|
||||
behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||
resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
||||
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
||||
backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [],
|
||||
isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
|
||||
contents: currentSelectedItem.map(directoryPreviewEntries(for:)) ?? [],
|
||||
directoryPreviewLimit: directoryPreviewLimit,
|
||||
isEmpty: library.sources.isEmpty,
|
||||
@ -84,6 +89,7 @@ struct ContentView: View {
|
||||
shareItem(item, from: anchorView)
|
||||
}
|
||||
)
|
||||
.frame(minWidth: 450)
|
||||
}
|
||||
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
|
||||
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
|
||||
@ -368,8 +374,54 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func packReferences(for item: MinecraftContentItem, type: MinecraftContentType) -> [ContentPackReference] {
|
||||
item.packReferences.filter { $0.type == type }
|
||||
private func logicalPackReferences(for item: MinecraftContentItem, type: MinecraftContentType) -> [ContentPackReference] {
|
||||
guard
|
||||
item.contentType == .world,
|
||||
let source = currentSource
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
return source.resolvedPackReferences(for: item.id, type: type)
|
||||
}
|
||||
|
||||
private func worldsUsingPack(for item: MinecraftContentItem) -> [MinecraftContentItem] {
|
||||
guard
|
||||
(item.contentType == .behaviorPack || item.contentType == .resourcePack),
|
||||
let source = currentSource,
|
||||
let logicalPack = source.logicalPack(forRepresentativeItemID: item.id)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
return source.worldsUsingPack(logicalPack.id).sorted(by: sortComparator)
|
||||
}
|
||||
|
||||
private func backingPackInstances(for item: MinecraftContentItem) -> [MinecraftContentItem] {
|
||||
guard
|
||||
(item.contentType == .behaviorPack || item.contentType == .resourcePack),
|
||||
let source = currentSource,
|
||||
let logicalPack = source.logicalPack(forRepresentativeItemID: item.id)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
return source
|
||||
.packInstances(for: logicalPack.id)
|
||||
.compactMap { source.rawItem(withID: $0.itemID) }
|
||||
.sorted(by: WorldScanner.sortItems)
|
||||
}
|
||||
|
||||
private func isSuspiciousPack(_ item: MinecraftContentItem) -> Bool {
|
||||
guard
|
||||
(item.contentType == .behaviorPack || item.contentType == .resourcePack),
|
||||
let source = currentSource,
|
||||
let logicalPack = source.logicalPack(forRepresentativeItemID: item.id)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return logicalPack.isSuspicious
|
||||
}
|
||||
|
||||
private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] {
|
||||
@ -716,7 +768,7 @@ private struct SidebarFilterRow: View {
|
||||
|
||||
private struct SidebarSourcesSectionHeaderView: View {
|
||||
var body: some View {
|
||||
Text("Library")
|
||||
Text("Libraries")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(nil)
|
||||
@ -849,6 +901,9 @@ private struct ItemDetailColumnView: View {
|
||||
let item: MinecraftContentItem?
|
||||
let behaviorPacks: [ContentPackReference]
|
||||
let resourcePacks: [ContentPackReference]
|
||||
let worldsUsingPack: [MinecraftContentItem]
|
||||
let backingPackInstances: [MinecraftContentItem]
|
||||
let isSuspiciousPack: Bool
|
||||
let contents: [DirectoryPreviewEntry]
|
||||
let directoryPreviewLimit: Int
|
||||
let isEmpty: Bool
|
||||
@ -868,6 +923,9 @@ private struct ItemDetailColumnView: View {
|
||||
item: item,
|
||||
behaviorPacks: behaviorPacks,
|
||||
resourcePacks: resourcePacks,
|
||||
worldsUsingPack: worldsUsingPack,
|
||||
backingPackInstances: backingPackInstances,
|
||||
isSuspiciousPack: isSuspiciousPack,
|
||||
contents: contents,
|
||||
directoryPreviewLimit: directoryPreviewLimit
|
||||
)
|
||||
@ -924,7 +982,7 @@ private struct ContentRowView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if !item.metadataLoaded {
|
||||
if !item.metadataLoaded || !item.sizeLoaded {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
@ -934,9 +992,14 @@ private struct ContentRowView: View {
|
||||
}
|
||||
|
||||
private var metadataLine: String {
|
||||
let sizeText = item.sizeBytes.map {
|
||||
ByteCountFormatter.string(fromByteCount: $0, countStyle: .file)
|
||||
} ?? "Size unavailable"
|
||||
let sizeText: String
|
||||
if let sizeBytes = item.sizeBytes {
|
||||
sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||
} else if item.metadataLoaded {
|
||||
sizeText = "Calculating size..."
|
||||
} else {
|
||||
sizeText = "Loading metadata..."
|
||||
}
|
||||
let dateText = item.displayDate.map {
|
||||
$0.formatted(date: .abbreviated, time: .omitted)
|
||||
} ?? "Date unavailable"
|
||||
@ -949,6 +1012,9 @@ private struct ItemDetailView: View {
|
||||
let item: MinecraftContentItem
|
||||
let behaviorPacks: [ContentPackReference]
|
||||
let resourcePacks: [ContentPackReference]
|
||||
let worldsUsingPack: [MinecraftContentItem]
|
||||
let backingPackInstances: [MinecraftContentItem]
|
||||
let isSuspiciousPack: Bool
|
||||
let contents: [DirectoryPreviewEntry]
|
||||
let directoryPreviewLimit: Int
|
||||
@State private var isTechnicalDetailsExpanded = false
|
||||
@ -975,6 +1041,12 @@ private struct ItemDetailView: View {
|
||||
Text("Details")
|
||||
.font(.headline)
|
||||
|
||||
if isSuspiciousPack {
|
||||
Label("Manifest UUID is missing or unreadable for this pack.", systemImage: "exclamationmark.triangle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
detailValueRow(title: "Size", value: sizeText)
|
||||
detailValueRow(title: item.displayDateLabel, value: displayDateText)
|
||||
|
||||
@ -1004,6 +1076,52 @@ private struct ItemDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !worldsUsingPack.isEmpty {
|
||||
detailCard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Used By Worlds")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(worldsUsingPack) { world in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
PackReferenceIconView(iconURL: world.iconURL)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(world.displayName)
|
||||
|
||||
Text(worldUsageSecondaryText(for: world))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !backingPackInstances.isEmpty {
|
||||
detailCard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Pack Instances")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(backingPackInstances) { instance in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
PackReferenceIconView(iconURL: instance.iconURL)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(instance.folderName)
|
||||
|
||||
Text(packInstanceSecondaryText(for: instance))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detailCard {
|
||||
DisclosureGroup(isExpanded: $isTechnicalDetailsExpanded) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
@ -1052,7 +1170,7 @@ private struct ItemDetailView: View {
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 760, alignment: .leading)
|
||||
.frame(maxWidth: 450, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1113,7 +1231,11 @@ private struct ItemDetailView: View {
|
||||
}
|
||||
|
||||
private var sizeText: String {
|
||||
item.sizeBytes.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "Unknown"
|
||||
if let sizeBytes = item.sizeBytes {
|
||||
return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||
}
|
||||
|
||||
return item.metadataLoaded ? "Calculating..." : "Loading..."
|
||||
}
|
||||
|
||||
private var displayDateText: String {
|
||||
@ -1125,6 +1247,19 @@ private struct ItemDetailView: View {
|
||||
.compactMap { $0 }
|
||||
return components.isEmpty ? nil : components.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private func worldUsageSecondaryText(for world: MinecraftContentItem) -> String {
|
||||
let dateText = world.displayDate?.formatted(date: .abbreviated, time: .omitted) ?? "Date unavailable"
|
||||
return "\(world.displayDateLabel) \(dateText)"
|
||||
}
|
||||
|
||||
private func packInstanceSecondaryText(for instance: MinecraftContentItem) -> String {
|
||||
if instance.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName) {
|
||||
return "Embedded in world copy"
|
||||
}
|
||||
|
||||
return "Top-level pack folder"
|
||||
}
|
||||
}
|
||||
|
||||
private struct DirectoryPreviewEntry: Identifiable {
|
||||
|
||||
150
World Manager for Minecraft/Models/LibraryIndex.swift
Normal file
150
World Manager for Minecraft/Models/LibraryIndex.swift
Normal file
@ -0,0 +1,150 @@
|
||||
//
|
||||
// LibraryIndex.swift
|
||||
// World Manager for Minecraft
|
||||
//
|
||||
// Created by OpenAI on 2026-05-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PackIdentitySource: String, Hashable, Sendable {
|
||||
case manifestUUID
|
||||
case fallback
|
||||
}
|
||||
|
||||
struct PackIdentity: Hashable, Sendable, Identifiable {
|
||||
let type: MinecraftContentType
|
||||
let uuid: String?
|
||||
let version: String?
|
||||
let fallbackName: String
|
||||
let fallbackLocationHint: String?
|
||||
let source: PackIdentitySource
|
||||
|
||||
var id: String {
|
||||
[
|
||||
type.rawValue,
|
||||
uuid ?? normalizedFallbackName,
|
||||
version ?? "",
|
||||
fallbackLocationHint ?? ""
|
||||
].joined(separator: "::")
|
||||
}
|
||||
|
||||
var isSuspicious: Bool {
|
||||
source == .fallback
|
||||
}
|
||||
|
||||
init(
|
||||
type: MinecraftContentType,
|
||||
uuid: String?,
|
||||
version: String?,
|
||||
fallbackName: String,
|
||||
fallbackLocationHint: String?
|
||||
) {
|
||||
self.type = type
|
||||
self.uuid = uuid?.lowercased()
|
||||
self.version = version
|
||||
self.fallbackName = fallbackName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.fallbackLocationHint = fallbackLocationHint
|
||||
self.source = self.uuid == nil ? .fallback : .manifestUUID
|
||||
}
|
||||
|
||||
private var normalizedFallbackName: String {
|
||||
fallbackName.lowercased()
|
||||
}
|
||||
|
||||
static func == (lhs: PackIdentity, rhs: PackIdentity) -> Bool {
|
||||
guard lhs.type == rhs.type else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let lhsUUID = lhs.uuid, let rhsUUID = rhs.uuid {
|
||||
return lhsUUID == rhsUUID && lhs.version == rhs.version
|
||||
}
|
||||
|
||||
return lhs.uuid == rhs.uuid
|
||||
&& lhs.version == rhs.version
|
||||
&& lhs.normalizedFallbackName == rhs.normalizedFallbackName
|
||||
&& lhs.fallbackLocationHint == rhs.fallbackLocationHint
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(type)
|
||||
|
||||
if let uuid {
|
||||
hasher.combine(uuid)
|
||||
hasher.combine(version)
|
||||
return
|
||||
}
|
||||
|
||||
hasher.combine(uuid)
|
||||
hasher.combine(version)
|
||||
hasher.combine(normalizedFallbackName)
|
||||
hasher.combine(fallbackLocationHint)
|
||||
}
|
||||
}
|
||||
|
||||
struct PackInstance: Identifiable, Hashable, Sendable {
|
||||
let id: URL
|
||||
let itemID: URL
|
||||
let sourceID: URL
|
||||
let logicalPackID: PackIdentity
|
||||
let origin: PackSource
|
||||
let hostWorldItemID: URL?
|
||||
}
|
||||
|
||||
struct LogicalPack: Identifiable, Hashable, Sendable {
|
||||
let id: PackIdentity
|
||||
let contentType: MinecraftContentType
|
||||
let displayName: String
|
||||
let uuid: String?
|
||||
let version: String?
|
||||
let representativeItemID: URL
|
||||
let instanceItemIDs: [URL]
|
||||
let isSuspicious: Bool
|
||||
}
|
||||
|
||||
struct LogicalWorld: Identifiable, Hashable, Sendable {
|
||||
let id: URL
|
||||
let itemID: URL
|
||||
let usedPackIDs: [PackIdentity]
|
||||
let unresolvedReferences: [ContentPackReference]
|
||||
}
|
||||
|
||||
struct WorldPackRelationship: Identifiable, Hashable, Sendable {
|
||||
let worldItemID: URL
|
||||
let logicalPackID: PackIdentity?
|
||||
let reference: ContentPackReference
|
||||
|
||||
var id: String {
|
||||
[
|
||||
worldItemID.path,
|
||||
logicalPackID?.id ?? "unresolved",
|
||||
reference.id
|
||||
].joined(separator: "::")
|
||||
}
|
||||
}
|
||||
|
||||
struct ItemSnapshot: Identifiable, Hashable, Sendable {
|
||||
let id: URL
|
||||
let relativePath: String
|
||||
let modifiedDate: Date?
|
||||
let sizeBytes: Int64?
|
||||
let packUUID: String?
|
||||
let packVersion: String?
|
||||
}
|
||||
|
||||
struct CollectionSnapshot: Identifiable, Hashable, Sendable {
|
||||
let folderName: String
|
||||
let modifiedDate: Date?
|
||||
let childDirectoryCount: Int
|
||||
let fingerprint: String
|
||||
|
||||
var id: String { folderName }
|
||||
}
|
||||
|
||||
struct SourceSnapshot: Hashable, Sendable {
|
||||
let sourceID: URL
|
||||
let rootModifiedDate: Date?
|
||||
let collectionSnapshots: [CollectionSnapshot]
|
||||
let itemSnapshots: [ItemSnapshot]
|
||||
}
|
||||
@ -104,8 +104,11 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
||||
var lastPlayedDate: Date?
|
||||
var modifiedDate: Date?
|
||||
var sizeBytes: Int64?
|
||||
var packUUID: String?
|
||||
var packVersion: String?
|
||||
var packReferences: [ContentPackReference]
|
||||
var metadataLoaded: Bool
|
||||
var sizeLoaded: Bool
|
||||
|
||||
nonisolated init(
|
||||
folderURL: URL,
|
||||
@ -117,8 +120,11 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
||||
lastPlayedDate: Date? = nil,
|
||||
modifiedDate: Date? = nil,
|
||||
sizeBytes: Int64? = nil,
|
||||
packUUID: String? = nil,
|
||||
packVersion: String? = nil,
|
||||
packReferences: [ContentPackReference] = [],
|
||||
metadataLoaded: Bool = false
|
||||
metadataLoaded: Bool = false,
|
||||
sizeLoaded: Bool = false
|
||||
) {
|
||||
self.id = folderURL.standardizedFileURL
|
||||
self.folderURL = folderURL
|
||||
@ -130,8 +136,11 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
||||
self.lastPlayedDate = lastPlayedDate
|
||||
self.modifiedDate = modifiedDate
|
||||
self.sizeBytes = sizeBytes
|
||||
self.packUUID = packUUID?.lowercased()
|
||||
self.packVersion = packVersion
|
||||
self.packReferences = packReferences
|
||||
self.metadataLoaded = metadataLoaded
|
||||
self.sizeLoaded = sizeLoaded
|
||||
}
|
||||
|
||||
nonisolated var folderID: String {
|
||||
|
||||
@ -11,7 +11,13 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
let id: URL
|
||||
let folderURL: URL
|
||||
var displayName: String
|
||||
var items: [MinecraftContentItem]
|
||||
var displayItems: [MinecraftContentItem]
|
||||
var rawItems: [MinecraftContentItem]
|
||||
var logicalPacks: [LogicalPack]
|
||||
var logicalWorlds: [LogicalWorld]
|
||||
var packInstances: [PackInstance]
|
||||
var worldPackRelationships: [WorldPackRelationship]
|
||||
var snapshot: SourceSnapshot?
|
||||
var isScanning: Bool
|
||||
var scanStatus: String
|
||||
var scanError: String?
|
||||
@ -24,7 +30,13 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
self.id = normalizedURL
|
||||
self.folderURL = normalizedURL
|
||||
self.displayName = normalizedURL.lastPathComponent
|
||||
self.items = []
|
||||
self.displayItems = []
|
||||
self.rawItems = []
|
||||
self.logicalPacks = []
|
||||
self.logicalWorlds = []
|
||||
self.packInstances = []
|
||||
self.worldPackRelationships = []
|
||||
self.snapshot = nil
|
||||
self.isScanning = false
|
||||
self.scanStatus = ""
|
||||
self.scanError = nil
|
||||
@ -34,6 +46,83 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
var itemCount: Int {
|
||||
items.count
|
||||
displayItems.count
|
||||
}
|
||||
|
||||
var items: [MinecraftContentItem] {
|
||||
displayItems
|
||||
}
|
||||
|
||||
func rawItem(withID itemID: URL) -> MinecraftContentItem? {
|
||||
rawItems.first(where: { $0.id == itemID })
|
||||
}
|
||||
|
||||
func logicalPack(forRepresentativeItemID itemID: URL) -> LogicalPack? {
|
||||
logicalPacks.first(where: { $0.representativeItemID == itemID })
|
||||
}
|
||||
|
||||
func logicalWorld(forItemID itemID: URL) -> LogicalWorld? {
|
||||
logicalWorlds.first(where: { $0.itemID == itemID })
|
||||
}
|
||||
|
||||
func packInstances(for logicalPackID: PackIdentity) -> [PackInstance] {
|
||||
packInstances.filter { $0.logicalPackID == logicalPackID }
|
||||
}
|
||||
|
||||
func worldsUsingPack(_ logicalPackID: PackIdentity) -> [MinecraftContentItem] {
|
||||
worldPackRelationships
|
||||
.filter { $0.logicalPackID == logicalPackID }
|
||||
.compactMap { rawItem(withID: $0.worldItemID) }
|
||||
.uniqued(by: \.id)
|
||||
.sorted(by: WorldScanner.sortItems)
|
||||
}
|
||||
|
||||
func resolvedPackReferences(for worldItemID: URL, type: MinecraftContentType) -> [ContentPackReference] {
|
||||
worldPackRelationships
|
||||
.filter { $0.worldItemID == worldItemID && $0.reference.type == type }
|
||||
.compactMap { relationship in
|
||||
if let logicalPackID = relationship.logicalPackID,
|
||||
let logicalPack = logicalPacks.first(where: { $0.id == logicalPackID }),
|
||||
let representativeItem = rawItem(withID: logicalPack.representativeItemID) {
|
||||
return ContentPackReference(
|
||||
name: logicalPack.displayName,
|
||||
type: logicalPack.contentType,
|
||||
iconURL: representativeItem.iconURL,
|
||||
uuid: logicalPack.uuid,
|
||||
version: logicalPack.version,
|
||||
source: relationship.reference.source
|
||||
)
|
||||
}
|
||||
|
||||
return relationship.reference
|
||||
}
|
||||
.uniqued(by: \.id)
|
||||
}
|
||||
|
||||
private func shouldIncludeAsStandalone(_ item: MinecraftContentItem) -> Bool {
|
||||
switch item.contentType {
|
||||
case .world, .behaviorPack, .resourcePack:
|
||||
return false
|
||||
case .skinPack, .worldTemplate:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func uniqued<Key: Hashable>(by keyPath: KeyPath<Element, Key>) -> [Element] {
|
||||
var seen = Set<Key>()
|
||||
var result: [Element] = []
|
||||
|
||||
for element in self {
|
||||
let key = element[keyPath: keyPath]
|
||||
guard seen.insert(key).inserted else {
|
||||
continue
|
||||
}
|
||||
|
||||
result.append(element)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,22 @@
|
||||
import Foundation
|
||||
|
||||
enum WorldScanner {
|
||||
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
|
||||
let fileManager = FileManager.default
|
||||
var sizedItem = item
|
||||
sizedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager)
|
||||
sizedItem.sizeLoaded = true
|
||||
return sizedItem
|
||||
}
|
||||
|
||||
nonisolated static func beginScanSession(for sourceRootURL: URL) async {
|
||||
await packReferenceIndexStore.reset(for: sourceRootURL)
|
||||
}
|
||||
|
||||
nonisolated static func endScanSession(for sourceRootURL: URL) async {
|
||||
await packReferenceIndexStore.reset(for: sourceRootURL)
|
||||
}
|
||||
|
||||
nonisolated static func discoverItems(
|
||||
in searchRootURL: URL,
|
||||
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
||||
@ -52,6 +68,16 @@ enum WorldScanner {
|
||||
seenItemURLs.insert(itemURL)
|
||||
discoveredItems.append(item)
|
||||
onDiscovered(item)
|
||||
|
||||
if contentType == .world {
|
||||
let embeddedPackItems = discoverEmbeddedPackItems(
|
||||
in: childDirectory,
|
||||
fileManager: fileManager,
|
||||
seenItemURLs: &seenItemURLs
|
||||
)
|
||||
discoveredItems.append(contentsOf: embeddedPackItems)
|
||||
embeddedPackItems.forEach(onDiscovered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -60,7 +86,7 @@ enum WorldScanner {
|
||||
return discoveredItems
|
||||
}
|
||||
|
||||
nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem {
|
||||
nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem {
|
||||
let fileManager = FileManager.default
|
||||
var enrichedItem = item
|
||||
|
||||
@ -68,9 +94,16 @@ enum WorldScanner {
|
||||
enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager)
|
||||
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager)
|
||||
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
|
||||
enrichedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager)
|
||||
enrichedItem.packReferences = packReferences(for: item, fileManager: fileManager)
|
||||
if let manifestMetadata = manifestMetadata(in: item.folderURL, fileManager: fileManager) {
|
||||
enrichedItem.packUUID = manifestMetadata.uuid
|
||||
enrichedItem.packVersion = manifestMetadata.version
|
||||
if !manifestMetadata.name.isEmpty {
|
||||
enrichedItem.displayName = manifestMetadata.name
|
||||
}
|
||||
}
|
||||
enrichedItem.packReferences = await packReferences(for: item, fileManager: fileManager)
|
||||
enrichedItem.metadataLoaded = true
|
||||
enrichedItem.sizeLoaded = false
|
||||
|
||||
return enrichedItem
|
||||
}
|
||||
@ -96,7 +129,7 @@ enum WorldScanner {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func immediateChildDirectories(of directoryURL: URL, fileManager: FileManager) throws -> [URL] {
|
||||
nonisolated fileprivate static func immediateChildDirectories(of directoryURL: URL, fileManager: FileManager) throws -> [URL] {
|
||||
let children = try fileManager.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
@ -122,6 +155,50 @@ enum WorldScanner {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func discoverEmbeddedPackItems(
|
||||
in worldDirectoryURL: URL,
|
||||
fileManager: FileManager,
|
||||
seenItemURLs: inout Set<URL>
|
||||
) -> [MinecraftContentItem] {
|
||||
let embeddedCollections: [(MinecraftContentType, URL)] = [
|
||||
(.behaviorPack, worldDirectoryURL.appendingPathComponent("behavior_packs", isDirectory: true)),
|
||||
(.resourcePack, worldDirectoryURL.appendingPathComponent("resource_packs", isDirectory: true))
|
||||
]
|
||||
|
||||
var embeddedItems: [MinecraftContentItem] = []
|
||||
|
||||
for (contentType, collectionURL) in embeddedCollections {
|
||||
guard
|
||||
fileManager.fileExists(atPath: collectionURL.path),
|
||||
let childDirectories = try? immediateChildDirectories(of: collectionURL, fileManager: fileManager)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
for childDirectory in childDirectories {
|
||||
let itemURL = childDirectory.standardizedFileURL
|
||||
guard !seenItemURLs.contains(itemURL) else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let item = MinecraftContentItem(
|
||||
folderURL: childDirectory,
|
||||
folderName: childDirectory.lastPathComponent,
|
||||
contentType: contentType,
|
||||
collectionRootURL: collectionURL
|
||||
)
|
||||
seenItemURLs.insert(itemURL)
|
||||
embeddedItems.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
return embeddedItems
|
||||
}
|
||||
|
||||
nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String {
|
||||
switch item.contentType {
|
||||
case .world:
|
||||
@ -145,19 +222,7 @@ enum WorldScanner {
|
||||
}
|
||||
|
||||
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
|
||||
manifestMetadata(in: directoryURL, fileManager: fileManager)?.name
|
||||
}
|
||||
|
||||
nonisolated private static func packIconURL(in directoryURL: URL, fileManager: FileManager) -> URL? {
|
||||
@ -235,10 +300,10 @@ enum WorldScanner {
|
||||
return totalSize
|
||||
}
|
||||
|
||||
nonisolated private static func packReferences(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] {
|
||||
nonisolated private static func packReferences(for item: MinecraftContentItem, fileManager: FileManager) async -> [ContentPackReference] {
|
||||
switch item.contentType {
|
||||
case .world:
|
||||
var references = referencedWorldPacks(for: item, fileManager: fileManager)
|
||||
var references = await referencedWorldPacks(for: item, fileManager: fileManager)
|
||||
references.append(contentsOf: embeddedWorldPacks(for: item, fileManager: fileManager))
|
||||
return uniquePackReferences(references)
|
||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||
@ -246,14 +311,14 @@ enum WorldScanner {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func referencedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] {
|
||||
let behaviorReferences = packReferences(
|
||||
nonisolated private static func referencedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) async -> [ContentPackReference] {
|
||||
let behaviorReferences = await packReferences(
|
||||
fromWorldReferenceFileNamed: "world_behavior_packs.json",
|
||||
type: .behaviorPack,
|
||||
worldFolderURL: item.folderURL,
|
||||
fileManager: fileManager
|
||||
)
|
||||
let resourceReferences = packReferences(
|
||||
let resourceReferences = await packReferences(
|
||||
fromWorldReferenceFileNamed: "world_resource_packs.json",
|
||||
type: .resourcePack,
|
||||
worldFolderURL: item.folderURL,
|
||||
@ -289,7 +354,7 @@ enum WorldScanner {
|
||||
type: MinecraftContentType,
|
||||
worldFolderURL: URL,
|
||||
fileManager: FileManager
|
||||
) -> [ContentPackReference] {
|
||||
) async -> [ContentPackReference] {
|
||||
let fileURL = worldFolderURL.appendingPathComponent(filename)
|
||||
guard
|
||||
fileManager.fileExists(atPath: fileURL.path),
|
||||
@ -299,19 +364,24 @@ enum WorldScanner {
|
||||
return []
|
||||
}
|
||||
|
||||
return jsonObject.compactMap { entry in
|
||||
var references: [ContentPackReference] = []
|
||||
|
||||
for entry in jsonObject {
|
||||
let uuid = (entry["pack_id"] as? String)?.lowercased()
|
||||
let version = versionString(from: entry["version"])
|
||||
let resolvedPack = uuid.flatMap {
|
||||
resolvedPackReference(
|
||||
uuid: $0,
|
||||
let resolvedPack: ContentPackReference?
|
||||
if let uuid {
|
||||
resolvedPack = await resolvedPackReference(
|
||||
uuid: uuid,
|
||||
type: type,
|
||||
worldCollectionRootURL: worldFolderURL.deletingLastPathComponent(),
|
||||
fileManager: fileManager
|
||||
worldCollectionRootURL: worldFolderURL.deletingLastPathComponent()
|
||||
)
|
||||
} else {
|
||||
resolvedPack = nil
|
||||
}
|
||||
let fallbackName = resolvedPack?.name ?? uuid ?? "Referenced Pack"
|
||||
return ContentPackReference(
|
||||
references.append(
|
||||
ContentPackReference(
|
||||
name: fallbackName,
|
||||
type: type,
|
||||
iconURL: resolvedPack?.iconURL,
|
||||
@ -319,7 +389,10 @@ enum WorldScanner {
|
||||
version: resolvedPack?.version ?? version,
|
||||
source: .referencedByWorld
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
nonisolated private static func embeddedPackReferences(
|
||||
@ -344,34 +417,22 @@ enum WorldScanner {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func packReference(
|
||||
nonisolated fileprivate static func packReference(
|
||||
fromPackFolder directoryURL: URL,
|
||||
type: MinecraftContentType,
|
||||
source: PackSource,
|
||||
fileManager: FileManager
|
||||
) -> ContentPackReference? {
|
||||
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]
|
||||
else {
|
||||
guard let metadata = manifestMetadata(in: directoryURL, fileManager: fileManager) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let header = jsonObject["header"] as? [String: Any]
|
||||
let name = ((header?["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
|
||||
$0.isEmpty ? nil : $0
|
||||
} ?? directoryURL.lastPathComponent
|
||||
let uuid = (header?["uuid"] as? String)?.lowercased()
|
||||
let version = versionString(from: header?["version"])
|
||||
|
||||
return ContentPackReference(
|
||||
name: name,
|
||||
name: metadata.name,
|
||||
type: type,
|
||||
iconURL: packIconURL(in: directoryURL, fileManager: fileManager),
|
||||
uuid: uuid,
|
||||
version: version,
|
||||
uuid: metadata.uuid,
|
||||
version: metadata.version,
|
||||
source: source
|
||||
)
|
||||
}
|
||||
@ -379,37 +440,17 @@ enum WorldScanner {
|
||||
nonisolated private static func resolvedPackReference(
|
||||
uuid: String,
|
||||
type: MinecraftContentType,
|
||||
worldCollectionRootURL: URL,
|
||||
fileManager: FileManager
|
||||
) -> ContentPackReference? {
|
||||
worldCollectionRootURL: URL
|
||||
) async -> ContentPackReference? {
|
||||
let siblingCollectionURL = worldCollectionRootURL
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
||||
|
||||
guard
|
||||
fileManager.fileExists(atPath: siblingCollectionURL.path),
|
||||
let childDirectories = try? immediateChildDirectories(of: siblingCollectionURL, fileManager: fileManager)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for childDirectory in childDirectories {
|
||||
guard
|
||||
let reference = packReference(
|
||||
fromPackFolder: childDirectory,
|
||||
return await packReferenceIndexStore.reference(
|
||||
forUUID: uuid,
|
||||
type: type,
|
||||
source: .foundInCollection,
|
||||
fileManager: fileManager
|
||||
),
|
||||
reference.uuid == uuid
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
return reference
|
||||
}
|
||||
|
||||
return nil
|
||||
in: siblingCollectionURL
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func versionString(from value: Any?) -> String? {
|
||||
@ -434,6 +475,28 @@ enum WorldScanner {
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated private static func manifestMetadata(in directoryURL: URL, fileManager: FileManager) -> ManifestMetadata? {
|
||||
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]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = ((header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
|
||||
$0.isEmpty ? nil : $0
|
||||
} ?? directoryURL.lastPathComponent
|
||||
|
||||
return ManifestMetadata(
|
||||
name: name,
|
||||
uuid: (header["uuid"] as? String)?.lowercased(),
|
||||
version: versionString(from: header["version"])
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
|
||||
var seen = Set<String>()
|
||||
var uniqueReferences: [ContentPackReference] = []
|
||||
@ -457,3 +520,62 @@ enum WorldScanner {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManifestMetadata {
|
||||
let name: String
|
||||
let uuid: String?
|
||||
let version: String?
|
||||
}
|
||||
|
||||
private actor PackReferenceIndexStore {
|
||||
private var referencesByCollectionURL: [URL: [String: ContentPackReference]] = [:]
|
||||
|
||||
func reset(for sourceRootURL: URL) {
|
||||
let sourceRootPath = sourceRootURL.standardizedFileURL.path
|
||||
referencesByCollectionURL = referencesByCollectionURL.filter { collectionURL, _ in
|
||||
!collectionURL.standardizedFileURL.path.hasPrefix(sourceRootPath + "/")
|
||||
}
|
||||
}
|
||||
|
||||
func reference(forUUID uuid: String, type: MinecraftContentType, in collectionURL: URL) -> ContentPackReference? {
|
||||
let normalizedCollectionURL = collectionURL.standardizedFileURL
|
||||
|
||||
if let cachedReferences = referencesByCollectionURL[normalizedCollectionURL] {
|
||||
return cachedReferences[uuid]
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
guard
|
||||
fileManager.fileExists(atPath: normalizedCollectionURL.path),
|
||||
let childDirectories = try? WorldScanner.immediateChildDirectories(
|
||||
of: normalizedCollectionURL,
|
||||
fileManager: fileManager
|
||||
)
|
||||
else {
|
||||
referencesByCollectionURL[normalizedCollectionURL] = [:]
|
||||
return nil
|
||||
}
|
||||
|
||||
var referencesByUUID: [String: ContentPackReference] = [:]
|
||||
for childDirectory in childDirectories {
|
||||
guard
|
||||
let reference = WorldScanner.packReference(
|
||||
fromPackFolder: childDirectory,
|
||||
type: type,
|
||||
source: .foundInCollection,
|
||||
fileManager: fileManager
|
||||
),
|
||||
let referenceUUID = reference.uuid
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
referencesByUUID[referenceUUID] = reference
|
||||
}
|
||||
|
||||
referencesByCollectionURL[normalizedCollectionURL] = referencesByUUID
|
||||
return referencesByUUID[uuid]
|
||||
}
|
||||
}
|
||||
|
||||
private let packReferenceIndexStore = PackReferenceIndexStore()
|
||||
|
||||
@ -15,6 +15,7 @@ struct World_Manager_for_MinecraftApp: App {
|
||||
.tint(Color("AccentColor"))
|
||||
.background(WindowChromeConfigurator())
|
||||
}
|
||||
.defaultSize(width: 1520, height: 980)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowToolbarStyle(.unified(showsTitle: false))
|
||||
}
|
||||
|
||||
@ -5,13 +5,157 @@
|
||||
// Created by John Burwell on 2026-05-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import World_Manager_for_Minecraft
|
||||
|
||||
@MainActor
|
||||
struct World_Manager_for_MinecraftTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
@Test func packIdentityUsesUUIDAndVersion() async throws {
|
||||
let first = PackIdentity(
|
||||
type: .behaviorPack,
|
||||
uuid: "ABC-123",
|
||||
version: "1.0.0",
|
||||
fallbackName: "Pack A",
|
||||
fallbackLocationHint: "behavior_packs/pack-a"
|
||||
)
|
||||
let second = PackIdentity(
|
||||
type: .behaviorPack,
|
||||
uuid: "abc-123",
|
||||
version: "1.0.0",
|
||||
fallbackName: "Different Name",
|
||||
fallbackLocationHint: "minecraftWorlds/world/behavior_packs/copy"
|
||||
)
|
||||
let third = PackIdentity(
|
||||
type: .behaviorPack,
|
||||
uuid: "abc-123",
|
||||
version: "2.0.0",
|
||||
fallbackName: "Pack A",
|
||||
fallbackLocationHint: "behavior_packs/pack-a-v2"
|
||||
)
|
||||
|
||||
#expect(first == second)
|
||||
#expect(first != third)
|
||||
#expect(first.isSuspicious == false)
|
||||
}
|
||||
|
||||
@Test func minecraftSourceItemsUseLogicalPackRepresentative() async throws {
|
||||
let sourceURL = URL(fileURLWithPath: "/tmp/source")
|
||||
let worldURL = sourceURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true)
|
||||
let topLevelPackURL = sourceURL.appendingPathComponent("behavior_packs/PackA", isDirectory: true)
|
||||
let embeddedPackURL = worldURL.appendingPathComponent("behavior_packs/PackA", isDirectory: true)
|
||||
|
||||
let world = MinecraftContentItem(
|
||||
folderURL: worldURL,
|
||||
folderName: "WorldA",
|
||||
contentType: .world,
|
||||
collectionRootURL: sourceURL.appendingPathComponent("minecraftWorlds", isDirectory: true)
|
||||
)
|
||||
let topLevelPack = MinecraftContentItem(
|
||||
folderURL: topLevelPackURL,
|
||||
folderName: "PackA",
|
||||
contentType: .behaviorPack,
|
||||
collectionRootURL: sourceURL.appendingPathComponent("behavior_packs", isDirectory: true),
|
||||
displayName: "Pack A"
|
||||
)
|
||||
let embeddedPack = MinecraftContentItem(
|
||||
folderURL: embeddedPackURL,
|
||||
folderName: "PackA",
|
||||
contentType: .behaviorPack,
|
||||
collectionRootURL: worldURL.appendingPathComponent("behavior_packs", isDirectory: true),
|
||||
displayName: "Pack A"
|
||||
)
|
||||
let packID = PackIdentity(
|
||||
type: .behaviorPack,
|
||||
uuid: "pack-a",
|
||||
version: "1.0.0",
|
||||
fallbackName: "Pack A",
|
||||
fallbackLocationHint: "behavior_packs/PackA"
|
||||
)
|
||||
|
||||
var source = MinecraftSource(folderURL: sourceURL)
|
||||
source.rawItems = [world, topLevelPack, embeddedPack]
|
||||
source.logicalWorlds = [
|
||||
LogicalWorld(
|
||||
id: world.id,
|
||||
itemID: world.id,
|
||||
usedPackIDs: [packID],
|
||||
unresolvedReferences: []
|
||||
)
|
||||
]
|
||||
source.logicalPacks = [
|
||||
LogicalPack(
|
||||
id: packID,
|
||||
contentType: .behaviorPack,
|
||||
displayName: "Pack A",
|
||||
uuid: "pack-a",
|
||||
version: "1.0.0",
|
||||
representativeItemID: topLevelPack.id,
|
||||
instanceItemIDs: [topLevelPack.id, embeddedPack.id],
|
||||
isSuspicious: false
|
||||
)
|
||||
]
|
||||
|
||||
let displayedItems = source.items
|
||||
|
||||
#expect(displayedItems.count == 2)
|
||||
#expect(displayedItems.contains(where: { $0.id == world.id }))
|
||||
#expect(displayedItems.contains(where: { $0.id == topLevelPack.id }))
|
||||
#expect(displayedItems.contains(where: { $0.id == embeddedPack.id }) == false)
|
||||
}
|
||||
|
||||
@Test func worldScannerResolvesReferencedPackFromIndexedCollection() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let sourceURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let worldsURL = sourceURL.appendingPathComponent("minecraftWorlds", isDirectory: true)
|
||||
let worldURL = worldsURL.appendingPathComponent("WorldA", isDirectory: true)
|
||||
let packsURL = sourceURL.appendingPathComponent("behavior_packs", isDirectory: true)
|
||||
let packURL = packsURL.appendingPathComponent("PackA", isDirectory: true)
|
||||
|
||||
try fileManager.createDirectory(at: worldURL, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: packURL, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: sourceURL) }
|
||||
|
||||
let manifest = """
|
||||
{
|
||||
"header": {
|
||||
"name": "Pack A",
|
||||
"uuid": "pack-a",
|
||||
"version": [1, 0, 0]
|
||||
}
|
||||
}
|
||||
"""
|
||||
let worldReference = """
|
||||
[
|
||||
{
|
||||
"pack_id": "pack-a",
|
||||
"version": [1, 0, 0]
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
try manifest.write(to: packURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
|
||||
try worldReference.write(
|
||||
to: worldURL.appendingPathComponent("world_behavior_packs.json"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
|
||||
let world = MinecraftContentItem(
|
||||
folderURL: worldURL,
|
||||
folderName: "WorldA",
|
||||
contentType: .world,
|
||||
collectionRootURL: worldsURL
|
||||
)
|
||||
|
||||
await WorldScanner.beginScanSession(for: sourceURL)
|
||||
let enrichedWorld = await WorldScanner.enrich(item: world)
|
||||
|
||||
#expect(enrichedWorld.packReferences.count == 1)
|
||||
#expect(enrichedWorld.packReferences.first?.name == "Pack A")
|
||||
#expect(enrichedWorld.packReferences.first?.uuid == "pack-a")
|
||||
#expect(enrichedWorld.packReferences.first?.version == "1.0.0")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user