import SwiftUI private struct PageIssue { let id: Int let page: Page let language: ContentLanguage let message: PageContentAnomaly init(page: Page, language: ContentLanguage, message: PageContentAnomaly) { self.id = .random() self.page = page self.language = language self.message = message } var title: String { page.localized(in: language).title } } extension PageIssue: Identifiable { } private struct FixSheet: View { @Binding var isPresented: Bool @Binding var message: String @Binding var infoItems: [String] let action: () -> Void init(isPresented: Binding, message: Binding, infoItems: Binding<[String]>, action: @escaping () -> Void) { self._isPresented = isPresented self._message = message self._infoItems = infoItems self.action = action } var body: some View { VStack { Text("Fix issue") .font(.headline) Text(message) .font(.body) List { ForEach(infoItems, id: \.self) { item in Text(item) } } HStack { Button("Fix", action: { isPresented = false action() }) Button("Cancel", action: { isPresented = false }) } } .frame(minHeight: 200) .padding() } } private struct ErrorSheet: View { @Binding var isPresented: Bool @Binding var message: String var body: some View { VStack { Text("Error") .font(.headline) Text(message) Button("Dismiss", action: { isPresented = false }) } } } struct PageSettingsContentView: View { @EnvironmentObject private var content: Content @State private var isCheckingPages: Bool = false @State private var issues: [PageIssue] = [] @State private var message: String = "No fix available" @State private var infoItems: [String] = ["No items set"] @State private var fixAction: () -> () = { print("No fix action defined") } @State private var showFixActionSheet: Bool = false @State private var errorMessage: String = "" @State private var showErrorAlert: Bool = false var body: some View { VStack(alignment: .leading) { HStack { Button("Check pages", action: checkAllPagesForErrors) .disabled(isCheckingPages) Button("Fix all", action: applyAllEasyFixes) if isCheckingPages { ProgressView() .progressViewStyle(.circular) .frame(height: 20) } } Text("\(issues.count) Issues") .font(.headline) List(issues) { issue in HStack { Button("Attempt Fix", action: { attemptFix(issue: issue) }) VStack(alignment: .leading) { Text(issue.message.description) Text("\(issue.title) (\(issue.language.rawValue.uppercased()))") .font(.caption) } } } } .padding() .sheet(isPresented: $showFixActionSheet) { FixSheet(isPresented: $showFixActionSheet, message: $message, infoItems: $infoItems) { fixAction() resetFixSheet() } } .sheet(isPresented: $showErrorAlert) { ErrorSheet(isPresented: $showErrorAlert, message: $errorMessage) } } private func checkAllPagesForErrors() { guard !isCheckingPages else { return } isCheckingPages = true issues = [] DispatchQueue.global(qos: .userInitiated).async { for language in ContentLanguage.allCases { let parser = PageContentParser( content: content, language: language) for page in content.pages { analyze(page: page, parser: parser) } } DispatchQueue.main.async { self.isCheckingPages = false } } } private func analyze(page: Page, parser: PageContentParser) { parser.reset() do { let rawPageContent = try content.storage.pageContent(for: page.id, language: parser.language) _ = parser.generatePage(from: rawPageContent) let results = parser.results.convertedWarnings.map { PageIssue(page: page, language: parser.language, message: $0) } DispatchQueue.main.async { issues = results + issues } } catch { let message = PageContentAnomaly.failedToLoadContent(error) let error = PageIssue(page: page, language: parser.language, message: message) DispatchQueue.main.async { issues.insert(error, at: 0) } } } private func applyAllEasyFixes() { issues.forEach { issue in switch issue.message { case .missingFile(let file): fix(missingFile: file, in: issue.page, language: issue.language, ask: false) case .unknownCommand(let string): fixUnknownCommand(string, in: issue.page, language: issue.language) default: return } } } private func attemptFix(issue: PageIssue) { switch issue.message { case .failedToLoadContent: show(error: "No fix available for read errors") case .missingFile(let string): fix(missingFile: string, in: issue.page, language: issue.language) case .missingPage(let string): show(error: "No fix available for missing page \(string)") case .unknownCommand(let string): fixUnknownCommand(string, in: issue.page, language: issue.language) case .invalidCommandArguments(let command, let arguments): show(error: "No fix available for invalid arguments to command \(command) (\(arguments))") case .missingTag(let string): show(error: "No fix available for missing tag \(string)") } } private func fix(missingFile: String, in page: Page, language: ContentLanguage, ask: Bool = true) { print("Fixing missing file \(missingFile)") let fileId = page.id + "-" + missingFile if let file = content.file(id: fileId) { replace(missingFile, with: file.id, in: page, language: language) // Remove all errors of the page, and generate them new recalculate(page: page, language: language) return } guard ask else { return } let partialMatches = content.files.filter { $0.id.contains(missingFile) } guard partialMatches.count == 1 else { show(error: "Found \(partialMatches.count) matches for file \(missingFile): \(partialMatches.map { $0.id })") return } let file = partialMatches[0] // Ask to fix partially matching file let occurences = findOccurences(of: missingFile, in: page, language: language) message = "Found file '\(file.id)' to match \(missingFile) on page '\(page.localized(in: language).title)'. Do you want to replace it?" infoItems = occurences fixAction = { replace(missingFile, with: file.id, in: page, language: language) // Remove all errors of the page, and generate them new recalculate(page: page, language: language) } DispatchQueue.main.async { showFixActionSheet = true } } private func recalculate(page: Page, language: ContentLanguage) { let remaining = issues.filter { $0.language != language || $0.page.id != page.id } DispatchQueue.main.async { self.issues = remaining self.isCheckingPages = true DispatchQueue.global(qos: .userInitiated).async { let parser = PageContentParser(content: content, language: language) self.analyze(page: page, parser: parser) self.isCheckingPages = false } } } private func resetFixSheet() { DispatchQueue.main.async { self.message = "No fix available" self.fixAction = { print("No fix action defined") } self.infoItems = ["No items set"] } } private func show(error: String) { DispatchQueue.main.async { errorMessage = error showErrorAlert = true } } private func findMatchingFile(with missingFile: String, in page: Page) -> FileResource? { let fileId = page.id + "-" + missingFile if let file = content.file(id: fileId) { return file } let partialMatches = content.files.filter { $0.id.contains(missingFile) } if partialMatches.count == 1 { return partialMatches[0] } show(error: "Found \(partialMatches.count) matches for file \(missingFile): \(partialMatches.map { $0.id })") return nil } private func findOccurences(of searchString: String, in page: Page, language: ContentLanguage) -> [String] { let parts: [String] do { parts = try content.storage.pageContent(for: page.id, language: language) .components(separatedBy: searchString) } catch { show(error: "Failed to get page content to find occurences: \(error.localizedDescription)") return [] } var occurrences: [String] = [] for index in parts.indices.dropLast() { let start = parts[index].suffix(10) let end = parts[index+1].prefix(10) let full = "...\(start)\(searchString)\(end)...".replacingOccurrences(of: "\n", with: "\\n") occurrences.append(full) } return occurrences } private func replace(_ oldString: String, with newString: String, in page: Page, language: ContentLanguage) { do { let pageContent = try content.storage.pageContent(for: page.id, language: language) .replacingOccurrences(of: oldString, with: newString) try content.storage.save(pageContent: pageContent, for: page.id, language: language) print("Replaced \(oldString) with \(newString) in page \(page.id) (\(language))") } catch { print("Failed to replace in page \(page.id) (\(language)): \(error)") } } private func fixUnknownCommand(_ string: String, in page: Page, language: ContentLanguage) { show(error: "No fix available for command '\(string)'") } }