First version

This commit is contained in:
Christoph Hagen
2024-10-14 19:22:32 +02:00
parent 7c812de089
commit 0989f06d87
51 changed files with 2477 additions and 234 deletions

View File

@ -0,0 +1,104 @@
import Foundation
final class Content: ObservableObject {
@Published
var posts: [Post] = []
@Published
var pages: [Page] = []
@Published
var tags: [Tag] = []
@Published
var images: [ImageResource] = []
@Published
var files: [FileResources] = []
func generateFeed(for language: ContentLanguage, bookmarkKey: String) {
let posts = posts.map { $0.feedEntry(for: language) }
DispatchQueue.global(qos: .userInitiated).async {
let navigationItems: [FeedNavigationLink] = [
.init(text: .init(en: "Projects", de: "Projekte"),
url: .init(en: "/projects", de: "/projekte")),
.init(text: .init(en: "Adventures", de: "Abenteuer"),
url: .init(en: "/adventures", de: "/abenteuer")),
.init(text: .init(en: "Services", de: "Dienste"),
url: .init(en: "/services", de: "/dienste")),
.init(text: .init(en: "Tags", de: "Kategorien"),
url: .init(en: "/tags", de: "/kategorien")),
]
let feed = Feed(
language: language,
title: .init(en: "Blog | CH", de: "Blog | CH"),
description: .init(en: "The latests posts, projects and adventures",
de: "Die neusten Beiträge, Projekte und Abenteuer"),
iconDescription: .init(en: "An icon consisting of the letters C and H in blue and orange",
de: "Ein Logo aus den Buchstaben C und H in Blau und Orange"),
navigationItems: navigationItems,
posts: posts)
let fileContent = feed.content
Content.accessFolderFromBookmark(key: bookmarkKey) { folder in
let outputFile = folder.appendingPathComponent("feed.html", isDirectory: false)
do {
try fileContent
.data(using: .utf8)!
.write(to: outputFile)
} catch {
print("Failed to save: \(error)")
}
}
}
}
func importOldContent() {
let importer = Importer()
do {
try importer.importOldContent()
} catch {
print(error)
return
}
self.posts = importer.posts
self.tags = importer.tags
#warning("TODO: Copy page sources to data folder")
self.pages = importer.pages
self.images = importer.images
#warning("TODO: Copy images to data folder")
}
static func accessFolderFromBookmark(key: String, operation: (URL) -> Void) {
guard let bookmarkData = UserDefaults.standard.data(forKey: key) else {
print("No bookmark data to access folder")
return
}
var isStale = false
let folderURL: URL
do {
// Resolve the bookmark to get the folder URL
folderURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
} catch {
print("Failed to resolve bookmark: \(error)")
return
}
if isStale {
print("Bookmark is stale, consider saving a new bookmark.")
}
// Start accessing the security-scoped resource
if folderURL.startAccessingSecurityScopedResource() {
print("Accessing folder: \(folderURL.path)")
operation(folderURL)
folderURL.stopAccessingSecurityScopedResource()
} else {
print("Failed to access folder: \(folderURL.path)")
}
}
}

View File

@ -0,0 +1,8 @@
import Foundation
enum ContentLanguage: String {
case english = "en"
case german = "de"
}

View File

@ -0,0 +1,16 @@
import Foundation
final class FileResources: ObservableObject {
/// Globally unique id
@Published
var uniqueId: String
@Published
var description: String
init(uniqueId: String, description: String) {
self.uniqueId = uniqueId
self.description = description
}
}

View File

