Source cache syncing behavior
This commit is contained in:
parent
42366c1713
commit
b7ea9ce89d
@ -96,6 +96,7 @@ struct ContentView: View {
|
|||||||
directoryPreviewLimit: directoryPreviewLimit,
|
directoryPreviewLimit: directoryPreviewLimit,
|
||||||
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
|
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
|
||||||
isPerformingItemAction: isPerformingItemAction,
|
isPerformingItemAction: isPerformingItemAction,
|
||||||
|
areFileActionsEnabled: areCurrentItemFileActionsEnabled,
|
||||||
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
|
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
|
||||||
exportAction: {
|
exportAction: {
|
||||||
guard let item = currentSelectedItem else {
|
guard let item = currentSelectedItem else {
|
||||||
@ -122,7 +123,7 @@ struct ContentView: View {
|
|||||||
.frame(minWidth: 450)
|
.frame(minWidth: 450)
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if library.isRestoringPersistedSources {
|
if library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty {
|
||||||
LaunchRestoreOverlayView()
|
LaunchRestoreOverlayView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,10 +142,7 @@ struct ContentView: View {
|
|||||||
.task {
|
.task {
|
||||||
AppTerminationCoordinator.shared.register(library: library)
|
AppTerminationCoordinator.shared.register(library: library)
|
||||||
}
|
}
|
||||||
.disabled(library.isRestoringPersistedSources)
|
.disabled(library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
|
||||||
library.shutdown()
|
|
||||||
}
|
|
||||||
.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 {
|
||||||
return
|
return
|
||||||
@ -318,6 +316,14 @@ struct ContentView: View {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var areCurrentItemFileActionsEnabled: Bool {
|
||||||
|
guard currentSelectedItem != nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSource?.availability == .available
|
||||||
|
}
|
||||||
|
|
||||||
private var searchScopeTitle: String {
|
private var searchScopeTitle: String {
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
case .some(.source(let sourceID)):
|
case .some(.source(let sourceID)):
|
||||||
@ -438,16 +444,19 @@ struct ContentView: View {
|
|||||||
Button("Share...") {
|
Button("Share...") {
|
||||||
shareItem(item, from: nil)
|
shareItem(item, from: nil)
|
||||||
}
|
}
|
||||||
|
.disabled(!areFileActionsEnabled(for: item))
|
||||||
|
|
||||||
Button(exportMenuTitle(for: item)) {
|
Button(exportMenuTitle(for: item)) {
|
||||||
saveItem(item)
|
saveItem(item)
|
||||||
}
|
}
|
||||||
|
.disabled(!areFileActionsEnabled(for: item))
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Reveal in Finder") {
|
Button("Reveal in Finder") {
|
||||||
revealInFinder(item)
|
revealInFinder(item)
|
||||||
}
|
}
|
||||||
|
.disabled(!areFileActionsEnabled(for: item))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func exportMenuTitle(for item: MinecraftContentItem) -> String {
|
private func exportMenuTitle(for item: MinecraftContentItem) -> String {
|
||||||
@ -644,8 +653,16 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func areFileActionsEnabled(for item: MinecraftContentItem) -> Bool {
|
||||||
|
guard let source = library.visibleSources.first(where: { $0.items.contains(where: { $0.id == item.id }) }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.availability == .available
|
||||||
|
}
|
||||||
|
|
||||||
private func saveItem(_ item: MinecraftContentItem) {
|
private func saveItem(_ item: MinecraftContentItem) {
|
||||||
guard !isPerformingItemAction else {
|
guard !isPerformingItemAction, areFileActionsEnabled(for: item) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let source = currentSource
|
let source = currentSource
|
||||||
@ -653,8 +670,9 @@ struct ContentView: View {
|
|||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
panel.canCreateDirectories = true
|
panel.canCreateDirectories = true
|
||||||
panel.isExtensionHidden = false
|
panel.isExtensionHidden = false
|
||||||
panel.title = primaryActionTitle(for: item)
|
panel.showsTagField = false
|
||||||
panel.message = primaryActionSubtitle(for: item)
|
panel.title = exportMenuTitle(for: item)
|
||||||
|
panel.prompt = "Save"
|
||||||
panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item)
|
panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item)
|
||||||
panel.allowedContentTypes = [archiveType(for: item)]
|
panel.allowedContentTypes = [archiveType(for: item)]
|
||||||
|
|
||||||
@ -693,7 +711,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func shareItem(_ item: MinecraftContentItem, from anchorView: NSView?) {
|
private func shareItem(_ item: MinecraftContentItem, from anchorView: NSView?) {
|
||||||
guard !isPerformingItemAction else {
|
guard !isPerformingItemAction, areFileActionsEnabled(for: item) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let source = currentSource
|
let source = currentSource
|
||||||
@ -742,7 +760,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func revealInFinder(_ item: MinecraftContentItem) {
|
private func revealInFinder(_ item: MinecraftContentItem) {
|
||||||
guard let source = currentSource else {
|
guard let source = currentSource, areFileActionsEnabled(for: item) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ struct ItemDetailColumnView: View {
|
|||||||
let directoryPreviewLimit: Int
|
let directoryPreviewLimit: Int
|
||||||
let isEmpty: Bool
|
let isEmpty: Bool
|
||||||
let isPerformingItemAction: Bool
|
let isPerformingItemAction: Bool
|
||||||
|
let areFileActionsEnabled: Bool
|
||||||
let exportTitle: String?
|
let exportTitle: String?
|
||||||
let exportAction: () -> Void
|
let exportAction: () -> Void
|
||||||
let revealAction: () -> Void
|
let revealAction: () -> Void
|
||||||
@ -42,6 +43,7 @@ struct ItemDetailColumnView: View {
|
|||||||
contents: contents,
|
contents: contents,
|
||||||
directoryPreviewLimit: directoryPreviewLimit,
|
directoryPreviewLimit: directoryPreviewLimit,
|
||||||
isPerformingItemAction: isPerformingItemAction,
|
isPerformingItemAction: isPerformingItemAction,
|
||||||
|
areFileActionsEnabled: areFileActionsEnabled,
|
||||||
exportTitle: exportTitle,
|
exportTitle: exportTitle,
|
||||||
exportAction: exportAction,
|
exportAction: exportAction,
|
||||||
revealAction: revealAction,
|
revealAction: revealAction,
|
||||||
@ -60,7 +62,7 @@ struct ItemDetailColumnView: View {
|
|||||||
Button(action: exportAction) {
|
Button(action: exportAction) {
|
||||||
Image(systemName: "arrow.down.circle")
|
Image(systemName: "arrow.down.circle")
|
||||||
}
|
}
|
||||||
.disabled(isPerformingItemAction)
|
.disabled(isPerformingItemAction || !areFileActionsEnabled)
|
||||||
.help(exportTitle ?? "Export")
|
.help(exportTitle ?? "Export")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,14 +70,14 @@ struct ItemDetailColumnView: View {
|
|||||||
Button(action: revealAction) {
|
Button(action: revealAction) {
|
||||||
Image(systemName: "folder")
|
Image(systemName: "folder")
|
||||||
}
|
}
|
||||||
.disabled(isPerformingItemAction)
|
.disabled(isPerformingItemAction || !areFileActionsEnabled)
|
||||||
.help("Reveal in Finder")
|
.help("Reveal in Finder")
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem {
|
ToolbarItem {
|
||||||
ToolbarShareButton(
|
ToolbarShareButton(
|
||||||
systemImage: "square.and.arrow.up",
|
systemImage: "square.and.arrow.up",
|
||||||
isEnabled: !isPerformingItemAction
|
isEnabled: !isPerformingItemAction && areFileActionsEnabled
|
||||||
) { anchorView in
|
) { anchorView in
|
||||||
shareAction(anchorView)
|
shareAction(anchorView)
|
||||||
}
|
}
|
||||||
@ -626,6 +628,7 @@ struct ItemDetailView: View {
|
|||||||
let contents: [DirectoryPreviewEntry]
|
let contents: [DirectoryPreviewEntry]
|
||||||
let directoryPreviewLimit: Int
|
let directoryPreviewLimit: Int
|
||||||
let isPerformingItemAction: Bool
|
let isPerformingItemAction: Bool
|
||||||
|
let areFileActionsEnabled: Bool
|
||||||
let exportTitle: String?
|
let exportTitle: String?
|
||||||
let exportAction: () -> Void
|
let exportAction: () -> Void
|
||||||
let revealAction: () -> Void
|
let revealAction: () -> Void
|
||||||
@ -1186,7 +1189,7 @@ struct ItemDetailView: View {
|
|||||||
ActionPillButton(
|
ActionPillButton(
|
||||||
title: actionRowExportTitle,
|
title: actionRowExportTitle,
|
||||||
systemImage: "arrow.down.circle.fill",
|
systemImage: "arrow.down.circle.fill",
|
||||||
isDisabled: isPerformingItemAction,
|
isDisabled: isPerformingItemAction || !areFileActionsEnabled,
|
||||||
prominence: .primary,
|
prominence: .primary,
|
||||||
action: exportAction
|
action: exportAction
|
||||||
)
|
)
|
||||||
@ -1194,7 +1197,7 @@ struct ItemDetailView: View {
|
|||||||
ActionPillButton(
|
ActionPillButton(
|
||||||
title: "Reveal",
|
title: "Reveal",
|
||||||
systemImage: "folder.fill",
|
systemImage: "folder.fill",
|
||||||
isDisabled: isPerformingItemAction,
|
isDisabled: isPerformingItemAction || !areFileActionsEnabled,
|
||||||
prominence: .secondary,
|
prominence: .secondary,
|
||||||
action: revealAction
|
action: revealAction
|
||||||
)
|
)
|
||||||
@ -1202,7 +1205,7 @@ struct ItemDetailView: View {
|
|||||||
SharingPillButton(
|
SharingPillButton(
|
||||||
title: "Share",
|
title: "Share",
|
||||||
systemImage: "square.and.arrow.up",
|
systemImage: "square.and.arrow.up",
|
||||||
isEnabled: !isPerformingItemAction,
|
isEnabled: !isPerformingItemAction && areFileActionsEnabled,
|
||||||
action: shareAction
|
action: shareAction
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -355,6 +355,7 @@ struct ItemDetailColumnPreviewContainer: View {
|
|||||||
directoryPreviewLimit: 12,
|
directoryPreviewLimit: 12,
|
||||||
isEmpty: false,
|
isEmpty: false,
|
||||||
isPerformingItemAction: false,
|
isPerformingItemAction: false,
|
||||||
|
areFileActionsEnabled: true,
|
||||||
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
||||||
exportAction: {},
|
exportAction: {},
|
||||||
revealAction: {},
|
revealAction: {},
|
||||||
|
|||||||
@ -168,6 +168,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await persistVisibleSourcesForShutdown()
|
||||||
shutdown()
|
shutdown()
|
||||||
try? await Task.sleep(for: .seconds(timeout))
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
}
|
}
|
||||||
@ -183,7 +184,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
||||||
}
|
}
|
||||||
startScan(for: normalizedURL)
|
startScan(for: normalizedURL, mode: .fullScan)
|
||||||
return normalizedURL
|
return normalizedURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,7 +226,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
persistSourceIfAvailable(withID: source.id)
|
persistSourceIfAvailable(withID: source.id)
|
||||||
}
|
}
|
||||||
if shouldScan {
|
if shouldScan {
|
||||||
startScan(for: source.id)
|
startScan(for: source.id, mode: .fullScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
return source.id
|
return source.id
|
||||||
@ -236,7 +237,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func rescanSource(withID sourceID: URL) {
|
func rescanSource(withID sourceID: URL) {
|
||||||
startScan(for: sourceID)
|
startScan(for: sourceID, mode: .fullScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
||||||
@ -305,7 +306,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return "Scanning \(scanningSources.count) sources..."
|
return "Scanning \(scanningSources.count) sources..."
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startScan(for sourceID: URL) {
|
private func startScan(for sourceID: URL, mode: SourceDiscoveryMode) {
|
||||||
guard !isShuttingDown else {
|
guard !isShuttingDown else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -319,7 +320,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.scanSource(withID: sourceID)
|
await self.scanSource(withID: sourceID, mode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
scanTasks[sourceID] = task
|
scanTasks[sourceID] = task
|
||||||
@ -333,7 +334,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
sources.contains { $0.isScanning && $0.origin.kind == .connectedDevice }
|
sources.contains { $0.isScanning && $0.origin.kind == .connectedDevice }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scanSource(withID sourceID: URL) async {
|
private func scanSource(withID sourceID: URL, mode: SourceDiscoveryMode) async {
|
||||||
var workerTasks: [Task<Void, Never>] = []
|
var workerTasks: [Task<Void, Never>] = []
|
||||||
var sizeWorkerTasks: [Task<Void, Never>] = []
|
var sizeWorkerTasks: [Task<Void, Never>] = []
|
||||||
let scanStartTime = Date()
|
let scanStartTime = Date()
|
||||||
@ -353,7 +354,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
source.isScanning = true
|
source.isScanning = true
|
||||||
source.scanError = nil
|
source.scanError = nil
|
||||||
source.scanDiagnostic = nil
|
source.scanDiagnostic = nil
|
||||||
source.scanStatus = initialScanStatus(for: source)
|
source.scanStatus = initialScanStatus(for: source, mode: mode)
|
||||||
source.scanProgress = nil
|
source.scanProgress = nil
|
||||||
source.indexedItemCount = 0
|
source.indexedItemCount = 0
|
||||||
source.indexedDetailCount = 0
|
source.indexedDetailCount = 0
|
||||||
@ -380,7 +381,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
|
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
source.availability = .available
|
source.availability = .available
|
||||||
source.scanStatus = scanningLibraryStatus(for: source)
|
source.scanStatus = scanningLibraryStatus(for: source, mode: mode)
|
||||||
}
|
}
|
||||||
refreshSidebarFooterState()
|
refreshSidebarFooterState()
|
||||||
|
|
||||||
@ -414,7 +415,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) { item in
|
_ = try await accessMethod.discoverItems(for: source, mode: mode) { item in
|
||||||
continuation.yield(item)
|
continuation.yield(item)
|
||||||
}
|
}
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
@ -428,7 +429,14 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) })
|
||||||
|
let previousSnapshotByItemID = Dictionary(
|
||||||
|
uniqueKeysWithValues: (previousSource.snapshot?.itemSnapshots ?? []).map { ($0.id, $0) }
|
||||||
|
)
|
||||||
|
let shouldReconcileFromCache = mode == .reconcile && previousSource.hasCachedContent
|
||||||
|
|
||||||
var discoveredCount = 0
|
var discoveredCount = 0
|
||||||
|
var discoveredCollectionNames = Set<String>()
|
||||||
let discoveryStartTime = Date()
|
let discoveryStartTime = Date()
|
||||||
|
|
||||||
for try await item in discoveryStream {
|
for try await item in discoveryStream {
|
||||||
@ -437,15 +445,58 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
discoveredCount += 1
|
discoveredCount += 1
|
||||||
|
discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent)
|
||||||
|
let itemForIndex: MinecraftContentItem
|
||||||
|
if shouldReconcileFromCache,
|
||||||
|
let cachedItem = previousItemsByID[item.id],
|
||||||
|
shouldReuseCachedItem(
|
||||||
|
cachedItem,
|
||||||
|
forDiscoveredItem: item,
|
||||||
|
source: source,
|
||||||
|
previousSnapshot: previousSnapshotByItemID[item.id]
|
||||||
|
) {
|
||||||
|
itemForIndex = cachedItem
|
||||||
|
} else {
|
||||||
|
itemForIndex = item
|
||||||
|
}
|
||||||
|
|
||||||
if let snapshot = await index.addDiscoveredItem(
|
if let snapshot = await index.addDiscoveredItem(
|
||||||
item,
|
itemForIndex,
|
||||||
discoveredCount: discoveredCount
|
discoveredCount: discoveredCount
|
||||||
) {
|
) {
|
||||||
applySnapshot(snapshot, to: sourceID)
|
applySnapshot(snapshot, to: sourceID)
|
||||||
}
|
}
|
||||||
scheduleSidebarFooterRefresh()
|
scheduleSidebarFooterRefresh()
|
||||||
|
|
||||||
await enrichmentQueue.enqueue(item)
|
if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false {
|
||||||
|
await enrichmentQueue.enqueue(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == .reconcile, source.origin.kind == .connectedDevice {
|
||||||
|
let cachedItemsByCollection = Dictionary(grouping: previousSource.rawItems) { item in
|
||||||
|
item.collectionRootURL.lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
for (collectionName, cachedItems) in cachedItemsByCollection {
|
||||||
|
guard !cachedItems.isEmpty else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !discoveredCollectionNames.contains(collectionName) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for cachedItem in cachedItems {
|
||||||
|
discoveredCount += 1
|
||||||
|
if let snapshot = await index.addDiscoveredItem(
|
||||||
|
cachedItem,
|
||||||
|
discoveredCount: discoveredCount
|
||||||
|
) {
|
||||||
|
applySnapshot(snapshot, to: sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logScanStage(
|
logScanStage(
|
||||||
@ -481,8 +532,9 @@ final class SourceLibrary: ObservableObject {
|
|||||||
refreshSidebarFooterState()
|
refreshSidebarFooterState()
|
||||||
|
|
||||||
let previewStageStartTime = Date()
|
let previewStageStartTime = Date()
|
||||||
|
let previewSeedItems = await index.currentItems()
|
||||||
let previewItems = await sourceAccessMethod.loadPreviewAssets(
|
let previewItems = await sourceAccessMethod.loadPreviewAssets(
|
||||||
for: await index.currentItems(),
|
for: previewSeedItems.filter { !$0.previewLoaded },
|
||||||
in: source
|
in: source
|
||||||
)
|
)
|
||||||
for previewItem in previewItems {
|
for previewItem in previewItems {
|
||||||
@ -507,8 +559,9 @@ final class SourceLibrary: ObservableObject {
|
|||||||
|
|
||||||
if source.origin.kind == .connectedDevice {
|
if source.origin.kind == .connectedDevice {
|
||||||
let sizeStageStartTime = Date()
|
let sizeStageStartTime = Date()
|
||||||
|
let sizeSeedItems = await index.currentItems()
|
||||||
let sizedItems = await sourceAccessMethod.loadSizeAssets(
|
let sizedItems = await sourceAccessMethod.loadSizeAssets(
|
||||||
for: await index.currentItems(),
|
for: sizeSeedItems.filter { !$0.sizeLoaded },
|
||||||
in: source
|
in: source
|
||||||
)
|
)
|
||||||
for sizedItem in sizedItems {
|
for sizedItem in sizedItems {
|
||||||
@ -582,7 +635,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for item in await index.currentItems() {
|
for item in await index.currentItems() where !item.sizeLoaded {
|
||||||
await sizeQueue.enqueue(item)
|
await sizeQueue.enqueue(item)
|
||||||
}
|
}
|
||||||
await sizeQueue.finish()
|
await sizeQueue.finish()
|
||||||
@ -1483,7 +1536,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
) {
|
) {
|
||||||
let nextAvailability = availability(for: device, hasMinecraftContainer: true)
|
let nextAvailability = availability(for: device, hasMinecraftContainer: true)
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
guard case .connectedDevice(_, let previousContainer) = source.origin else {
|
guard case .connectedDevice(let previousDevice, let previousContainer) = source.origin else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1491,8 +1544,17 @@ final class SourceLibrary: ObservableObject {
|
|||||||
$0.appID == previousContainer.appID && $0.accessMode == previousContainer.accessMode
|
$0.appID == previousContainer.appID && $0.accessMode == previousContainer.accessMode
|
||||||
}) ?? previousContainer
|
}) ?? previousContainer
|
||||||
|
|
||||||
source.origin = .connectedDevice(device: device, container: resolvedContainer)
|
var resolvedDevice = device
|
||||||
source.displayName = "\(device.name) • \(resolvedContainer.appName)"
|
resolvedDevice.name = preferredConnectedDeviceName(
|
||||||
|
currentName: device.name,
|
||||||
|
fallbackDeviceName: previousDevice.name,
|
||||||
|
fallbackDisplayName: source.displayName
|
||||||
|
)
|
||||||
|
source.origin = .connectedDevice(device: resolvedDevice, container: resolvedContainer)
|
||||||
|
source.displayName = connectedDeviceSourceFactory.displayName(
|
||||||
|
for: resolvedDevice,
|
||||||
|
container: resolvedContainer
|
||||||
|
)
|
||||||
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
||||||
}
|
}
|
||||||
let transition = updateAvailability(for: sourceID, to: nextAvailability)
|
let transition = updateAvailability(for: sourceID, to: nextAvailability)
|
||||||
@ -1503,12 +1565,14 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if transition.becameAvailable {
|
if transition.becameAvailable {
|
||||||
queueAutomaticSync(
|
if shouldRefreshConnectedDeviceOnReconnect(source, device: device) {
|
||||||
for: sourceID,
|
queueAutomaticSync(
|
||||||
reason: source.hasCachedContent
|
for: sourceID,
|
||||||
? "Device available. Refreshing cached library..."
|
reason: source.hasCachedContent
|
||||||
: "Device available. Scanning Minecraft library..."
|
? "Device available. Refreshing cached library..."
|
||||||
)
|
: "Device available. Scanning Minecraft library..."
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1544,6 +1608,42 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func preferredConnectedDeviceName(
|
||||||
|
currentName: String,
|
||||||
|
fallbackDeviceName: String,
|
||||||
|
fallbackDisplayName: String
|
||||||
|
) -> String {
|
||||||
|
if let sanitizedCurrentName = sanitizedConnectedDeviceName(currentName) {
|
||||||
|
return sanitizedCurrentName
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sanitizedFallbackName = sanitizedConnectedDeviceName(fallbackDeviceName) {
|
||||||
|
return sanitizedFallbackName
|
||||||
|
}
|
||||||
|
|
||||||
|
if let displayNamePrefix = fallbackDisplayName.components(separatedBy: " • ").first,
|
||||||
|
let sanitizedDisplayName = sanitizedConnectedDeviceName(displayNamePrefix) {
|
||||||
|
return sanitizedDisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown Device"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sanitizedConnectedDeviceName(_ candidate: String) -> String? {
|
||||||
|
let trimmedCandidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedCandidate.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedCandidate = trimmedCandidate.lowercased()
|
||||||
|
guard normalizedCandidate != "unknown device",
|
||||||
|
normalizedCandidate != "unknown device..." else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedCandidate
|
||||||
|
}
|
||||||
|
|
||||||
private func restorePersistedSources() async {
|
private func restorePersistedSources() async {
|
||||||
defer {
|
defer {
|
||||||
isRestoringPersistedSources = false
|
isRestoringPersistedSources = false
|
||||||
@ -1566,29 +1666,64 @@ final class SourceLibrary: ObservableObject {
|
|||||||
accessDescriptor: record.accessDescriptor,
|
accessDescriptor: record.accessDescriptor,
|
||||||
availability: record.availability
|
availability: record.availability
|
||||||
)
|
)
|
||||||
source.displayName = record.displayName
|
if case .connectedDevice(let device, let container) = source.origin {
|
||||||
source.rawItems = await restoreCachedImages(in: record.rawItems)
|
var repairedDevice = device
|
||||||
|
repairedDevice.name = preferredConnectedDeviceName(
|
||||||
|
currentName: device.name,
|
||||||
|
fallbackDeviceName: "",
|
||||||
|
fallbackDisplayName: record.displayName
|
||||||
|
)
|
||||||
|
source.origin = .connectedDevice(device: repairedDevice, container: container)
|
||||||
|
let persistedDeviceName = record.displayName.components(separatedBy: " • ").first ?? record.displayName
|
||||||
|
if sanitizedConnectedDeviceName(persistedDeviceName) == nil {
|
||||||
|
source.displayName = connectedDeviceSourceFactory.displayName(
|
||||||
|
for: repairedDevice,
|
||||||
|
container: container
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
source.displayName = record.displayName
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
source.displayName = record.displayName
|
||||||
|
}
|
||||||
|
source.rawItems = record.rawItems
|
||||||
source.indexedItemCount = record.rawItems.count
|
source.indexedItemCount = record.rawItems.count
|
||||||
source.indexedDetailCount = source.rawItems.filter(\.metadataLoaded).count
|
source.indexedDetailCount = record.rawItems.filter(\.metadataLoaded).count
|
||||||
source.previewLoadedCount = source.rawItems.filter(\.previewLoaded).count
|
source.previewLoadedCount = record.rawItems.filter(\.previewLoaded).count
|
||||||
source.sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count
|
source.sizeLoadedCount = record.rawItems.filter(\.sizeLoaded).count
|
||||||
source.lastScanDate = record.lastScanDate
|
source.lastScanDate = record.lastScanDate
|
||||||
source.snapshot = record.snapshot
|
source.snapshot = record.snapshot
|
||||||
|
source.scanStatus = source.indexedItemCount == 0
|
||||||
|
? "No Minecraft items found."
|
||||||
|
: "Loaded \(source.indexedDetailCount) items."
|
||||||
|
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
rebuildNormalizedIndex(for: source.id)
|
rebuildNormalizedIndex(for: source.id)
|
||||||
|
}
|
||||||
|
|
||||||
updateSource(source.id) { source in
|
for record in records where record.needsRepair {
|
||||||
source.displayItems = source.displayItems.sorted(by: WorldScanner.sortItems)
|
Task.detached(priority: .utility) { [persistenceStore] in
|
||||||
source.previewLoadedCount = source.rawItems.filter(\.previewLoaded).count
|
try? await persistenceStore.repair(record: record)
|
||||||
source.sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count
|
|
||||||
source.scanStatus = source.indexedItemCount == 0
|
|
||||||
? "No Minecraft items found."
|
|
||||||
: "Loaded \(source.indexedDetailCount) items."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
||||||
|
refreshSidebarFooterState()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
for record in records {
|
||||||
|
let restoredItems = await restoreCachedImages(in: record.rawItems)
|
||||||
|
updateSource(record.sourceID) { source in
|
||||||
|
source.rawItems = restoredItems
|
||||||
|
source.indexedItemCount = restoredItems.count
|
||||||
|
source.indexedDetailCount = restoredItems.filter(\.metadataLoaded).count
|
||||||
|
source.previewLoadedCount = restoredItems.filter(\.previewLoaded).count
|
||||||
|
source.sizeLoadedCount = restoredItems.filter(\.sizeLoaded).count
|
||||||
|
source.lastScanDate = record.lastScanDate
|
||||||
|
source.snapshot = record.snapshot
|
||||||
|
}
|
||||||
|
rebuildNormalizedIndex(for: record.sourceID)
|
||||||
|
}
|
||||||
|
|
||||||
await refreshConnectedDevices()
|
await refreshConnectedDevices()
|
||||||
await refreshLocalSources()
|
await refreshLocalSources()
|
||||||
@ -1638,10 +1773,9 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let sourceURL = record.folderURL
|
let sourceURL = record.folderURL
|
||||||
|
|
||||||
guard fileManager.fileExists(atPath: sourceURL.path) else {
|
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1653,19 +1787,8 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (folderName, persistedCollection) in persistedCollections {
|
for (folderName, persistedCollection) in persistedCollections {
|
||||||
guard let currentCollection = currentCollections[folderName], currentCollection == persistedCollection else {
|
guard let currentCollection = currentCollections[folderName],
|
||||||
return true
|
currentCollection.fingerprint == persistedCollection.fingerprint else {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for itemSnapshot in snapshot.itemSnapshots {
|
|
||||||
let itemURL = sourceURL.appendingPathComponent(itemSnapshot.relativePath, isDirectory: true)
|
|
||||||
guard fileManager.fileExists(atPath: itemURL.path) else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
let modifiedDate = try? itemURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
|
||||||
if modifiedDate != itemSnapshot.modifiedDate {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1682,10 +1805,9 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let sourceURL = source.folderURL
|
let sourceURL = source.folderURL
|
||||||
|
|
||||||
guard fileManager.fileExists(atPath: sourceURL.path) else {
|
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1697,19 +1819,8 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (folderName, persistedCollection) in persistedCollections {
|
for (folderName, persistedCollection) in persistedCollections {
|
||||||
guard let currentCollection = currentCollections[folderName], currentCollection == persistedCollection else {
|
guard let currentCollection = currentCollections[folderName],
|
||||||
return true
|
currentCollection.fingerprint == persistedCollection.fingerprint else {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for itemSnapshot in snapshot.itemSnapshots {
|
|
||||||
let itemURL = sourceURL.appendingPathComponent(itemSnapshot.relativePath, isDirectory: true)
|
|
||||||
guard fileManager.fileExists(atPath: itemURL.path) else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
let modifiedDate = try? itemURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
|
||||||
if modifiedDate != itemSnapshot.modifiedDate {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1749,35 +1860,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
|
private func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
|
||||||
let fileManager = FileManager.default
|
WorldScanner.collectionSnapshots(in: sourceURL)
|
||||||
|
|
||||||
return MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in
|
|
||||||
let collectionURL = sourceURL.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
|
||||||
guard fileManager.fileExists(atPath: collectionURL.path) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let children = (try? fileManager.contentsOfDirectory(
|
|
||||||
at: collectionURL,
|
|
||||||
includingPropertiesForKeys: [.isDirectoryKey],
|
|
||||||
options: [.skipsHiddenFiles]
|
|
||||||
)) ?? []
|
|
||||||
let childDirectoryCount = children.filter {
|
|
||||||
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
|
||||||
}.count
|
|
||||||
let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
|
||||||
|
|
||||||
return CollectionSnapshot(
|
|
||||||
folderName: type.collectionFolderName,
|
|
||||||
modifiedDate: modifiedDate,
|
|
||||||
childDirectoryCount: childDirectoryCount,
|
|
||||||
fingerprint: [
|
|
||||||
type.collectionFolderName,
|
|
||||||
String(childDirectoryCount),
|
|
||||||
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil"
|
|
||||||
].joined(separator: "::")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildDisplayItems(
|
private func buildDisplayItems(
|
||||||
@ -1829,6 +1912,13 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func persistVisibleSourcesForShutdown() async {
|
||||||
|
let persistedSources = sources
|
||||||
|
for source in persistedSources {
|
||||||
|
try? await persistenceStore.save(source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func deletePersistedSource(withID sourceID: URL) {
|
private func deletePersistedSource(withID sourceID: URL) {
|
||||||
Task {
|
Task {
|
||||||
try? await persistenceStore.deleteSource(withID: sourceID)
|
try? await persistenceStore.deleteSource(withID: sourceID)
|
||||||
@ -1864,6 +1954,8 @@ final class SourceLibrary: ObservableObject {
|
|||||||
source.scanProgress = nil
|
source.scanProgress = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mode: SourceDiscoveryMode = source.hasCachedContent ? .reconcile : .fullScan
|
||||||
|
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
do {
|
do {
|
||||||
try await Task.sleep(for: .seconds(resolvedDebounce))
|
try await Task.sleep(for: .seconds(resolvedDebounce))
|
||||||
@ -1876,7 +1968,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.startScan(for: sourceID)
|
self.startScan(for: sourceID, mode: mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2032,19 +2124,45 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if connectedDeviceSourceHasRefreshDebt(source) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_ = device
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldRefreshConnectedDeviceOnReconnect(_ source: MinecraftSource, device: ConnectedDevice) -> Bool {
|
||||||
|
guard !source.isScanning else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if connectedDeviceSourceHasRefreshDebt(source) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
guard let lastScanDate = source.lastScanDate else {
|
guard let lastScanDate = source.lastScanDate else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let refreshInterval: TimeInterval
|
let reconnectGracePeriod: TimeInterval = 5 * 60
|
||||||
switch device.connection {
|
if Date().timeIntervalSince(lastScanDate) < reconnectGracePeriod {
|
||||||
case .usb:
|
return false
|
||||||
refreshInterval = Self.usbConnectedDeviceAutoRefreshInterval
|
|
||||||
case .network:
|
|
||||||
refreshInterval = Self.networkConnectedDeviceAutoRefreshInterval
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Date().timeIntervalSince(lastScanDate) >= refreshInterval
|
return shouldRefreshConnectedDeviceSource(source, device: device)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectedDeviceSourceHasRefreshDebt(_ source: MinecraftSource) -> Bool {
|
||||||
|
guard source.origin.kind == .connectedDevice else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !source.rawItems.isEmpty else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let itemCount = source.rawItems.count
|
||||||
|
return source.previewLoadedCount < itemCount || source.sizeLoadedCount < itemCount
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cancelFooterReset() {
|
private func cancelFooterReset() {
|
||||||
@ -2052,21 +2170,64 @@ final class SourceLibrary: ObservableObject {
|
|||||||
footerResetTask = nil
|
footerResetTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initialScanStatus(for source: MinecraftSource) -> String {
|
private func initialScanStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
|
||||||
switch source.origin {
|
switch (source.origin, mode) {
|
||||||
case .localFolder:
|
case (.localFolder, .fullScan):
|
||||||
return "Preparing folder scan..."
|
return "Preparing folder scan..."
|
||||||
case .connectedDevice:
|
case (.localFolder, .reconcile):
|
||||||
|
return "Preparing cached library refresh..."
|
||||||
|
case (.connectedDevice, .fullScan):
|
||||||
return "Connecting to device and discovering Minecraft items..."
|
return "Connecting to device and discovering Minecraft items..."
|
||||||
|
case (.connectedDevice, .reconcile):
|
||||||
|
return "Connecting to device and refreshing cached library..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scanningLibraryStatus(for source: MinecraftSource) -> String {
|
private func scanningLibraryStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
|
||||||
switch source.origin {
|
switch (source.origin, mode) {
|
||||||
case .localFolder:
|
case (.localFolder, .fullScan):
|
||||||
return "Scanning Minecraft library..."
|
return "Scanning Minecraft library..."
|
||||||
case .connectedDevice:
|
case (.localFolder, .reconcile):
|
||||||
|
return "Reconciling cached library..."
|
||||||
|
case (.connectedDevice, .fullScan):
|
||||||
return "Scanning Minecraft library on device..."
|
return "Scanning Minecraft library on device..."
|
||||||
|
case (.connectedDevice, .reconcile):
|
||||||
|
return "Reconciling cached device library..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldReuseCachedItem(
|
||||||
|
_ cachedItem: MinecraftContentItem,
|
||||||
|
forDiscoveredItem discoveredItem: MinecraftContentItem,
|
||||||
|
source: MinecraftSource,
|
||||||
|
previousSnapshot: ItemSnapshot?
|
||||||
|
) -> Bool {
|
||||||
|
guard cachedItem.contentType == discoveredItem.contentType else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard cachedItem.metadataLoaded, cachedItem.previewLoaded, cachedItem.sizeLoaded else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch source.origin.kind {
|
||||||
|
case .localFolder:
|
||||||
|
guard let previousSnapshot else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentModifiedDate = try? discoveredItem.folderURL
|
||||||
|
.resourceValues(forKeys: [.contentModificationDateKey])
|
||||||
|
.contentModificationDate
|
||||||
|
return previousSnapshot.modifiedDate == currentModifiedDate
|
||||||
|
case .connectedDevice:
|
||||||
|
return cachedItem.folderName == discoveredItem.folderName
|
||||||
|
&& cachedItem.displayName == discoveredItem.displayName
|
||||||
|
&& cachedItem.hasKnownIcon == discoveredItem.hasKnownIcon
|
||||||
|
&& cachedItem.packUUID == discoveredItem.packUUID
|
||||||
|
&& cachedItem.packVersion == discoveredItem.packVersion
|
||||||
|
&& cachedItem.packMetadataDetails == discoveredItem.packMetadataDetails
|
||||||
|
&& cachedItem.packReferences == discoveredItem.packReferences
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2087,33 +2248,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
scanRootURL: URL,
|
scanRootURL: URL,
|
||||||
packMetadataByItemID: [URL: PackMetadata]
|
packMetadataByItemID: [URL: PackMetadata]
|
||||||
) -> SourceSnapshot {
|
) -> SourceSnapshot {
|
||||||
let collectionSnapshots = MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in
|
let collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL)
|
||||||
let collectionURL = scanRootURL.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
|
||||||
guard FileManager.default.fileExists(atPath: collectionURL.path) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let children = (try? FileManager.default.contentsOfDirectory(
|
|
||||||
at: collectionURL,
|
|
||||||
includingPropertiesForKeys: [.isDirectoryKey],
|
|
||||||
options: [.skipsHiddenFiles]
|
|
||||||
)) ?? []
|
|
||||||
let childDirectoryCount = children.filter {
|
|
||||||
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
|
||||||
}.count
|
|
||||||
let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
|
||||||
|
|
||||||
return CollectionSnapshot(
|
|
||||||
folderName: type.collectionFolderName,
|
|
||||||
modifiedDate: modifiedDate,
|
|
||||||
childDirectoryCount: childDirectoryCount,
|
|
||||||
fingerprint: [
|
|
||||||
type.collectionFolderName,
|
|
||||||
String(childDirectoryCount),
|
|
||||||
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil"
|
|
||||||
].joined(separator: "::")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let itemSnapshots = source.rawItems.map { item in
|
let itemSnapshots = source.rawItems.map { item in
|
||||||
let relativePath = item.folderURL.path.replacingOccurrences(of: scanRootURL.path + "/", with: "")
|
let relativePath = item.folderURL.path.replacingOccurrences(of: scanRootURL.path + "/", with: "")
|
||||||
@ -2323,6 +2458,15 @@ private actor SourceIndexActor {
|
|||||||
orderedItemIDs.append(item.id)
|
orderedItemIDs.append(item.id)
|
||||||
itemsByID[item.id] = item
|
itemsByID[item.id] = item
|
||||||
indexedItemCount = discoveredCount
|
indexedItemCount = discoveredCount
|
||||||
|
if item.metadataLoaded {
|
||||||
|
indexedDetailCount += 1
|
||||||
|
}
|
||||||
|
if item.previewLoaded {
|
||||||
|
previewLoadedCount += 1
|
||||||
|
}
|
||||||
|
if item.sizeLoaded {
|
||||||
|
sizeLoadedCount += 1
|
||||||
|
}
|
||||||
return snapshotIfNeeded()
|
return snapshotIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ struct PersistedSourceRecord: Sendable {
|
|||||||
let rawItems: [MinecraftContentItem]
|
let rawItems: [MinecraftContentItem]
|
||||||
let snapshot: SourceSnapshot?
|
let snapshot: SourceSnapshot?
|
||||||
let lastScanDate: Date?
|
let lastScanDate: Date?
|
||||||
|
let needsRepair: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PersistedItemSnapshotPayload: Codable, Sendable {
|
private struct PersistedItemSnapshotPayload: Codable, Sendable {
|
||||||
@ -173,6 +174,7 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
|||||||
|
|
||||||
actor SourcePersistenceStore {
|
actor SourcePersistenceStore {
|
||||||
static let shared = SourcePersistenceStore()
|
static let shared = SourcePersistenceStore()
|
||||||
|
private static let cacheGeneration = "v2026-05-28"
|
||||||
|
|
||||||
private let databaseURL: URL
|
private let databaseURL: URL
|
||||||
|
|
||||||
@ -181,7 +183,10 @@ actor SourcePersistenceStore {
|
|||||||
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true)
|
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true)
|
||||||
let directoryURL = applicationSupportURL
|
let directoryURL = applicationSupportURL
|
||||||
.appendingPathComponent("World Manager for Minecraft", isDirectory: true)
|
.appendingPathComponent("World Manager for Minecraft", isDirectory: true)
|
||||||
self.databaseURL = directoryURL.appendingPathComponent("LibraryCache.sqlite", isDirectory: false)
|
self.databaseURL = directoryURL.appendingPathComponent(
|
||||||
|
"LibraryCache-\(Self.cacheGeneration).sqlite",
|
||||||
|
isDirectory: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(databaseURL: URL) {
|
init(databaseURL: URL) {
|
||||||
@ -214,21 +219,17 @@ actor SourcePersistenceStore {
|
|||||||
|
|
||||||
let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL
|
let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL
|
||||||
let folderPath = String(cString: folderPathPointer)
|
let folderPath = String(cString: folderPathPointer)
|
||||||
let origin = try decodeColumn(MinecraftSourceOrigin.self, statement: statement, columnIndex: 2)
|
|
||||||
?? .localFolder(bookmarkData: nil)
|
|
||||||
let accessDescriptor = try decodeColumn(SourceAccessDescriptor.self, statement: statement, columnIndex: 3)
|
|
||||||
?? SourceAccessDescriptor(
|
|
||||||
accessorIdentifier: origin.defaultAccessorIdentifier,
|
|
||||||
kind: origin.kind,
|
|
||||||
capabilities: origin.defaultCapabilities,
|
|
||||||
refreshStrategy: origin.defaultRefreshStrategy
|
|
||||||
)
|
|
||||||
let availability = decodeAvailability(statement: statement, columnIndex: 4)
|
|
||||||
let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 5)
|
let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 5)
|
||||||
|
let originResult = decodeOrigin(statement: statement, columnIndex: 2, bookmarkData: bookmarkData)
|
||||||
|
let origin = originResult.value
|
||||||
|
let accessDescriptorResult = decodeAccessDescriptor(statement: statement, columnIndex: 3, origin: origin)
|
||||||
|
let accessDescriptor = accessDescriptorResult.value
|
||||||
|
let availability = decodeAvailability(statement: statement, columnIndex: 4)
|
||||||
let displayName = String(cString: sqlite3_column_text(statement, 6))
|
let displayName = String(cString: sqlite3_column_text(statement, 6))
|
||||||
let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 7) ?? []
|
let rawItemsResult = decodeRawItems(statement: statement, columnIndex: 7)
|
||||||
let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 8)
|
let snapshotResult = decodeSnapshot(statement: statement, columnIndex: 8)
|
||||||
let snapshot = snapshotPayload?.sourceSnapshot
|
let rawItems = rawItemsResult.value
|
||||||
|
let snapshot = snapshotResult.value
|
||||||
let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL
|
let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL
|
||||||
? nil
|
? nil
|
||||||
: Date(timeIntervalSince1970: sqlite3_column_double(statement, 9))
|
: Date(timeIntervalSince1970: sqlite3_column_double(statement, 9))
|
||||||
@ -244,7 +245,11 @@ actor SourcePersistenceStore {
|
|||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
rawItems: rawItems,
|
rawItems: rawItems,
|
||||||
snapshot: snapshot,
|
snapshot: snapshot,
|
||||||
lastScanDate: lastScanDate
|
lastScanDate: lastScanDate,
|
||||||
|
needsRepair: originResult.didRepair
|
||||||
|
|| accessDescriptorResult.didRepair
|
||||||
|
|| rawItemsResult.didRepair
|
||||||
|
|| snapshotResult.didRepair
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -256,6 +261,32 @@ actor SourcePersistenceStore {
|
|||||||
let database = try openDatabase()
|
let database = try openDatabase()
|
||||||
defer { sqlite3_close(database) }
|
defer { sqlite3_close(database) }
|
||||||
|
|
||||||
|
try save(
|
||||||
|
record: PersistedSourceRecord(
|
||||||
|
sourceID: source.id,
|
||||||
|
folderURL: source.folderURL,
|
||||||
|
origin: source.origin,
|
||||||
|
accessDescriptor: source.accessDescriptor,
|
||||||
|
availability: source.availability,
|
||||||
|
bookmarkData: source.bookmarkData,
|
||||||
|
displayName: source.displayName,
|
||||||
|
rawItems: source.rawItems,
|
||||||
|
snapshot: source.snapshot,
|
||||||
|
lastScanDate: source.lastScanDate,
|
||||||
|
needsRepair: false
|
||||||
|
),
|
||||||
|
on: database
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func repair(record: PersistedSourceRecord) throws {
|
||||||
|
let database = try openDatabase()
|
||||||
|
defer { sqlite3_close(database) }
|
||||||
|
|
||||||
|
try save(record: record, on: database)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save(record: PersistedSourceRecord, on database: OpaquePointer?) throws {
|
||||||
let sql = """
|
let sql = """
|
||||||
INSERT INTO source_cache (
|
INSERT INTO source_cache (
|
||||||
source_id,
|
source_id,
|
||||||
@ -287,17 +318,17 @@ actor SourcePersistenceStore {
|
|||||||
}
|
}
|
||||||
defer { sqlite3_finalize(statement) }
|
defer { sqlite3_finalize(statement) }
|
||||||
|
|
||||||
try bindText(normalizedIdentifierText(for: source.id), to: statement, at: 1)
|
try bindText(normalizedIdentifierText(for: record.sourceID), to: statement, at: 1)
|
||||||
try bindText(source.folderURL.path, to: statement, at: 2)
|
try bindText(record.folderURL.path, to: statement, at: 2)
|
||||||
try bindJSON(source.origin, to: statement, at: 3)
|
try bindJSON(record.origin, to: statement, at: 3)
|
||||||
try bindJSON(source.accessDescriptor, to: statement, at: 4)
|
try bindJSON(record.accessDescriptor, to: statement, at: 4)
|
||||||
try bindText(source.availability.rawValue, to: statement, at: 5)
|
try bindText(record.availability.rawValue, to: statement, at: 5)
|
||||||
try bindData(source.bookmarkData, to: statement, at: 6)
|
try bindData(record.bookmarkData, to: statement, at: 6)
|
||||||
try bindText(source.displayName, to: statement, at: 7)
|
try bindText(record.displayName, to: statement, at: 7)
|
||||||
try bindJSON(source.rawItems, to: statement, at: 8)
|
try bindJSON(record.rawItems, to: statement, at: 8)
|
||||||
try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 9)
|
try bindJSON(record.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 9)
|
||||||
|
|
||||||
if let lastScanDate = source.lastScanDate {
|
if let lastScanDate = record.lastScanDate {
|
||||||
sqlite3_bind_double(statement, 10, lastScanDate.timeIntervalSince1970)
|
sqlite3_bind_double(statement, 10, lastScanDate.timeIntervalSince1970)
|
||||||
} else {
|
} else {
|
||||||
sqlite3_bind_null(statement, 10)
|
sqlite3_bind_null(statement, 10)
|
||||||
@ -450,19 +481,10 @@ actor SourcePersistenceStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func decodeColumn<T: Decodable>(_ type: T.Type, statement: OpaquePointer?, columnIndex: Int32) throws -> T? {
|
private func decodeColumn<T: Decodable>(_ type: T.Type, statement: OpaquePointer?, columnIndex: Int32) throws -> T? {
|
||||||
guard sqlite3_column_type(statement, columnIndex) != SQLITE_NULL else {
|
guard let data = decodeDataColumn(statement: statement, columnIndex: columnIndex) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let byteCount = Int(sqlite3_column_bytes(statement, columnIndex))
|
|
||||||
guard
|
|
||||||
byteCount > 0,
|
|
||||||
let bytes = sqlite3_column_blob(statement, columnIndex)
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = Data(bytes: bytes, count: byteCount)
|
|
||||||
return try JSONDecoder().decode(type, from: data)
|
return try JSONDecoder().decode(type, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -482,6 +504,98 @@ actor SourcePersistenceStore {
|
|||||||
return Data(bytes: bytes, count: byteCount)
|
return Data(bytes: bytes, count: byteCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func decodeOrigin(
|
||||||
|
statement: OpaquePointer?,
|
||||||
|
columnIndex: Int32,
|
||||||
|
bookmarkData: Data?
|
||||||
|
) -> (value: MinecraftSourceOrigin, didRepair: Bool) {
|
||||||
|
do {
|
||||||
|
if let origin = try decodeColumn(MinecraftSourceOrigin.self, statement: statement, columnIndex: columnIndex) {
|
||||||
|
return (origin, false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
return (.localFolder(bookmarkData: bookmarkData), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeAccessDescriptor(
|
||||||
|
statement: OpaquePointer?,
|
||||||
|
columnIndex: Int32,
|
||||||
|
origin: MinecraftSourceOrigin
|
||||||
|
) -> (value: SourceAccessDescriptor, didRepair: Bool) {
|
||||||
|
do {
|
||||||
|
if let accessDescriptor = try decodeColumn(SourceAccessDescriptor.self, statement: statement, columnIndex: columnIndex) {
|
||||||
|
return (accessDescriptor, false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: origin.defaultAccessorIdentifier,
|
||||||
|
kind: origin.kind,
|
||||||
|
capabilities: origin.defaultCapabilities,
|
||||||
|
refreshStrategy: origin.defaultRefreshStrategy
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeRawItems(statement: OpaquePointer?, columnIndex: Int32) -> (value: [MinecraftContentItem], didRepair: Bool) {
|
||||||
|
do {
|
||||||
|
if let items = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: columnIndex) {
|
||||||
|
return (items, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ([], false)
|
||||||
|
} catch {
|
||||||
|
guard let data = decodeDataColumn(statement: statement, columnIndex: columnIndex) else {
|
||||||
|
return ([], true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let items = decodeArrayElementsLeniently(MinecraftContentItem.self, from: data)
|
||||||
|
return (items, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeSnapshot(statement: OpaquePointer?, columnIndex: Int32) -> (value: SourceSnapshot?, didRepair: Bool) {
|
||||||
|
do {
|
||||||
|
let payload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: columnIndex)
|
||||||
|
return (payload?.sourceSnapshot, false)
|
||||||
|
} catch {
|
||||||
|
return (nil, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeArrayElementsLeniently<Element: Decodable>(_ type: Element.Type, from data: Data) -> [Element] {
|
||||||
|
guard let rawArray = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
var decodedElements: [Element] = []
|
||||||
|
decodedElements.reserveCapacity(rawArray.count)
|
||||||
|
|
||||||
|
for rawElement in rawArray {
|
||||||
|
guard JSONSerialization.isValidJSONObject(rawElement) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let elementData = try? JSONSerialization.data(withJSONObject: rawElement) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let element = try? decoder.decode(Element.self, from: elementData) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedElements.append(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodedElements
|
||||||
|
}
|
||||||
|
|
||||||
private func sourceID(from statement: OpaquePointer?) -> URL? {
|
private func sourceID(from statement: OpaquePointer?) -> URL? {
|
||||||
guard let pointer = sqlite3_column_text(statement, 0) else {
|
guard let pointer = sqlite3_column_text(statement, 0) else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -86,6 +86,66 @@ enum WorldScanner {
|
|||||||
return discoveredItems
|
return discoveredItems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated static func discoverItems(
|
||||||
|
inCollectionRootURL collectionRootURL: URL,
|
||||||
|
contentType: MinecraftContentType,
|
||||||
|
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
||||||
|
) throws -> [MinecraftContentItem] {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
guard fileManager.fileExists(atPath: collectionRootURL.path) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let childDirectories = try immediateChildDirectories(of: collectionRootURL, fileManager: fileManager)
|
||||||
|
var discoveredItems: [MinecraftContentItem] = []
|
||||||
|
var seenItemURLs = Set<URL>()
|
||||||
|
|
||||||
|
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: collectionRootURL
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredItems.sort(by: sortItems)
|
||||||
|
return discoveredItems
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
return MinecraftContentType.allCases.compactMap { type in
|
||||||
|
collectionSnapshot(
|
||||||
|
for: sourceRootURL.appendingPathComponent(type.collectionFolderName, isDirectory: true),
|
||||||
|
contentType: type,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem {
|
nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
var enrichedItem = item
|
var enrichedItem = item
|
||||||
@ -146,6 +206,52 @@ enum WorldScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private static func collectionSnapshot(
|
||||||
|
for collectionURL: URL,
|
||||||
|
contentType: MinecraftContentType,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> CollectionSnapshot? {
|
||||||
|
guard fileManager.fileExists(atPath: collectionURL.path) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let children = (try? fileManager.contentsOfDirectory(
|
||||||
|
at: collectionURL,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)) ?? []
|
||||||
|
let childDirectorySnapshots = children.compactMap { childURL -> (name: String, modifiedDate: Date?)? in
|
||||||
|
guard (try? childURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let modifiedDate = try? childURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
||||||
|
return (childURL.lastPathComponent, modifiedDate)
|
||||||
|
}.sorted {
|
||||||
|
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
||||||
|
let childFingerprint = childDirectorySnapshots.map { child in
|
||||||
|
[
|
||||||
|
child.name,
|
||||||
|
child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil"
|
||||||
|
].joined(separator: "@")
|
||||||
|
}.joined(separator: "|")
|
||||||
|
|
||||||
|
return CollectionSnapshot(
|
||||||
|
folderName: contentType.collectionFolderName,
|
||||||
|
modifiedDate: modifiedDate,
|
||||||
|
childDirectoryCount: childDirectorySnapshots.count,
|
||||||
|
fingerprint: [
|
||||||
|
contentType.collectionFolderName,
|
||||||
|
String(childDirectorySnapshots.count),
|
||||||
|
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
|
||||||
|
childFingerprint
|
||||||
|
].joined(separator: "::")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated fileprivate 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,
|
||||||
|
|||||||
@ -85,7 +85,7 @@ struct SourcesSidebarView: View {
|
|||||||
)
|
)
|
||||||
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
|
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.padding(.top, 6)
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 8))
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Rescan \"\(source.displayName)\"") {
|
Button("Rescan \"\(source.displayName)\"") {
|
||||||
rescanSourceAction(source)
|
rescanSourceAction(source)
|
||||||
@ -113,7 +113,7 @@ struct SourcesSidebarView: View {
|
|||||||
} : nil
|
} : nil
|
||||||
)
|
)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.padding(.top, 6)
|
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,11 +233,7 @@ private struct SourceHeaderRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundStyle: AnyShapeStyle {
|
private var backgroundStyle: AnyShapeStyle {
|
||||||
if isSelected {
|
if isHovering && !isSelected {
|
||||||
return AnyShapeStyle(Color.appAccent.opacity(0.14))
|
|
||||||
}
|
|
||||||
|
|
||||||
if isHovering {
|
|
||||||
return AnyShapeStyle(.secondary.opacity(0.08))
|
return AnyShapeStyle(.secondary.opacity(0.08))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -101,8 +101,10 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
|||||||
|
|
||||||
nonisolated func discoverItems(
|
nonisolated func discoverItems(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode,
|
||||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
) async throws -> [MinecraftContentItem] {
|
) async throws -> [MinecraftContentItem] {
|
||||||
|
_ = mode
|
||||||
guard case .connectedDevice(_, let container) = source.origin else {
|
guard case .connectedDevice(_, let container) = source.origin else {
|
||||||
throw SourceAccessError.accessFailed(
|
throw SourceAccessError.accessFailed(
|
||||||
reason: "The selected source is not backed by a connected mobile device."
|
reason: "The selected source is not backed by a connected mobile device."
|
||||||
|
|||||||
@ -7,12 +7,18 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
enum SourceDiscoveryMode: Sendable {
|
||||||
|
case fullScan
|
||||||
|
case reconcile
|
||||||
|
}
|
||||||
|
|
||||||
protocol SourceAccessMethod: Sendable {
|
protocol SourceAccessMethod: Sendable {
|
||||||
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
|
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
|
||||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
||||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
||||||
nonisolated func discoverItems(
|
nonisolated func discoverItems(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode,
|
||||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
) async throws -> [MinecraftContentItem]
|
) async throws -> [MinecraftContentItem]
|
||||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
|
||||||
@ -46,9 +52,11 @@ extension SourceAccessMethod {
|
|||||||
|
|
||||||
nonisolated func discoverItems(
|
nonisolated func discoverItems(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode,
|
||||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
) async throws -> [MinecraftContentItem] {
|
) async throws -> [MinecraftContentItem] {
|
||||||
_ = source
|
_ = source
|
||||||
|
_ = mode
|
||||||
_ = onDiscovered
|
_ = onDiscovered
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@ -147,9 +155,14 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
|||||||
|
|
||||||
nonisolated func discoverItems(
|
nonisolated func discoverItems(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode,
|
||||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
) async throws -> [MinecraftContentItem] {
|
) async throws -> [MinecraftContentItem] {
|
||||||
return try await accessMethod(for: source).discoverItems(for: source, onDiscovered: onDiscovered)
|
return try await accessMethod(for: source).discoverItems(
|
||||||
|
for: source,
|
||||||
|
mode: mode,
|
||||||
|
onDiscovered: onDiscovered
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
|
|||||||
@ -46,6 +46,7 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
|
|
||||||
nonisolated func discoverItems(
|
nonisolated func discoverItems(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode,
|
||||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
) async throws -> [MinecraftContentItem] {
|
) async throws -> [MinecraftContentItem] {
|
||||||
guard case .localFolder(let bookmarkData) = source.origin else {
|
guard case .localFolder(let bookmarkData) = source.origin else {
|
||||||
@ -80,6 +81,16 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if case .reconcile = mode,
|
||||||
|
let snapshot = source.snapshot {
|
||||||
|
return try discoverItemsByReconcilingCache(
|
||||||
|
for: source,
|
||||||
|
snapshot: snapshot,
|
||||||
|
resolvedURL: resolvedURL,
|
||||||
|
onDiscovered: onDiscovered
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return try WorldScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
|
return try WorldScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,4 +131,63 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
_ = source
|
_ = source
|
||||||
return item.folderURL
|
return item.folderURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private func discoverItemsByReconcilingCache(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
snapshot: SourceSnapshot,
|
||||||
|
resolvedURL: URL,
|
||||||
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
|
) throws -> [MinecraftContentItem] {
|
||||||
|
let currentCollections = Dictionary(
|
||||||
|
uniqueKeysWithValues: WorldScanner.collectionSnapshots(in: resolvedURL).map { ($0.folderName, $0) }
|
||||||
|
)
|
||||||
|
let previousCollections = Dictionary(
|
||||||
|
uniqueKeysWithValues: snapshot.collectionSnapshots.map { ($0.folderName, $0) }
|
||||||
|
)
|
||||||
|
|
||||||
|
var changedCollectionNames = Set<String>()
|
||||||
|
for type in MinecraftContentType.allCases {
|
||||||
|
let collectionName = type.collectionFolderName
|
||||||
|
let currentFingerprint = currentCollections[collectionName]?.fingerprint
|
||||||
|
let previousFingerprint = previousCollections[collectionName]?.fingerprint
|
||||||
|
if currentFingerprint != previousFingerprint {
|
||||||
|
changedCollectionNames.insert(collectionName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unchangedCollectionNames = Set(currentCollections.keys).subtracting(changedCollectionNames)
|
||||||
|
var reconciledItems = source.rawItems.filter { item in
|
||||||
|
guard let collectionName = topLevelCollectionName(for: item, sourceRootURL: resolvedURL) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return unchangedCollectionNames.contains(collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
for type in MinecraftContentType.allCases {
|
||||||
|
let collectionName = type.collectionFolderName
|
||||||
|
guard changedCollectionNames.contains(collectionName) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let collectionURL = resolvedURL.appendingPathComponent(collectionName, isDirectory: true)
|
||||||
|
let discoveredItems = try WorldScanner.discoverItems(
|
||||||
|
inCollectionRootURL: collectionURL,
|
||||||
|
contentType: type
|
||||||
|
)
|
||||||
|
reconciledItems.append(contentsOf: discoveredItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
reconciledItems.sort(by: WorldScanner.sortItems)
|
||||||
|
for item in reconciledItems {
|
||||||
|
onDiscovered(item)
|
||||||
|
}
|
||||||
|
return reconciledItems
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func topLevelCollectionName(for item: MinecraftContentItem, sourceRootURL: URL) -> String? {
|
||||||
|
let relativePath = item.folderURL.path.replacingOccurrences(of: sourceRootURL.path + "/", with: "")
|
||||||
|
let components = relativePath.split(separator: "/")
|
||||||
|
return components.first.map(String.init)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user