Rework content commands, add audio player

This commit is contained in:
Christoph Hagen
2024-12-14 16:31:40 +01:00
parent b3b8c9a610
commit be2aab2ea8
52 changed files with 1758 additions and 767 deletions

View File

@ -1,33 +1,5 @@
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
@ -72,275 +44,35 @@ private struct FixSheet: View {
}
}
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
@StateObject
var checker: PageIssueChecker = .init()
var body: some View {
VStack(alignment: .leading) {
HStack {
Button("Check pages", action: checkAllPagesForErrors)
.disabled(isCheckingPages)
Button("Fix all", action: applyAllEasyFixes)
if isCheckingPages {
Button("Check pages", action: { checker.check(pages: content.pages) })
.disabled(checker.isCheckingPages)
if checker.isCheckingPages {
ProgressView()
.progressViewStyle(.circular)
.frame(height: 20)
}
}
Text("\(issues.count) Issues")
Text("\(checker.issues.count) Issues")
.font(.headline)
List(issues) { issue in
List(checker.issues.sorted()) { 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)
}
PageIssueView(issue: issue)
.id(issue.id)
}
.environmentObject(checker)
}
}
.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)'")
}
}

View File

@ -0,0 +1,49 @@
struct PageIssue {
let page: Page
let language: ContentLanguage
let message: PageContentAnomaly
init(page: Page, language: ContentLanguage, message: PageContentAnomaly) {
self.page = page
self.language = language
self.message = message
print("\(title) (\(language)): \(message)")
}
var title: String {
page.localized(in: language).title
}
}
extension PageIssue: Identifiable {
var id: String {
page.id + "-" + language.rawValue + "-" + message.id
}
}
extension PageIssue: Equatable {
static func == (lhs: PageIssue, rhs: PageIssue) -> Bool {
lhs.id == rhs.id
}
}
extension PageIssue: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
extension PageIssue: Comparable {
static func < (lhs: PageIssue, rhs: PageIssue) -> Bool {
lhs.id < rhs.id
}
}

View File

@ -0,0 +1,84 @@
import Foundation
final class PageIssueChecker: ObservableObject {
@Published
var isCheckingPages: Bool = false
@Published
var issues: Set<PageIssue> = []
init() {
}
func check(pages: [Page], clearListBeforeStart: Bool = true) {
guard !isCheckingPages else {
return
}
isCheckingPages = true
if clearListBeforeStart {
issues = []
}
DispatchQueue.global(qos: .userInitiated).async {
for language in ContentLanguage.allCases {
self.check(pages: pages, in: language)
}
DispatchQueue.main.async {
self.isCheckingPages = false
}
}
}
private func check(pages: [Page], in language: ContentLanguage) {
for page in pages {
analyze(page: page, in: language)
}
}
func check(page: Page, in language: ContentLanguage) {
guard !isCheckingPages else {
return
}
isCheckingPages = true
DispatchQueue.global(qos: .userInitiated).async {
self.analyze(page: page, in: language)
DispatchQueue.main.async {
self.isCheckingPages = false
}
}
}
private func analyze(page: Page, in language: ContentLanguage) {
let parser = PageContentParser(content: page.content, language: language)
let hasPreviousIssues = issues.contains { $0.page == page && $0.language == language }
let pageIssues: [PageIssue]
do {
let rawPageContent = try page.content.storage.pageContent(for: page.id, language: language)
_ = parser.generatePage(from: rawPageContent)
pageIssues = parser.results.issues.map {
PageIssue(page: page, language: language, message: $0)
}
} catch {
let message = PageContentAnomaly.failedToLoadContent(error)
let error = PageIssue(page: page, language: language, message: message)
pageIssues = [error]
}
guard hasPreviousIssues || !pageIssues.isEmpty else {
return
}
update(issues: pageIssues, for: page, in: parser.language)
}
private func update(issues: [PageIssue], for page: Page, in language: ContentLanguage) {
let newIssues = self.issues
.filter { $0.page != page || $0.language != language }
.union(issues)
DispatchQueue.main.async {
self.issues = newIssues
}
}
}

View File

