298 lines
11 KiB
Swift
298 lines
11 KiB
Swift
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Foundation
|
|
|
|
struct SourceContentIndex {
|
|
let rawItems: [MinecraftContentItem]
|
|
let logicalPacks: [LogicalPack]
|
|
let logicalWorlds: [LogicalWorld]
|
|
let packInstances: [PackInstance]
|
|
let worldPackRelationships: [WorldPackRelationship]
|
|
let displayItems: [MinecraftContentItem]
|
|
let displayItemCountsByType: [MinecraftContentType: Int]
|
|
let displayItemCountsByKind: [MinecraftContentKind: Int]
|
|
}
|
|
|
|
enum SourceContentIndexer {
|
|
static func buildIndex(for source: MinecraftSource) -> SourceContentIndex {
|
|
let rawItems = source.rawItems.sorted(by: WorldScanner.sortItems)
|
|
let rawItemsByID = Dictionary(uniqueKeysWithValues: rawItems.map { ($0.id, $0) })
|
|
|
|
let rawPacks = rawItems.filter {
|
|
$0.contentType == .behaviorPack || $0.contentType == .resourcePack
|
|
}
|
|
let rawWorlds = rawItems.filter { $0.contentType == .world }
|
|
|
|
let packMetadataByItemID = Dictionary(uniqueKeysWithValues: rawPacks.map { item in
|
|
(item.id, packMetadata(for: item, sourceRootURL: source.folderURL))
|
|
})
|
|
|
|
var chosenRepresentativeByIdentity: [PackIdentity: MinecraftContentItem] = [:]
|
|
var allPackItemsByIdentity: [PackIdentity: [MinecraftContentItem]] = [:]
|
|
|
|
for item in rawPacks {
|
|
let metadata = packMetadataByItemID[item.id] ?? packMetadata(for: item, sourceRootURL: source.folderURL)
|
|
let identity = metadata.identity
|
|
allPackItemsByIdentity[identity, default: []].append(item)
|
|
|
|
guard let existing = chosenRepresentativeByIdentity[identity] else {
|
|
chosenRepresentativeByIdentity[identity] = item
|
|
continue
|
|
}
|
|
|
|
if shouldPreferPackItem(item, over: existing) {
|
|
chosenRepresentativeByIdentity[identity] = item
|
|
}
|
|
}
|
|
|
|
let logicalPacks = allPackItemsByIdentity.keys.sorted {
|
|
let lhs = chosenRepresentativeByIdentity[$0]?.displayName ?? ""
|
|
let rhs = chosenRepresentativeByIdentity[$1]?.displayName ?? ""
|
|
let nameOrder = lhs.localizedStandardCompare(rhs)
|
|
if nameOrder != .orderedSame {
|
|
return nameOrder == .orderedAscending
|
|
}
|
|
|
|
return $0.id.localizedStandardCompare($1.id) == .orderedAscending
|
|
}.compactMap { identity -> LogicalPack? in
|
|
guard
|
|
let representativeItem = chosenRepresentativeByIdentity[identity],
|
|
let instances = allPackItemsByIdentity[identity]
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let metadata = packMetadataByItemID[representativeItem.id]
|
|
|
|
return LogicalPack(
|
|
id: identity,
|
|
contentType: identity.type,
|
|
displayName: representativeItem.displayName,
|
|
uuid: metadata?.uuid,
|
|
version: metadata?.version,
|
|
representativeItemID: representativeItem.id,
|
|
instanceItemIDs: instances.map(\.id).sorted {
|
|
$0.path.localizedStandardCompare($1.path) == .orderedAscending
|
|
},
|
|
isSuspicious: identity.isSuspicious
|
|
)
|
|
}
|
|
|
|
var packInstances: [PackInstance] = []
|
|
for logicalPack in logicalPacks {
|
|
for itemID in logicalPack.instanceItemIDs {
|
|
guard let item = rawItemsByID[itemID] else {
|
|
continue
|
|
}
|
|
|
|
packInstances.append(
|
|
PackInstance(
|
|
id: item.id,
|
|
itemID: item.id,
|
|
sourceID: source.id,
|
|
logicalPackID: logicalPack.id,
|
|
origin: packOrigin(for: item),
|
|
hostWorldItemID: hostWorldItemID(for: item, in: rawWorlds)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
let logicalPacksByID = Dictionary(uniqueKeysWithValues: logicalPacks.map { ($0.id, $0) })
|
|
var worldRelationships: [WorldPackRelationship] = []
|
|
var logicalWorlds: [LogicalWorld] = []
|
|
|
|
for world in rawWorlds {
|
|
var usedPackIDs = Set<PackIdentity>()
|
|
var unresolvedReferences: [ContentPackReference] = []
|
|
|
|
for reference in world.packReferences {
|
|
let referenceIdentity = PackIdentity(
|
|
type: reference.type,
|
|
uuid: reference.uuid,
|
|
version: reference.version,
|
|
fallbackName: reference.name,
|
|
fallbackLocationHint: world.folderName
|
|
)
|
|
let resolvedID = logicalPacksByID[referenceIdentity]?.id
|
|
|
|
if let resolvedID {
|
|
usedPackIDs.insert(resolvedID)
|
|
} else {
|
|
unresolvedReferences.append(reference)
|
|
}
|
|
|
|
worldRelationships.append(
|
|
WorldPackRelationship(
|
|
worldItemID: world.id,
|
|
logicalPackID: resolvedID,
|
|
reference: reference
|
|
)
|
|
)
|
|
}
|
|
|
|
logicalWorlds.append(
|
|
LogicalWorld(
|
|
id: world.id,
|
|
itemID: world.id,
|
|
usedPackIDs: usedPackIDs.sorted {
|
|
$0.id.localizedStandardCompare($1.id) == .orderedAscending
|
|
},
|
|
unresolvedReferences: unresolvedReferences
|
|
)
|
|
)
|
|
}
|
|
|
|
let sortedLogicalWorlds = logicalWorlds.sorted {
|
|
guard
|
|
let lhs = rawItemsByID[$0.itemID],
|
|
let rhs = rawItemsByID[$1.itemID]
|
|
else {
|
|
return $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending
|
|
}
|
|
|
|
return WorldScanner.sortItems(lhs, rhs)
|
|
}
|
|
|
|
let sortedPackInstances = packInstances.sorted {
|
|
$0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending
|
|
}
|
|
|
|
let displayItems = buildDisplayItems(
|
|
from: rawItems,
|
|
logicalPacks: logicalPacks,
|
|
rawItemsByID: rawItemsByID
|
|
)
|
|
|
|
return SourceContentIndex(
|
|
rawItems: rawItems,
|
|
logicalPacks: logicalPacks,
|
|
logicalWorlds: sortedLogicalWorlds,
|
|
packInstances: sortedPackInstances,
|
|
worldPackRelationships: worldRelationships,
|
|
displayItems: displayItems,
|
|
displayItemCountsByType: displayItemCounts(for: displayItems),
|
|
displayItemCountsByKind: displayItemKindCounts(for: displayItems)
|
|
)
|
|
}
|
|
|
|
private static func buildDisplayItems(
|
|
from rawItems: [MinecraftContentItem],
|
|
logicalPacks: [LogicalPack],
|
|
rawItemsByID: [URL: MinecraftContentItem]
|
|
) -> [MinecraftContentItem] {
|
|
var normalizedItemIDs = Set<URL>()
|
|
var normalizedItems: [MinecraftContentItem] = []
|
|
|
|
for item in rawItems where item.contentType == .world {
|
|
guard normalizedItemIDs.insert(item.id).inserted else {
|
|
continue
|
|
}
|
|
|
|
normalizedItems.append(item)
|
|
}
|
|
|
|
for logicalPack in logicalPacks {
|
|
guard
|
|
let item = rawItemsByID[logicalPack.representativeItemID],
|
|
normalizedItemIDs.insert(item.id).inserted
|
|
else {
|
|
continue
|
|
}
|
|
|
|
normalizedItems.append(item)
|
|
}
|
|
|
|
for item in rawItems where item.contentType == .skinPack || item.contentType == .worldTemplate {
|
|
guard normalizedItemIDs.insert(item.id).inserted else {
|
|
continue
|
|
}
|
|
|
|
normalizedItems.append(item)
|
|
}
|
|
|
|
return normalizedItems
|
|
}
|
|
|
|
private static func displayItemCounts(for items: [MinecraftContentItem]) -> [MinecraftContentType: Int] {
|
|
items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
|
counts[item.contentType, default: 0] += 1
|
|
}
|
|
}
|
|
|
|
private static func displayItemKindCounts(for items: [MinecraftContentItem]) -> [MinecraftContentKind: Int] {
|
|
items.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
|
|
counts[item.contentKind, default: 0] += 1
|
|
}
|
|
}
|
|
|
|
private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
|
|
let candidateEmbedded = isEmbeddedWorldPack(candidate)
|
|
let existingEmbedded = isEmbeddedWorldPack(existing)
|
|
|
|
if candidateEmbedded != existingEmbedded {
|
|
return !candidateEmbedded
|
|
}
|
|
|
|
if candidate.metadataLoaded != existing.metadataLoaded {
|
|
return candidate.metadataLoaded
|
|
}
|
|
|
|
if (candidate.iconURL != nil) != (existing.iconURL != nil) {
|
|
return candidate.iconURL != nil
|
|
}
|
|
|
|
if candidate.previewLoaded != existing.previewLoaded {
|
|
return candidate.previewLoaded
|
|
}
|
|
|
|
if candidate.modifiedDate != existing.modifiedDate {
|
|
return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast)
|
|
}
|
|
|
|
return candidate.folderURL.path.localizedStandardCompare(existing.folderURL.path) == .orderedAscending
|
|
}
|
|
|
|
private static func packOrigin(for item: MinecraftContentItem) -> PackSource {
|
|
isEmbeddedWorldPack(item) ? .embeddedInWorld : .foundInCollection
|
|
}
|
|
|
|
private static func isEmbeddedWorldPack(_ item: MinecraftContentItem) -> Bool {
|
|
item.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName)
|
|
}
|
|
|
|
private static func hostWorldItemID(for packItem: MinecraftContentItem, in rawWorlds: [MinecraftContentItem]) -> URL? {
|
|
rawWorlds.first(where: { world in
|
|
packItem.folderURL.path.hasPrefix(world.folderURL.path + "/")
|
|
})?.id
|
|
}
|
|
|
|
private static func packMetadata(for item: MinecraftContentItem, sourceRootURL: URL) -> IndexedPackMetadata {
|
|
let uuid = item.packUUID
|
|
let version = item.packVersion
|
|
|
|
return IndexedPackMetadata(
|
|
uuid: uuid,
|
|
version: version,
|
|
identity: PackIdentity(
|
|
type: item.contentType,
|
|
uuid: uuid,
|
|
version: version,
|
|
fallbackName: item.displayName,
|
|
fallbackLocationHint: relativePathHint(for: item, sourceRootURL: sourceRootURL)
|
|
)
|
|
)
|
|
}
|
|
|
|
private static func relativePathHint(for item: MinecraftContentItem, sourceRootURL: URL) -> String {
|
|
item.folderURL.path.replacingOccurrences(of: sourceRootURL.path + "/", with: "")
|
|
}
|
|
}
|
|
|
|
private struct IndexedPackMetadata {
|
|
let uuid: String?
|
|
let version: String?
|
|
let identity: PackIdentity
|
|
}
|