First version
This commit is contained in:
104
CHDataManagement/Model/Content.swift
Normal file
104
CHDataManagement/Model/Content.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
8
CHDataManagement/Model/ContentLanguage.swift
Normal file
8
CHDataManagement/Model/ContentLanguage.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
enum ContentLanguage: String {
|
||||
|
||||
case english = "en"
|
||||
|
||||
case german = "de"
|
||||
}
|
16
CHDataManagement/Model/FileResource.swift
Normal file
16
CHDataManagement/Model/FileResource.swift
Normal 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
|
||||
}
|
||||
}
|
91
CHDataManagement/Model/ImageResource.swift
Normal file
91
CHDataManagement/Model/ImageResource.swift
Normal 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))
|
||||
}
|
||||
}
|
48
CHDataManagement/Model/LocalizedText.swift
Normal file
48
CHDataManagement/Model/LocalizedText.swift
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
89
CHDataManagement/Model/Page.swift
Normal file
89
CHDataManagement/Model/Page.swift
Normal 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)
|
||||
}
|
||||
}
|
170
CHDataManagement/Model/Post.swift
Normal file
170
CHDataManagement/Model/Post.swift
Normal 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 }
|
||||
}
|
||||
}
|
69
CHDataManagement/Model/Tag.swift
Normal file
69
CHDataManagement/Model/Tag.swift
Normal 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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user