Improve display of results

This commit is contained in:
Christoph Hagen 2025-02-07 14:08:51 +01:00
parent 7ebc9d8404
commit dc7ab6fb15
7 changed files with 276 additions and 84 deletions

View File

@ -22,57 +22,76 @@ final class PageGenerationResults: ObservableObject {
private unowned let delegate: GenerationResults private unowned let delegate: GenerationResults
/// The files that could not be accessed /// The files that could not be accessed
@Published
private(set) var inaccessibleFiles: Set<FileResource> private(set) var inaccessibleFiles: Set<FileResource>
/// The files that could not be parsed, with the error message produced /// The files that could not be parsed, with the error message produced
@Published
private(set) var unparsableFiles: [FileResource : Set<String>] private(set) var unparsableFiles: [FileResource : Set<String>]
/// The missing files directly used by this page, and the source of the file /// The missing files directly used by this page, and the source of the file
@Published
private(set) var missingFiles: [String: Set<String>] private(set) var missingFiles: [String: Set<String>]
/// The missing files linked to from other files. /// The missing files linked to from other files.
@Published
private(set) var missingLinkedFiles: [String : Set<FileResource>] private(set) var missingLinkedFiles: [String : Set<FileResource>]
/// The missing tags linked to by this page, and the source of the link /// The missing tags linked to by this page, and the source of the link
@Published
private(set) var missingLinkedTags: [String : Set<String>] private(set) var missingLinkedTags: [String : Set<String>]
/// The missing pages linked to by this page, and the source of the link /// The missing pages linked to by this page, and the source of the link
@Published
private(set) var missingLinkedPages: [String : Set<String>] private(set) var missingLinkedPages: [String : Set<String>]
/// The footer scripts or html to add to the end of the body /// The footer scripts or html to add to the end of the body
@Published
private(set) var requiredFooters: Set<String> private(set) var requiredFooters: Set<String>
/// The known header elements to include in the page /// The known header elements to include in the page
@Published
private(set) var requiredHeaders: Set<KnownHeaderElement> private(set) var requiredHeaders: Set<KnownHeaderElement>
/// The known icons that need to be included as hidden SVGs /// The known icons that need to be included as hidden SVGs
@Published
private(set) var requiredIcons: Set<PageIcon> private(set) var requiredIcons: Set<PageIcon>
/// The pages linked to by the page /// The pages linked to by the page
@Published
private(set) var linkedPages: Set<Page> private(set) var linkedPages: Set<Page>
/// The tags linked to by this page /// The tags linked to by this page
@Published
private(set) var linkedTags: Set<Tag> private(set) var linkedTags: Set<Tag>
/// The links to external content in this page /// The links to external content in this page
@Published
private(set) var externalLinks: Set<String> private(set) var externalLinks: Set<String>
/// The files used by this page, but not necessarily required in the output folder /// The files used by this page, but not necessarily required in the output folder
@Published
private(set) var usedFiles: Set<FileResource> private(set) var usedFiles: Set<FileResource>
/// The files that need to be copied /// The files that need to be copied
@Published
private(set) var requiredFiles: Set<FileResource> private(set) var requiredFiles: Set<FileResource>
/// The image versions required for this page /// The image versions required for this page
@Published
private(set) var imagesToGenerate: Set<ImageVersion> private(set) var imagesToGenerate: Set<ImageVersion>
@Published
private(set) var invalidCommands: [(command: CommandType?, markdown: String)] private(set) var invalidCommands: [(command: CommandType?, markdown: String)]
@Published
private(set) var invalidBlocks: [(block: ContentBlock?, markdown: String)] private(set) var invalidBlocks: [(block: ContentBlock?, markdown: String)]
@Published
private(set) var warnings: Set<String> private(set) var warnings: Set<String>
/// The files that could not be saved to the output folder /// The files that could not be saved to the output folder
@Published
private(set) var unsavedOutputFiles: [String: Set<ItemReference>] = [:] private(set) var unsavedOutputFiles: [String: Set<ItemReference>] = [:]
private(set) var pageIsEmpty: Bool private(set) var pageIsEmpty: Bool
@ -105,159 +124,167 @@ final class PageGenerationResults: ObservableObject {
redirect = nil redirect = nil
} }
func onMain(_ operation: @escaping () -> Void) {
DispatchQueue.main.async {
operation()
}
}
func reset() { func reset() {
inaccessibleFiles = [] onMain {
unparsableFiles = [:] self.inaccessibleFiles = []
missingFiles = [:] self.unparsableFiles = [:]
missingLinkedFiles = [:] self.missingFiles = [:]
missingLinkedTags = [:] self.missingLinkedFiles = [:]
missingLinkedPages = [:] self.missingLinkedTags = [:]
requiredHeaders = [] self.missingLinkedPages = [:]
requiredFooters = [] self.requiredHeaders = []
requiredIcons = [] self.requiredFooters = []
linkedPages = [] self.requiredIcons = []
linkedTags = [] self.linkedPages = []
externalLinks = [] self.linkedTags = []
usedFiles = [] self.externalLinks = []
requiredFiles = [] self.usedFiles = []
imagesToGenerate = [] self.requiredFiles = []
invalidCommands = [] self.imagesToGenerate = []
invalidBlocks = [] self.invalidCommands = []
warnings = [] self.invalidBlocks = []
unsavedOutputFiles = [:] self.warnings = []
pageIsEmpty = false self.unsavedOutputFiles = [:]
redirect = nil self.pageIsEmpty = false
self.redirect = nil
}
} }
// MARK: Adding entries // MARK: Adding entries
func inaccessibleContent(file: FileResource) { func inaccessibleContent(file: FileResource) {
inaccessibleFiles.insert(file) onMain { self.inaccessibleFiles.insert(file) }
delegate.inaccessibleContent(file: file) delegate.inaccessibleContent(file: file)
} }
func invalid(command: CommandType?, _ markdown: Substring) { func invalid(command: CommandType?, _ markdown: Substring) {
let markdown = String(markdown) let markdown = String(markdown)
invalidCommands.append((command, markdown)) onMain { self.invalidCommands.append((command, markdown)) }
delegate.invalidCommand(markdown) delegate.invalidCommand(markdown)
} }
func invalid(block: ContentBlock?, _ markdown: Substring) { func invalid(block: ContentBlock?, _ markdown: Substring) {
let markdown = String(markdown) let markdown = String(markdown)
invalidBlocks.append((block, markdown)) onMain { self.invalidBlocks.append((block, markdown)) }
delegate.invalidBlock(markdown) delegate.invalidBlock(markdown)
} }
func missing(page: String, source: String) { func missing(page: String, source: String) {
missingLinkedPages[page, default: []].insert(source) onMain { self.missingLinkedPages[page, default: []].insert(source) }
delegate.missing(page: page) delegate.missing(page: page)
} }
func missing(tag: String, source: String) { func missing(tag: String, source: String) {
missingLinkedTags[tag, default: []].insert(source) onMain { self.missingLinkedTags[tag, default: []].insert(source) }
delegate.missing(tag: tag) delegate.missing(tag: tag)
} }
func missing(file: String, source: String) { func missing(file: String, source: String) {
missingFiles[file, default: []].insert(source) onMain { self.missingFiles[file, default: []].insert(source) }
delegate.missing(file: file) delegate.missing(file: file)
} }
func require(image: ImageVersion) { func require(image: ImageVersion) {
imagesToGenerate.insert(image) onMain { self.imagesToGenerate.insert(image) }
used(file: image.image) used(file: image.image)
delegate.generate(image) delegate.generate(image)
} }
func require(imageSet: ImageSet) { func require(imageSet: ImageSet) {
let jobs = imageSet.jobs let jobs = imageSet.jobs
imagesToGenerate.formUnion(jobs) onMain { self.imagesToGenerate.formUnion(jobs) }
used(file: imageSet.image) used(file: imageSet.image)
delegate.generate(jobs) delegate.generate(jobs)
} }
func invalidFormat(file: FileResource, error: String) { func invalidFormat(file: FileResource, error: String) {
unparsableFiles[file, default: []].insert(error) onMain { self.unparsableFiles[file, default: []].insert(error) }
delegate.unparsable(file: file) delegate.unparsable(file: file)
} }
func missing(file: String, containedIn sourceFile: FileResource) { func missing(file: String, containedIn sourceFile: FileResource) {
missingLinkedFiles[file, default: []].insert(sourceFile) onMain { self.missingLinkedFiles[file, default: []].insert(sourceFile) }
delegate.missing(file: file) delegate.missing(file: file)
} }
func used(file: FileResource) { func used(file: FileResource) {
usedFiles.insert(file) onMain { self.usedFiles.insert(file) }
// TODO: Notify delegate // TODO: Notify delegate
} }
func require(file: FileResource) { func require(file: FileResource) {
requiredFiles.insert(file) onMain { self.requiredFiles.insert(file) }
usedFiles.insert(file) used(file: file)
delegate.require(file: file) delegate.require(file: file)
} }
func require(files: [FileResource]) { func require(files: [FileResource]) {
requiredFiles.formUnion(files) onMain { self.requiredFiles.formUnion(files) }
usedFiles.formUnion(files) onMain { self.usedFiles.formUnion(files) }
delegate.require(files: files) delegate.require(files: files)
} }
func require(footer: String) { func require(footer: String) {
requiredFooters.insert(footer) onMain { self.requiredFooters.insert(footer) }
} }
func require(header: KnownHeaderElement) { func require(header: KnownHeaderElement) {
requiredHeaders.insert(header) onMain { self.requiredHeaders.insert(header) }
} }
func require(headers: KnownHeaderElement...) { func require(headers: KnownHeaderElement...) {
requiredHeaders.formUnion(headers) onMain { self.requiredHeaders.formUnion(headers) }
} }
func require(icon: PageIcon) { func require(icon: PageIcon) {
requiredIcons.insert(icon) onMain { self.requiredIcons.insert(icon) }
} }
func require(icons: PageIcon...) { func require(icons: PageIcon...) {
requiredIcons.formUnion(icons) onMain { self.requiredIcons.formUnion(icons) }
} }
func require(icons: [PageIcon]) { func require(icons: [PageIcon]) {
requiredIcons.formUnion(icons) onMain { self.requiredIcons.formUnion(icons) }
} }
func linked(to page: Page) { func linked(to page: Page) {
linkedPages.insert(page) onMain { self.linkedPages.insert(page) }
} }
func linked(to tag: Tag) { func linked(to tag: Tag) {
linkedTags.insert(tag) onMain { self.linkedTags.insert(tag) }
} }
func externalLink(to url: String) { func externalLink(to url: String) {
externalLinks.insert(url) onMain { self.externalLinks.insert(url) }
delegate.externalLink(url) delegate.externalLink(url)
} }
func warning(_ warning: String) { func warning(_ warning: String) {
warnings.insert(warning) onMain { self.warnings.insert(warning) }
delegate.warning(warning) delegate.warning(warning)
} }
func unsavedOutput(_ path: String, source: ItemReference) { func unsavedOutput(_ path: String, source: ItemReference) {
unsavedOutputFiles[path, default: []].insert(source) onMain { self.unsavedOutputFiles[path, default: []].insert(source) }
delegate.unsaved(path) delegate.unsaved(path)
} }
func markPageAsEmpty() { func markPageAsEmpty() {
guard case .page(let page) = itemId.itemType else { return } guard case .page(let page) = itemId.itemType else { return }
pageIsEmpty = true onMain { self.pageIsEmpty = true }
delegate.empty(.init(language: itemId.language, pageId: page.id)) delegate.empty(.init(language: itemId.language, pageId: page.id))
} }
func redirect(from originalUrl: String, to newUrl: String) { func redirect(from originalUrl: String, to newUrl: String) {
redirect = (originalUrl, newUrl) onMain { self.redirect = (originalUrl, newUrl) }
delegate.redirect(from: originalUrl, to: newUrl) delegate.redirect(from: originalUrl, to: newUrl)
} }
} }

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import SFSafeSymbols
enum FileTypeCategory: String, CaseIterable { enum FileTypeCategory: String, CaseIterable {
case image case image
@ -22,6 +23,19 @@ enum FileTypeCategory: String, CaseIterable {
case .audio: return "Audio" 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 { extension FileTypeCategory: Hashable {

View File

@ -66,3 +66,23 @@ extension ItemReference: Comparable {
lhs.id < rhs.id 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"
}
}
}

View File

@ -5,10 +5,14 @@ struct ListPopup: View {
@Environment(\.dismiss) @Environment(\.dismiss)
var dismiss var dismiss
let title: LocalizedStringKey
let items: [String] let items: [String]
var body: some View { var body: some View {
VStack { VStack {
Text(title)
.font(.title)
List { List {
ForEach(items, id: \.self) { page in ForEach(items, id: \.self) { page in
Text(page) Text(page)

View File

@ -5,12 +5,14 @@ struct TextWithPopup: View {
let symbol: SFSymbol let symbol: SFSymbol
let title: LocalizedStringKey
let text: LocalizedStringKey let text: LocalizedStringKey
let items: [String] let items: [String]
@State @State
private var isHovering = false private var showSheet = false
var body: some View { var body: some View {
HStack { HStack {
@ -23,14 +25,69 @@ struct TextWithPopup: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
if items.count > 0 { if items.count > 0 {
isHovering.toggle() showSheet.toggle()
} }
} }
.sheet(isPresented: $isHovering) { .sheet(isPresented: $showSheet) {
ListPopup(items: items) ListPopup(title: title, items: items)
.onTapGesture { .onTapGesture {
isHovering.toggle() showSheet.toggle()
} }
} }
} }
} }
struct PopupWithList<ListContent>: 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<Content>: View where Content: View {
@Environment(\.dismiss)
var dismiss
let content: Content
var body: some View {
VStack {
content
Button("Dismiss") { dismiss() }
}.padding()
}
}

View File

@ -69,6 +69,7 @@ struct LocalizedPageContentView: View {
let linkingPosts = content.posts.filter { $0.linkedPage == page } let linkingPosts = content.posts.filter { $0.linkedPage == page }
TextWithPopup( TextWithPopup(
symbol: .ipadAndArrowForward, symbol: .ipadAndArrowForward,
title: "Post linking to page",
text: "\(linkingPosts.count) linking posts", text: "\(linkingPosts.count) linking posts",
items: linkingPosts.map { $0.title(in: language) }) items: linkingPosts.map { $0.title(in: language) })
}.foregroundStyle(.secondary) }.foregroundStyle(.secondary)

View File

@ -1,6 +1,23 @@
import SwiftUI import SwiftUI
import SFSafeSymbols 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 { struct PageContentResultsView: View {
@Environment(\.language) @Environment(\.language)
@ -9,48 +26,100 @@ struct PageContentResultsView: View {
@ObservedObject @ObservedObject
var results: PageGenerationResults var results: PageGenerationResults
#warning("Rework to only show a single popup with all files, and indicate missing ones")
private var totalFileCount: Int { private var totalFileCount: Int {
results.usedFiles.count + results.missingFiles.count + results.missingLinkedFiles.count 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 { var body: some View {
HStack { HStack {
TextWithPopup( PopupWithList(
symbol: .photoOnRectangleAngled, symbol: .photoOnRectangleAngled,
text: "\(totalFileCount) images and files", text: "\(totalFileCount) files", canShowPopup: totalFileCount > 0) {
items: results.usedFiles.sorted().map { $0.id }) Text("Files")
.foregroundStyle(.secondary) .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( PopupWithList(
symbol: .docBadgePlus, symbol: .photoOnRectangleAngled,
text: "\(results.linkedPages.count + results.missingLinkedPages.count) page links", text: "\(totalLinkCount) links", canShowPopup: totalLinkCount > 0) {
items: results.linkedPages.sorted().map { $0.localized(in: language).title }) Text("Links")
.foregroundStyle(.secondary) .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 { if !results.invalidCommands.isEmpty {
TextWithPopup( TextWithPopup(
symbol: .exclamationmarkTriangleFill, symbol: .exclamationmarkTriangleFill,
title: "Invalid commands",
text: "\(results.invalidCommands.count) invalid commands", text: "\(results.invalidCommands.count) invalid commands",
items: results.invalidCommands.map { $0.markdown }.sorted()) items: results.invalidCommands.map { $0.markdown }.sorted())
.foregroundStyle(.red) .foregroundStyle(.red)