Rework content commands, add audio player
This commit is contained in:
@ -1,24 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
final class GenerationResultsHandler {
|
||||
|
||||
var requiredVideoFiles: Set<String> = []
|
||||
|
||||
/// Generic warnings for pages
|
||||
private var pageWarnings: [(message: String, source: String)] = []
|
||||
|
||||
private var missingPages: [String : [String]] = [:]
|
||||
|
||||
func warning(_ message: String, page: Page) {
|
||||
pageWarnings.append((message, page.id))
|
||||
print("Page: \(page.id): \(message)")
|
||||
}
|
||||
|
||||
func addRequiredVideoFile(fileId: String) {
|
||||
requiredVideoFiles.insert(fileId)
|
||||
}
|
||||
|
||||
func missing(page: String, linkedBy source: String) {
|
||||
missingPages[page, default: []].append(source)
|
||||
}
|
||||
}
|
@ -130,7 +130,7 @@ final class LocalizedWebsiteGenerator {
|
||||
return true
|
||||
}
|
||||
|
||||
let path = self.content.absoluteUrlToPage(page, language: language) + ".html"
|
||||
let path = page.absoluteUrl(for: language) + ".html"
|
||||
guard save(content, to: path) else {
|
||||
print("Failed to save page")
|
||||
return false
|
||||
@ -151,9 +151,8 @@ final class LocalizedWebsiteGenerator {
|
||||
continue
|
||||
}
|
||||
|
||||
let outputPath = content.absoluteUrlToFile(file)
|
||||
do {
|
||||
try content.storage.copy(file: file.id, to: outputPath)
|
||||
try content.storage.copy(file: file.id, to: file.absoluteUrl)
|
||||
} catch {
|
||||
print("Failed to copy file \(file.id): \(error)")
|
||||
return false
|
||||
|
@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
struct AudioPlayerCommandProcessor: CommandProcessor {
|
||||
|
||||
let commandType: ShorthandMarkdownKey = .audioPlayer
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 2 else {
|
||||
results.invalid(command: .audioPlayer, "Invalid audio player arguments")
|
||||
return ""
|
||||
}
|
||||
let fileId = arguments[0]
|
||||
let titleText = arguments[1]
|
||||
|
||||
guard content.isValidIdForFile(fileId) else {
|
||||
results.invalid(command: .audioPlayer, "Invalid file id \(fileId) for audio player")
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missingFiles.insert(fileId)
|
||||
return ""
|
||||
}
|
||||
let songs: [Song]
|
||||
do {
|
||||
let data = try file.dataContent()
|
||||
songs = try JSONDecoder().decode([Song].self, from: data)
|
||||
} catch {
|
||||
results.issues.insert(.failedToLoadContent(error))
|
||||
return ""
|
||||
}
|
||||
|
||||
var playlist: [AudioPlayer.PlaylistItem] = []
|
||||
var amplitude: [AmplitudeSong] = []
|
||||
|
||||
for song in songs {
|
||||
guard let image = content.image(song.cover) else {
|
||||
results.missing(file: song.cover, markdown: "Missing cover image \(song.cover) in \(file.id)")
|
||||
continue
|
||||
}
|
||||
|
||||
guard let audioFile = content.file(song.file) else {
|
||||
results.missing(file: song.file, markdown: "Missing audio file \(song.file) in \(file.id)")
|
||||
continue
|
||||
}
|
||||
#warning("Check if file is audio")
|
||||
let coverUrl = image.absoluteUrl
|
||||
|
||||
let playlistItem = AudioPlayer.PlaylistItem(
|
||||
index: playlist.count,
|
||||
image: coverUrl,
|
||||
name: song.name,
|
||||
album: song.album,
|
||||
track: song.track,
|
||||
artist: song.artist)
|
||||
|
||||
let amplitudeSong = AmplitudeSong(
|
||||
name: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
track: "\(song.track)",
|
||||
url: audioFile.absoluteUrl,
|
||||
cover_art_url: coverUrl)
|
||||
|
||||
playlist.append(playlistItem)
|
||||
amplitude.append(amplitudeSong)
|
||||
}
|
||||
|
||||
let footerScript = AudioPlayerScript(items: amplitude).content
|
||||
results.requiredFooters.insert(footerScript)
|
||||
results.requiredHeaders.insert(.audioPlayerCss)
|
||||
results.requiredHeaders.insert(.amplitude)
|
||||
|
||||
results.requiredIcons.formUnion([
|
||||
.audioPlayerClose,
|
||||
.audioPlayerPlaylist,
|
||||
.audioPlayerNext,
|
||||
.audioPlayerPrevious,
|
||||
.audioPlayerPlay,
|
||||
.audioPlayerPause
|
||||
])
|
||||
|
||||
return AudioPlayer(playingText: titleText, items: playlist).content
|
||||
}
|
||||
}
|
103
CHDataManagement/Generator/Page Content/ButtonCommand.swift
Normal file
103
CHDataManagement/Generator/Page Content/ButtonCommand.swift
Normal file
@ -0,0 +1,103 @@
|
||||
|
||||
struct ButtonCommandProcessor: CommandProcessor {
|
||||
|
||||
let commandType: ShorthandMarkdownKey = .buttons
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ``
|
||||
Format: ``
|
||||
Types:
|
||||
- Download: `download=<fileId>,<text>,<download-filename?>`
|
||||
- External link: `external=<url>,<text>`
|
||||
- Git: `git=<url>,<text>`
|
||||
- Play: `play-circle=<text>,<click-action>`
|
||||
*/
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
let buttons = arguments.compactMap { convert(button: $0, markdown: markdown) }
|
||||
return ContentButtons(items: buttons).content
|
||||
}
|
||||
|
||||
private func convert(button: String, markdown: Substring) -> ContentButtons.Item? {
|
||||
guard let type = PageIcon(rawValue: button.dropAfterFirst("=").trimmed) else {
|
||||
results.invalid(command: commandType, markdown)
|
||||
return nil
|
||||
}
|
||||
let parts = button.dropBeforeFirst("=").components(separatedBy: ",").map { $0.trimmed }
|
||||
switch type {
|
||||
case .buttonDownload:
|
||||
return download(arguments: parts, markdown: markdown)
|
||||
case .buttonGitLink:
|
||||
return link(icon: .buttonGitLink, arguments: parts, markdown: markdown)
|
||||
case .buttonExternalLink:
|
||||
return link(icon: .buttonExternalLink, arguments: parts, markdown: markdown)
|
||||
case .buttonPlay:
|
||||
return play(arguments: parts, markdown: markdown)
|
||||
default:
|
||||
results.invalid(command: commandType, markdown)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func download(arguments: [String], markdown: Substring) -> ContentButtons.Item? {
|
||||
guard (2...3).contains(arguments.count) else {
|
||||
results.invalid(command: commandType, markdown)
|
||||
return nil
|
||||
}
|
||||
let fileId = arguments[0].trimmed
|
||||
let title = arguments[1].trimmed
|
||||
let downloadName = arguments.count > 2 ? arguments[2].trimmed : nil
|
||||
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, markdown: markdown)
|
||||
return nil
|
||||
}
|
||||
results.files.insert(file)
|
||||
results.requiredIcons.insert(.buttonDownload)
|
||||
return ContentButtons.Item(
|
||||
icon: .buttonDownload,
|
||||
filePath: file.absoluteUrl,
|
||||
text: title,
|
||||
downloadFileName: downloadName)
|
||||
}
|
||||
|
||||
private func link(icon: PageIcon, arguments: [String], markdown: Substring) -> ContentButtons.Item? {
|
||||
guard arguments.count == 2 else {
|
||||
results.invalid(command: .buttons, markdown)
|
||||
return nil
|
||||
}
|
||||
let rawUrl = arguments[0].trimmed
|
||||
guard let url = rawUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
|
||||
results.invalid(command: .buttons, markdown)
|
||||
return nil
|
||||
}
|
||||
|
||||
results.externalLinks.insert(rawUrl)
|
||||
results.requiredIcons.insert(icon)
|
||||
|
||||
let title = arguments[1].trimmed
|
||||
|
||||
return .init(icon: icon, filePath: url, text: title)
|
||||
}
|
||||
|
||||
private func play(arguments: [String], markdown: Substring) -> ContentButtons.Item? {
|
||||
guard arguments.count == 2 else {
|
||||
results.invalid(command: .buttons, markdown)
|
||||
return nil
|
||||
}
|
||||
let text = arguments[0].trimmed
|
||||
let event = arguments[1].trimmed
|
||||
|
||||
results.requiredIcons.insert(.buttonPlay)
|
||||
|
||||
return .init(icon: .buttonPlay, filePath: nil, text: text, onClickText: event)
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
|
||||
protocol CommandProcessor {
|
||||
|
||||
var commandType: ShorthandMarkdownKey { get }
|
||||
|
||||
init(content: Content, results: PageGenerationResults)
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String
|
||||
}
|
30
CHDataManagement/Generator/Page Content/LabelsCommand.swift
Normal file
30
CHDataManagement/Generator/Page Content/LabelsCommand.swift
Normal file
@ -0,0 +1,30 @@
|
||||
|
||||
struct LabelsCommandProcessor: CommandProcessor {
|
||||
|
||||
let commandType: ShorthandMarkdownKey = .labels
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
let labels: [ContentLabel] = arguments.compactMap { arg in
|
||||
let parts = arg.components(separatedBy: "=")
|
||||
guard parts.count == 2 else {
|
||||
results.invalid(command: .labels, markdown)
|
||||
return nil
|
||||
}
|
||||
guard let icon = PageIcon(rawValue: parts[0].trimmed) else {
|
||||
results.invalid(command: .labels, markdown)
|
||||
return nil
|
||||
}
|
||||
return .init(icon: icon, value: parts[1])
|
||||
}
|
||||
return ContentLabels(labels: labels).content
|
||||
}
|
||||
}
|
@ -1,20 +1,21 @@
|
||||
import Ink
|
||||
|
||||
#warning("Remove if unused")
|
||||
final class PageCommandExtractor {
|
||||
|
||||
private var occurences: [(full: String, command: String, arguments: [String])] = []
|
||||
private var occurrences: [(full: String, command: String, arguments: [String])] = []
|
||||
|
||||
func findOccurences(of command: ShorthandMarkdownKey, in content: String) -> [(full: String, arguments: [String])] {
|
||||
findOccurences(of: command.rawValue, in: content)
|
||||
func findOccurrences(of command: ShorthandMarkdownKey, in content: String) -> [(full: String, arguments: [String])] {
|
||||
findOccurrences(of: command.rawValue, in: content)
|
||||
}
|
||||
|
||||
func findOccurences(of command: String, in content: String) -> [(full: String, arguments: [String])] {
|
||||
func findOccurrences(of command: String, in content: String) -> [(full: String, arguments: [String])] {
|
||||
let parser = MarkdownParser(modifiers: [
|
||||
Modifier(target: .images, closure: processMarkdownImage),
|
||||
])
|
||||
_ = parser.html(from: content)
|
||||
|
||||
return occurences
|
||||
return occurrences
|
||||
.filter { $0.command == command }
|
||||
.map { ($0.full, $0.arguments) }
|
||||
}
|
||||
@ -25,7 +26,7 @@ final class PageCommandExtractor {
|
||||
|
||||
|
||||
let command = markdown.between("![", and: "]").trimmed
|
||||
occurences.append((full: String(markdown), command: command, arguments: arguments))
|
||||
occurrences.append((full: String(markdown), command: command, arguments: arguments))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,10 @@
|
||||
|
||||
|
||||
enum PageContentAnomaly {
|
||||
case failedToLoadContent(Error)
|
||||
case missingFile(String)
|
||||
case missingPage(String)
|
||||
case missingTag(String)
|
||||
case unknownCommand(String)
|
||||
case invalidCommandArguments(command: ShorthandMarkdownKey, arguments: [String])
|
||||
case missingFile(file: String, markdown: String)
|
||||
case missingPage(page: String, markdown: String)
|
||||
case missingTag(tag: String, markdown: String)
|
||||
case invalidCommand(command: ShorthandMarkdownKey?, markdown: String)
|
||||
}
|
||||
|
||||
extension PageContentAnomaly: Identifiable {
|
||||
@ -15,20 +13,32 @@ extension PageContentAnomaly: Identifiable {
|
||||
switch self {
|
||||
case .failedToLoadContent:
|
||||
return "load-failed"
|
||||
case .missingFile(let string):
|
||||
case .missingFile(let string, _):
|
||||
return "missing-file-\(string)"
|
||||
case .missingPage(let string):
|
||||
case .missingPage(let string, _):
|
||||
return "missing-page-\(string)"
|
||||
case .missingTag(let string):
|
||||
case .missingTag(let string, _):
|
||||
return "missing-tag-\(string)"
|
||||
case .unknownCommand(let string):
|
||||
return "unknown-command-\(string)"
|
||||
case .invalidCommandArguments(let command, let arguments):
|
||||
return "invalid-arguments-\(command)-\(arguments.joined(separator: "-"))"
|
||||
case .invalidCommand(_, let markdown):
|
||||
return "invalid-command-\(markdown)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PageContentAnomaly: Equatable {
|
||||
|
||||
static func == (lhs: PageContentAnomaly, rhs: PageContentAnomaly) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension PageContentAnomaly: Hashable {
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
extension PageContentAnomaly {
|
||||
|
||||
enum Severity: String, CaseIterable {
|
||||
@ -40,7 +50,7 @@ extension PageContentAnomaly {
|
||||
switch self {
|
||||
case .failedToLoadContent:
|
||||
return .error
|
||||
case .missingFile, .missingPage, .missingTag, .unknownCommand, .invalidCommandArguments:
|
||||
case .missingFile, .missingPage, .missingTag, .invalidCommand:
|
||||
return .warning
|
||||
}
|
||||
}
|
||||
@ -52,16 +62,14 @@ extension PageContentAnomaly: CustomStringConvertible {
|
||||
switch self {
|
||||
case .failedToLoadContent(let error):
|
||||
return "Failed to load content: \(error)"
|
||||
case .missingFile(let string):
|
||||
return "Missing file \(string)"
|
||||
case .missingPage(let string):
|
||||
return "Missing page \(string)"
|
||||
case .missingTag(let string):
|
||||
return "Missing tag \(string)"
|
||||
case .unknownCommand(let string):
|
||||
return "Unknown command \(string)"
|
||||
case .invalidCommandArguments(let command, let arguments):
|
||||
return "Invalid command arguments for \(command): \(arguments)"
|
||||
case .missingFile(let string, _):
|
||||
return "Missing file: \(string)"
|
||||
case .missingPage(let string, _):
|
||||
return "Missing page: \(string)"
|
||||
case .missingTag(let string, _):
|
||||
return "Missing tag: \(string)"
|
||||
case .invalidCommand(_, let markdown):
|
||||
return "Invalid command: \(markdown)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import Ink
|
||||
import Splash
|
||||
import SwiftSoup
|
||||
|
||||
typealias VideoSource = (url: String, type: VideoFileType)
|
||||
|
||||
@ -18,6 +19,12 @@ final class PageContentParser {
|
||||
|
||||
private let content: Content
|
||||
|
||||
private let buttonHandler: ButtonCommandProcessor
|
||||
|
||||
private let labelHandler: LabelsCommandProcessor
|
||||
|
||||
private let audioPlayer: AudioPlayerCommandProcessor
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
var largeImageWidth: Int {
|
||||
@ -31,6 +38,9 @@ final class PageContentParser {
|
||||
init(content: Content, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.language = language
|
||||
self.buttonHandler = .init(content: content, results: results)
|
||||
self.labelHandler = .init(content: content, results: results)
|
||||
self.audioPlayer = .init(content: content, results: results)
|
||||
}
|
||||
|
||||
func requestImages(_ generator: ImageGenerator) {
|
||||
@ -77,7 +87,7 @@ final class PageContentParser {
|
||||
if file.hasPrefix(tagLinkMarker) {
|
||||
return handleTagLink(file: file, html: html, markdown: markdown)
|
||||
}
|
||||
#warning("Check existence of linked file")
|
||||
results.externalLinks.insert(file)
|
||||
return html
|
||||
}
|
||||
|
||||
@ -86,12 +96,12 @@ final class PageContentParser {
|
||||
let textToChange = file.dropAfterFirst("#")
|
||||
let pageId = textToChange.replacingOccurrences(of: pageLinkMarker, with: "")
|
||||
guard let page = content.page(pageId) else {
|
||||
results.missingPages.insert(pageId)
|
||||
results.missing(page: pageId, markdown: markdown)
|
||||
// Remove link since the page can't be found
|
||||
return markdown.between("[", and: "]")
|
||||
}
|
||||
results.linkedPages.insert(page)
|
||||
let pagePath = content.absoluteUrlToPage(page, language: language)
|
||||
let pagePath = page.absoluteUrl(for: language)
|
||||
return html.replacingOccurrences(of: textToChange, with: pagePath)
|
||||
}
|
||||
|
||||
@ -100,7 +110,7 @@ final class PageContentParser {
|
||||
let textToChange = file.dropAfterFirst("#")
|
||||
let tagId = textToChange.replacingOccurrences(of: tagLinkMarker, with: "")
|
||||
guard let tag = content.tag(tagId) else {
|
||||
results.missingTags.insert(tagId)
|
||||
results.missing(tag: tagId, markdown: markdown)
|
||||
// Remove link since the tag can't be found
|
||||
return markdown.between("[", and: "]")
|
||||
}
|
||||
@ -109,10 +119,71 @@ final class PageContentParser {
|
||||
return html.replacingOccurrences(of: textToChange, with: tagPath)
|
||||
}
|
||||
|
||||
private func handleHTML(html: String, markdown: Substring) -> String {
|
||||
private func handleHTML(_: String, markdown: Substring) -> String {
|
||||
let result = String(markdown)
|
||||
#warning("Check HTML code in markdown for required resources")
|
||||
findImages(in: result)
|
||||
findLinks(in: result)
|
||||
findSourceSets(in: result)
|
||||
// Things to check: <img src= <a href= <source>
|
||||
return html
|
||||
return result
|
||||
}
|
||||
|
||||
private func findImages(in markdown: String) {
|
||||
do {
|
||||
// Parse the HTML string
|
||||
let document = try SwiftSoup.parse(markdown)
|
||||
|
||||
// Select all 'img' elements
|
||||
let imgElements = try document.select("img")
|
||||
|
||||
// Extract the 'src' attributes from each 'img' element
|
||||
let srcAttributes = try imgElements.array().compactMap { try $0.attr("src") }
|
||||
|
||||
for src in srcAttributes {
|
||||
print("Found image in html: \(src)")
|
||||
}
|
||||
} catch {
|
||||
print("Error parsing HTML: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func findLinks(in markdown: String) {
|
||||
do {
|
||||
// Parse the HTML string
|
||||
let document = try SwiftSoup.parse(markdown)
|
||||
|
||||
// Select all 'img' elements
|
||||
let linkElements = try document.select("a")
|
||||
|
||||
// Extract the 'src' attributes from each 'img' element
|
||||
let srcAttributes = try linkElements.array().compactMap { try $0.attr("href") }
|
||||
|
||||
for src in srcAttributes {
|
||||
print("Found link in html: \(src)")
|
||||
}
|
||||
} catch {
|
||||
print("Error parsing HTML: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func findSourceSets(in markdown: String) {
|
||||
do {
|
||||
// Parse the HTML string
|
||||
let document = try SwiftSoup.parse(markdown)
|
||||
|
||||
// Select all 'img' elements
|
||||
let linkElements = try document.select("source")
|
||||
|
||||
// Extract the 'src' attributes from each 'img' element
|
||||
let srcAttributes = try linkElements.array().compactMap { try $0.attr("srcset") }
|
||||
|
||||
for src in srcAttributes {
|
||||
print("Found source set in html: \(src)")
|
||||
}
|
||||
} catch {
|
||||
print("Error parsing HTML: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -151,40 +222,38 @@ final class PageContentParser {
|
||||
|
||||
let rawCommand = percentDecoded(markdown.between("![", and: "]").trimmed)
|
||||
guard rawCommand != "" else {
|
||||
return handleImage(arguments)
|
||||
return handleImage(arguments, markdown: markdown)
|
||||
}
|
||||
|
||||
guard let command = ShorthandMarkdownKey(rawValue: rawCommand) else {
|
||||
// Treat unknown commands as normal links
|
||||
results.unknownCommands.append(rawCommand)
|
||||
results.invalid(command: nil, markdown)
|
||||
return html
|
||||
}
|
||||
|
||||
switch command {
|
||||
case .image:
|
||||
return handleImage(arguments)
|
||||
case .hikingStatistics:
|
||||
return handleHikingStatistics(arguments)
|
||||
case .downloadButtons:
|
||||
return handleDownloadButtons(arguments)
|
||||
return handleImage(arguments, markdown: markdown)
|
||||
case .labels:
|
||||
return labelHandler.process(arguments, markdown: markdown)
|
||||
case .buttons:
|
||||
return buttonHandler.process(arguments, markdown: markdown)
|
||||
case .video:
|
||||
return handleVideo(arguments)
|
||||
case .externalLink:
|
||||
return handleExternalButtons(arguments)
|
||||
case .gitLink:
|
||||
return handleGitButtons(arguments)
|
||||
return handleVideo(arguments, markdown: markdown)
|
||||
case .pageLink:
|
||||
return handlePageLink(arguments)
|
||||
return handlePageLink(arguments, markdown: markdown)
|
||||
case .includedHtml:
|
||||
return handleExternalHtml(arguments)
|
||||
return handleExternalHtml(arguments, markdown: markdown)
|
||||
case .box:
|
||||
return handleSimpleBox(arguments)
|
||||
return handleSimpleBox(arguments, markdown: markdown)
|
||||
case .model:
|
||||
return handleModel(arguments)
|
||||
return handleModel(arguments, markdown: markdown)
|
||||
case .svg:
|
||||
return handleSvg(arguments)
|
||||
return handleSvg(arguments, markdown: markdown)
|
||||
case .audioPlayer:
|
||||
return audioPlayer.process(arguments, markdown: markdown)
|
||||
default:
|
||||
results.unknownCommands.append(command.rawValue)
|
||||
results.invalid(command: nil, markdown)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@ -192,15 +261,15 @@ final class PageContentParser {
|
||||
/**
|
||||
Format: `[image](<imageId>;<caption?>]`
|
||||
*/
|
||||
private func handleImage(_ arguments: [String]) -> String {
|
||||
private func handleImage(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard (1...2).contains(arguments.count) else {
|
||||
results.invalidCommandArguments.append((.image , arguments))
|
||||
results.invalid(command: .image, markdown)
|
||||
return ""
|
||||
}
|
||||
let imageId = arguments[0]
|
||||
|
||||
guard let image = content.image(imageId) else {
|
||||
results.missingFiles.insert(imageId)
|
||||
results.missing(file: imageId, markdown: markdown)
|
||||
return ""
|
||||
}
|
||||
results.files.insert(image)
|
||||
@ -208,7 +277,7 @@ final class PageContentParser {
|
||||
let caption = arguments.count == 2 ? arguments[1] : nil
|
||||
let altText = image.getDescription(for: language)
|
||||
|
||||
let path = content.absoluteUrlToFile(image)
|
||||
let path = image.absoluteUrl
|
||||
|
||||
guard !image.type.isSvg else {
|
||||
return SvgImage(imagePath: path, altText: altText).content
|
||||
@ -235,170 +304,80 @@ final class PageContentParser {
|
||||
caption: caption).content
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
private func handleHikingStatistics(_ arguments: [String]) -> String {
|
||||
#warning("Make statistics more generic using key-value pairs")
|
||||
guard (1...5).contains(arguments.count) else {
|
||||
results.invalidCommandArguments.append((.hikingStatistics, arguments))
|
||||
return ""
|
||||
}
|
||||
|
||||
let time = arguments[0].trimmed
|
||||
let elevationUp = arguments.count > 1 ? arguments[1].trimmed : nil
|
||||
let elevationDown = arguments.count > 2 ? arguments[2].trimmed : nil
|
||||
let distance = arguments.count > 3 ? arguments[3].trimmed : nil
|
||||
let calories = arguments.count > 4 ? arguments[4].trimmed : nil
|
||||
|
||||
return HikingStatistics(
|
||||
time: time,
|
||||
elevationUp: elevationUp,
|
||||
elevationDown: elevationDown,
|
||||
distance: distance,
|
||||
calories: calories)
|
||||
.content
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
private func handleDownloadButtons(_ arguments: [String]) -> String {
|
||||
let buttons = arguments.compactMap(convertButton)
|
||||
return ContentButtons(items: buttons).content
|
||||
}
|
||||
|
||||
private func convertButton(definition button: String) -> ContentButtons.Item? {
|
||||
let parts = button.components(separatedBy: ",")
|
||||
guard (2...3).contains(parts.count) else {
|
||||
results.invalidCommandArguments.append((.downloadButtons, parts))
|
||||
return nil
|
||||
}
|
||||
let fileId = parts[0].trimmed
|
||||
let title = parts[1].trimmed
|
||||
let downloadName = parts.count > 2 ? parts[2].trimmed : nil
|
||||
|
||||
guard let file = content.file(id: fileId) else {
|
||||
results.missingFiles.insert(fileId)
|
||||
return nil
|
||||
}
|
||||
results.files.insert(file)
|
||||
let filePath = content.absoluteUrlToFile(file)
|
||||
return ContentButtons.Item(icon: .download, filePath: filePath, text: title, downloadFileName: downloadName)
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ` -> String {
|
||||
private func handleVideo(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count >= 1 else {
|
||||
results.invalidCommandArguments.append((.video, arguments))
|
||||
results.invalid(command: .video, markdown)
|
||||
return ""
|
||||
}
|
||||
let fileId = arguments[0].trimmed
|
||||
|
||||
let options = arguments.dropFirst().compactMap(convertVideoOption)
|
||||
let options = arguments.dropFirst().compactMap { convertVideoOption($0, markdown: markdown) }
|
||||
|
||||
guard let file = content.file(id: fileId) else {
|
||||
results.missingFiles.insert(fileId)
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, markdown: markdown)
|
||||
return ""
|
||||
}
|
||||
results.files.insert(file)
|
||||
|
||||
guard let videoType = file.type.videoType?.htmlType else {
|
||||
results.invalidCommandArguments.append((.video, arguments))
|
||||
results.invalid(command: .video, markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
let filePath = content.absoluteUrlToFile(file)
|
||||
return ContentPageVideo(
|
||||
filePath: filePath,
|
||||
filePath: file.absoluteUrl,
|
||||
videoType: videoType,
|
||||
options: options)
|
||||
.content
|
||||
}
|
||||
|
||||
private func convertVideoOption(_ videoOption: String) -> VideoOption? {
|
||||
private func convertVideoOption(_ videoOption: String, markdown: Substring) -> VideoOption? {
|
||||
guard let optionText = videoOption.trimmed.nonEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard let option = VideoOption(rawValue: optionText) else {
|
||||
results.invalidCommandArguments.append((.video, [optionText]))
|
||||
results.invalid(command: .video, markdown)
|
||||
return nil
|
||||
}
|
||||
if case let .poster(imageId) = option {
|
||||
if let image = content.image(imageId) {
|
||||
results.files.insert(image)
|
||||
let link = content.absoluteUrlToFile(image)
|
||||
let width = 2*thumbnailWidth
|
||||
let fullLink = WebsiteImage.imagePath(source: link, width: width, height: width)
|
||||
let fullLink = WebsiteImage.imagePath(source: image.absoluteUrl, width: width, height: width)
|
||||
return .poster(image: fullLink)
|
||||
} else {
|
||||
results.missingFiles.insert(imageId)
|
||||
results.missing(file: imageId, markdown: markdown)
|
||||
return nil // Image file not present, so skip the option
|
||||
}
|
||||
}
|
||||
if case let .src(videoId) = option {
|
||||
if let video = content.video(videoId) {
|
||||
results.files.insert(video)
|
||||
let link = content.absoluteUrlToFile(video)
|
||||
let link = video.absoluteUrl
|
||||
// TODO: Set correct video path?
|
||||
return .src(link)
|
||||
} else {
|
||||
results.missingFiles.insert(videoId)
|
||||
results.missing(file: videoId, markdown: markdown)
|
||||
return nil // Video file not present, so skip the option
|
||||
}
|
||||
}
|
||||
return option
|
||||
}
|
||||
|
||||
private func handleExternalButtons(_ arguments: [String]) -> String {
|
||||
// 
|
||||
}
|
||||
|
||||
private func handleGitButtons(_ arguments: [String]) -> String {
|
||||
// 
|
||||
}
|
||||
|
||||
private func handleButtons(icon: PageIcon, arguments: [String]) -> String {
|
||||
guard arguments.count >= 1 else {
|
||||
results.invalidCommandArguments.append((.externalLink, arguments))
|
||||
return ""
|
||||
}
|
||||
let buttons: [ContentButtons.Item] = arguments.compactMap { button in
|
||||
let parts = button.components(separatedBy: ",")
|
||||
guard parts.count == 2 else {
|
||||
results.invalidCommandArguments.append((.externalLink, parts))
|
||||
return nil
|
||||
}
|
||||
let rawUrl = parts[0].trimmed
|
||||
guard let url = rawUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
|
||||
results.invalidCommandArguments.append((.externalLink, parts))
|
||||
return nil
|
||||
}
|
||||
let title = parts[1].trimmed
|
||||
|
||||
return .init(
|
||||
icon: icon,
|
||||
filePath: url,
|
||||
text: title)
|
||||
}
|
||||
return ContentButtons(items: buttons).content
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
private func handleExternalHtml(_ arguments: [String]) -> String {
|
||||
private func handleExternalHtml(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 1 else {
|
||||
results.invalidCommandArguments.append((.includedHtml, arguments))
|
||||
results.invalid(command: .includedHtml, markdown)
|
||||
return ""
|
||||
}
|
||||
let fileId = arguments[0]
|
||||
guard let file = content.file(id: fileId) else {
|
||||
results.missingFiles.insert(fileId)
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, markdown: markdown)
|
||||
return ""
|
||||
}
|
||||
return file.textContent()
|
||||
@ -407,9 +386,9 @@ final class PageContentParser {
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
private func handleSimpleBox(_ arguments: [String]) -> String {
|
||||
private func handleSimpleBox(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count > 1 else {
|
||||
results.invalidCommandArguments.append((.box, arguments))
|
||||
results.invalid(command: .box, markdown)
|
||||
return ""
|
||||
}
|
||||
let title = arguments[0]
|
||||
@ -420,15 +399,15 @@ final class PageContentParser {
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
private func handlePageLink(_ arguments: [String]) -> String {
|
||||
private func handlePageLink(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 1 else {
|
||||
results.invalidCommandArguments.append((.pageLink, arguments))
|
||||
results.invalid(command: .pageLink, markdown)
|
||||
return ""
|
||||
}
|
||||
let pageId = arguments[0]
|
||||
|
||||
guard let page = content.page(pageId) else {
|
||||
results.missingPages.insert(pageId)
|
||||
results.missing(page: pageId, markdown: markdown)
|
||||
return ""
|
||||
}
|
||||
guard !page.isDraft else {
|
||||
@ -437,7 +416,7 @@ final class PageContentParser {
|
||||
}
|
||||
|
||||
let localized = page.localized(in: language)
|
||||
let url = content.absoluteUrlToPage(page, language: language)
|
||||
let url = page.absoluteUrl(for: language)
|
||||
let title = localized.linkPreviewTitle ?? localized.title
|
||||
let description = localized.linkPreviewDescription ?? ""
|
||||
|
||||
@ -447,7 +426,7 @@ final class PageContentParser {
|
||||
results.imagesToGenerate.insert(.init(size: size, image: image))
|
||||
|
||||
return RelatedPageLink.Image(
|
||||
url: content.absoluteUrlToFile(image),
|
||||
url: image.absoluteUrl,
|
||||
description: image.getDescription(for: language),
|
||||
size: size)
|
||||
}
|
||||
@ -463,32 +442,31 @@ final class PageContentParser {
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
private func handleModel(_ arguments: [String]) -> String {
|
||||
private func handleModel(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 1 else {
|
||||
results.invalidCommandArguments.append((.model, arguments))
|
||||
results.invalid(command: .model, markdown)
|
||||
return ""
|
||||
}
|
||||
let fileId = arguments[0]
|
||||
guard fileId.hasSuffix(".glb") else {
|
||||
results.invalidCommandArguments.append((.model, ["\(fileId) is not a .glb file"]))
|
||||
results.invalid(command: .model, markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let file = content.file(id: fileId) else {
|
||||
results.missingFiles.insert(fileId)
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, markdown: markdown)
|
||||
return ""
|
||||
}
|
||||
results.files.insert(file)
|
||||
results.requiredHeaders.insert(.modelViewer)
|
||||
|
||||
let path = content.absoluteUrlToFile(file)
|
||||
let description = file.getDescription(for: language)
|
||||
return ModelViewer(file: path, description: description).content
|
||||
return ModelViewer(file: file.absoluteUrl, description: description).content
|
||||
}
|
||||
|
||||
private func handleSvg(_ arguments: [String]) -> String {
|
||||
private func handleSvg(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 5 else {
|
||||
results.invalidCommandArguments.append((.svg, arguments))
|
||||
results.invalid(command: .svg, markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -496,26 +474,24 @@ final class PageContentParser {
|
||||
let y = Int(arguments[2]),
|
||||
let partWidth = Int(arguments[3]),
|
||||
let partHeight = Int(arguments[4]) else {
|
||||
results.invalidCommandArguments.append((.svg, arguments))
|
||||
results.invalid(command: .svg, markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
let imageId = arguments[0]
|
||||
|
||||
guard let image = content.image(imageId) else {
|
||||
results.missingFiles.insert(imageId)
|
||||
results.missing(file: imageId, markdown: markdown)
|
||||
return ""
|
||||
}
|
||||
guard case .image(let imageType) = image.type,
|
||||
imageType == .svg else {
|
||||
results.invalidCommandArguments.append((.svg, arguments))
|
||||
results.invalid(command: .svg, markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
let path = content.absoluteUrlToFile(image)
|
||||
|
||||
return PartialSvgImage(
|
||||
imagePath: path,
|
||||
imagePath: image.absoluteUrl,
|
||||
altText: image.getDescription(for: language),
|
||||
x: x,
|
||||
y: y,
|
||||
@ -523,7 +499,6 @@ final class PageContentParser {
|
||||
height: partHeight)
|
||||
.content
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -23,6 +23,9 @@ final class PageGenerationResults: ObservableObject {
|
||||
@Published
|
||||
var linkedTags: Set<Tag> = []
|
||||
|
||||
@Published
|
||||
var externalLinks: Set<String> = []
|
||||
|
||||
@Published
|
||||
var files: Set<FileResource> = []
|
||||
|
||||
@ -39,10 +42,7 @@ final class PageGenerationResults: ObservableObject {
|
||||
var missingTags: Set<String> = []
|
||||
|
||||
@Published
|
||||
var unknownCommands: [String] = []
|
||||
|
||||
@Published
|
||||
var invalidCommandArguments: [(command: ShorthandMarkdownKey, arguments: [String])] = []
|
||||
var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)] = []
|
||||
|
||||
@Published
|
||||
var requiredHeaders: RequiredHeaders = []
|
||||
@ -50,26 +50,45 @@ final class PageGenerationResults: ObservableObject {
|
||||
@Published
|
||||
var requiredFooters: Set<String> = []
|
||||
|
||||
@Published
|
||||
var requiredIcons: Set<PageIcon> = []
|
||||
|
||||
@Published
|
||||
var issues: Set<PageContentAnomaly> = []
|
||||
|
||||
func reset() {
|
||||
linkedPages = []
|
||||
linkedTags = []
|
||||
externalLinks = []
|
||||
files = []
|
||||
imagesToGenerate = []
|
||||
missingPages = []
|
||||
missingFiles = []
|
||||
missingTags = []
|
||||
unknownCommands = []
|
||||
invalidCommandArguments = []
|
||||
invalidCommands = []
|
||||
requiredHeaders = []
|
||||
requiredFooters = []
|
||||
requiredIcons = []
|
||||
issues = []
|
||||
}
|
||||
|
||||
var convertedWarnings: [PageContentAnomaly] {
|
||||
var result = [PageContentAnomaly]()
|
||||
result += missingPages.map { .missingPage($0) }
|
||||
result += missingFiles.map { .missingFile($0) }
|
||||
result += unknownCommands.map { .unknownCommand($0) }
|
||||
result += invalidCommandArguments.map { .invalidCommandArguments(command: $0.command, arguments: $0.arguments) }
|
||||
return result
|
||||
func invalid(command: ShorthandMarkdownKey?, _ markdown: Substring) {
|
||||
invalidCommands.append((command, String(markdown)))
|
||||
issues.insert(.invalidCommand(command: command, markdown: String(markdown)))
|
||||
}
|
||||
|
||||
func missing(page: String, markdown: Substring) {
|
||||
missingPages.insert(page)
|
||||
issues.insert(.missingPage(page: page, markdown: String(markdown)))
|
||||
}
|
||||
|
||||
func missing(tag: String, markdown: Substring) {
|
||||
missingTags.insert(tag)
|
||||
issues.insert(.missingTag(tag: tag, markdown: String(markdown)))
|
||||
}
|
||||
|
||||
func missing(file: String, markdown: Substring) {
|
||||
missingFiles.insert(file)
|
||||
issues.insert(.missingFile(file: file, markdown: String(markdown)))
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +43,8 @@ final class PageGenerator {
|
||||
navigationBarLinks: navigationBarLinks,
|
||||
pageContent: pageContent,
|
||||
headers: headers.content,
|
||||
footers: contentGenerator.results.requiredFooters.sorted())
|
||||
footers: contentGenerator.results.requiredFooters.sorted(),
|
||||
icons: contentGenerator.results.requiredIcons)
|
||||
.content
|
||||
|
||||
return (fullPage, contentGenerator.results)
|
||||
|
@ -62,7 +62,7 @@ final class PostListPageGenerator {
|
||||
|
||||
let linkUrl = post.linkedPage.map {
|
||||
FeedEntryData.Link(
|
||||
url: content.absoluteUrlToPage($0, language: language),
|
||||
url: $0.absoluteUrl(for: language),
|
||||
text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings
|
||||
}
|
||||
|
||||
@ -104,7 +104,7 @@ final class PostListPageGenerator {
|
||||
maxWidth: mainContentMaximumWidth,
|
||||
maxHeight: mainContentMaximumWidth)
|
||||
return .init(
|
||||
rawImagePath: content.absoluteUrlToFile(image),
|
||||
rawImagePath: image.absoluteUrl,
|
||||
width: Int(mainContentMaximumWidth),
|
||||
height: Int(mainContentMaximumWidth),
|
||||
altText: image.getDescription(for: language))
|
||||
|
@ -5,10 +5,16 @@ enum HeaderFile: String {
|
||||
|
||||
case modelViewer = "model-viewer.min.js"
|
||||
|
||||
case audioPlayerCss = "audio-player.css"
|
||||
|
||||
case amplitude = "amplitude.min.js"
|
||||
|
||||
var asModule: Bool {
|
||||
switch self {
|
||||
case .codeHightlighting: return false
|
||||
case .modelViewer: return true
|
||||
case .amplitude: return false
|
||||
case .audioPlayerCss: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,20 +13,17 @@ enum ShorthandMarkdownKey: String {
|
||||
/// Format: ``
|
||||
case labels
|
||||
|
||||
/// A video
|
||||
/// Format: ``
|
||||
|
||||
/// A variable number of download buttons for file downloads
|
||||
/// Format: `[download](<<fileId>,<text>,<download-filename?>;...)`
|
||||
case downloadButtons = "download"
|
||||
/// Format: `[buttons](type=<<fileId>,<text>,<download-filename?>;...)`
|
||||
case buttons
|
||||
|
||||
/// A box with a title and content
|
||||
/// Format: ``
|
||||
@ -40,20 +37,16 @@ enum ShorthandMarkdownKey: String {
|
||||
/// Format: ``
|
||||
case pageLink = "page"
|
||||
|
||||
/// A large button to an external page.
|
||||
/// Format: ``
|
||||
case includedHtml = "html"
|
||||
|
||||
/// SVG Image showing only a part of the image
|
||||
/// Format ``
|
||||
case svg
|
||||
|
||||
/// A player to play audio files
|
||||
/// Format: ``
|
||||
case audioPlayer = "audio-player"
|
||||
|
||||
}
|
||||
|
@ -4,14 +4,6 @@ extension Content {
|
||||
("/" + path).replacingOccurrences(of: "//", with: "/")
|
||||
}
|
||||
|
||||
private func pathPrefix(for file: FileResource) -> String {
|
||||
switch file.type {
|
||||
case .image: return settings.paths.imagesOutputFolderPath
|
||||
case .video: return settings.paths.videosOutputFolderPath
|
||||
default: return settings.paths.filesOutputFolderPath
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Paths to items
|
||||
|
||||
func absoluteUrlPrefixForTag(_ tag: Tag, language: ContentLanguage) -> String {
|
||||
@ -22,20 +14,6 @@ extension Content {
|
||||
absoluteUrlPrefixForTag(tag, language: language) + ".html"
|
||||
}
|
||||
|
||||
func absoluteUrlToPage(_ page: Page, language: ContentLanguage) -> String {
|
||||
// TODO: Record link to trace connections between pages
|
||||
makeCleanAbsolutePath(settings.pages.pageUrlPrefix + "/" + page.localized(in: language).urlString)
|
||||
}
|
||||
|
||||
/**
|
||||
Get the url path to a file in the output folder.
|
||||
The result is an absolute path from the output folder for use in HTML.
|
||||
*/
|
||||
func absoluteUrlToFile(_ file: FileResource) -> String {
|
||||
let path = pathPrefix(for: file) + "/" + file.id
|
||||
return makeCleanAbsolutePath(path)
|
||||
}
|
||||
|
||||
// MARK: Find items by id
|
||||
|
||||
func page(_ pageId: String) -> Page? {
|
||||
@ -50,8 +28,8 @@ extension Content {
|
||||
files.first { $0.id == videoId && $0.type.isVideo }
|
||||
}
|
||||
|
||||
func file(id: String) -> FileResource? {
|
||||
files.first { $0.id == id }
|
||||
func file(_ fileId: String) -> FileResource? {
|
||||
files.first { $0.id == fileId }
|
||||
}
|
||||
|
||||
func tag(_ tagId: String) -> Tag? {
|
||||
|
@ -135,6 +135,7 @@ extension Content {
|
||||
pages[pageId] = Page(
|
||||
content: self,
|
||||
id: pageId,
|
||||
externalLink: page.externalLink,
|
||||
isDraft: page.isDraft,
|
||||
createdDate: page.createdDate,
|
||||
startDate: page.startDate,
|
||||
|
@ -47,6 +47,7 @@ private extension Page {
|
||||
|
||||
var pageFile: PageFile {
|
||||
.init(isDraft: isDraft,
|
||||
externalLink: externalLink,
|
||||
tags: tags.map { $0.id },
|
||||
createdDate: createdDate,
|
||||
startDate: startDate,
|
||||
|
28
CHDataManagement/Model/Content+Validation.swift
Normal file
28
CHDataManagement/Model/Content+Validation.swift
Normal file
@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
extension Content {
|
||||
|
||||
private static let disallowedCharactersInIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
|
||||
|
||||
private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted
|
||||
|
||||
func isNewIdForTag(_ id: String) -> Bool {
|
||||
!tags.contains { $0.id == id }
|
||||
}
|
||||
|
||||
func isNewIdForPage(_ id: String) -> Bool {
|
||||
!pages.contains { $0.id == id }
|
||||
}
|
||||
|
||||
func isNewIdForPost(_ id: String) -> Bool {
|
||||
!posts.contains { $0.id == id }
|
||||
}
|
||||
|
||||
func isValidIdForTagOrTagOrPost(_ id: String) -> Bool {
|
||||
id.rangeOfCharacter(from: Content.disallowedCharactersInIds) == nil
|
||||
}
|
||||
|
||||
func isValidIdForFile(_ id: String) -> Bool {
|
||||
id.rangeOfCharacter(from: Content.disallowedCharactersInFileIds) == nil
|
||||
}
|
||||
}
|
@ -1,9 +1,7 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class FileResource: ObservableObject {
|
||||
|
||||
unowned let content: Content
|
||||
final class FileResource: Item {
|
||||
|
||||
let type: FileType
|
||||
|
||||
@ -24,24 +22,24 @@ final class FileResource: ObservableObject {
|
||||
var size: CGSize = .zero
|
||||
|
||||
init(content: Content, id: String, isExternallyStored: Bool, en: String, de: String) {
|
||||
self.content = content
|
||||
self.id = id
|
||||
self.type = FileType(fileExtension: id.fileExtension)
|
||||
self.englishDescription = en
|
||||
self.germanDescription = de
|
||||
self.isExternallyStored = isExternallyStored
|
||||
super.init(content: content)
|
||||
}
|
||||
|
||||
/**
|
||||
Only for bundle images
|
||||
*/
|
||||
init(resourceImage: String, type: ImageFileType) {
|
||||
self.content = .mock // TODO: Add images to mock
|
||||
self.type = .image(type)
|
||||
self.id = resourceImage
|
||||
self.englishDescription = "A test image included in the bundle"
|
||||
self.germanDescription = "Ein Testbild aus dem Bundle"
|
||||
self.isExternallyStored = true
|
||||
super.init(content: .mock) // TODO: Add images to mock
|
||||
}
|
||||
|
||||
func getDescription(for language: ContentLanguage) -> String {
|
||||
@ -62,6 +60,10 @@ final class FileResource: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func dataContent() throws -> Data {
|
||||
try content.storage.fileData(for: id)
|
||||
}
|
||||
|
||||
// MARK: Images
|
||||
|
||||
var aspectRatio: CGFloat {
|
||||
@ -94,6 +96,26 @@ final class FileResource: ObservableObject {
|
||||
private var failureImage: Image {
|
||||
Image(systemSymbol: .exclamationmarkTriangle)
|
||||
}
|
||||
|
||||
// MARK: Paths
|
||||
|
||||
/**
|
||||
Get the url path to a file in the output folder.
|
||||
The result is an absolute path from the output folder for use in HTML.
|
||||
*/
|
||||
var absoluteUrl: String {
|
||||
let path = pathPrefix + "/" + id
|
||||
return makeCleanAbsolutePath(path)
|
||||
}
|
||||
|
||||
|
||||
private var pathPrefix: String {
|
||||
switch type {
|
||||
case .image: return content.settings.paths.imagesOutputFolderPath
|
||||
case .video: return content.settings.paths.videosOutputFolderPath
|
||||
default: return content.settings.paths.filesOutputFolderPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FileResource: Identifiable {
|
||||
|
18
CHDataManagement/Model/Item.swift
Normal file
18
CHDataManagement/Model/Item.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
class Item: ObservableObject {
|
||||
|
||||
unowned let content: Content
|
||||
|
||||
init(content: Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
func makeCleanAbsolutePath(_ path: String) -> String {
|
||||
"/" + makeCleanRelativePath(path)
|
||||
}
|
||||
|
||||
func makeCleanRelativePath(_ path: String) -> String {
|
||||
path.components(separatedBy: "/").filter { !$0.isEmpty }.joined(separator: "/")
|
||||
}
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
final class Page: ObservableObject {
|
||||
|
||||
unowned let content: Content
|
||||
final class Page: Item {
|
||||
|
||||
/**
|
||||
The unique id of the entry
|
||||
@ -10,6 +8,15 @@ final class Page: ObservableObject {
|
||||
@Published
|
||||
var id: String
|
||||
|
||||
/**
|
||||
The external link this page points to.
|
||||
|
||||
If this value is not `nil`, then the page has no content
|
||||
and many other features are disabled.
|
||||
*/
|
||||
@Published
|
||||
var externalLink: String?
|
||||
|
||||
@Published
|
||||
var isDraft: Bool
|
||||
|
||||
@ -44,6 +51,7 @@ final class Page: ObservableObject {
|
||||
|
||||
init(content: Content,
|
||||
id: String,
|
||||
externalLink: String?,
|
||||
isDraft: Bool,
|
||||
createdDate: Date,
|
||||
startDate: Date,
|
||||
@ -51,8 +59,8 @@ final class Page: ObservableObject {
|
||||
german: LocalizedPage,
|
||||
english: LocalizedPage,
|
||||
tags: [Tag]) {
|
||||
self.content = content
|
||||
self.id = id
|
||||
self.externalLink = externalLink
|
||||
self.isDraft = isDraft
|
||||
self.createdDate = createdDate
|
||||
self.startDate = startDate
|
||||
@ -61,6 +69,8 @@ final class Page: ObservableObject {
|
||||
self.german = german
|
||||
self.english = english
|
||||
self.tags = tags
|
||||
|
||||
super.init(content: content)
|
||||
}
|
||||
|
||||
func localized(in language: ContentLanguage) -> LocalizedPage {
|
||||
@ -78,6 +88,28 @@ final class Page: ObservableObject {
|
||||
id = newId
|
||||
return true
|
||||
}
|
||||
|
||||
var isExternalUrl: Bool {
|
||||
externalLink != nil
|
||||
}
|
||||
|
||||
// MARK: Paths
|
||||
|
||||
func absoluteUrl(for language: ContentLanguage) -> String {
|
||||
if let url = externalLink {
|
||||
return url
|
||||
}
|
||||
// TODO: Record link to trace connections between pages
|
||||
return makeCleanAbsolutePath(internalPath(for: language))
|
||||
}
|
||||
|
||||
func filePathRelativeToOutputFolder(for language: ContentLanguage) -> String {
|
||||
makeCleanRelativePath(internalPath(for: language))
|
||||
}
|
||||
|
||||
private func internalPath(for language: ContentLanguage) -> String {
|
||||
content.settings.pages.pageUrlPrefix + "/" + localized(in: language).urlString
|
||||
}
|
||||
}
|
||||
|
||||
extension Page: Identifiable {
|
||||
|
22
CHDataManagement/Model/Song.swift
Normal file
22
CHDataManagement/Model/Song.swift
Normal file
@ -0,0 +1,22 @@
|
||||
|
||||
struct Song {
|
||||
|
||||
let name: String
|
||||
|
||||
let artist: String
|
||||
|
||||
let album: String
|
||||
|
||||
let track: Int
|
||||
|
||||
/// The file id of the audio file
|
||||
let file: String
|
||||
|
||||
/// The file id of the cover image
|
||||
let cover: String
|
||||
}
|
||||
|
||||
|
||||
extension Song: Codable {
|
||||
|
||||
}
|
@ -15,6 +15,13 @@ final class Tag: ObservableObject {
|
||||
@Published
|
||||
var english: LocalizedTag
|
||||
|
||||
init(id: String) {
|
||||
self.isVisible = true
|
||||
self.english = .init(urlComponent: id, name: id)
|
||||
let deId = id + "-" + ContentLanguage.german.rawValue
|
||||
self.german = .init(urlComponent: deId, name: deId)
|
||||
}
|
||||
|
||||
init(isVisible: Bool = true, german: LocalizedTag, english: LocalizedTag) {
|
||||
self.isVisible = isVisible
|
||||
self.german = german
|
||||
|
@ -5,10 +5,17 @@ struct AdditionalPageHeaders {
|
||||
|
||||
let assetPath: String
|
||||
|
||||
#warning("Provide paths in settings, import files")
|
||||
var content: String {
|
||||
headers.map { header in
|
||||
let module = header.asModule ? " type='module'" : ""
|
||||
return "<script\(module) src='\(assetPath)/\(header.rawValue)'></script>"
|
||||
}.sorted().joined()
|
||||
headers.map(header).sorted().joined()
|
||||
}
|
||||
|
||||
private func header(for asset: HeaderFile) -> String {
|
||||
let file = asset.rawValue
|
||||
guard file.hasSuffix(".js") else {
|
||||
return "<link rel='stylesheet' type='text/css' href='\(assetPath)/css/\(file)'>"
|
||||
}
|
||||
let module = asset.asModule ? " type='module'" : ""
|
||||
return "<script\(module) src='\(assetPath)/js/\(file)'></script>"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,128 @@
|
||||
|
||||
struct AudioPlayer: HtmlProducer {
|
||||
|
||||
let playingText: String
|
||||
|
||||
let items: [PlaylistItem]
|
||||
|
||||
private var top: String {
|
||||
"""
|
||||
<div class='top'>
|
||||
<div> </div>
|
||||
<div class='top-center'>\(playingText)</div>
|
||||
<div class='show-playlist'><svg><use href='#\(AudioPlayerPlaylistIcon.name)'></use></svg></div>
|
||||
</div>
|
||||
"""
|
||||
}
|
||||
|
||||
private var center: String {
|
||||
"""
|
||||
<div class='center'>
|
||||
<img data-amplitude-song-info='cover_art_url' class='main-album-art'/>
|
||||
<div class='song-meta-data'>
|
||||
<span data-amplitude-song-info='name' class='song-name'></span>
|
||||
<span data-amplitude-song-info='artist' class='song-artist'></span>
|
||||
</div>
|
||||
<div class='time-progress'>
|
||||
<div id='progress-container'>
|
||||
<input type='range' class='amplitude-song-slider'/>
|
||||
<progress id='song-played-progress' class='amplitude-song-played-progress'></progress>
|
||||
<progress id='song-buffered-progress' class='amplitude-buffered-progress' value='0'></progress>
|
||||
</div>
|
||||
<div class='time-container'>
|
||||
<span class='current-time'>
|
||||
<span class='amplitude-current-hours'></span>:<span class='amplitude-current-minutes'></span>:<span class='amplitude-current-seconds'></span>
|
||||
</span>
|
||||
<span class='duration'>
|
||||
<span class='amplitude-duration-hours'></span>:<span class='amplitude-duration-minutes'></span>:<span class='amplitude-duration-seconds'></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
}
|
||||
|
||||
private var controls: String {
|
||||
"""
|
||||
<div id="audio-player-controls">
|
||||
<svg id="previous" class="amplitude-prev"><use href='#\(AudioPlayerPreviousIcon.name)'></use></svg>
|
||||
<div class="amplitude-play-pause" id="play-pause">
|
||||
<svg class="play-icon"><use href='#\(AudioPlayerPlayIcon.name)'></use></svg>
|
||||
<svg class="pause-icon"><use href='#\(AudioPlayerPauseIcon.name)'></use></svg>
|
||||
</div>
|
||||
<svg id="next" class="amplitude-next"><use href='#\(AudioPlayerNextIcon.name)'></use></svg>
|
||||
</div>
|
||||
"""
|
||||
}
|
||||
|
||||
private var playlistStart: String {
|
||||
"""
|
||||
<div id="playlist-container">
|
||||
<div class="top">
|
||||
<div class="queue">Playlist</div>
|
||||
<div class="close-playlist"><svg><use href='#\(AudioPlayerCloseIcon.name)'></use></svg></div>
|
||||
</div>
|
||||
<div class="playlist">
|
||||
"""
|
||||
}
|
||||
|
||||
private var playlistEnd: String {
|
||||
"""
|
||||
</div>
|
||||
|
||||
<div class="white-player-playlist-controls">
|
||||
<img data-amplitude-song-info="cover_art_url" class="playlist-album-art"/>
|
||||
<div class="playlist-controls">
|
||||
<div class="playlist-meta-data">
|
||||
<span data-amplitude-song-info="name" class="song-name"></span>
|
||||
<span data-amplitude-song-info="artist" class="song-artist"></span>
|
||||
</div>
|
||||
<div class="playlist-control-wrapper">
|
||||
<svg class="amplitude-prev" id="playlist-previous"><use href='#\(AudioPlayerPreviousIcon.name)'></use></svg>
|
||||
<div class="amplitude-play-pause" id="playlist-play-pause">
|
||||
<svg class="play-icon"><use href='#\(AudioPlayerPlayIcon.name)'></use></svg>
|
||||
<svg class="pause-icon"><use href='#\(AudioPlayerPauseIcon.name)'></use></svg>
|
||||
</div>
|
||||
<svg class="amplitude-next" id="playlist-next"><use href='#\(AudioPlayerNextIcon.name)'></use></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
}
|
||||
|
||||
func populate(_ result: inout String) {
|
||||
result += "<div class='audio-player'>"
|
||||
result += top
|
||||
result += center
|
||||
result += controls
|
||||
result += playlistStart
|
||||
for item in items {
|
||||
result += item.content
|
||||
}
|
||||
result += playlistEnd
|
||||
result += "</div>"
|
||||
}
|
||||
|
||||
struct PlaylistItem {
|
||||
|
||||
let index: Int
|
||||
|
||||
let image: String
|
||||
|
||||
let name: String
|
||||
|
||||
let album: String
|
||||
|
||||
let track: Int
|
||||
|
||||
let artist: String
|
||||
|
||||
var content: String {
|
||||
"""
|
||||
<div class="playlist-song amplitude-song-container amplitude-play-pause amplitude-paused" data-amplitude-song-index="\(index)"><img src="\(image)"><div class="playlist-song-meta"><span class="playlist-song-name">\(name)</span><span class="playlist-song-artist">\(album) • \(artist)</span></div></div>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
struct AmplitudeSong: Codable {
|
||||
let name: String
|
||||
let artist: String
|
||||
let album: String
|
||||
let track: String
|
||||
let url: String
|
||||
let cover_art_url: String
|
||||
}
|
||||
|
||||
struct AudioPlayerScript: HtmlProducer {
|
||||
|
||||
let items: [AmplitudeSong]
|
||||
|
||||
init(items: [AmplitudeSong]) {
|
||||
self.items = items
|
||||
}
|
||||
|
||||
func populate(_ result: inout String) {
|
||||
result += "<script>\n"
|
||||
result += "Amplitude.init({ songs: "
|
||||
let songData = try! JSONEncoder().encode(items)
|
||||
result += String(data: songData, encoding: .utf8)!
|
||||
result += "});"
|
||||
result += "function playEntry(index) { Amplitude.playSongAtIndex(index) };"
|
||||
result += animatePlaylist
|
||||
result += "</script>"
|
||||
}
|
||||
|
||||
private var animatePlaylist: String {
|
||||
"""
|
||||
const el = document.getElementById('playlist-container')
|
||||
document.getElementsByClassName('show-playlist')[0].addEventListener('click', function(){
|
||||
el.classList.remove('slide-out-top');
|
||||
el.classList.add('slide-in-top');
|
||||
el.style.display = "block";
|
||||
});
|
||||
document.getElementsByClassName('close-playlist')[0].addEventListener('click', function(){
|
||||
el.classList.remove('slide-in-top');
|
||||
el.classList.add('slide-out-top');
|
||||
el.style.display = "none";
|
||||
});
|
||||
"""
|
||||
}
|
||||
}
|
@ -5,17 +5,20 @@ struct ContentButtons {
|
||||
|
||||
let icon: PageIcon
|
||||
|
||||
let filePath: String
|
||||
let filePath: String?
|
||||
|
||||
let text: String
|
||||
|
||||
let downloadFileName: String?
|
||||
|
||||
init(icon: PageIcon, filePath: String, text: String, downloadFileName: String? = nil) {
|
||||
let onClickText: String?
|
||||
|
||||
init(icon: PageIcon, filePath: String?, text: String, downloadFileName: String? = nil, onClickText: String? = nil) {
|
||||
self.icon = icon
|
||||
self.filePath = filePath
|
||||
self.text = text
|
||||
self.downloadFileName = downloadFileName
|
||||
self.onClickText = onClickText
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,8 +39,10 @@ struct ContentButtons {
|
||||
|
||||
private func addButton(of item: Item, to result: inout String) {
|
||||
let downloadText = item.downloadFileName.map { " download='\($0)'" } ?? ""
|
||||
result += "<a class='tag' href='\(item.filePath)'\(downloadText)>"
|
||||
result += "<svg><use href='#\(item.icon.name)'></use></svg>\(item.text)"
|
||||
let linkText = item.filePath.map { " href='\($0)'" } ?? ""
|
||||
let onClickText = item.onClickText.map { " onClick='\($0)'" } ?? ""
|
||||
result += "<a class='tag'\(linkText)\(downloadText)\(onClickText)>"
|
||||
result += "<svg><use href='#\(item.icon.icon.name)'></use></svg>\(item.text)"
|
||||
result += "</a>"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,28 @@
|
||||
|
||||
struct ContentLabel {
|
||||
|
||||
let icon: PageIcon
|
||||
|
||||
let value: String
|
||||
}
|
||||
|
||||
struct ContentLabels {
|
||||
|
||||
private let labels: [ContentLabel]
|
||||
|
||||
init(labels: [ContentLabel]) {
|
||||
self.labels = labels
|
||||
}
|
||||
|
||||
var content: String {
|
||||
guard !labels.isEmpty else {
|
||||
return ""
|
||||
}
|
||||
var result = "<div class='labels-container'>"
|
||||
for label in labels {
|
||||
result += "<div><svg><use href='#\(label.icon.icon.name)'></use></svg>\(label.value)</div>"
|
||||
}
|
||||
result += "</div>"
|
||||
return result
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
|
||||
struct HikingStatistics {
|
||||
|
||||
private let time: String?
|
||||
|
||||
private let elevationUp: String?
|
||||
|
||||
private let elevationDown: String?
|
||||
|
||||
private let distance: String?
|
||||
|
||||
private let calories: String?
|
||||
|
||||
init(time: String?, elevationUp: String?, elevationDown: String?, distance: String?, calories: String?) {
|
||||
self.time = time
|
||||
self.elevationUp = elevationUp
|
||||
self.elevationDown = elevationDown
|
||||
self.distance = distance
|
||||
self.calories = calories
|
||||
}
|
||||
|
||||
var content: String {
|
||||
var result = "<div class='stats-container'>"
|
||||
if let time {
|
||||
result += "<div><svg><use href='#icon-clock'></use></svg>\(time)</div>"
|
||||
}
|
||||
if let elevationUp {
|
||||
result += "<div><svg><use href='#icon-arrow-up'></use></svg>\(elevationUp)</div>"
|
||||
}
|
||||
if let elevationDown {
|
||||
result += "<div><svg><use href='#icon-arrow-down'></use></svg>\(elevationDown)</div>"
|
||||
}
|
||||
if let distance {
|
||||
result += "<div><svg><use href='#icon-sign'></use></svg>\(distance)</div>"
|
||||
}
|
||||
if let calories {
|
||||
result += "<div><svg><use href='#icon-flame'></use></svg>\(calories)</div>"
|
||||
}
|
||||
result += "</div>"
|
||||
return result
|
||||
}
|
||||
}
|
@ -1,107 +0,0 @@
|
||||
|
||||
enum PageIcon: CaseIterable {
|
||||
|
||||
case time
|
||||
|
||||
case elevationUp
|
||||
|
||||
case elevationDown
|
||||
|
||||
case distance
|
||||
|
||||
case calories
|
||||
|
||||
case download
|
||||
|
||||
case externalLink
|
||||
|
||||
case gitLink
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .time: return PageIcon.timeIcon
|
||||
case .elevationUp: return PageIcon.elevationUpIcon
|
||||
case .elevationDown: return PageIcon.elevationDownIcon
|
||||
case .distance: return PageIcon.distanceIcon
|
||||
case .calories: return PageIcon.caloriesIcon
|
||||
case .download: return PageIcon.downloadIcon
|
||||
case .externalLink: return PageIcon.externalLinkIcon
|
||||
case .gitLink: return PageIcon.gitLinkIcon
|
||||
}
|
||||
}
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .time: return "icon-clock"
|
||||
case .elevationUp: return "icon-arrow-up"
|
||||
case .elevationDown: return "icon-arrow-down"
|
||||
case .distance: return "icon-sign"
|
||||
case .calories: return "icon-flame"
|
||||
case .download: return "icon-download"
|
||||
case .externalLink: return "icon-external"
|
||||
case .gitLink: return "icon-git"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PageIcon {
|
||||
|
||||
|
||||
private static let timeIcon =
|
||||
"""
|
||||
<svg id="icon-clock" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .3.4l3.5 2a.5.5 0 0 0 .4-.8L8 8.7V3.5z"/>
|
||||
<path fill="currentColor" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
private static let elevationUpIcon =
|
||||
"""
|
||||
<svg id="icon-arrow-up" width="16" height="16">
|
||||
<path fill="currentColor" d="m14 2.5a.5.5 0 0 0 -.5-.5h-6a.5.5 0 0 0 0 1h4.8l-10.16 10.15a.5.5 0 0 0 .7.7l10.16-10.14v4.79a.5.5 0 0 0 1 0z"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
private static let elevationDownIcon =
|
||||
"""
|
||||
<svg id="icon-arrow-down" width="16" height="16">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M14 13.5a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1 0-1h4.8L2.14 2.85a.5.5 0 1 1 .7-.7L13 12.29V7.5a.5.5 0 0 1 1 0v6z"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
private static let distanceIcon =
|
||||
"""
|
||||
<svg id="icon-sign" width="16" height="16">
|
||||
<path fill="currentColor" d="M7 1.4V4H2a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h5v6h2v-6h3.5a1 1 0 0 0 .8-.4l2-2.3a.5.5 0 0 0 0-.6l-2-2.3a1 1 0 0 0-.8-.4H9V1.4a1 1 0 0 0-2 0zM12.5 5l1.7 2-1.7 2H2V5h10.5z"/>
|
||||
</svg>
|
||||
|
||||
"""
|
||||
|
||||
private static let caloriesIcon =
|
||||
"""
|
||||
<svg id="icon-flame" width="16" height="16">
|
||||
<path fill="currentColor" d="M8 16c3.3 0 6-2 6-5.5 0-1.5-.5-4-2.5-6 .3 1.5-1.3 2-1.3 2C11 4 9 .5 6 0c.4 2 .5 4-2 6-1.3 1-2 2.7-2 4.5C2 14 4.7 16 8 16Zm0-1c-1.7 0-3-1-3-2.8 0-.7.3-2 1.3-3-.2.8.7 1.3.7 1.3-.4-1.3.5-3.3 2-3.5-.2 1-.3 2 1 3a3 3 0 0 1 1 2.3C11 14 9.7 15 8 15Z"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
private static let downloadIcon: String =
|
||||
"""
|
||||
<svg id="icon-download" viewBox="0 0 40 40">
|
||||
<path fill="currentColor" fill-rule="evenodd" stroke="none" d="M20 40a20 20 0 1 1 20-20 20 20 0 0 1-20 20zm0-36.8A16.8 16.8 0 1 0 36.8 20 16.8 16.8 0 0 0 20 3.2zm.8 27a1 1 0 0 1-1.6 0L12.1 21c-.4-.4 0-1 .7-1H17v-8.7a.8.8 0 0 1 .8-.8h4.4a.8.8 0 0 1 .8.8V20h4.2c.6 0 1.1.5.7 1l-7.1 9.2z"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
private static let externalLinkIcon: String =
|
||||
"""
|
||||
<svg id="icon-external" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5zm6.853 3.472A7 7 0 0 0 13.745 12H11.91a9.3 9.3 0 0 1-.64 1.539 7 7 0 0 1-.597.933M8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
private static let gitLinkIcon: String =
|
||||
"""
|
||||
<svg id="icon-git" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M15.698 7.287 8.712.302a1.03 1.03 0 0 0-1.457 0l-1.45 1.45 1.84 1.84a1.223 1.223 0 0 1 1.55 1.56l1.773 1.774a1.224 1.224 0 0 1 1.267 2.025 1.226 1.226 0 0 1-2.002-1.334L8.58 5.963v4.353a1.226 1.226 0 1 1-1.008-.036V5.887a1.226 1.226 0 0 1-.666-1.608L5.093 2.465l-4.79 4.79a1.03 1.03 0 0 0 0 1.457l6.986 6.986a1.03 1.03 0 0 0 1.457 0l6.953-6.953a1.03 1.03 0 0 0 0-1.457"/>
|
||||
</svg>
|
||||
"""
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
|
||||
struct AudioPlayerPlaylistIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-playlist"
|
||||
|
||||
static let content = """
|
||||
<svg id='icon-playlist' viewBox="0 0 28 20"><g fill="none"><rect width="15" height="4" x="13" fill="currentColor" rx="2"/><g fill="currentColor"><path d="M0 1.2C0 .7.4.4.8.7l9.3 5.8c.5.3.5.7 0 1L.8 13.3c-.4.2-.8 0-.8-.5z"/><rect width="14" height="4" x="14" y="8" rx="2"/><rect width="28" height="4" y="16" rx="2"/></g></g></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct AudioPlayerCloseIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-close"
|
||||
|
||||
static let content = """
|
||||
<svg id='icon-close' viewBox="0 0 18 18"><path fill="currentColor" d="M9 6.194 3.392.586A1.986 1.986 0 0 0 .582.582c-.78.78-.773 2.033.004 2.81L6.194 9 .586 14.608a1.986 1.986 0 0 0-.004 2.81c.78.78 2.033.773 2.81-.004L9 11.806l5.608 5.608a1.986 1.986 0 0 0 2.81.004c.78-.78.773-2.033-.004-2.81L11.806 9l5.608-5.608a1.986 1.986 0 0 0 .004-2.81 1.982 1.982 0 0 0-2.81.004z"/></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct AudioPlayerPauseIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-pause"
|
||||
|
||||
static let content = """
|
||||
<svg id='icon-pause' viewBox="0 0 85 85"><g fill="none"><circle cx="42.5" cy="42.5" fill="currentColor" r="42.5"/><path d="m34 55h6v-24h-6zm12 0h6v-24h-6z" fill="#fff" stroke="#fff"/></g></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct AudioPlayerPlayIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-play"
|
||||
|
||||
static let content = """
|
||||
<svg id='icon-play' viewBox="0 0 85 85"><g fill="none"><circle cx="42.5" cy="42.5" r="42.5" fill="currentColor"/><path fill="#fff" d="M33.3 31.3c0-2.3 1.5-3.1 3.4-2l18.8 11.5c2 1.1 2 3 0 4.1L36.7 56.3c-1.9 1.2-3.4.3-3.4-1.9z"/></g>
|
||||
</svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct AudioPlayerPreviousIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-previous"
|
||||
|
||||
static let content = """
|
||||
<svg id='icon-previous' viewBox="0 0 53 53"><g fill="none" transform="matrix(-1 0 0 1 53 0)"><circle cx="26.5" cy="26.5" r="26.5" fill="currentColor"/><g fill="#fff" transform="translate(16 17)"><path d="M.4 1.8C.4.6 1.2.2 2.2.8l12.3 7.5c1 .5 1 1.5 0 2L2.2 17.8c-1 .6-1.8.1-1.8-1z"/><rect width="3" height="17" x="18" y="1" rx="1.5"/></g></g></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct AudioPlayerNextIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-next"
|
||||
|
||||
static let content = """
|
||||
<svg id='icon-next' viewBox="0 0 53 53"><g fill="none"><circle cx="26.5" cy="26.5" r="26.5" fill="currentColor"/><g fill="#fff" transform="translate(16 17)"><path d="M.4 1.8C.4.6 1.2.2 2.2.8l12.3 7.5c1 .5 1 1.5 0 2L2.2 17.8c-1 .6-1.8.1-1.8-1z"/><rect width="3" height="17" x="18" y="1" rx="1.5"/></g></g></svg>
|
||||
"""
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
|
||||
struct ButtonDownloadIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-download"
|
||||
|
||||
static let content = """
|
||||
<svg id="icon-download" viewBox="0 0 40 40"><path fill="currentColor" fill-rule="evenodd" stroke="none" d="M20 40a20 20 0 1 1 20-20 20 20 0 0 1-20 20zm0-36.8A16.8 16.8 0 1 0 36.8 20 16.8 16.8 0 0 0 20 3.2zm.8 27a1 1 0 0 1-1.6 0L12.1 21c-.4-.4 0-1 .7-1H17v-8.7a.8.8 0 0 1 .8-.8h4.4a.8.8 0 0 1 .8.8V20h4.2c.6 0 1.1.5.7 1l-7.1 9.2z"/></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct ButtonExternalIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-external"
|
||||
|
||||
static let content = """
|
||||
<svg id="icon-external" viewBox="0 0 16 16"><path fill="currentColor" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5zm6.853 3.472A7 7 0 0 0 13.745 12H11.91a9.3 9.3 0 0 1-.64 1.539 7 7 0 0 1-.597.933M8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z"/></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct ButtonGitIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-git"
|
||||
|
||||
static let content = """
|
||||
<svg id="icon-git" viewBox="0 0 16 16"><path fill="currentColor" d="M15.698 7.287 8.712.302a1.03 1.03 0 0 0-1.457 0l-1.45 1.45 1.84 1.84a1.223 1.223 0 0 1 1.55 1.56l1.773 1.774a1.224 1.224 0 0 1 1.267 2.025 1.226 1.226 0 0 1-2.002-1.334L8.58 5.963v4.353a1.226 1.226 0 1 1-1.008-.036V5.887a1.226 1.226 0 0 1-.666-1.608L5.093 2.465l-4.79 4.79a1.03 1.03 0 0 0 0 1.457l6.986 6.986a1.03 1.03 0 0 0 1.457 0l6.953-6.953a1.03 1.03 0 0 0 0-1.457"/></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct ButtonPlayIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-play-circle"
|
||||
|
||||
static let content = """
|
||||
<svg id='icon-play-circle' viewBox="0 0 1000 1000"><g fill="currentColor"><path d="M452.6 11.2A495 495 0 0 0 90.1 229.8 525.5 525.5 0 0 0 19.8 398c-8.3 40.7-9.8 56.6-9.8 101.6s1.5 60.5 9.8 101.5A529.7 529.7 0 0 0 90 769.5 493.9 493.9 0 0 0 499.6 990c185 0 355.6-106 438.6-272.3a486.8 486.8 0 0 0-46.8-512.3A494.2 494.2 0 0 0 568.6 13.9c-24-3.7-91.5-5.4-116-2.7zm85.5 76.4c31 3.1 59.6 9.2 90.6 19.4a413.4 413.4 0 0 1 263.9 264.2 412 412 0 0 1-100.8 420.6A413.7 413.7 0 0 1 460 911.4 415.2 415.2 0 0 1 87.6 538a416.4 416.4 0 0 1 143.7-353.3 417.2 417.2 0 0 1 306.8-97.2z"/><path d="M375.4 291.7c-4.2 2-7.5 5.4-10 11-3.6 8-3.8 14.1-3.8 196.9 0 183 .2 189 3.8 197 5 10.9 14.4 15.3 26 12.2 11.4-3 320-183.1 329.5-192.3 6.9-6.8 7.6-8.3 7.6-17s-.7-10-7.6-16.8c-7.9-7.6-314-187.2-326.5-191.6-8.3-2.9-11.1-2.9-19 .6z"/></g></svg>
|
||||
"""
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
|
||||
protocol ContentIcon {
|
||||
|
||||
static var name: String { get }
|
||||
|
||||
static var content: String { get }
|
||||
}
|
||||
|
||||
extension ContentIcon {
|
||||
|
||||
var name: String {
|
||||
Self.name
|
||||
}
|
||||
|
||||
var content: String {
|
||||
Self.content
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
|
||||
enum PageIcon: String, CaseIterable {
|
||||
|
||||
// MARK: Statistics
|
||||
|
||||
case statisticsTime = "time"
|
||||
|
||||
case statisticsElevationUp = "elevation-up"
|
||||
|
||||
case statisticsElevationDown = "elevation-down"
|
||||
|
||||
case statisticsDistance = "distance"
|
||||
|
||||
case statisticsEnergy = "energy"
|
||||
|
||||
// MARK: Buttons
|
||||
|
||||
case buttonDownload = "download"
|
||||
|
||||
case buttonExternalLink = "external"
|
||||
|
||||
case buttonGitLink = "git"
|
||||
|
||||
case buttonPlay = "play-circle"
|
||||
|
||||
// MARK: Audio player
|
||||
|
||||
case audioPlayerPlaylist = "playlist"
|
||||
|
||||
case audioPlayerClose = "close"
|
||||
|
||||
case audioPlayerPlay = "play"
|
||||
|
||||
case audioPlayerPause = "pause"
|
||||
|
||||
case audioPlayerPrevious = "previous"
|
||||
|
||||
case audioPlayerNext = "next"
|
||||
|
||||
var icon: ContentIcon.Type {
|
||||
switch self {
|
||||
case .statisticsTime: return StatisticsTimeIcon.self
|
||||
case .statisticsElevationUp: return StatisticsElevationUpIcon.self
|
||||
case .statisticsElevationDown: return StatisticsElevationDownIcon.self
|
||||
case .statisticsDistance: return StatisticsDistanceIcon.self
|
||||
case .statisticsEnergy: return StatisticsEnergyIcon.self
|
||||
case .buttonDownload: return ButtonDownloadIcon.self
|
||||
case .buttonExternalLink: return ButtonExternalIcon.self
|
||||
case .buttonGitLink: return ButtonGitIcon.self
|
||||
case .buttonPlay: return ButtonPlayIcon.self
|
||||
case .audioPlayerPlaylist: return AudioPlayerPlaylistIcon.self
|
||||
case .audioPlayerClose: return AudioPlayerCloseIcon.self
|
||||
case .audioPlayerPlay: return AudioPlayerPlayIcon.self
|
||||
case .audioPlayerPause: return AudioPlayerPauseIcon.self
|
||||
case .audioPlayerPrevious: return AudioPlayerPreviousIcon.self
|
||||
case .audioPlayerNext: return AudioPlayerNextIcon.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PageIcon: Hashable {
|
||||
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
|
||||
struct StatisticsTimeIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-time"
|
||||
|
||||
static let content = """
|
||||
<svg id="icon-time" width="16" height="16" viewBox="0 0 16 16"><path fill="currentColor" d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .3.4l3.5 2a.5.5 0 0 0 .4-.8L8 8.7V3.5z"/><path fill="currentColor" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct StatisticsElevationUpIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-elevation-up"
|
||||
|
||||
static let content = """
|
||||
<svg id="icon-elevation-up" width="16" height="16"><path fill="currentColor" d="m14 2.5a.5.5 0 0 0 -.5-.5h-6a.5.5 0 0 0 0 1h4.8l-10.16 10.15a.5.5 0 0 0 .7.7l10.16-10.14v4.79a.5.5 0 0 0 1 0z"/></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct StatisticsElevationDownIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-elevation-down"
|
||||
|
||||
static let content = """
|
||||
<svg id="icon-elevation-down" width="16" height="16"><path fill="currentColor" fill-rule="evenodd" d="M14 13.5a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1 0-1h4.8L2.14 2.85a.5.5 0 1 1 .7-.7L13 12.29V7.5a.5.5 0 0 1 1 0v6z"/></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct StatisticsDistanceIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-distance"
|
||||
|
||||
static let content = """
|
||||
<svg id="icon-distance" width="16" height="16"><path fill="currentColor" d="M7 1.4V4H2a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h5v6h2v-6h3.5a1 1 0 0 0 .8-.4l2-2.3a.5.5 0 0 0 0-.6l-2-2.3a1 1 0 0 0-.8-.4H9V1.4a1 1 0 0 0-2 0zM12.5 5l1.7 2-1.7 2H2V5h10.5z"/></svg>
|
||||
"""
|
||||
}
|
||||
|
||||
struct StatisticsEnergyIcon: ContentIcon {
|
||||
|
||||
static let name = "icon-energy"
|
||||
|
||||
static let content = """
|
||||
<svg id="icon-energy" width="16" height="16"><path fill="currentColor" d="M8 16c3.3 0 6-2 6-5.5 0-1.5-.5-4-2.5-6 .3 1.5-1.3 2-1.3 2C11 4 9 .5 6 0c.4 2 .5 4-2 6-1.3 1-2 2.7-2 4.5C2 14 4.7 16 8 16Zm0-1c-1.7 0-3-1-3-2.8 0-.7.3-2 1.3-3-.2.8.7 1.3.7 1.3-.4-1.3.5-3.3 2-3.5-.2 1-.3 2 1 3a3 3 0 0 1 1 2.3C11 14 9.7 15 8 15Z"/></svg>
|
||||
"""
|
||||
}
|
@ -22,7 +22,9 @@ struct ContentPage: HtmlProducer {
|
||||
|
||||
private let footers: String
|
||||
|
||||
init(language: ContentLanguage, dateString: String, title: String, tags: [FeedEntryData.Tag], linkTitle: String, description: String, navigationBarLinks: [NavigationBar.Link], pageContent: String, headers: String, footers: [String]) {
|
||||
private let icons: Set<PageIcon>
|
||||
|
||||
init(language: ContentLanguage, dateString: String, title: String, tags: [FeedEntryData.Tag], linkTitle: String, description: String, navigationBarLinks: [NavigationBar.Link], pageContent: String, headers: String, footers: [String], icons: Set<PageIcon>) {
|
||||
self.language = language
|
||||
self.dateString = dateString
|
||||
self.title = title
|
||||
@ -33,6 +35,7 @@ struct ContentPage: HtmlProducer {
|
||||
self.pageContent = pageContent
|
||||
self.headers = headers
|
||||
self.footers = footers.joined()
|
||||
self.icons = icons
|
||||
}
|
||||
|
||||
func populate(_ result: inout String) {
|
||||
@ -55,13 +58,12 @@ struct ContentPage: HtmlProducer {
|
||||
result += "</body></html>" // Close content
|
||||
}
|
||||
|
||||
#warning("Select only required symbols")
|
||||
private let symbols: String = {
|
||||
private var symbols: String {
|
||||
var result = "<div style='display:none'>"
|
||||
for icon in PageIcon.allCases {
|
||||
result += icon.icon
|
||||
for icon in icons {
|
||||
result += icon.icon.content
|
||||
}
|
||||
result += "</div>"
|
||||
return result
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ extension Page {
|
||||
.init(
|
||||
content: .mock,
|
||||
id: "my-id",
|
||||
externalLink: nil,
|
||||
isDraft: true,
|
||||
createdDate: Date(),
|
||||
startDate: Date().addingTimeInterval(-86400),
|
||||
|
@ -4,6 +4,8 @@ struct PageFile {
|
||||
|
||||
let isDraft: Bool
|
||||
|
||||
let externalLink: String?
|
||||
|
||||
let tags: [String]
|
||||
|
||||
let createdDate: Date
|
||||
|
28
CHDataManagement/Views/Files/FileSelectionView.swift
Normal file
28
CHDataManagement/Views/Files/FileSelectionView.swift
Normal 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()
|
||||
}
|
||||
}
|
@ -69,6 +69,7 @@ struct AddPageView: View {
|
||||
let page = Page(
|
||||
content: content,
|
||||
id: newPageId,
|
||||
externalLink: nil,
|
||||
isDraft: true,
|
||||
createdDate: .now,
|
||||
startDate: .now,
|
||||
|
@ -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)")
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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)'")
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user