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(_:),
|
revealFooterURLAction: revealURLInFinder(_:),
|
||||||
filters: sidebarFilters(for:)
|
filters: sidebarFilters(for:)
|
||||||
)
|
)
|
||||||
|
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
||||||
} content: {
|
} content: {
|
||||||
ItemListColumnView(
|
ItemListColumnView(
|
||||||
isEmpty: library.sources.isEmpty,
|
isEmpty: library.sources.isEmpty,
|
||||||
@ -52,11 +53,15 @@ struct ContentView: View {
|
|||||||
refreshAction: rescanCurrentSource,
|
refreshAction: rescanCurrentSource,
|
||||||
itemContextMenu: itemContextMenu(for:)
|
itemContextMenu: itemContextMenu(for:)
|
||||||
)
|
)
|
||||||
|
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
|
||||||
} detail: {
|
} detail: {
|
||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: currentSelectedItem,
|
item: currentSelectedItem,
|
||||||
behaviorPacks: currentSelectedItem.map { packReferences(for: $0, type: .behaviorPack) } ?? [],
|
behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||||
resourcePacks: currentSelectedItem.map { packReferences(for: $0, type: .resourcePack) } ?? [],
|
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:)) ?? [],
|
contents: currentSelectedItem.map(directoryPreviewEntries(for:)) ?? [],
|
||||||
directoryPreviewLimit: directoryPreviewLimit,
|
directoryPreviewLimit: directoryPreviewLimit,
|
||||||
isEmpty: library.sources.isEmpty,
|
isEmpty: library.sources.isEmpty,
|
||||||
@ -84,6 +89,7 @@ struct ContentView: View {
|
|||||||
shareItem(item, from: anchorView)
|
shareItem(item, from: anchorView)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.frame(minWidth: 450)
|
||||||
}
|
}
|
||||||
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
|
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
|
||||||
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
|
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
|
||||||
@ -368,8 +374,54 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func packReferences(for item: MinecraftContentItem, type: MinecraftContentType) -> [ContentPackReference] {
|
private func logicalPackReferences(for item: MinecraftContentItem, type: MinecraftContentType) -> [ContentPackReference] {
|
||||||
item.packReferences.filter { $0.type == type }
|
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] {
|
private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] {
|
||||||
@ -716,7 +768,7 @@ private struct SidebarFilterRow: View {
|
|||||||
|
|
||||||
private struct SidebarSourcesSectionHeaderView: View {
|
private struct SidebarSourcesSectionHeaderView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("Library")
|
Text("Libraries")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.textCase(nil)
|
.textCase(nil)
|
||||||
@ -849,6 +901,9 @@ private struct ItemDetailColumnView: View {
|
|||||||
let item: MinecraftContentItem?
|
let item: MinecraftContentItem?
|
||||||
let behaviorPacks: [ContentPackReference]
|
let behaviorPacks: [ContentPackReference]
|
||||||
let resourcePacks: [ContentPackReference]
|
let resourcePacks: [ContentPackReference]
|
||||||
|
let worldsUsingPack: [MinecraftContentItem]
|
||||||
|
let backingPackInstances: [MinecraftContentItem]
|
||||||
|
let isSuspiciousPack: Bool
|
||||||
let contents: [DirectoryPreviewEntry]
|
let contents: [DirectoryPreviewEntry]
|
||||||
let directoryPreviewLimit: Int
|
let directoryPreviewLimit: Int
|
||||||
let isEmpty: Bool
|
let isEmpty: Bool
|
||||||
@ -868,6 +923,9 @@ private struct ItemDetailColumnView: View {
|
|||||||
item: item,
|
item: item,
|
||||||
behaviorPacks: behaviorPacks,
|
behaviorPacks: behaviorPacks,
|
||||||
resourcePacks: resourcePacks,
|
resourcePacks: resourcePacks,
|
||||||
|
worldsUsingPack: worldsUsingPack,
|
||||||
|
backingPackInstances: backingPackInstances,
|
||||||
|
isSuspiciousPack: isSuspiciousPack,
|
||||||
contents: contents,
|
contents: contents,
|
||||||
directoryPreviewLimit: directoryPreviewLimit
|
directoryPreviewLimit: directoryPreviewLimit
|
||||||
)
|
)
|
||||||
@ -924,7 +982,7 @@ private struct ContentRowView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if !item.metadataLoaded {
|
if !item.metadataLoaded || !item.sizeLoaded {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
@ -934,9 +992,14 @@ private struct ContentRowView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var metadataLine: String {
|
private var metadataLine: String {
|
||||||
let sizeText = item.sizeBytes.map {
|
let sizeText: String
|
||||||
ByteCountFormatter.string(fromByteCount: $0, countStyle: .file)
|
if let sizeBytes = item.sizeBytes {
|
||||||
} ?? "Size unavailable"
|
sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||||
|
} else if item.metadataLoaded {
|
||||||
|
sizeText = "Calculating size..."
|
||||||
|
} else {
|
||||||
|
sizeText = "Loading metadata..."
|
||||||
|
}
|
||||||
let dateText = item.displayDate.map {
|
let dateText = item.displayDate.map {
|
||||||
$0.formatted(date: .abbreviated, time: .omitted)
|
$0.formatted(date: .abbreviated, time: .omitted)
|
||||||
} ?? "Date unavailable"
|
} ?? "Date unavailable"
|
||||||
@ -949,6 +1012,9 @@ private struct ItemDetailView: View {
|
|||||||
let item: MinecraftContentItem
|
let item: MinecraftContentItem
|
||||||
let behaviorPacks: [ContentPackReference]
|
let behaviorPacks: [ContentPackReference]
|
||||||
let resourcePacks: [ContentPackReference]
|
let resourcePacks: [ContentPackReference]
|
||||||
|
let worldsUsingPack: [MinecraftContentItem]
|
||||||
|
let backingPackInstances: [MinecraftContentItem]
|
||||||
|
let isSuspiciousPack: Bool
|
||||||
let contents: [DirectoryPreviewEntry]
|
let contents: [DirectoryPreviewEntry]
|
||||||
let directoryPreviewLimit: Int
|
let directoryPreviewLimit: Int
|
||||||
@State private var isTechnicalDetailsExpanded = false
|
@State private var isTechnicalDetailsExpanded = false
|
||||||
@ -975,6 +1041,12 @@ private struct ItemDetailView: View {
|
|||||||
Text("Details")
|
Text("Details")
|
||||||
.font(.headline)
|
.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: "Size", value: sizeText)
|
||||||
detailValueRow(title: item.displayDateLabel, value: displayDateText)
|
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 {
|
detailCard {
|
||||||
DisclosureGroup(isExpanded: $isTechnicalDetailsExpanded) {
|
DisclosureGroup(isExpanded: $isTechnicalDetailsExpanded) {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
@ -1052,7 +1170,7 @@ private struct ItemDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(28)
|
.padding(28)
|
||||||
.frame(maxWidth: 760, alignment: .leading)
|
.frame(maxWidth: 450, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1113,7 +1231,11 @@ private struct ItemDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var sizeText: String {
|
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 {
|
private var displayDateText: String {
|
||||||
@ -1125,6 +1247,19 @@ private struct ItemDetailView: View {
|
|||||||
.compactMap { $0 }
|
.compactMap { $0 }
|
||||||
return components.isEmpty ? nil : components.joined(separator: " • ")
|
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 {
|
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 lastPlayedDate: Date?
|
||||||
var modifiedDate: Date?
|
var modifiedDate: Date?
|
||||||
var sizeBytes: Int64?
|
var sizeBytes: Int64?
|
||||||
|
var packUUID: String?
|
||||||
|
var packVersion: String?
|
||||||
var packReferences: [ContentPackReference]
|
var packReferences: [ContentPackReference]
|
||||||
var metadataLoaded: Bool
|
var metadataLoaded: Bool
|
||||||
|
var sizeLoaded: Bool
|
||||||
|
|
||||||
nonisolated init(
|
nonisolated init(
|
||||||
folderURL: URL,
|
folderURL: URL,
|
||||||
@ -117,8 +120,11 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
|||||||
lastPlayedDate: Date? = nil,
|
lastPlayedDate: Date? = nil,
|
||||||
modifiedDate: Date? = nil,
|
modifiedDate: Date? = nil,
|
||||||
sizeBytes: Int64? = nil,
|
sizeBytes: Int64? = nil,
|
||||||
|
packUUID: String? = nil,
|
||||||
|
packVersion: String? = nil,
|
||||||
packReferences: [ContentPackReference] = [],
|
packReferences: [ContentPackReference] = [],
|
||||||
metadataLoaded: Bool = false
|
metadataLoaded: Bool = false,
|
||||||
|
sizeLoaded: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = folderURL.standardizedFileURL
|
self.id = folderURL.standardizedFileURL
|
||||||
self.folderURL = folderURL
|
self.folderURL = folderURL
|
||||||
@ -130,8 +136,11 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
|||||||
self.lastPlayedDate = lastPlayedDate
|
self.lastPlayedDate = lastPlayedDate
|
||||||
self.modifiedDate = modifiedDate
|
self.modifiedDate = modifiedDate
|
||||||
self.sizeBytes = sizeBytes
|
self.sizeBytes = sizeBytes
|
||||||
|
self.packUUID = packUUID?.lowercased()
|
||||||
|
self.packVersion = packVersion
|
||||||
self.packReferences = packReferences
|
self.packReferences = packReferences
|
||||||
self.metadataLoaded = metadataLoaded
|
self.metadataLoaded = metadataLoaded
|
||||||
|
self.sizeLoaded = sizeLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated var folderID: String {
|
nonisolated var folderID: String {
|
||||||
|
|||||||
@ -11,7 +11,13 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
let id: URL
|
let id: URL
|
||||||
let folderURL: URL
|
let folderURL: URL
|
||||||
var displayName: String
|
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 isScanning: Bool
|
||||||
var scanStatus: String
|
var scanStatus: String
|
||||||
var scanError: String?
|
var scanError: String?
|
||||||
@ -24,7 +30,13 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
self.id = normalizedURL
|
self.id = normalizedURL
|
||||||
self.folderURL = normalizedURL
|
self.folderURL = normalizedURL
|
||||||
self.displayName = normalizedURL.lastPathComponent
|
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.isScanning = false
|
||||||
self.scanStatus = ""
|
self.scanStatus = ""
|
||||||
self.scanError = nil
|
self.scanError = nil
|
||||||
@ -34,6 +46,83 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var itemCount: Int {
|
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
|
import Foundation
|
||||||
|
|
||||||
enum WorldScanner {
|
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(
|
nonisolated static func discoverItems(
|
||||||
in searchRootURL: URL,
|
in searchRootURL: URL,
|
||||||
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
||||||
@ -52,6 +68,16 @@ enum WorldScanner {
|
|||||||
seenItemURLs.insert(itemURL)
|
seenItemURLs.insert(itemURL)
|
||||||
discoveredItems.append(item)
|
discoveredItems.append(item)
|
||||||
onDiscovered(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
|
return discoveredItems
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem {
|
nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
var enrichedItem = item
|
var enrichedItem = item
|
||||||
|
|
||||||
@ -68,9 +94,16 @@ enum WorldScanner {
|
|||||||
enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager)
|
enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager)
|
||||||
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager)
|
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager)
|
||||||
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
|
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
|
||||||
enrichedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager)
|
if let manifestMetadata = manifestMetadata(in: item.folderURL, fileManager: fileManager) {
|
||||||
enrichedItem.packReferences = packReferences(for: item, 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.metadataLoaded = true
|
||||||
|
enrichedItem.sizeLoaded = false
|
||||||
|
|
||||||
return enrichedItem
|
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(
|
let children = try fileManager.contentsOfDirectory(
|
||||||
at: directoryURL,
|
at: directoryURL,
|
||||||
includingPropertiesForKeys: [.isDirectoryKey],
|
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 {
|
nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String {
|
||||||
switch item.contentType {
|
switch item.contentType {
|
||||||
case .world:
|
case .world:
|
||||||
@ -145,19 +222,7 @@ enum WorldScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func manifestName(in directoryURL: URL, fileManager: FileManager) -> String? {
|
nonisolated private static func manifestName(in directoryURL: URL, fileManager: FileManager) -> String? {
|
||||||
let manifestURL = directoryURL.appendingPathComponent("manifest.json")
|
manifestMetadata(in: directoryURL, fileManager: fileManager)?.name
|
||||||
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 packIconURL(in directoryURL: URL, fileManager: FileManager) -> URL? {
|
nonisolated private static func packIconURL(in directoryURL: URL, fileManager: FileManager) -> URL? {
|
||||||
@ -235,10 +300,10 @@ enum WorldScanner {
|
|||||||
return totalSize
|
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 {
|
switch item.contentType {
|
||||||
case .world:
|
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))
|
references.append(contentsOf: embeddedWorldPacks(for: item, fileManager: fileManager))
|
||||||
return uniquePackReferences(references)
|
return uniquePackReferences(references)
|
||||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
@ -246,14 +311,14 @@ enum WorldScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func referencedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] {
|
nonisolated private static func referencedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) async -> [ContentPackReference] {
|
||||||
let behaviorReferences = packReferences(
|
let behaviorReferences = await packReferences(
|
||||||
fromWorldReferenceFileNamed: "world_behavior_packs.json",
|
fromWorldReferenceFileNamed: "world_behavior_packs.json",
|
||||||
type: .behaviorPack,
|
type: .behaviorPack,
|
||||||
worldFolderURL: item.folderURL,
|
worldFolderURL: item.folderURL,
|
||||||
fileManager: fileManager
|
fileManager: fileManager
|
||||||
)
|
)
|
||||||
let resourceReferences = packReferences(
|
let resourceReferences = await packReferences(
|
||||||
fromWorldReferenceFileNamed: "world_resource_packs.json",
|
fromWorldReferenceFileNamed: "world_resource_packs.json",
|
||||||
type: .resourcePack,
|
type: .resourcePack,
|
||||||
worldFolderURL: item.folderURL,
|
worldFolderURL: item.folderURL,
|
||||||
@ -289,7 +354,7 @@ enum WorldScanner {
|
|||||||
type: MinecraftContentType,
|
type: MinecraftContentType,
|
||||||
worldFolderURL: URL,
|
worldFolderURL: URL,
|
||||||
fileManager: FileManager
|
fileManager: FileManager
|
||||||
) -> [ContentPackReference] {
|
) async -> [ContentPackReference] {
|
||||||
let fileURL = worldFolderURL.appendingPathComponent(filename)
|
let fileURL = worldFolderURL.appendingPathComponent(filename)
|
||||||
guard
|
guard
|
||||||
fileManager.fileExists(atPath: fileURL.path),
|
fileManager.fileExists(atPath: fileURL.path),
|
||||||
@ -299,19 +364,24 @@ enum WorldScanner {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonObject.compactMap { entry in
|
var references: [ContentPackReference] = []
|
||||||
|
|
||||||
|
for entry in jsonObject {
|
||||||
let uuid = (entry["pack_id"] as? String)?.lowercased()
|
let uuid = (entry["pack_id"] as? String)?.lowercased()
|
||||||
let version = versionString(from: entry["version"])
|
let version = versionString(from: entry["version"])
|
||||||
let resolvedPack = uuid.flatMap {
|
let resolvedPack: ContentPackReference?
|
||||||
resolvedPackReference(
|
if let uuid {
|
||||||
uuid: $0,
|
resolvedPack = await resolvedPackReference(
|
||||||
|
uuid: uuid,
|
||||||
type: type,
|
type: type,
|
||||||
worldCollectionRootURL: worldFolderURL.deletingLastPathComponent(),
|
worldCollectionRootURL: worldFolderURL.deletingLastPathComponent()
|
||||||
fileManager: fileManager
|
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
resolvedPack = nil
|
||||||
}
|
}
|
||||||
let fallbackName = resolvedPack?.name ?? uuid ?? "Referenced Pack"
|
let fallbackName = resolvedPack?.name ?? uuid ?? "Referenced Pack"
|
||||||
return ContentPackReference(
|
references.append(
|
||||||
|
ContentPackReference(
|
||||||
name: fallbackName,
|
name: fallbackName,
|
||||||
type: type,
|
type: type,
|
||||||
iconURL: resolvedPack?.iconURL,
|
iconURL: resolvedPack?.iconURL,
|
||||||
@ -319,7 +389,10 @@ enum WorldScanner {
|
|||||||
version: resolvedPack?.version ?? version,
|
version: resolvedPack?.version ?? version,
|
||||||
source: .referencedByWorld
|
source: .referencedByWorld
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return references
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func embeddedPackReferences(
|
nonisolated private static func embeddedPackReferences(
|
||||||
@ -344,34 +417,22 @@ enum WorldScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func packReference(
|
nonisolated fileprivate static func packReference(
|
||||||
fromPackFolder directoryURL: URL,
|
fromPackFolder directoryURL: URL,
|
||||||
type: MinecraftContentType,
|
type: MinecraftContentType,
|
||||||
source: PackSource,
|
source: PackSource,
|
||||||
fileManager: FileManager
|
fileManager: FileManager
|
||||||
) -> ContentPackReference? {
|
) -> ContentPackReference? {
|
||||||
let manifestURL = directoryURL.appendingPathComponent("manifest.json")
|
guard let metadata = manifestMetadata(in: directoryURL, fileManager: fileManager) else {
|
||||||
guard
|
|
||||||
fileManager.fileExists(atPath: manifestURL.path),
|
|
||||||
let data = try? Data(contentsOf: manifestURL),
|
|
||||||
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
||||||
else {
|
|
||||||
return nil
|
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(
|
return ContentPackReference(
|
||||||
name: name,
|
name: metadata.name,
|
||||||
type: type,
|
type: type,
|
||||||
iconURL: packIconURL(in: directoryURL, fileManager: fileManager),
|
iconURL: packIconURL(in: directoryURL, fileManager: fileManager),
|
||||||
uuid: uuid,
|
uuid: metadata.uuid,
|
||||||
version: version,
|
version: metadata.version,
|
||||||
source: source
|
source: source
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -379,37 +440,17 @@ enum WorldScanner {
|
|||||||
nonisolated private static func resolvedPackReference(
|
nonisolated private static func resolvedPackReference(
|
||||||
uuid: String,
|
uuid: String,
|
||||||
type: MinecraftContentType,
|
type: MinecraftContentType,
|
||||||
worldCollectionRootURL: URL,
|
worldCollectionRootURL: URL
|
||||||
fileManager: FileManager
|
) async -> ContentPackReference? {
|
||||||
) -> ContentPackReference? {
|
|
||||||
let siblingCollectionURL = worldCollectionRootURL
|
let siblingCollectionURL = worldCollectionRootURL
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
||||||
|
|
||||||
guard
|
return await packReferenceIndexStore.reference(
|
||||||
fileManager.fileExists(atPath: siblingCollectionURL.path),
|
forUUID: uuid,
|
||||||
let childDirectories = try? immediateChildDirectories(of: siblingCollectionURL, fileManager: fileManager)
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for childDirectory in childDirectories {
|
|
||||||
guard
|
|
||||||
let reference = packReference(
|
|
||||||
fromPackFolder: childDirectory,
|
|
||||||
type: type,
|
type: type,
|
||||||
source: .foundInCollection,
|
in: siblingCollectionURL
|
||||||
fileManager: fileManager
|
)
|
||||||
),
|
|
||||||
reference.uuid == uuid
|
|
||||||
else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return reference
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func versionString(from value: Any?) -> String? {
|
nonisolated private static func versionString(from value: Any?) -> String? {
|
||||||
@ -434,6 +475,28 @@ enum WorldScanner {
|
|||||||
return nil
|
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] {
|
nonisolated private static func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
var uniqueReferences: [ContentPackReference] = []
|
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"))
|
.tint(Color("AccentColor"))
|
||||||
.background(WindowChromeConfigurator())
|
.background(WindowChromeConfigurator())
|
||||||
}
|
}
|
||||||
|
.defaultSize(width: 1520, height: 980)
|
||||||
.windowStyle(.hiddenTitleBar)
|
.windowStyle(.hiddenTitleBar)
|
||||||
.windowToolbarStyle(.unified(showsTitle: false))
|
.windowToolbarStyle(.unified(showsTitle: false))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,13 +5,157 @@
|
|||||||
// Created by John Burwell on 2026-05-25.
|
// Created by John Burwell on 2026-05-25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import World_Manager_for_Minecraft
|
@testable import World_Manager_for_Minecraft
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct World_Manager_for_MinecraftTests {
|
struct World_Manager_for_MinecraftTests {
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test func packIdentityUsesUUIDAndVersion() async throws {
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
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