@ -0,0 +1,91 @@
import SwiftUI
final class ImageResource: ObservableObject {
/// Globally unique id
@Published
var id: String
@Published
var altText: LocalizedText
@Published
var size: CGSize = .zero
var aspectRatio: CGFloat {
guard size.height > 0 else {
return 0
}
return size.width / size.height
}
private let source: ImageSource
init(uniqueId: String, altText: LocalizedText, fileUrl: URL) {
self.id = uniqueId
self.source = .file(fileUrl)
self.altText = altText
}
init(resourceName: String) {
self.id = resourceName
self.source = .resource(resourceName)
self.altText = .init(en: "A test image included in the bundle", de: "Ein Test-Image aus dem Bundle")
}
private enum ImageSource {
case file(URL)
case resource(String)
}
}
extension ImageResource: Identifiable {
}
extension ImageResource: Equatable {
static func == (lhs: ImageResource, rhs: ImageResource) -> Bool {
lhs.id == rhs.id
}
}
extension ImageResource {
var imageToDisplay: Image {
switch source {
case .file(let url):
return image(at: url)
case .resource(let name):
return .init(name)
}
}
private func image(at url: URL) -> Image {
let imageData: Data
do {
imageData = try Data(contentsOf: url)
} catch {
print("Failed to load image data from \(url.path): \(error)")
return failureImage
}
guard let loadedImage = NSImage(data: imageData) else {
print("Failed to create image from \(url.path)")
return failureImage
}
self.size = loadedImage.size
return .init(nsImage: loadedImage)
}
private var failureImage: SwiftUI.Image {
Image(systemSymbol: .exclamationmarkTriangle)
}
}
extension ImageResource {
func feedEntryImage(for language: ContentLanguage) -> FeedEntryData.Image {
.init(mainImageUrl: "images/\(id)", altText: altText.getText(for: language))
}
}

View File

@ -0,0 +1,48 @@
import Foundation
import SwiftUI
/// A simple container for localized text
final class LocalizedText: ObservableObject {
@Published
var en: String
@Published
var de: String
init(en: String, de: String) {
self.en = en
self.de = de
}
var id: String {
en
}
func set(text: String, for language: ContentLanguage) {
switch language {
case .english: self.en = text
case .german: self.de = text
}
}
func getText(for language: ContentLanguage) -> String {
switch language {
case .english: return en
case .german: return de
}
}
@MainActor
func text(for language: ContentLanguage) -> Binding<String> {
Binding(
get: {
self.getText(for: language)
},
set: { newValue in
self.set(text: newValue, for: language)
}
)
}
}

View File