@ -0,0 +1,316 @@
import SwiftUI
private struct ButtonAction {
let name: String
let action: () -> Void
}
private struct PopupSheet: View {
@Binding
var isPresented: Bool
@Binding
var title: String
@Binding
var message: String
var body: some View {
VStack {
Text(title)
.font(.headline)
Text(message)
Button("Dismiss") {
message = ""
isPresented = false
}
}.padding()
}
}
private struct PageIssueGenericView: View {
let issue: PageIssue
let buttons: [ButtonAction]
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(issue.message.description)
Text("\(issue.title) (\(issue.language.rawValue.uppercased()))")
.font(.caption)
}
Spacer()
ForEach(buttons, id: \.name) { button in
Button(button.name, action: button.action)
}
}
}
}
struct PageIssueView: View {
let issue: PageIssue
@EnvironmentObject
private var checker: PageIssueChecker
@EnvironmentObject
private var content: Content
@State
private var showPopupMessage = false
@State
private var popupTitle = "Error"
@State
private var popupMessage = ""
@State
private var showPagePicker = false
@State
private var selectedPage: Page?
@State
private var showFilePicker = false
@State
private var selectedFile: FileResource?
private var buttons: [ButtonAction] {
switch issue.message {
case .failedToLoadContent:
return [.init(name: "Retry", action: retryPageCheck)]
case .missingFile(let missing, _):
return [
.init(name: "Select file", action: { selectFile(missingFile: missing) }),
.init(name: "Create external file", action: { createExternalFile(fileId: missing) })
]
case .missingPage(let missing, _):
return [
.init(name: "Select page", action: selectPage),
.init(name: "Create page", action: { createPage(pageId: missing) })
]
case .missingTag(let missing, _):
return [
.init(name: "Select tag", action: { selectTag(missingPage: missing) }),
.init(name: "Create tag", action: { createTag(tagId: missing) })
]
case .invalidCommand(_, let markdown):
return [.init(name: "Replace text", action: { replaceCommand(originalText: markdown) })]
}
}
var body: some View {
PageIssueGenericView(issue: issue, buttons: buttons)
.sheet(isPresented: $showPopupMessage) {
PopupSheet(isPresented: $showPopupMessage, title: $popupTitle, message: $popupMessage)
}
.sheet(isPresented: $showPagePicker) {
if let page = selectedPage {
didSelect(page: page)
}
} content: {
PagePickerView(
showPagePicker: $showPagePicker,
selectedPage: $selectedPage)
}
.sheet(isPresented: $showFilePicker) {
if let file = selectedFile {
didSelect(file: file)
}
} content: {
FileSelectionView(selectedFile: $selectedFile)
}
}
private func show(error: String) {
DispatchQueue.main.async {
self.popupTitle = "Error"
self.popupMessage = error
self.showPopupMessage = true
}
}
private func show(info: String) {
DispatchQueue.main.async {
self.popupTitle = "Info"
self.popupMessage = info
self.showPopupMessage = true
}
}
private func retryPageCheck() {
DispatchQueue.main.async {
checker.check(pages: content.pages, clearListBeforeStart: false)
}
}
private func selectFile(missingFile: String) {
selectedFile = nil
showFilePicker = true
}
private func didSelect(file newFile: FileResource) {
guard case .missingFile(let missingFile, let markdown) = issue.message else {
show(error: "Inconsistency: Selected file, but issue is not a missing file")
return
}
replace(missing: missingFile, with: newFile.id, in: markdown)
retryPageCheck()
DispatchQueue.main.async {
selectedFile = nil
}
}
private func createExternalFile(fileId: String) {
guard content.isValidIdForFile(fileId) else {
show(error: "Invalid file id, can't create external file")
return
}
let file = FileResource(
content: content,
id: fileId,
isExternallyStored: true,
en: "",
de: "")
content.files.append(file)
retryPageCheck()
}
private func selectPage() {
selectedPage = nil
showPagePicker = true
}
private func didSelect(page newPage: Page) {
guard case .missingPage(let missingPage, let markdown) = issue.message else {
show(error: "Inconsistency: Selected page, but issue is not a missing page")
return
}
replace(missing: missingPage, with: newPage.id, in: markdown)
retryPageCheck()
DispatchQueue.main.async {
selectedPage = nil
}
}
private func createPage(pageId: String) {
guard content.isValidIdForTagOrTagOrPost(pageId) else {
show(error: "Invalid page id, can't create page")
return
}
let deString = pageId + "-" + ContentLanguage.german.rawValue
let page = Page(
content: content,
id: pageId,
externalLink: nil,
isDraft: true,
createdDate: .now,
startDate: .now,
endDate: nil,
german: .init(content: content,
urlString: deString,
title: pageId),
english: .init(content: content,
urlString: pageId,
title: pageId),
tags: [])
content.pages.insert(page, at: 0)
retryPageCheck()
}
private func selectTag(missingPage: String) {
// TODO: Show sheet to select a tag
// TODO: Replace tag id in page content with new tag id
retryPageCheck()
}
private func createTag(tagId: String) {
guard content.isValidIdForTagOrTagOrPost(tagId) else {
show(error: "Invalid tag id, can't create tag")
return
}
let tag = Tag(id: tagId)
content.tags.append(tag)
retryPageCheck()
}
private func replaceCommand(originalText: String) {
// TODO: Show sheet with text input
// TODO: Replace original text in page content with new text
retryPageCheck()
}
// MARK: Page Content manipulation
private func replace(missing: String, with newText: String, in markdown: String) {
let newString = markdown.replacingOccurrences(of: missing, with: newText)
guard newString != markdown else {
show(error: "No change in content detected trying to perform replacement")
return
}
let occurrences = findOccurrences(of: markdown, in: issue.page, language: issue.language)
guard !occurrences.isEmpty else {
show(error: "No occurrences of '\(markdown)' found in the page")
return
}
replace(markdown, with: newString, in: issue.page, language: issue.language)
show(info: "Replaced \(occurrences.count) occurrences of '\(missing)' with '\(newText)'")
retryPageCheck()
}
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 findOccurrences(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 {
print("Failed to get page content to find occurrences: \(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
}
}