second iteration: layout adjustments
This commit is contained in:
parent
dcfc25091b
commit
711ac54f00
@ -15,23 +15,33 @@ struct ContentView: View {
|
|||||||
@State private var selectedSidebarSelection: SidebarSelection?
|
@State private var selectedSidebarSelection: SidebarSelection?
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var isDropTargeted = false
|
@State private var isDropTargeted = false
|
||||||
@State private var itemActionAlert: ItemActionAlert?
|
|
||||||
@State private var isPerformingItemAction = false
|
@State private var isPerformingItemAction = false
|
||||||
|
|
||||||
|
private let directoryPreviewLimit = 12
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List(selection: $selectedSidebarSelection) {
|
VStack(spacing: 0) {
|
||||||
ForEach(library.sources) { source in
|
List(selection: $selectedSidebarSelection) {
|
||||||
Section(source.displayName) {
|
ForEach(library.sources) { source in
|
||||||
ForEach(sidebarFilters(for: source)) { filter in
|
Section(source.displayName) {
|
||||||
SidebarFilterRow(filter: filter)
|
ForEach(sidebarFilters(for: source)) { filter in
|
||||||
.tag(filter.selection as SidebarSelection?)
|
SidebarFilterRow(filter: filter)
|
||||||
|
.tag(filter.selection as SidebarSelection?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
.navigationTitle("Sources")
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
SidebarFooterView(
|
||||||
|
state: library.sidebarFooterState,
|
||||||
|
revealAction: revealURLInFinder(_:)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
|
||||||
.navigationTitle("Sources")
|
|
||||||
} content: {
|
} content: {
|
||||||
if library.sources.isEmpty {
|
if library.sources.isEmpty {
|
||||||
EmptySourcesView(
|
EmptySourcesView(
|
||||||
@ -40,150 +50,51 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: handleDroppedProviders)
|
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: handleDroppedProviders)
|
||||||
} else {
|
} else {
|
||||||
List(filteredItems, selection: $selectedItem) { item in
|
VStack(spacing: 0) {
|
||||||
HStack(alignment: .top, spacing: 10) {
|
ContentCollectionHeaderView(
|
||||||
ItemThumbnailView(iconURL: item.iconURL)
|
title: collectionHeaderTitle,
|
||||||
|
subtitle: collectionHeaderSubtitle,
|
||||||
|
prompt: searchPrompt,
|
||||||
|
searchText: $searchText
|
||||||
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Divider()
|
||||||
Text(item.displayName)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(item.contentType.rawValue)
|
List(filteredItems, selection: $selectedItem) { item in
|
||||||
.font(.caption)
|
ContentRowView(item: item)
|
||||||
.foregroundStyle(.secondary)
|
.tag(item)
|
||||||
|
.contextMenu {
|
||||||
Text(item.folderName)
|
itemContextMenu(for: item)
|
||||||
.font(.caption2)
|
}
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if !item.metadataLoaded {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.tag(item)
|
|
||||||
.contextMenu {
|
|
||||||
itemContextMenu(for: item)
|
|
||||||
}
|
}
|
||||||
|
.listStyle(.inset)
|
||||||
}
|
}
|
||||||
.navigationTitle(contentListTitle)
|
|
||||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Search Content")
|
|
||||||
}
|
}
|
||||||
} detail: {
|
} detail: {
|
||||||
if library.sources.isEmpty {
|
if library.sources.isEmpty {
|
||||||
Text("Add a source folder to start scanning Minecraft content")
|
Text("Add a source folder to start scanning Minecraft content")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else if let selectedItem = currentSelectedItem {
|
} else if let selectedItem = currentSelectedItem {
|
||||||
ScrollView {
|
ItemDetailView(
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
item: selectedItem,
|
||||||
HStack(alignment: .top, spacing: 16) {
|
behaviorPacks: packReferences(for: selectedItem, type: .behaviorPack),
|
||||||
LargeItemThumbnailView(iconURL: selectedItem.iconURL)
|
resourcePacks: packReferences(for: selectedItem, type: .resourcePack),
|
||||||
|
contents: directoryPreviewEntries(for: selectedItem),
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
directoryPreviewLimit: directoryPreviewLimit,
|
||||||
Text(selectedItem.displayName)
|
isPerformingItemAction: isPerformingItemAction,
|
||||||
.font(.title2)
|
primaryActionTitle: primaryActionTitle(for: selectedItem),
|
||||||
|
primaryActionSubtitle: primaryActionSubtitle(for: selectedItem),
|
||||||
Text(selectedItem.contentType.rawValue)
|
primaryAction: { saveItem(selectedItem) },
|
||||||
.font(.headline)
|
shareAction: { anchorView in shareItem(selectedItem, from: anchorView) },
|
||||||
.foregroundStyle(.secondary)
|
revealAction: { revealInFinder(selectedItem) }
|
||||||
|
)
|
||||||
Text(selectedItem.folderName)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 8) {
|
|
||||||
SharingPickerButton(
|
|
||||||
title: "Share",
|
|
||||||
systemImage: "square.and.arrow.up",
|
|
||||||
isEnabled: !isPerformingItemAction
|
|
||||||
) { anchorView in
|
|
||||||
shareItem(selectedItem, from: anchorView)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
saveItem(selectedItem)
|
|
||||||
} label: {
|
|
||||||
Label("Export .\(selectedItem.contentType.archiveExtension)", systemImage: "square.and.arrow.down")
|
|
||||||
}
|
|
||||||
.disabled(isPerformingItemAction)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
revealInFinder(selectedItem)
|
|
||||||
} label: {
|
|
||||||
Label("Reveal in Finder", systemImage: "folder")
|
|
||||||
}
|
|
||||||
.disabled(isPerformingItemAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
detailSection("Location") {
|
|
||||||
detailRow(title: "Folder Path", value: selectedItem.folderURL.path)
|
|
||||||
detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
detailSection("Details") {
|
|
||||||
if let modifiedDate = selectedItem.modifiedDate {
|
|
||||||
detailRow(
|
|
||||||
title: "Modified",
|
|
||||||
value: modifiedDate.formatted(date: .abbreviated, time: .shortened)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sizeBytes = selectedItem.sizeBytes {
|
|
||||||
detailRow(
|
|
||||||
title: "Size",
|
|
||||||
value: ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
detailRow(
|
|
||||||
title: "Metadata",
|
|
||||||
value: selectedItem.metadataLoaded ? "Loaded" : "Loading..."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
detailSection("Contents") {
|
|
||||||
let previewEntries = directoryPreviewEntries(for: selectedItem)
|
|
||||||
|
|
||||||
if previewEntries.isEmpty {
|
|
||||||
Text("No visible files or folders")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
ForEach(previewEntries) { entry in
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: entry.isDirectory ? "folder" : "doc")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(entry.name)
|
|
||||||
.lineLimit(1)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if previewEntries.count == directoryPreviewLimit {
|
|
||||||
Text("Showing the first \(directoryPreviewLimit) items")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Text("Select a world or pack to see details")
|
Text("Select a world or pack to see details")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Minecraft World Manager")
|
.navigationTitle("Minecraft World Manager")
|
||||||
|
.tint(.minecraftAccent)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
if let selectedExportableItem = selectedExportableItem {
|
if let selectedExportableItem = selectedExportableItem {
|
||||||
@ -194,11 +105,12 @@ struct ContentView: View {
|
|||||||
) { anchorView in
|
) { anchorView in
|
||||||
shareItem(selectedExportableItem, from: anchorView)
|
shareItem(selectedExportableItem, from: anchorView)
|
||||||
}
|
}
|
||||||
|
.help("Share")
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
saveItem(selectedExportableItem)
|
saveItem(selectedExportableItem)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Export", systemImage: "square.and.arrow.down")
|
Label("Export", systemImage: "arrow.down.circle")
|
||||||
}
|
}
|
||||||
.disabled(isPerformingItemAction)
|
.disabled(isPerformingItemAction)
|
||||||
|
|
||||||
@ -233,21 +145,6 @@ struct ContentView: View {
|
|||||||
.help("Source actions")
|
.help("Source actions")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .secondaryAction) {
|
|
||||||
if let activeScanSummary = library.activeScanSummary {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
|
|
||||||
Text(activeScanSummary)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: 320, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: filteredItems.map(\.id)) { _, filteredIDs in
|
.onChange(of: filteredItems.map(\.id)) { _, filteredIDs in
|
||||||
guard let selectedItem, !filteredIDs.contains(selectedItem.id) else {
|
guard let selectedItem, !filteredIDs.contains(selectedItem.id) else {
|
||||||
@ -259,40 +156,29 @@ struct ContentView: View {
|
|||||||
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in
|
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in
|
||||||
syncSelection(with: sourceIDs)
|
syncSelection(with: sourceIDs)
|
||||||
}
|
}
|
||||||
.alert(item: $itemActionAlert) { alert in
|
|
||||||
Alert(
|
|
||||||
title: Text(alert.title),
|
|
||||||
message: Text(alert.message),
|
|
||||||
dismissButton: .default(Text("OK"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let directoryPreviewLimit = 12
|
private var scopedItems: [MinecraftContentItem] {
|
||||||
|
|
||||||
private var filteredItems: [MinecraftContentItem] {
|
|
||||||
guard let selectedSidebarSelection else {
|
guard let selectedSidebarSelection else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
let scopedItems: [MinecraftContentItem]
|
|
||||||
|
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
case .allContent(let sourceID):
|
case .allContent(let sourceID):
|
||||||
scopedItems = library.source(withID: sourceID)?.items ?? []
|
return library.source(withID: sourceID)?.items ?? []
|
||||||
case .contentType(let sourceID, let contentType):
|
case .contentType(let sourceID, let contentType):
|
||||||
scopedItems = library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? []
|
return library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? []
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredItems: [MinecraftContentItem] {
|
||||||
let trimmedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmedSearchText.isEmpty else {
|
guard !trimmedSearchText.isEmpty else {
|
||||||
return scopedItems
|
return scopedItems
|
||||||
}
|
}
|
||||||
|
|
||||||
return scopedItems.filter { item in
|
return scopedItems.filter { item in
|
||||||
item.displayName.localizedCaseInsensitiveContains(trimmedSearchText)
|
item.searchText.localizedCaseInsensitiveContains(trimmedSearchText)
|
||||||
|| item.folderName.localizedCaseInsensitiveContains(trimmedSearchText)
|
|
||||||
|| item.contentType.rawValue.localizedCaseInsensitiveContains(trimmedSearchText)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -318,19 +204,60 @@ struct ContentView: View {
|
|||||||
currentSelectedItem
|
currentSelectedItem
|
||||||
}
|
}
|
||||||
|
|
||||||
private var contentListTitle: String {
|
private var collectionHeaderTitle: String {
|
||||||
guard let selectedSidebarSelection else {
|
guard let selectedSidebarSelection else {
|
||||||
return "Minecraft Content"
|
return "Minecraft Content"
|
||||||
}
|
}
|
||||||
|
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
case .allContent(let sourceID):
|
case .allContent:
|
||||||
return library.source(withID: sourceID)?.displayName ?? "Minecraft Content"
|
return "All Content"
|
||||||
case .contentType(_, let contentType):
|
case .contentType(_, let contentType):
|
||||||
return sidebarTitle(for: contentType)
|
return sidebarTitle(for: contentType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var collectionHeaderSubtitle: String {
|
||||||
|
let totalCount = scopedItems.count
|
||||||
|
let filteredCount = filteredItems.count
|
||||||
|
let noun = collectionCountNoun
|
||||||
|
|
||||||
|
if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return "\(totalCount.formatted(.number)) \(noun)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(filteredCount.formatted(.number)) of \(totalCount.formatted(.number)) \(noun)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var collectionCountNoun: String {
|
||||||
|
guard let selectedSidebarSelection else {
|
||||||
|
return "items"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch selectedSidebarSelection {
|
||||||
|
case .allContent:
|
||||||
|
return scopedItems.count == 1 ? "item" : "items"
|
||||||
|
case .contentType(_, let contentType):
|
||||||
|
switch contentType {
|
||||||
|
case .world:
|
||||||
|
return scopedItems.count == 1 ? "world" : "worlds"
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
|
return scopedItems.count == 1 ? "pack" : "packs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchPrompt: String {
|
||||||
|
switch selectedSidebarSelection {
|
||||||
|
case .some(.allContent):
|
||||||
|
return "Search All Content"
|
||||||
|
case .some(.contentType(_, let contentType)):
|
||||||
|
return "Search \(sidebarTitle(for: contentType))"
|
||||||
|
case .none:
|
||||||
|
return "Search Content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
||||||
var filters = [
|
var filters = [
|
||||||
SidebarFilter(
|
SidebarFilter(
|
||||||
@ -390,35 +317,13 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func detailSection<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text(title)
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func detailRow(title: String, value: String) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(title)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Text(value)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func itemContextMenu(for item: MinecraftContentItem) -> some View {
|
private func itemContextMenu(for item: MinecraftContentItem) -> some View {
|
||||||
Button("Share...") {
|
Button("Share...") {
|
||||||
shareItem(item, from: nil)
|
shareItem(item, from: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Export .\(item.contentType.archiveExtension)") {
|
Button(exportMenuTitle(for: item)) {
|
||||||
saveItem(item)
|
saveItem(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,6 +334,43 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func exportMenuTitle(for item: MinecraftContentItem) -> String {
|
||||||
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
return "Create Minecraft World File..."
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack:
|
||||||
|
return "Create Minecraft Pack File..."
|
||||||
|
case .worldTemplate:
|
||||||
|
return "Create Minecraft Template File..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func primaryActionTitle(for item: MinecraftContentItem) -> String {
|
||||||
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
return "Create Minecraft World File..."
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack:
|
||||||
|
return "Create Minecraft Pack File..."
|
||||||
|
case .worldTemplate:
|
||||||
|
return "Create Minecraft Template File..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func primaryActionSubtitle(for item: MinecraftContentItem) -> String {
|
||||||
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
return "Creates a .mcworld file that can be opened on another device to import this world into Minecraft."
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack:
|
||||||
|
return "Creates a .mcpack file that can be shared or opened on another device."
|
||||||
|
case .worldTemplate:
|
||||||
|
return "Creates a .mctemplate file that can be opened on another device."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func packReferences(for item: MinecraftContentItem, type: MinecraftContentType) -> [ContentPackReference] {
|
||||||
|
item.packReferences.filter { $0.type == type }
|
||||||
|
}
|
||||||
|
|
||||||
private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] {
|
private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
|
|
||||||
@ -546,8 +488,8 @@ struct ContentView: View {
|
|||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
panel.canCreateDirectories = true
|
panel.canCreateDirectories = true
|
||||||
panel.isExtensionHidden = false
|
panel.isExtensionHidden = false
|
||||||
panel.title = "Export \(item.contentType.exportTitle)"
|
panel.title = primaryActionTitle(for: item)
|
||||||
panel.message = "Choose where to save the .\(item.contentType.archiveExtension) file."
|
panel.message = primaryActionSubtitle(for: item)
|
||||||
panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item)
|
panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item)
|
||||||
panel.allowedContentTypes = [archiveType(for: item)]
|
panel.allowedContentTypes = [archiveType(for: item)]
|
||||||
|
|
||||||
@ -556,6 +498,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isPerformingItemAction = true
|
isPerformingItemAction = true
|
||||||
|
library.setItemActionInProgress("Creating \(item.contentType.archiveExtension) file...")
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@ -563,20 +506,20 @@ struct ContentView: View {
|
|||||||
try ContentPackageExporter.exportItem(item, to: destinationURL)
|
try ContentPackageExporter.exportItem(item, to: destinationURL)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
|
let finalURL = ContentPackageExporter.finalArchiveURL(for: item, destinationURL: destinationURL)
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isPerformingItemAction = false
|
isPerformingItemAction = false
|
||||||
itemActionAlert = ItemActionAlert(
|
library.setItemActionSuccess(
|
||||||
title: "Export Complete",
|
title: "Created \(finalURL.lastPathComponent)",
|
||||||
message: "\"\(item.displayName)\" was exported as \(ContentPackageExporter.suggestedFilename(for: item))."
|
subtitle: "Ready to move to another device",
|
||||||
|
revealURL: finalURL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isPerformingItemAction = false
|
isPerformingItemAction = false
|
||||||
itemActionAlert = ItemActionAlert(
|
library.setItemActionFailure(error.localizedDescription)
|
||||||
title: "Export Failed",
|
|
||||||
message: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -588,6 +531,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isPerformingItemAction = true
|
isPerformingItemAction = true
|
||||||
|
library.setItemActionInProgress("Preparing \(item.contentType.archiveExtension) file...")
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@ -600,24 +544,27 @@ struct ContentView: View {
|
|||||||
|
|
||||||
let presentationView = anchorView ?? NSApp.keyWindow?.contentView
|
let presentationView = anchorView ?? NSApp.keyWindow?.contentView
|
||||||
guard let presentationView else {
|
guard let presentationView else {
|
||||||
itemActionAlert = ItemActionAlert(
|
library.setItemActionFailure("Could not present the share menu.")
|
||||||
title: "Share Failed",
|
|
||||||
message: "Could not find a view to present the sharing menu."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
library.setItemActionSuccess(
|
||||||
|
title: "Share ready",
|
||||||
|
subtitle: shareURL.lastPathComponent,
|
||||||
|
revealURL: shareURL
|
||||||
|
)
|
||||||
|
|
||||||
let picker = NSSharingServicePicker(items: [shareURL])
|
let picker = NSSharingServicePicker(items: [shareURL])
|
||||||
let targetRect = anchorView?.bounds ?? presentationView.bounds.insetBy(dx: presentationView.bounds.width / 2, dy: presentationView.bounds.height / 2)
|
let targetRect = anchorView?.bounds ?? presentationView.bounds.insetBy(
|
||||||
|
dx: presentationView.bounds.width / 2,
|
||||||
|
dy: presentationView.bounds.height / 2
|
||||||
|
)
|
||||||
picker.show(relativeTo: targetRect, of: presentationView, preferredEdge: .minY)
|
picker.show(relativeTo: targetRect, of: presentationView, preferredEdge: .minY)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isPerformingItemAction = false
|
isPerformingItemAction = false
|
||||||
itemActionAlert = ItemActionAlert(
|
library.setItemActionFailure(error.localizedDescription)
|
||||||
title: "Share Failed",
|
|
||||||
message: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -627,6 +574,10 @@ struct ContentView: View {
|
|||||||
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
|
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func revealURLInFinder(_ url: URL) {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
|
}
|
||||||
|
|
||||||
private func archiveType(for item: MinecraftContentItem) -> UTType {
|
private func archiveType(for item: MinecraftContentItem) -> UTType {
|
||||||
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data
|
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data
|
||||||
}
|
}
|
||||||
@ -671,10 +622,302 @@ private struct SidebarFilterRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ItemActionAlert: Identifiable {
|
private struct SidebarFooterView: View {
|
||||||
let id = UUID()
|
let state: SidebarFooterState
|
||||||
|
let revealAction: (URL) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if state.style == .inProgress {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(state.title)
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(primaryColor)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let subtitle = state.subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let revealURL = state.revealURL {
|
||||||
|
Button("Reveal in Finder") {
|
||||||
|
revealAction(revealURL)
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var primaryColor: Color {
|
||||||
|
switch state.style {
|
||||||
|
case .idle, .inProgress:
|
||||||
|
return .primary
|
||||||
|
case .failure:
|
||||||
|
return .red
|
||||||
|
case .success:
|
||||||
|
return .minecraftAccent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContentCollectionHeaderView: View {
|
||||||
let title: String
|
let title: String
|
||||||
let message: String
|
let subtitle: String
|
||||||
|
let prompt: String
|
||||||
|
@Binding var searchText: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
TextField(prompt, text: $searchText)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContentRowView: View {
|
||||||
|
let item: MinecraftContentItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
ItemThumbnailView(iconURL: item.iconURL)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(item.displayName)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(metadataLine)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !item.metadataLoaded {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var metadataLine: String {
|
||||||
|
let sizeText = item.sizeBytes.map {
|
||||||
|
ByteCountFormatter.string(fromByteCount: $0, countStyle: .file)
|
||||||
|
} ?? "Size unavailable"
|
||||||
|
let dateText = item.displayDate.map {
|
||||||
|
$0.formatted(date: .abbreviated, time: .omitted)
|
||||||
|
} ?? "Date unavailable"
|
||||||
|
|
||||||
|
return "\(item.contentType.rawValue) • \(sizeText) • \(item.displayDateLabel) \(dateText)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ItemDetailView: View {
|
||||||
|
let item: MinecraftContentItem
|
||||||
|
let behaviorPacks: [ContentPackReference]
|
||||||
|
let resourcePacks: [ContentPackReference]
|
||||||
|
let contents: [DirectoryPreviewEntry]
|
||||||
|
let directoryPreviewLimit: Int
|
||||||
|
let isPerformingItemAction: Bool
|
||||||
|
let primaryActionTitle: String
|
||||||
|
let primaryActionSubtitle: String
|
||||||
|
let primaryAction: () -> Void
|
||||||
|
let shareAction: (NSView) -> Void
|
||||||
|
let revealAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
LargeItemThumbnailView(iconURL: item.iconURL)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(item.displayName)
|
||||||
|
.font(.largeTitle.weight(.semibold))
|
||||||
|
|
||||||
|
Text(item.contentType.rawValue)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
metadataChip(title: "Size", value: sizeText)
|
||||||
|
metadataChip(title: item.displayDateLabel, value: displayDateText)
|
||||||
|
|
||||||
|
if item.lastPlayedDate == nil, item.contentType == .world {
|
||||||
|
metadataChip(title: "Last Played", value: "Not available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Actions")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Button(primaryActionTitle) {
|
||||||
|
primaryAction()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(isPerformingItemAction)
|
||||||
|
|
||||||
|
Text(primaryActionSubtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
SharingPickerButton(
|
||||||
|
title: "Share...",
|
||||||
|
systemImage: "square.and.arrow.up",
|
||||||
|
isEnabled: !isPerformingItemAction
|
||||||
|
) { anchorView in
|
||||||
|
shareAction(anchorView)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Reveal in Finder") {
|
||||||
|
revealAction()
|
||||||
|
}
|
||||||
|
.disabled(isPerformingItemAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.contentType == .world {
|
||||||
|
if !behaviorPacks.isEmpty || !resourcePacks.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Packs Used")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if !behaviorPacks.isEmpty {
|
||||||
|
packSection(title: "Behavior Packs", packs: behaviorPacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resourcePacks.isEmpty {
|
||||||
|
packSection(title: "Resource Packs", packs: resourcePacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisclosureGroup("Technical Details") {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
detailRow(title: "Folder ID", value: item.folderID)
|
||||||
|
detailRow(title: "Folder Path", value: item.folderURL.path)
|
||||||
|
detailRow(title: "Collection Root", value: item.collectionRootURL.path)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Contents")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if contents.isEmpty {
|
||||||
|
Text("No visible files or folders")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(contents) { entry in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: entry.isDirectory ? "folder" : "doc")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(entry.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if contents.count == directoryPreviewLimit {
|
||||||
|
Text("Showing the first \(directoryPreviewLimit) items")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func metadataChip(title: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func packSection(title: String, packs: [ContentPackReference]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
||||||
|
ForEach(packs) { pack in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(pack.name)
|
||||||
|
|
||||||
|
if let secondary = packSecondaryText(pack), !secondary.isEmpty {
|
||||||
|
Text(secondary)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func detailRow(title: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sizeText: String {
|
||||||
|
item.sizeBytes.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayDateText: String {
|
||||||
|
item.displayDate.map { $0.formatted(date: .abbreviated, time: .omitted) } ?? "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func packSecondaryText(_ pack: ContentPackReference) -> String? {
|
||||||
|
let components = [pack.version.map { "v\($0)" }, pack.uuid]
|
||||||
|
.compactMap { $0 }
|
||||||
|
return components.isEmpty ? nil : components.joined(separator: " • ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct DirectoryPreviewEntry: Identifiable {
|
private struct DirectoryPreviewEntry: Identifiable {
|
||||||
@ -739,12 +982,12 @@ private struct EmptySourcesView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 24)
|
RoundedRectangle(cornerRadius: 24)
|
||||||
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10]))
|
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10]))
|
||||||
.foregroundStyle(isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.25))
|
.foregroundStyle(isDropTargeted ? Color.minecraftAccent : Color.secondary.opacity(0.25))
|
||||||
.frame(width: 220, height: 160)
|
.frame(width: 220, height: 160)
|
||||||
|
|
||||||
Image(systemName: "folder.badge.plus")
|
Image(systemName: "folder.badge.plus")
|
||||||
.font(.system(size: 56, weight: .regular))
|
.font(.system(size: 56, weight: .regular))
|
||||||
.foregroundStyle(isDropTargeted ? Color.accentColor : Color.secondary)
|
.foregroundStyle(isDropTargeted ? Color.minecraftAccent : Color.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
@ -775,12 +1018,12 @@ private struct ItemThumbnailView: View {
|
|||||||
Image(nsImage: image)
|
Image(nsImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 40, height: 40)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 7)
|
||||||
.fill(.quaternary)
|
.fill(.quaternary)
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 40, height: 40)
|
||||||
.overlay(
|
.overlay(
|
||||||
Image(systemName: "shippingbox")
|
Image(systemName: "shippingbox")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -797,12 +1040,12 @@ private struct LargeItemThumbnailView: View {
|
|||||||
Image(nsImage: image)
|
Image(nsImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 128, height: 128)
|
.frame(width: 180, height: 180)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.fill(.quaternary)
|
.fill(.quaternary)
|
||||||
.frame(width: 128, height: 128)
|
.frame(width: 180, height: 180)
|
||||||
.overlay(
|
.overlay(
|
||||||
Image(systemName: "shippingbox")
|
Image(systemName: "shippingbox")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
@ -820,6 +1063,10 @@ private func loadImage(from url: URL?) -> NSImage? {
|
|||||||
return NSImage(contentsOf: url)
|
return NSImage(contentsOf: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension Color {
|
||||||
|
static let minecraftAccent = Color(red: 0.36, green: 0.63, blue: 0.24)
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
|||||||
@ -56,6 +56,40 @@ enum MinecraftContentType: String, CaseIterable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PackSource: String, Hashable, Sendable {
|
||||||
|
case referencedByWorld
|
||||||
|
case embeddedInWorld
|
||||||
|
case foundInCollection
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentPackReference: Identifiable, Hashable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let type: MinecraftContentType
|
||||||
|
let uuid: String?
|
||||||
|
let version: String?
|
||||||
|
let source: PackSource
|
||||||
|
|
||||||
|
nonisolated init(
|
||||||
|
name: String,
|
||||||
|
type: MinecraftContentType,
|
||||||
|
uuid: String? = nil,
|
||||||
|
version: String? = nil,
|
||||||
|
source: PackSource
|
||||||
|
) {
|
||||||
|
self.type = type
|
||||||
|
self.uuid = uuid?.lowercased()
|
||||||
|
self.version = version
|
||||||
|
self.source = source
|
||||||
|
self.name = name
|
||||||
|
self.id = [
|
||||||
|
type.rawValue,
|
||||||
|
self.uuid ?? name,
|
||||||
|
version ?? source.rawValue
|
||||||
|
].joined(separator: "::")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
||||||
let id: URL
|
let id: URL
|
||||||
let folderURL: URL
|
let folderURL: URL
|
||||||
@ -64,8 +98,10 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
|||||||
let collectionRootURL: URL
|
let collectionRootURL: URL
|
||||||
var displayName: String
|
var displayName: String
|
||||||
var iconURL: URL?
|
var iconURL: URL?
|
||||||
|
var lastPlayedDate: Date?
|
||||||
var modifiedDate: Date?
|
var modifiedDate: Date?
|
||||||
var sizeBytes: Int64?
|
var sizeBytes: Int64?
|
||||||
|
var packReferences: [ContentPackReference]
|
||||||
var metadataLoaded: Bool
|
var metadataLoaded: Bool
|
||||||
|
|
||||||
nonisolated init(
|
nonisolated init(
|
||||||
@ -75,8 +111,10 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
|||||||
collectionRootURL: URL,
|
collectionRootURL: URL,
|
||||||
displayName: String? = nil,
|
displayName: String? = nil,
|
||||||
iconURL: URL? = nil,
|
iconURL: URL? = nil,
|
||||||
|
lastPlayedDate: Date? = nil,
|
||||||
modifiedDate: Date? = nil,
|
modifiedDate: Date? = nil,
|
||||||
sizeBytes: Int64? = nil,
|
sizeBytes: Int64? = nil,
|
||||||
|
packReferences: [ContentPackReference] = [],
|
||||||
metadataLoaded: Bool = false
|
metadataLoaded: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = folderURL.standardizedFileURL
|
self.id = folderURL.standardizedFileURL
|
||||||
@ -86,11 +124,40 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable {
|
|||||||
self.collectionRootURL = collectionRootURL
|
self.collectionRootURL = collectionRootURL
|
||||||
self.displayName = displayName ?? folderName
|
self.displayName = displayName ?? folderName
|
||||||
self.iconURL = iconURL
|
self.iconURL = iconURL
|
||||||
|
self.lastPlayedDate = lastPlayedDate
|
||||||
self.modifiedDate = modifiedDate
|
self.modifiedDate = modifiedDate
|
||||||
self.sizeBytes = sizeBytes
|
self.sizeBytes = sizeBytes
|
||||||
|
self.packReferences = packReferences
|
||||||
self.metadataLoaded = metadataLoaded
|
self.metadataLoaded = metadataLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated var folderID: String {
|
||||||
|
folderName
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var displayDate: Date? {
|
||||||
|
lastPlayedDate ?? modifiedDate
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var displayDateLabel: String {
|
||||||
|
lastPlayedDate == nil ? "Modified" : "Last Played"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var searchText: String {
|
||||||
|
let values = [
|
||||||
|
displayName,
|
||||||
|
folderName,
|
||||||
|
folderURL.path,
|
||||||
|
contentType.rawValue,
|
||||||
|
packReferences.map(\.name).joined(separator: " "),
|
||||||
|
packReferences.compactMap(\.uuid).joined(separator: " ")
|
||||||
|
]
|
||||||
|
|
||||||
|
return values
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated static func == (lhs: MinecraftContentItem, rhs: MinecraftContentItem) -> Bool {
|
nonisolated static func == (lhs: MinecraftContentItem, rhs: MinecraftContentItem) -> Bool {
|
||||||
lhs.id == rhs.id
|
lhs.id == rhs.id
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,9 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
var isScanning: Bool
|
var isScanning: Bool
|
||||||
var scanStatus: String
|
var scanStatus: String
|
||||||
var scanError: String?
|
var scanError: String?
|
||||||
|
var indexedItemCount: Int
|
||||||
|
var indexedDetailCount: Int
|
||||||
|
var lastScanDate: Date?
|
||||||
|
|
||||||
init(folderURL: URL) {
|
init(folderURL: URL) {
|
||||||
let normalizedURL = folderURL.standardizedFileURL
|
let normalizedURL = folderURL.standardizedFileURL
|
||||||
@ -25,6 +28,9 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
self.isScanning = false
|
self.isScanning = false
|
||||||
self.scanStatus = ""
|
self.scanStatus = ""
|
||||||
self.scanError = nil
|
self.scanError = nil
|
||||||
|
self.indexedItemCount = 0
|
||||||
|
self.indexedDetailCount = 0
|
||||||
|
self.lastScanDate = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var itemCount: Int {
|
var itemCount: Int {
|
||||||
|
|||||||
@ -21,7 +21,7 @@ enum ContentPackageExporter {
|
|||||||
|
|
||||||
nonisolated static func exportItem(_ item: MinecraftContentItem, to destinationURL: URL) throws {
|
nonisolated static func exportItem(_ item: MinecraftContentItem, to destinationURL: URL) throws {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let archiveURL = normalizedArchiveURL(for: item, destinationURL: destinationURL)
|
let archiveURL = finalArchiveURL(for: item, destinationURL: destinationURL)
|
||||||
let temporaryArchiveURL = temporaryArchiveURL(for: item, fileManager: fileManager)
|
let temporaryArchiveURL = temporaryArchiveURL(for: item, fileManager: fileManager)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
@ -63,6 +63,10 @@ enum ContentPackageExporter {
|
|||||||
"\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)"
|
"\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
|
||||||
|
normalizedArchiveURL(for: item, destinationURL: destinationURL)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private static func createArchive(for item: MinecraftContentItem, at archiveURL: URL) throws {
|
nonisolated private static func createArchive(for item: MinecraftContentItem, at archiveURL: URL) throws {
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
|
||||||
|
|||||||
@ -8,9 +8,29 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct SidebarFooterState {
|
||||||
|
enum Style {
|
||||||
|
case idle
|
||||||
|
case inProgress
|
||||||
|
case failure
|
||||||
|
case success
|
||||||
|
}
|
||||||
|
|
||||||
|
let style: Style
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let revealURL: URL?
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SourceLibrary: ObservableObject {
|
final class SourceLibrary: ObservableObject {
|
||||||
@Published var sources: [MinecraftSource] = []
|
@Published var sources: [MinecraftSource] = []
|
||||||
|
@Published private(set) var sidebarFooterState = SidebarFooterState(
|
||||||
|
style: .idle,
|
||||||
|
title: "Ready",
|
||||||
|
subtitle: nil,
|
||||||
|
revealURL: nil
|
||||||
|
)
|
||||||
|
|
||||||
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||||
|
|
||||||
@ -40,6 +60,34 @@ final class SourceLibrary: ObservableObject {
|
|||||||
scanTasks[sourceID]?.cancel()
|
scanTasks[sourceID]?.cancel()
|
||||||
scanTasks[sourceID] = nil
|
scanTasks[sourceID] = nil
|
||||||
sources.removeAll { $0.id == sourceID }
|
sources.removeAll { $0.id == sourceID }
|
||||||
|
refreshSidebarFooterState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setItemActionInProgress(_ description: String) {
|
||||||
|
sidebarFooterState = SidebarFooterState(
|
||||||
|
style: .inProgress,
|
||||||
|
title: description,
|
||||||
|
subtitle: nil,
|
||||||
|
revealURL: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setItemActionFailure(_ message: String) {
|
||||||
|
sidebarFooterState = SidebarFooterState(
|
||||||
|
style: .failure,
|
||||||
|
title: "Action Failed",
|
||||||
|
subtitle: message,
|
||||||
|
revealURL: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setItemActionSuccess(title: String, subtitle: String, revealURL: URL?) {
|
||||||
|
sidebarFooterState = SidebarFooterState(
|
||||||
|
style: .success,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
revealURL: revealURL
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeScanSummary: String? {
|
var activeScanSummary: String? {
|
||||||
@ -75,7 +123,10 @@ final class SourceLibrary: ObservableObject {
|
|||||||
source.scanError = nil
|
source.scanError = nil
|
||||||
source.scanStatus = "Searching for Minecraft content..."
|
source.scanStatus = "Searching for Minecraft content..."
|
||||||
source.items = []
|
source.items = []
|
||||||
|
source.indexedItemCount = 0
|
||||||
|
source.indexedDetailCount = 0
|
||||||
}
|
}
|
||||||
|
refreshSidebarFooterState()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let discoveredItems = try await Task.detached(priority: .userInitiated) {
|
let discoveredItems = try await Task.detached(priority: .userInitiated) {
|
||||||
@ -88,10 +139,12 @@ final class SourceLibrary: ObservableObject {
|
|||||||
|
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
source.items = discoveredItems
|
source.items = discoveredItems
|
||||||
|
source.indexedItemCount = discoveredItems.count
|
||||||
source.scanStatus = discoveredItems.isEmpty
|
source.scanStatus = discoveredItems.isEmpty
|
||||||
? "No Minecraft content found."
|
? "No Minecraft content found."
|
||||||
: "Found \(discoveredItems.count) items. Loading details..."
|
: "Found \(discoveredItems.count) items. Loading details..."
|
||||||
}
|
}
|
||||||
|
refreshSidebarFooterState()
|
||||||
|
|
||||||
var loadedCount = 0
|
var loadedCount = 0
|
||||||
|
|
||||||
@ -114,22 +167,27 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
source.items[index] = enrichedItem
|
source.items[index] = enrichedItem
|
||||||
|
source.indexedDetailCount = loadedCount
|
||||||
source.items.sort(by: WorldScanner.sortItems)
|
source.items.sort(by: WorldScanner.sortItems)
|
||||||
|
|
||||||
if loadedCount == discoveredItems.count {
|
if loadedCount == discoveredItems.count {
|
||||||
source.scanStatus = "Loaded \(loadedCount) items."
|
source.scanStatus = "Loaded \(loadedCount) items."
|
||||||
source.isScanning = false
|
source.isScanning = false
|
||||||
|
source.lastScanDate = Date()
|
||||||
} else {
|
} else {
|
||||||
source.scanStatus = "Loaded details for \(loadedCount) of \(discoveredItems.count) items..."
|
source.scanStatus = "Loaded details for \(loadedCount) of \(discoveredItems.count) items..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
refreshSidebarFooterState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if discoveredItems.isEmpty {
|
if discoveredItems.isEmpty {
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
source.isScanning = false
|
source.isScanning = false
|
||||||
|
source.lastScanDate = Date()
|
||||||
}
|
}
|
||||||
|
refreshSidebarFooterState()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else {
|
guard !Task.isCancelled else {
|
||||||
@ -141,6 +199,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
source.scanStatus = ""
|
source.scanStatus = ""
|
||||||
source.isScanning = false
|
source.isScanning = false
|
||||||
}
|
}
|
||||||
|
refreshSidebarFooterState()
|
||||||
}
|
}
|
||||||
|
|
||||||
scanTasks[sourceID] = nil
|
scanTasks[sourceID] = nil
|
||||||
@ -153,4 +212,49 @@ final class SourceLibrary: ObservableObject {
|
|||||||
|
|
||||||
mutate(&sources[index])
|
mutate(&sources[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshSidebarFooterState() {
|
||||||
|
let scanningSources = sources.filter(\.isScanning)
|
||||||
|
if let source = scanningSources.first {
|
||||||
|
let title = source.itemCount == 0 ? "Scanning worlds..." : "Scanning worlds..."
|
||||||
|
let subtitle: String
|
||||||
|
if source.indexedItemCount > 0 {
|
||||||
|
subtitle = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
|
||||||
|
} else {
|
||||||
|
subtitle = "Searching \(source.displayName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarFooterState = SidebarFooterState(
|
||||||
|
style: .inProgress,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
revealURL: nil
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let source = sources.first(where: { $0.scanError != nil }) {
|
||||||
|
sidebarFooterState = SidebarFooterState(
|
||||||
|
style: .failure,
|
||||||
|
title: "Scan failed",
|
||||||
|
subtitle: source.scanError,
|
||||||
|
revealURL: nil
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalItems = sources.reduce(0) { $0 + $1.itemCount }
|
||||||
|
let subtitle = totalItems == 0 ? "No content indexed" : "\(totalItems.formatted(.number)) items indexed"
|
||||||
|
let lastUpdatedDate = sources.compactMap(\.lastScanDate).max()
|
||||||
|
let secondaryText = lastUpdatedDate.map {
|
||||||
|
"\(subtitle) \u{2022} Last updated \($0.formatted(.relative(presentation: .named)))"
|
||||||
|
} ?? subtitle
|
||||||
|
|
||||||
|
sidebarFooterState = SidebarFooterState(
|
||||||
|
style: .idle,
|
||||||
|
title: "Ready",
|
||||||
|
subtitle: secondaryText,
|
||||||
|
revealURL: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,8 +63,10 @@ enum WorldScanner {
|
|||||||
|
|
||||||
enrichedItem.displayName = displayName(for: item, fileManager: fileManager)
|
enrichedItem.displayName = displayName(for: item, fileManager: fileManager)
|
||||||
enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager)
|
enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager)
|
||||||
|
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager)
|
||||||
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
|
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
|
||||||
enrichedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager)
|
enrichedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager)
|
||||||
|
enrichedItem.packReferences = packReferences(for: item, fileManager: fileManager)
|
||||||
enrichedItem.metadataLoaded = true
|
enrichedItem.metadataLoaded = true
|
||||||
|
|
||||||
return enrichedItem
|
return enrichedItem
|
||||||
@ -175,6 +177,18 @@ enum WorldScanner {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private static func lastPlayedDate(for item: MinecraftContentItem, fileManager: FileManager) -> Date? {
|
||||||
|
guard item.contentType == .world else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock's level.dat requires format-specific parsing to distinguish a true
|
||||||
|
// last-played timestamp from general save metadata. Until that is implemented
|
||||||
|
// reliably, prefer surfacing the filesystem modified date only.
|
||||||
|
_ = fileManager
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? {
|
nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? {
|
||||||
try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
||||||
}
|
}
|
||||||
@ -204,4 +218,224 @@ enum WorldScanner {
|
|||||||
|
|
||||||
return totalSize
|
return totalSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private static func packReferences(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] {
|
||||||
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
var references = referencedWorldPacks(for: item, fileManager: fileManager)
|
||||||
|
references.append(contentsOf: embeddedWorldPacks(for: item, fileManager: fileManager))
|
||||||
|
return uniquePackReferences(references)
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func referencedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] {
|
||||||
|
let behaviorReferences = packReferences(
|
||||||
|
fromWorldReferenceFileNamed: "world_behavior_packs.json",
|
||||||
|
type: .behaviorPack,
|
||||||
|
worldFolderURL: item.folderURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
let resourceReferences = packReferences(
|
||||||
|
fromWorldReferenceFileNamed: "world_resource_packs.json",
|
||||||
|
type: .resourcePack,
|
||||||
|
worldFolderURL: item.folderURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
|
||||||
|
return behaviorReferences + resourceReferences
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func embeddedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] {
|
||||||
|
var references: [ContentPackReference] = []
|
||||||
|
|
||||||
|
references.append(
|
||||||
|
contentsOf: embeddedPackReferences(
|
||||||
|
in: item.folderURL.appendingPathComponent("behavior_packs", isDirectory: true),
|
||||||
|
type: .behaviorPack,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
)
|
||||||
|
references.append(
|
||||||
|
contentsOf: embeddedPackReferences(
|
||||||
|
in: item.folderURL.appendingPathComponent("resource_packs", isDirectory: true),
|
||||||
|
type: .resourcePack,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return references
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func packReferences(
|
||||||
|
fromWorldReferenceFileNamed filename: String,
|
||||||
|
type: MinecraftContentType,
|
||||||
|
worldFolderURL: URL,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> [ContentPackReference] {
|
||||||
|
let fileURL = worldFolderURL.appendingPathComponent(filename)
|
||||||
|
guard
|
||||||
|
fileManager.fileExists(atPath: fileURL.path),
|
||||||
|
let data = try? Data(contentsOf: fileURL),
|
||||||
|
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonObject.compactMap { entry in
|
||||||
|
let uuid = (entry["pack_id"] as? String)?.lowercased()
|
||||||
|
let version = versionString(from: entry["version"])
|
||||||
|
let resolvedName = uuid.flatMap {
|
||||||
|
resolvedPackName(
|
||||||
|
uuid: $0,
|
||||||
|
type: type,
|
||||||
|
worldCollectionRootURL: worldFolderURL.deletingLastPathComponent(),
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let fallbackName = resolvedName ?? uuid ?? "Referenced Pack"
|
||||||
|
return ContentPackReference(
|
||||||
|
name: fallbackName,
|
||||||
|
type: type,
|
||||||
|
uuid: uuid,
|
||||||
|
version: version,
|
||||||
|
source: .referencedByWorld
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func embeddedPackReferences(
|
||||||
|
in directoryURL: URL,
|
||||||
|
type: MinecraftContentType,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> [ContentPackReference] {
|
||||||
|
guard
|
||||||
|
fileManager.fileExists(atPath: directoryURL.path),
|
||||||
|
let childDirectories = try? immediateChildDirectories(of: directoryURL, fileManager: fileManager)
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return childDirectories.compactMap { childDirectory in
|
||||||
|
packReference(
|
||||||
|
fromPackFolder: childDirectory,
|
||||||
|
type: type,
|
||||||
|
source: .embeddedInWorld,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func packReference(
|
||||||
|
fromPackFolder directoryURL: URL,
|
||||||
|
type: MinecraftContentType,
|
||||||
|
source: PackSource,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> ContentPackReference? {
|
||||||
|
let manifestURL = directoryURL.appendingPathComponent("manifest.json")
|
||||||
|
guard
|
||||||
|
fileManager.fileExists(atPath: manifestURL.path),
|
||||||
|
let data = try? Data(contentsOf: manifestURL),
|
||||||
|
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = jsonObject["header"] as? [String: Any]
|
||||||
|
let name = ((header?["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
} ?? directoryURL.lastPathComponent
|
||||||
|
let uuid = (header?["uuid"] as? String)?.lowercased()
|
||||||
|
let version = versionString(from: header?["version"])
|
||||||
|
|
||||||
|
return ContentPackReference(
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
uuid: uuid,
|
||||||
|
version: version,
|
||||||
|
source: source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func resolvedPackName(
|
||||||
|
uuid: String,
|
||||||
|
type: MinecraftContentType,
|
||||||
|
worldCollectionRootURL: URL,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> String? {
|
||||||
|
let siblingCollectionURL = worldCollectionRootURL
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
||||||
|
|
||||||
|
guard
|
||||||
|
fileManager.fileExists(atPath: siblingCollectionURL.path),
|
||||||
|
let childDirectories = try? immediateChildDirectories(of: siblingCollectionURL, fileManager: fileManager)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for childDirectory in childDirectories {
|
||||||
|
guard
|
||||||
|
let reference = packReference(
|
||||||
|
fromPackFolder: childDirectory,
|
||||||
|
type: type,
|
||||||
|
source: .foundInCollection,
|
||||||
|
fileManager: fileManager
|
||||||
|
),
|
||||||
|
reference.uuid == uuid
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return reference.name
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func versionString(from value: Any?) -> String? {
|
||||||
|
if let versionString = value as? String, !versionString.isEmpty {
|
||||||
|
return versionString
|
||||||
|
}
|
||||||
|
|
||||||
|
if let versionArray = value as? [Any] {
|
||||||
|
let components = versionArray.compactMap { component -> String? in
|
||||||
|
if let intComponent = component as? Int {
|
||||||
|
return String(intComponent)
|
||||||
|
}
|
||||||
|
if let stringComponent = component as? String {
|
||||||
|
return stringComponent
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return components.isEmpty ? nil : components.joined(separator: ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var uniqueReferences: [ContentPackReference] = []
|
||||||
|
|
||||||
|
for reference in references {
|
||||||
|
let dedupeKey = [reference.type.rawValue, reference.uuid ?? reference.name, reference.version ?? ""]
|
||||||
|
.joined(separator: "::")
|
||||||
|
guard seen.insert(dedupeKey).inserted else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueReferences.append(reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueReferences.sorted { lhs, rhs in
|
||||||
|
if lhs.type != rhs.type {
|
||||||
|
return lhs.type.rawValue.localizedStandardCompare(rhs.type.rawValue) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user