@ -0,0 +1,89 @@
import Foundation
final class Page: ObservableObject {
/**
The unique id of the entry
*/
@Published
var id: String
@Published
var isDraft: Bool
@Published
var metadata: [LocalizedPage]
/**
All files which may occur in content but is stored externally.
Missing files which would otherwise produce a warning are ignored when included here.
- Note: This property defaults to an empty set.
*/
@Published
var externalFiles: Set<String> = []
/**
Specifies additional files which should be copied to the destination when generating the content.
- Note: This property defaults to an empty set.
*/
@Published
var requiredFiles: Set<String> = []
/**
Additional images required by the element.
These images are specified as: `source_name destination_name width (height)`.
*/
@Published
var images: Set<String> = []
init(id: String, isDraft: Bool, metadata: [LocalizedPage], externalFiles: Set<String> = [], requiredFiles: Set<String> = [], images: Set<String> = []) {
self.id = id
self.isDraft = isDraft
self.metadata = metadata
self.externalFiles = externalFiles
self.requiredFiles = requiredFiles
self.images = images
}
func metadata(for language: ContentLanguage) -> LocalizedPage? {
metadata.first { $0.language == language }
}
}
struct LocalizedPage {
let language: ContentLanguage
/**
The string to use when creating the url for the page.
Defaults to ``id`` if unset.
*/
var urlString: String?
/**
The headline to use when showing the entry on it's own page
*/
var headline: String
}
extension Page: Identifiable {
}
extension Page: Equatable {
static func == (lhs: Page, rhs: Page) -> Bool {
lhs.id == rhs.id
}
}
extension Page: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@ -0,0 +1,170 @@
import SwiftUI
final class Post: ObservableObject {
let id: Int
@Published
var isDraft: Bool
@Published
var startDate: Date
@Published
var hasEndDate: Bool
@Published
var endDate: Date
@Published
var tags: [Tag]
let title: LocalizedText
let text: LocalizedText
var images: [ImageResource]
/// The page linked to by this post
@Published
var linkedPage: Page?
init(id: Int,
isDraft: Bool = false,
startDate: Date,
endDate: Date? = nil,
title: LocalizedText,
text: LocalizedText,
tags: [Tag],
images: [ImageResource]) {
self.id = id
self.isDraft = isDraft
self.startDate = startDate
self.hasEndDate = endDate != nil
self.endDate = endDate ?? startDate
self.title = title
self.text = text
self.tags = tags
self.images = images
}
}
extension Post: Identifiable {
}
extension Post: Equatable {
static func == (lhs: Post, rhs: Post) -> Bool {
lhs.id == rhs.id
}
}
extension Post: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
// MARK: Feed entry
extension Post {
private static let englishDate: DateFormatter = {
let df = DateFormatter()
df.locale = .init(identifier: "en")
df.dateFormat = "d. MMMM yyyy"
return df
}()
private static let germanDate: DateFormatter = {
let df = DateFormatter()
df.locale = .init(identifier: "de")
df.dateFormat = "d. MMMM yyyy"
return df
}()
private static let englishDayAndMonth: DateFormatter = {
let df = DateFormatter()
df.locale = .init(identifier: "en")
df.dateFormat = "d. MMMM"
return df
}()
private static let germanDayAndMonth: DateFormatter = {
let df = DateFormatter()
df.locale = .init(identifier: "de")
df.dateFormat = "d. MMMM"
return df
}()
private static let day: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "d."
return df
}()
private static func dayAndMonth(of date: Date, in language: ContentLanguage) -> String {
switch language {
case .english: return englishDayAndMonth.string(from: date)
case .german: return germanDayAndMonth.string(from: date)
}
}
private static func dateString(for date: Date, in language: ContentLanguage) -> String {
switch language {
case .english: return englishDate.string(from: date)
case .german: return germanDate.string(from: date)
}
}
private func datePrefixString(in language: ContentLanguage) -> String {
guard Calendar.current.isDate(startDate, equalTo: endDate, toGranularity: .year) else {
// Different year, return full string
return startDate.formatted(date: .long, time: .omitted)
}
guard Calendar.current.isDate(startDate, equalTo: endDate, toGranularity: .month) else {
// Different month
return Post.dayAndMonth(of: startDate, in: language)
}
return Post.day.string(from: startDate)
}
func dateText(in language: ContentLanguage) -> String {
guard hasEndDate else {
return Post.dateString(for: startDate, in: language)
}
let endText = Post.dateString(for: endDate, in: language)
return "\(datePrefixString(in: language)) - \(endText)"
}
private func paragraphs(in language: ContentLanguage) -> [String] {
text
.getText(for: language)
.components(separatedBy: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0 != "" }
}
func linkToPageInFeed(for language: ContentLanguage) -> FeedEntryData.Link? {
nil //.init(url: <#T##String#>, text: <#T##String#>)
}
func feedEntry(for language: ContentLanguage) -> FeedEntryData {
.init(
entryId: "\(id)",
title: title.getText(for: language),
textAboveTitle: dateText(in: language),
link: linkToPageInFeed(for: language),
tags: tags.map { $0.data(in: language) },
text: paragraphs(in: language),
images: images.map { $0.feedEntryImage(for: language) })
}
var displayImages: [Image] {
images.map { $0.imageToDisplay }
}
}

View File

@ -0,0 +1,69 @@
import Foundation
final class Tag: ObservableObject {
var id: String {
name.getText(for: .english).lowercased().replacingOccurrences(of: " ", with: "-")
}
@Published
var name: LocalizedText
init(en: String, de: String) {
self.name = .init(en: en, de: de)
}
var linkName: String {
id.lowercased().replacingOccurrences(of: " ", with: "-")
}
var url: String {
"/tags/\(linkName).html"
}
}
extension Tag {
func getUrl(for language: ContentLanguage) -> String {
"/\(language.rawValue)/tags/\(id).html"
}
func data(in language: ContentLanguage) -> FeedEntryData.Tag {
.init(
name: name.getText(for: language),
url: getUrl(for: language)
)
}
}
extension Tag: ExpressibleByStringLiteral {
convenience init(stringLiteral value: StringLiteralType) {
self.init(en: value.capitalized, de: value.capitalized)
}
}
extension Tag: Identifiable {
}
extension Tag: Equatable {
static func == (_ lhs: Tag, _ rhs: Tag) -> Bool {
lhs.id == rhs.id
}
}
extension Tag: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
extension Tag: Comparable {
static func < (lhs: Tag, rhs: Tag) -> Bool {
lhs.id < rhs.id
}
}