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

@@ -0,0 +1,28 @@
import SwiftUI
struct FileSelectionView: View {
@Binding
private var selectedFile: FileResource?
@Environment(\.dismiss)
private var dismiss
init(selectedFile: Binding<FileResource?>) {
self._selectedFile = selectedFile
}
var body: some View {
VStack {
FileListView(selectedFile: $selectedFile)
.frame(minHeight: 500, idealHeight: 600)
HStack {
Button("Cancel") {
selectedFile = nil
dismiss() }
Button("Select") { dismiss() }
}
}
.padding()
}
}

View File

@@ -69,6 +69,7 @@ struct AddPageView: View {
let page = Page(
content: content,
id: newPageId,
externalLink: nil,
isDraft: true,
createdDate: .now,
startDate: .now,

View File

@@ -6,18 +6,14 @@ struct LocalizedPageContentView: View {
let pageId: String
let language: ContentLanguage
@ObservedObject
var page: LocalizedPage
@Environment(\.language)
private var language
@State
private var isGeneratingWebsite = false
@State
private var loadedPageContentLanguage: ContentLanguage?
@State
private var pageContent: String = ""
@@ -27,10 +23,13 @@ struct LocalizedPageContentView: View {
@State
private var generationResults = PageGenerationResults()
@State
private var didChangeContent = false
init(pageId: String, page: LocalizedPage) {
init(pageId: String, page: LocalizedPage, language: ContentLanguage) {
self.pageId = pageId
self.page = page
self.language = language
}
var body: some View {
@@ -55,6 +54,9 @@ struct LocalizedPageContentView: View {
HighlightedTextEditor(
text: $pageContent,
highlightRules: .markdown)
.onChange(of: pageContent) {
didChangeContent = true
}
}
.padding()
.onAppear(perform: loadContent)
@@ -68,25 +70,33 @@ struct LocalizedPageContentView: View {
guard content != "" else {
pageContent = "New file"
loadedPageContentLanguage = nil
DispatchQueue.main.async {
didChangeContent = false
}
return
}
pageContent = content
loadedPageContentLanguage = language
checkContent()
} catch {
print("Failed to load page content: \(error)")
pageContent = "Failed to load"
loadedPageContentLanguage = nil
}
DispatchQueue.main.async {
didChangeContent = false
}
}
private func saveContent() {
guard let loadedPageContentLanguage else {
guard pageContent != "New file", pageContent != "" else {
// TODO: Delete file?
return
}
guard didChangeContent else {
return
}
do {
try page.content.storage.save(pageContent: pageContent, for: pageId, language: loadedPageContentLanguage)
try page.content.storage.save(pageContent: pageContent, for: pageId, language: language)
didChangeContent = false
} catch {
print("Failed to save content: \(error)")
}

View File

@@ -34,10 +34,12 @@ struct LocalizedPageDetailView: View {
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Page URL String")
.font(.headline)
TextField("", text: $newUrlString)
.textFieldStyle(.roundedBorder)
Button("Update", action: setNewId)
.disabled(newUrlString.isEmpty || containsInvalidCharacters || idExists)
.disabled(newUrlString.isEmpty || containsInvalidCharacters || idExists)
}
.padding(.bottom)

View File

@@ -74,11 +74,19 @@ struct PageContentResultsView: View {
text: "\(results.files.count + results.missingFiles.count) images and files",
items: results.files.sorted().map { $0.id })
.foregroundStyle(.secondary)
TextWithPopup(
symbol: .docBadgePlus,
text: "\(results.linkedPages.count + results.missingPages.count) page links",
items: results.linkedPages.sorted().map { $0.localized(in: language).title })
.foregroundStyle(.secondary)
TextWithPopup(
symbol: .globe,
text: "\(results.externalLinks.count) external links",
items: results.externalLinks.sorted())
.foregroundStyle(.secondary)
if !results.missingPages.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
@@ -93,20 +101,11 @@ struct PageContentResultsView: View {
items: results.missingFiles.sorted())
.foregroundStyle(.red)
}
if !results.unknownCommands.isEmpty {
if !results.invalidCommands.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
text: "\(results.unknownCommands.count) unknown commands",
items: results.unknownCommands.sorted())
.foregroundStyle(.red)
}
if !results.invalidCommandArguments.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
text: "\(results.invalidCommandArguments.count) errors",
items: results.invalidCommandArguments.map {
"\($0.command.rawValue): \($0.arguments.joined(separator: ";"))"
})
text: "\(results.invalidCommands.count) invalid commands",
items: results.invalidCommands.map { $0.markdown }.sorted())
.foregroundStyle(.red)
}
}

View File

@@ -24,16 +24,25 @@ struct PageContentView: View {
@EnvironmentObject
private var content: Content
@State
private var isGeneratingWebsite = false
init(page: Page) {
self.page = page
}
var body: some View {
LocalizedPageContentView(pageId: page.id, page: page.localized(in: language))
.id(page.id + language.rawValue)
if page.isExternalUrl {
VStack {
PageTitleView(page: page.localized(in: language))
.id(page.id + language.rawValue)
Spacer()
Text("No content available for external page")
.font(.title)
.foregroundStyle(.secondary)
Spacer()
}.padding()
} else {
LocalizedPageContentView(pageId: page.id, page: page.localized(in: language), language: language)
.id(page.id + language.rawValue)
}
}
}

View File

@@ -62,6 +62,13 @@ struct PageDetailView: View {
}
.padding(.bottom)
Text("External url")
.font(.headline)
OptionalTextField("", text: $page.externalLink,
prompt: "External url")
.textFieldStyle(.roundedBorder)
.padding(.bottom)
HStack {
Text("Draft")
.font(.headline)
@@ -120,19 +127,20 @@ struct PageDetailView: View {
return
}
isGeneratingWebsite = true
print("Generating page")
DispatchQueue.global(qos: .userInitiated).async {
var success = true
for language in ContentLanguage.allCases {
let generator = LocalizedWebsiteGenerator(
content: content,
language: language)
if !generator.generate(page: page) {
print("Generation failed")
success = false
}
}
DispatchQueue.main.async {
isGeneratingWebsite = false
print("Done")
didGenerateWebsite = success
}
}
}

View File

@@ -39,6 +39,9 @@ struct PostDetailView: View {
@State
private var newId: String
@State
private var showLinkedPagePicker = false
init(post: Post) {
self.post = post
self.newId = post.id
@@ -105,11 +108,29 @@ struct PostDetailView: View {
}
}
HStack {
Text("Linked page")
.font(.headline)
IconButton(symbol: .squareAndPencilCircleFill,
size: 22,
color: .blue) {
showLinkedPagePicker = true
}
Spacer()
}
Text(post.linkedPage?.localized(in: language).title ?? "No page linked")
LocalizedPostDetailView(post: post.localized(in: language))
}
.padding()
}
.sheet(isPresented: $showLinkedPagePicker) {
PagePickerView(
showPagePicker: $showLinkedPagePicker,
selectedPage: $post.linkedPage)
}
}
private func setNewId() {

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
}
}