Remove dead scan path and tighten discovery API

This commit is contained in:
John Burwell 2026-05-28 17:39:32 -05:00
parent 4a3b336643
commit 4cdf8b64a8
4 changed files with 11 additions and 216 deletions

View File

@ -334,7 +334,7 @@ final class SourceLibrary: ObservableObject {
let accessMethod = sourceAccessMethod let accessMethod = sourceAccessMethod
let discoveryTask = Task.detached(priority: .userInitiated) { let discoveryTask = Task.detached(priority: .userInitiated) {
do { do {
_ = try await accessMethod.discoverItems(for: source, mode: mode) { item in try await accessMethod.discoverItems(for: source, mode: mode) { item in
continuation.yield(item) continuation.yield(item)
} }
continuation.finish() continuation.finish()
@ -630,46 +630,6 @@ final class SourceLibrary: ObservableObject {
} }
} }
private func handleEnrichedItem(_ enrichedItem: MinecraftContentItem, for sourceID: URL) {
var previousItem: MinecraftContentItem?
updateSource(sourceID) { source in
guard let index = source.rawItems.firstIndex(where: { $0.id == enrichedItem.id }) else {
return
}
previousItem = source.rawItems[index]
source.rawItems[index] = enrichedItem
source.indexedDetailCount += 1
if source.indexedDetailCount < source.indexedItemCount {
source.scanStatus = "Loaded details for \(source.indexedDetailCount) of \(source.indexedItemCount) items..."
}
handleMetadataUpdate(
for: enrichedItem,
previousItem: previousItem,
in: &source,
sourceID: sourceID
)
}
}
private func handleSizedItem(_ sizedItem: MinecraftContentItem, for sourceID: URL) {
updateSource(sourceID) { source in
guard let index = source.rawItems.firstIndex(where: { $0.id == sizedItem.id }) else {
return
}
source.rawItems[index].sizeBytes = sizedItem.sizeBytes
source.rawItems[index].sizeLoaded = sizedItem.sizeLoaded
if source.isScanning {
source.scanStatus = "Calculating sizes for \(source.rawItems.filter(\.sizeLoaded).count) of \(source.indexedItemCount) items..."
}
}
}
private func rebuildNormalizedIndex(for sourceID: URL) { private func rebuildNormalizedIndex(for sourceID: URL) {
updateSource(sourceID) { source in updateSource(sourceID) { source in
let rawItems = source.rawItems.sorted(by: WorldScanner.sortItems) let rawItems = source.rawItems.sorted(by: WorldScanner.sortItems)
@ -921,39 +881,6 @@ final class SourceLibrary: ObservableObject {
return "Failed to scan device library." return "Failed to scan device library."
} }
private func handleDiscoveredItem(_ item: MinecraftContentItem, in source: inout MinecraftSource, sourceID: URL) {
guard isLogicalPackType(item.contentType) else {
return
}
let identity = packMetadata(for: item, sourceRootURL: source.folderURL).identity
refreshLogicalPack(identity: identity, in: &source, sourceID: sourceID)
}
private func handleMetadataUpdate(
for item: MinecraftContentItem,
previousItem: MinecraftContentItem?,
in source: inout MinecraftSource,
sourceID: URL
) {
if isLogicalPackType(item.contentType) {
let newIdentity = packMetadata(for: item, sourceRootURL: source.folderURL).identity
let previousIdentity = previousItem.map { packMetadata(for: $0, sourceRootURL: source.folderURL).identity }
if let previousIdentity, previousIdentity != newIdentity {
refreshLogicalPack(identity: previousIdentity, in: &source, sourceID: sourceID)
}
refreshLogicalPack(identity: newIdentity, in: &source, sourceID: sourceID)
refreshWorldRelationships(in: &source, filteringTo: item.contentType)
return
}
if item.contentType == .world {
refreshWorldRelationship(for: item, in: &source)
}
}
private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) { private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) {
guard let index = sources.firstIndex(where: { $0.id == sourceID }) else { guard let index = sources.firstIndex(where: { $0.id == sourceID }) else {
return return
@ -987,135 +914,6 @@ final class SourceLibrary: ObservableObject {
} }
} }
private func refreshLogicalPack(identity: PackIdentity, in source: inout MinecraftSource, sourceID: URL) {
let matchingItems = source.rawItems.filter { item in
guard isLogicalPackType(item.contentType) else {
return false
}
return packMetadata(for: item, sourceRootURL: source.folderURL).identity == identity
}
source.logicalPacks.removeAll { $0.id == identity }
source.packInstances.removeAll { $0.logicalPackID == identity }
guard !matchingItems.isEmpty else {
return
}
let representativeItem = matchingItems.reduce(matchingItems[0]) { current, candidate in
shouldPreferPackItem(candidate, over: current) ? candidate : current
}
let representativeMetadata = packMetadata(for: representativeItem, sourceRootURL: source.folderURL)
source.logicalPacks.append(
LogicalPack(
id: identity,
contentType: identity.type,
displayName: representativeItem.displayName,
uuid: representativeMetadata.uuid,
version: representativeMetadata.version,
representativeItemID: representativeItem.id,
instanceItemIDs: matchingItems.map(\.id).sorted {
$0.path.localizedStandardCompare($1.path) == .orderedAscending
},
isSuspicious: identity.isSuspicious
)
)
source.logicalPacks.sort {
let nameOrder = $0.displayName.localizedStandardCompare($1.displayName)
if nameOrder != .orderedSame {
return nameOrder == .orderedAscending
}
return $0.id.id.localizedStandardCompare($1.id.id) == .orderedAscending
}
let rawWorlds = source.rawItems.filter { $0.contentType == .world }
source.packInstances.append(
contentsOf: matchingItems.map { item in
PackInstance(
id: item.id,
itemID: item.id,
sourceID: sourceID,
logicalPackID: identity,
origin: packOrigin(for: item),
hostWorldItemID: hostWorldItemID(for: item, in: rawWorlds)
)
}
)
source.packInstances.sort {
$0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending
}
}
private func refreshWorldRelationships(in source: inout MinecraftSource, filteringTo type: MinecraftContentType? = nil) {
let worlds = source.rawItems.filter { $0.contentType == .world }
for world in worlds {
guard type == nil || world.packReferences.contains(where: { $0.type == type }) else {
continue
}
refreshWorldRelationship(for: world, in: &source)
}
}
private func refreshWorldRelationship(for world: MinecraftContentItem, in source: inout MinecraftSource) {
source.worldPackRelationships.removeAll { $0.worldItemID == world.id }
source.logicalWorlds.removeAll { $0.itemID == world.id }
let logicalPacksByID = Dictionary(uniqueKeysWithValues: source.logicalPacks.map { ($0.id, $0) })
var usedPackIDs = Set<PackIdentity>()
var unresolvedReferences: [ContentPackReference] = []
var relationships: [WorldPackRelationship] = []
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)
}
relationships.append(
WorldPackRelationship(
worldItemID: world.id,
logicalPackID: resolvedID,
reference: reference
)
)
}
source.worldPackRelationships.append(contentsOf: relationships)
source.logicalWorlds.append(
LogicalWorld(
id: world.id,
itemID: world.id,
usedPackIDs: usedPackIDs.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending },
unresolvedReferences: unresolvedReferences
)
)
let rawItemsByID = Dictionary(uniqueKeysWithValues: source.rawItems.map { ($0.id, $0) })
source.logicalWorlds.sort {
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)
}
}
private func runConnectedDeviceRefreshLoop() async { private func runConnectedDeviceRefreshLoop() async {
while !Task.isCancelled && !isShuttingDown { while !Task.isCancelled && !isShuttingDown {
if hasActiveConnectedDeviceScan { if hasActiveConnectedDeviceScan {

View File

@ -103,7 +103,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode, mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] { ) async throws {
_ = mode _ = mode
guard case .connectedDevice(_, let container) = source.origin else { guard case .connectedDevice(_, let container) = source.origin else {
throw SourceAccessError.accessFailed( throw SourceAccessError.accessFailed(
@ -141,8 +141,6 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
for item in items { for item in items {
onDiscovered(item) onDiscovered(item)
} }
return items
} }
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem { nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {

View File

@ -20,7 +20,7 @@ protocol SourceAccessMethod: Sendable {
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode, mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] ) async throws
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
@ -54,11 +54,10 @@ extension SourceAccessMethod {
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode, mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] { ) async throws {
_ = source _ = source
_ = mode _ = mode
_ = onDiscovered _ = onDiscovered
return []
} }
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem { nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
@ -157,8 +156,8 @@ struct SourceAccessCoordinator: SourceAccessMethod {
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode, mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] { ) async throws {
return try await accessMethod(for: source).discoverItems( try await accessMethod(for: source).discoverItems(
for: source, for: source,
mode: mode, mode: mode,
onDiscovered: onDiscovered onDiscovered: onDiscovered

View File

@ -48,7 +48,7 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode, mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] { ) async throws {
guard case .localFolder(let bookmarkData) = source.origin else { guard case .localFolder(let bookmarkData) = source.origin else {
throw SourceAccessError.accessFailed( throw SourceAccessError.accessFailed(
reason: "No local-folder access method is configured for this source type." reason: "No local-folder access method is configured for this source type."
@ -83,15 +83,16 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
if case .reconcile = mode, if case .reconcile = mode,
let snapshot = source.snapshot { let snapshot = source.snapshot {
return try discoverItemsByReconcilingCache( try discoverItemsByReconcilingCache(
for: source, for: source,
snapshot: snapshot, snapshot: snapshot,
resolvedURL: resolvedURL, resolvedURL: resolvedURL,
onDiscovered: onDiscovered onDiscovered: onDiscovered
) )
return
} }
return try WorldScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered) _ = try WorldScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
} }
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem { nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
@ -137,7 +138,7 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
snapshot: SourceSnapshot, snapshot: SourceSnapshot,
resolvedURL: URL, resolvedURL: URL,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) throws -> [MinecraftContentItem] { ) throws {
let currentCollections = Dictionary( let currentCollections = Dictionary(
uniqueKeysWithValues: WorldScanner.collectionSnapshots(in: resolvedURL).map { ($0.folderName, $0) } uniqueKeysWithValues: WorldScanner.collectionSnapshots(in: resolvedURL).map { ($0.folderName, $0) }
) )
@ -182,7 +183,6 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
for item in reconciledItems { for item in reconciledItems {
onDiscovered(item) onDiscovered(item)
} }
return reconciledItems
} }
nonisolated private func topLevelCollectionName(for item: MinecraftContentItem, sourceRootURL: URL) -> String? { nonisolated private func topLevelCollectionName(for item: MinecraftContentItem, sourceRootURL: URL) -> String? {