From dc7ab6fb158e404f55fb9d03ed82f172d1802601 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Fri, 7 Feb 2025 14:08:51 +0100 Subject: [PATCH] Improve display of results --- .../Results/PageGenerationResults.swift | 125 ++++++++++------- CHDataManagement/Model/FileType.swift | 14 ++ .../Model/Item/ItemReference.swift | 20 +++ .../Views/Generic/ListPopup.swift | 4 + .../Views/Generic/TextWithPopup.swift | 67 ++++++++- .../Pages/LocalizedPageContentView.swift | 1 + .../Views/Pages/PageContentResultsView.swift | 129 ++++++++++++++---- 7 files changed, 276 insertions(+), 84 deletions(-) diff --git a/CHDataManagement/Generator/Results/PageGenerationResults.swift b/CHDataManagement/Generator/Results/PageGenerationResults.swift index 4283fcd..d4efe9b 100644 --- a/CHDataManagement/Generator/Results/PageGenerationResults.swift +++ b/CHDataManagement/Generator/Results/PageGenerationResults.swift @@ -22,57 +22,76 @@ final class PageGenerationResults: ObservableObject { private unowned let delegate: GenerationResults /// The files that could not be accessed + @Published private(set) var inaccessibleFiles: Set /// The files that could not be parsed, with the error message produced + @Published private(set) var unparsableFiles: [FileResource : Set] /// The missing files directly used by this page, and the source of the file + @Published private(set) var missingFiles: [String: Set] /// The missing files linked to from other files. + @Published private(set) var missingLinkedFiles: [String : Set] /// The missing tags linked to by this page, and the source of the link + @Published private(set) var missingLinkedTags: [String : Set] /// The missing pages linked to by this page, and the source of the link + @Published private(set) var missingLinkedPages: [String : Set] /// The footer scripts or html to add to the end of the body + @Published private(set) var requiredFooters: Set /// The known header elements to include in the page + @Published private(set) var requiredHeaders: Set /// The known icons that need to be included as hidden SVGs + @Published private(set) var requiredIcons: Set /// The pages linked to by the page + @Published private(set) var linkedPages: Set /// The tags linked to by this page + @Published private(set) var linkedTags: Set /// The links to external content in this page + @Published private(set) var externalLinks: Set /// The files used by this page, but not necessarily required in the output folder + @Published private(set) var usedFiles: Set /// The files that need to be copied + @Published private(set) var requiredFiles: Set /// The image versions required for this page + @Published private(set) var imagesToGenerate: Set + @Published private(set) var invalidCommands: [(command: CommandType?, markdown: String)] + @Published private(set) var invalidBlocks: [(block: ContentBlock?, markdown: String)] + @Published private(set) var warnings: Set /// The files that could not be saved to the output folder + @Published private(set) var unsavedOutputFiles: [String: Set] = [:] private(set) var pageIsEmpty: Bool @@ -105,159 +124,167 @@ final class PageGenerationResults: ObservableObject { redirect = nil } + func onMain(_ operation: @escaping () -> Void) { + DispatchQueue.main.async { + operation() + } + } + func reset() { - inaccessibleFiles = [] - unparsableFiles = [:] - missingFiles = [:] - missingLinkedFiles = [:] - missingLinkedTags = [:] - missingLinkedPages = [:] - requiredHeaders = [] - requiredFooters = [] - requiredIcons = [] - linkedPages = [] - linkedTags = [] - externalLinks = [] - usedFiles = [] - requiredFiles = [] - imagesToGenerate = [] - invalidCommands = [] - invalidBlocks = [] - warnings = [] - unsavedOutputFiles = [:] - pageIsEmpty = false - redirect = nil + onMain { + self.inaccessibleFiles = [] + self.unparsableFiles = [:] + self.missingFiles = [:] + self.missingLinkedFiles = [:] + self.missingLinkedTags = [:] + self.missingLinkedPages = [:] + self.requiredHeaders = [] + self.requiredFooters = [] + self.requiredIcons = [] + self.linkedPages = [] + self.linkedTags = [] + self.externalLinks = [] + self.usedFiles = [] + self.requiredFiles = [] + self.imagesToGenerate = [] + self.invalidCommands = [] + self.invalidBlocks = [] + self.warnings = [] + self.unsavedOutputFiles = [:] + self.pageIsEmpty = false + self.redirect = nil + } } // MARK: Adding entries func inaccessibleContent(file: FileResource) { - inaccessibleFiles.insert(file) + onMain { self.inaccessibleFiles.insert(file) } delegate.inaccessibleContent(file: file) } func invalid(command: CommandType?, _ markdown: Substring) { let markdown = String(markdown) - invalidCommands.append((command, markdown)) + onMain { self.invalidCommands.append((command, markdown)) } delegate.invalidCommand(markdown) } func invalid(block: ContentBlock?, _ markdown: Substring) { let markdown = String(markdown) - invalidBlocks.append((block, markdown)) + onMain { self.invalidBlocks.append((block, markdown)) } delegate.invalidBlock(markdown) } func missing(page: String, source: String) { - missingLinkedPages[page, default: []].insert(source) + onMain { self.missingLinkedPages[page, default: []].insert(source) } delegate.missing(page: page) } func missing(tag: String, source: String) { - missingLinkedTags[tag, default: []].insert(source) + onMain { self.missingLinkedTags[tag, default: []].insert(source) } delegate.missing(tag: tag) } func missing(file: String, source: String) { - missingFiles[file, default: []].insert(source) + onMain { self.missingFiles[file, default: []].insert(source) } delegate.missing(file: file) } func require(image: ImageVersion) { - imagesToGenerate.insert(image) + onMain { self.imagesToGenerate.insert(image) } used(file: image.image) delegate.generate(image) } func require(imageSet: ImageSet) { let jobs = imageSet.jobs - imagesToGenerate.formUnion(jobs) + onMain { self.imagesToGenerate.formUnion(jobs) } used(file: imageSet.image) delegate.generate(jobs) } func invalidFormat(file: FileResource, error: String) { - unparsableFiles[file, default: []].insert(error) + onMain { self.unparsableFiles[file, default: []].insert(error) } delegate.unparsable(file: file) } func missing(file: String, containedIn sourceFile: FileResource) { - missingLinkedFiles[file, default: []].insert(sourceFile) + onMain { self.missingLinkedFiles[file, default: []].insert(sourceFile) } delegate.missing(file: file) } func used(file: FileResource) { - usedFiles.insert(file) + onMain { self.usedFiles.insert(file) } // TODO: Notify delegate } func require(file: FileResource) { - requiredFiles.insert(file) - usedFiles.insert(file) + onMain { self.requiredFiles.insert(file) } + used(file: file) delegate.require(file: file) } func require(files: [FileResource]) { - requiredFiles.formUnion(files) - usedFiles.formUnion(files) + onMain { self.requiredFiles.formUnion(files) } + onMain { self.usedFiles.formUnion(files) } delegate.require(files: files) } func require(footer: String) { - requiredFooters.insert(footer) + onMain { self.requiredFooters.insert(footer) } } func require(header: KnownHeaderElement) { - requiredHeaders.insert(header) + onMain { self.requiredHeaders.insert(header) } } func require(headers: KnownHeaderElement...) { - requiredHeaders.formUnion(headers) + onMain { self.requiredHeaders.formUnion(headers) } } func require(icon: PageIcon) { - requiredIcons.insert(icon) + onMain { self.requiredIcons.insert(icon) } } func require(icons: PageIcon...) { - requiredIcons.formUnion(icons) + onMain { self.requiredIcons.formUnion(icons) } } func require(icons: [PageIcon]) { - requiredIcons.formUnion(icons) + onMain { self.requiredIcons.formUnion(icons) } } func linked(to page: Page) { - linkedPages.insert(page) + onMain { self.linkedPages.insert(page) } } func linked(to tag: Tag) { - linkedTags.insert(tag) + onMain { self.linkedTags.insert(tag) } } func externalLink(to url: String) { - externalLinks.insert(url) + onMain { self.externalLinks.insert(url) } delegate.externalLink(url) } func warning(_ warning: String) { - warnings.insert(warning) + onMain { self.warnings.insert(warning) } delegate.warning(warning) } func unsavedOutput(_ path: String, source: ItemReference) { - unsavedOutputFiles[path, default: []].insert(source) + onMain { self.unsavedOutputFiles[path, default: []].insert(source) } delegate.unsaved(path) } func markPageAsEmpty() { guard case .page(let page) = itemId.itemType else { return } - pageIsEmpty = true + onMain { self.pageIsEmpty = true } delegate.empty(.init(language: itemId.language, pageId: page.id)) } func redirect(from originalUrl: String, to newUrl: String) { - redirect = (originalUrl, newUrl) + onMain { self.redirect = (originalUrl, newUrl) } delegate.redirect(from: originalUrl, to: newUrl) } } diff --git a/CHDataManagement/Model/FileType.swift b/CHDataManagement/Model/FileType.swift index 538e98d..aecd9a5 100644 --- a/CHDataManagement/Model/FileType.swift +++ b/CHDataManagement/Model/FileType.swift @@ -1,4 +1,5 @@ import Foundation +import SFSafeSymbols enum FileTypeCategory: String, CaseIterable { case image @@ -22,6 +23,19 @@ enum FileTypeCategory: String, CaseIterable { case .audio: return "Audio" } } + + var symbol: SFSymbol { + switch self { + case .image: .photo + case .code: .keyboard + case .model: .cubeTransparent + case .text: .docText + case .video: .video + case .resource: .docZipper + case .asset: .network + case .audio: .speakerWave2CircleFill + } + } } extension FileTypeCategory: Hashable { diff --git a/CHDataManagement/Model/Item/ItemReference.swift b/CHDataManagement/Model/Item/ItemReference.swift index 111e939..3b851a5 100644 --- a/CHDataManagement/Model/Item/ItemReference.swift +++ b/CHDataManagement/Model/Item/ItemReference.swift @@ -66,3 +66,23 @@ extension ItemReference: Comparable { lhs.id < rhs.id } } + +extension ItemReference: CustomStringConvertible { + + var description: String { + switch self { + case .general: + return "General" + case .feed: + return "Feed" + case .post(let post): + return "Post \(post.id)" + case .page(let page): + return "Page \(page.id)" + case .tagPage(let tag): + return "Tag \(tag.id)" + case .tagOverview: + return "Tag Overview" + } + } +} diff --git a/CHDataManagement/Views/Generic/ListPopup.swift b/CHDataManagement/Views/Generic/ListPopup.swift index 7fd3dd0..466f599 100644 --- a/CHDataManagement/Views/Generic/ListPopup.swift +++ b/CHDataManagement/Views/Generic/ListPopup.swift @@ -5,10 +5,14 @@ struct ListPopup: View { @Environment(\.dismiss) var dismiss + let title: LocalizedStringKey + let items: [String] var body: some View { VStack { + Text(title) + .font(.title) List { ForEach(items, id: \.self) { page in Text(page) diff --git a/CHDataManagement/Views/Generic/TextWithPopup.swift b/CHDataManagement/Views/Generic/TextWithPopup.swift index 8f64704..c16c34f 100644 --- a/CHDataManagement/Views/Generic/TextWithPopup.swift +++ b/CHDataManagement/Views/Generic/TextWithPopup.swift @@ -5,12 +5,14 @@ struct TextWithPopup: View { let symbol: SFSymbol + let title: LocalizedStringKey + let text: LocalizedStringKey let items: [String] @State - private var isHovering = false + private var showSheet = false var body: some View { HStack { @@ -23,14 +25,69 @@ struct TextWithPopup: View { .contentShape(Rectangle()) .onTapGesture { if items.count > 0 { - isHovering.toggle() + showSheet.toggle() } } - .sheet(isPresented: $isHovering) { - ListPopup(items: items) + .sheet(isPresented: $showSheet) { + ListPopup(title: title, items: items) .onTapGesture { - isHovering.toggle() + showSheet.toggle() } } } } + +struct PopupWithList: View where ListContent: View { + + let symbol: SFSymbol + + let text: LocalizedStringKey + + let canShowPopup: Bool + + let content: ListContent + + init(symbol: SFSymbol, text: LocalizedStringKey, canShowPopup: Bool, @ViewBuilder content: () -> ListContent) { + self.symbol = symbol + self.text = text + self.canShowPopup = canShowPopup + self.content = content() + } + + @State + private var showSheet = false + + var body: some View { + HStack { + Image(systemSymbol: symbol) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + Text(text) + } + .contentShape(Rectangle()) + .onTapGesture { + if canShowPopup { + showSheet.toggle() + } + } + .sheet(isPresented: $showSheet) { + Popup(content: content) + } + } +} + +private struct Popup: View where Content: View { + + @Environment(\.dismiss) + var dismiss + + let content: Content + + var body: some View { + VStack { + content + Button("Dismiss") { dismiss() } + }.padding() + } +} diff --git a/CHDataManagement/Views/Pages/LocalizedPageContentView.swift b/CHDataManagement/Views/Pages/LocalizedPageContentView.swift index 9d8964d..dec76ba 100644 --- a/CHDataManagement/Views/Pages/LocalizedPageContentView.swift +++ b/CHDataManagement/Views/Pages/LocalizedPageContentView.swift @@ -69,6 +69,7 @@ struct LocalizedPageContentView: View { let linkingPosts = content.posts.filter { $0.linkedPage == page } TextWithPopup( symbol: .ipadAndArrowForward, + title: "Post linking to page", text: "\(linkingPosts.count) linking posts", items: linkingPosts.map { $0.title(in: language) }) }.foregroundStyle(.secondary) diff --git a/CHDataManagement/Views/Pages/PageContentResultsView.swift b/CHDataManagement/Views/Pages/PageContentResultsView.swift index ffaf7c4..6c19bfb 100644 --- a/CHDataManagement/Views/Pages/PageContentResultsView.swift +++ b/CHDataManagement/Views/Pages/PageContentResultsView.swift @@ -1,6 +1,23 @@ import SwiftUI import SFSafeSymbols +private struct TextWithSymbol: Comparable, Identifiable { + + let symbol: SFSymbol + + let color: Color + + let text: String + + static func < (lhs: TextWithSymbol, rhs: TextWithSymbol) -> Bool { + lhs.text < rhs.text + } + + var id: String { + text + } +} + struct PageContentResultsView: View { @Environment(\.language) @@ -9,48 +26,100 @@ struct PageContentResultsView: View { @ObservedObject var results: PageGenerationResults - #warning("Rework to only show a single popup with all files, and indicate missing ones") private var totalFileCount: Int { results.usedFiles.count + results.missingFiles.count + results.missingLinkedFiles.count } + private var allFiles: [TextWithSymbol] { + results.usedFiles.map { + TextWithSymbol( + symbol: $0.type.category.symbol, + color: .blue, + text: $0.id) + } + + results.missingFiles.keys.map { + TextWithSymbol( + symbol: .questionmarkCircleFill, + color: .red, + text: $0) + } + + results.missingLinkedFiles.keys.map { + TextWithSymbol( + symbol: .questionmarkCircleFill, + color: .red, + text: $0) + } + } + + private var totalLinkCount: Int { + results.externalLinks.count + + results.missingLinkedPages.count + + results.linkedPages.count + } + + private var allLinks: [TextWithSymbol] { + results.externalLinks.map { + TextWithSymbol( + symbol: .network, + color: .blue, + text: $0) + } + + results.missingLinkedPages.keys.map { + TextWithSymbol( + symbol: .questionmarkCircleFill, + color: .red, + text: $0) + } + + results.linkedPages.map { + TextWithSymbol( + symbol: .docBadgePlus, + color: .blue, + text: $0.title(in: language)) + } + } + var body: some View { HStack { - TextWithPopup( + PopupWithList( symbol: .photoOnRectangleAngled, - text: "\(totalFileCount) images and files", - items: results.usedFiles.sorted().map { $0.id }) - .foregroundStyle(.secondary) + text: "\(totalFileCount) files", canShowPopup: totalFileCount > 0) { + Text("Files") + .font(.title) + List { + ForEach(allFiles.sorted()) { file in + HStack { + Image(systemSymbol: file.symbol) + .frame(width: 18) + .foregroundStyle(file.color) + Text(file.text) + } + } + } + .frame(minHeight: 400) + } - TextWithPopup( - symbol: .docBadgePlus, - text: "\(results.linkedPages.count + results.missingLinkedPages.count) page links", - items: results.linkedPages.sorted().map { $0.localized(in: language).title }) - .foregroundStyle(.secondary) + PopupWithList( + symbol: .photoOnRectangleAngled, + text: "\(totalLinkCount) links", canShowPopup: totalLinkCount > 0) { + Text("Links") + .font(.title) + List { + ForEach(allLinks.sorted()) { file in + HStack { + Image(systemSymbol: file.symbol) + .frame(width: 18) + .foregroundStyle(file.color) + Text(file.text) + } + } + } + .frame(minHeight: 400) + } - TextWithPopup( - symbol: .globe, - text: "\(results.externalLinks.count) external links", - items: results.externalLinks.sorted()) - .foregroundStyle(.secondary) - - if !results.missingLinkedPages.isEmpty { - TextWithPopup( - symbol: .exclamationmarkTriangleFill, - text: "\(results.missingLinkedPages.count) missing pages", - items: results.missingLinkedPages.keys.sorted()) - .foregroundStyle(.red) - } - if !results.missingFiles.isEmpty { - TextWithPopup( - symbol: .exclamationmarkTriangleFill, - text: "\(results.missingFiles.count) missing files", - items: results.missingFiles.keys.sorted()) - .foregroundStyle(.red) - } if !results.invalidCommands.isEmpty { TextWithPopup( symbol: .exclamationmarkTriangleFill, + title: "Invalid commands", text: "\(results.invalidCommands.count) invalid commands", items: results.invalidCommands.map { $0.markdown }.sorted()) .foregroundStyle(.red)