Generate first feed pages, images

This commit is contained in:
Christoph Hagen
2024-12-04 08:10:45 +01:00
parent dc7b7a0e90
commit b3cc4a57db
25 changed files with 928 additions and 272 deletions

View File

@ -0,0 +1,44 @@
import Foundation
extension NSSize {
/// Scales the current size to fit within the target size while maintaining the aspect ratio.
/// - Parameter targetSize: The size to fit into.
/// - Returns: A new `NSSize` that fits within the `targetSize`.
func scaledToFit(in targetSize: NSSize) -> NSSize {
guard self.width > 0 && self.height > 0 else {
return .zero // Avoid division by zero if the size is invalid.
}
let widthScale = targetSize.width / self.width
let heightScale = targetSize.height / self.height
let scale = min(widthScale, heightScale)
return NSSize(width: self.width * scale, height: self.height * scale)
}
func scaledDown(to desiredWidth: CGFloat) -> NSSize {
if width == desiredWidth {
return self
}
if width < desiredWidth {
// Don't scale larger
return self
}
let height = (height * desiredWidth / width).rounded(.down)
return NSSize(width: desiredWidth, height: height)
}
}
extension NSSize {
var ratio: CGFloat {
guard height != 0 else {
return 0
}
return width / height
}
}

View File

@ -13,3 +13,20 @@ extension String {
isEmpty ? nil : self
}
}
extension String {
var fileExtension: String? {
let parts = components(separatedBy: ".")
guard parts.count > 1 else { return nil }
return parts.last
}
var fileNameAndExtension: (fileName: String, fileExtension: String?) {
let parts = components(separatedBy: ".")
guard parts.count > 1 else {
return (self, nil)
}
return (fileName: parts.dropLast().joined(separator: "."), fileExtension: parts.last)
}
}

View File

@ -0,0 +1,72 @@
import Foundation
extension URL {
func ensureParentFolderExistence() throws {
try deletingLastPathComponent().ensureFolderExistence()
}
func ensureFolderExistence() throws {
guard !exists else {
return
}
try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true)
}
var isDirectory: Bool {
do {
let resources = try resourceValues(forKeys: [.isDirectoryKey])
guard let isDirectory = resources.isDirectory else {
print("No isDirectory info for \(path)")
return false
}
return isDirectory
} catch {
print("Failed to get directory information from \(path): \(error)")
return false
}
}
var exists: Bool {
FileManager.default.fileExists(atPath: path)
}
/**
Delete the file at the url.
*/
func delete() throws {
try FileManager.default.removeItem(at: self)
}
func copy(to url: URL) throws {
if url.exists {
try url.delete()
}
try url.ensureParentFolderExistence()
try FileManager.default.copyItem(at: self, to: url)
}
var size: Int? {
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
return (attributes?[.size] as? NSNumber)?.intValue
}
func resolvingFolderTraversal() -> URL? {
var components = [String]()
absoluteString.components(separatedBy: "/").forEach { part in
if part == ".." {
if !components.isEmpty {
_ = components.popLast()
} else {
components.append("..")
}
return
}
if part == "." {
return
}
components.append(part)
}
return URL(string: components.joined(separator: "/"))
}
}

View File

@ -46,7 +46,7 @@ final class Importer {
let thumbnailUrl = folder.appending(path: "thumbnail.jpg", directoryHint: .notDirectory)
var thumbnail: FileOnDisk? = nil
if FileManager.default.fileExists(atPath: thumbnailUrl.path()) {
thumbnail = FileOnDisk(type: .image, url: thumbnailUrl, name: "\(name)-thumbnail.jpg")
thumbnail = FileOnDisk(type: .image(.jpg), url: thumbnailUrl, name: "\(name)-thumbnail.jpg")
add(resource: thumbnail!)
}
@ -104,7 +104,7 @@ final class Importer {
}
let type = FileType(fileExtension: fileExtension)
guard type != .resource else {
guard case .resource = type else {
self.ignoredFiles.append(url)
return nil
}

View File

@ -85,47 +85,20 @@ final class Content: ObservableObject {
return
}
try? storage.update(baseFolder: URL(filePath: contentPath), moveContent: false)
try? storage.update(baseFolder: URL(filePath: contentPath))
observeContentPath()
}
private func observeContentPath() {
$contentPath.sink { newValue in
let url = URL(filePath: newValue)
try? self.storage.update(baseFolder: url, moveContent: true)
do {
try self.storage.update(baseFolder: url)
try self.loadFromDisk()
} catch {
print("Failed to switch content path: \(error)")
}
}
.store(in: &cancellables)
}
// MARK: Folder access
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

@ -93,10 +93,3 @@ extension ImageResource {
}
}
extension ImageResource {
func feedEntryImage(for language: ContentLanguage) -> FeedEntryData.Image {
.init(mainImageUrl: "images/\(id)", altText: altText.getText(for: language))
}
}

View File

@ -0,0 +1,53 @@
import Foundation
import AppKit
enum ImageType {
case jpg
case png
case avif
case webp
case gif
var fileExtension: String {
switch self {
case .jpg: return "jpg"
case .png: return "png"
case .avif: return "avif"
case .webp: return "webp"
case .gif: return "gif"
}
}
var fileType: NSBitmapImageRep.FileType {
switch self {
case .jpg:
return .jpeg
case .png, .avif, .webp:
return .png
case .gif:
return .gif
}
}
}
extension ImageType: CaseIterable {
}
extension ImageType {
init?(fileExtension: String) {
switch fileExtension {
case "jpg", "jpeg":
self = .jpg
case "png":
self = .png
case "avif":
self = .avif
case "webp":
self = .webp
default:
return nil
}
}
}

View File

@ -97,4 +97,8 @@ final class LocalizedPage: ObservableObject {
}
)
}
var relativeUrl: String {
"/page/\(urlString)"
}
}

View File

@ -153,29 +153,4 @@ extension Post {
let endText = Post.dateString(for: endDate, in: language)
return "\(datePrefixString(in: language)) - \(endText)"
}
private func paragraphs(in language: ContentLanguage) -> [String] {
localized(in: language).content
.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 {
let post = localized(in: language)
return .init(
entryId: "\(id)",
title: post.title,
textAboveTitle: dateText(in: language),
link: linkToPageInFeed(for: language),
tags: tags.map { $0.data(in: language) },
text: paragraphs(in: language),
images: post.images.map {
$0.feedEntryImage(for: language)
})
}
}

View File

@ -1,102 +0,0 @@
import Foundation
struct WebsiteGeneratorConfiguration {
let language: ContentLanguage
let postsPerPage: Int
let postFeedTitle: String
let postFeedDescription: String
let postFeedUrlPrefix: String
}
final class WebsiteGenerator {
let language: ContentLanguage
let content: Content
let postsPerPage: Int
let postFeedTitle: String
let postFeedDescription: String
let postFeedUrlPrefix: String
init(content: Content, configuration: WebsiteGeneratorConfiguration) {
self.content = content
self.language = configuration.language
self.postsPerPage = configuration.postsPerPage
self.postFeedTitle = configuration.postFeedTitle
self.postFeedDescription = configuration.postFeedDescription
self.postFeedUrlPrefix = configuration.postFeedUrlPrefix
}
func generateWebsite() {
createPostFeedPages()
}
private func createPostFeedPages() {
let totalCount = content.posts.count
guard totalCount > 0 else { return }
let navBarData = createNavigationBarData()
let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up
for pageIndex in 1...numberOfPages {
let startIndex = (pageIndex - 1) * postsPerPage
let endIndex = min(pageIndex * postsPerPage, totalCount)
let postsOnPage = content.posts[startIndex..<endIndex]
createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navBarData)
}
}
private func createNavigationBarData() -> NavigationBarData {
let data = content.websiteData.localized(in: language)
let navigationItems: [NavigationBarLink] = content.websiteData.navigationTags.map {
let localized = $0.localized(in: language)
return .init(text: localized.name, url: localized.urlComponent)
}
return NavigationBarData(
navigationIconPath: "/assets/icons/ch.svg",
iconDescription: data.iconDescription,
navigationItems: navigationItems)
}
private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice<Post>, bar: NavigationBarData) {
let posts = posts.map { $0.feedEntry(for: language) }
let feed = PageInFeed(
language: language,
title: postFeedTitle,
description: postFeedDescription,
navigationBarData: bar,
pageNumber: pageIndex,
totalPages: pageCount,
posts: posts)
let fileContent = feed.content
if pageIndex == 1 {
save(fileContent, to: "\(postFeedUrlPrefix).html")
} else {
save(fileContent, to: "\(postFeedUrlPrefix)-\(pageIndex).html")
}
}
private func save(_ content: String, to relativePath: String) {
Content.accessFolderFromBookmark(key: Storage.outputPathBookmarkKey) { folder in
let outputFile = folder.appendingPathComponent(relativePath, isDirectory: false)
do {
try content
.data(using: .utf8)!
.write(to: outputFile)
} catch {
print("Failed to save: \(error)")
}
}
}
}

View File

@ -8,25 +8,26 @@ struct FeedEntry {
self.data = data
}
func addContent(to result: inout String) {
var content: String {
#warning("TODO: Select CSS classes based on existence of link (hover effects, mouse pointer")
result += "<div class='card'>"
var result = "<div class='card'>"
ImageGallery(id: data.entryId, images: data.images)
.addContent(to: &result)
if let url = data.link?.url {
result += "<div class=\"card-content\" onclick=\"window.location.href='\(url)'\">"
result += "<div class='card-content' onclick=\"window.location.href='\(url)'\">"
} else {
result += "<div class=\"card-content\">"
result += "<div class='card-content'>"
}
result += "<h3>\(data.textAboveTitle)</h3>"
if let title = data.title {
result += "<h2>\(title.htmlEscaped())</h2>"
}
if !data.tags.isEmpty {
result += "<div class=\"tags\">"
result += "<div class='tags'>"
for tag in data.tags {
result += "<a class=\"tag\" href=\"\(tag.url)\">\(tag.name)</a>"
result += "<span class='tag' onclick=\"location.href='\(tag.url)'; event.stopPropagation();\">\(tag.name)</span>"
//result += "<a class='tag' href='\(tag.url)'>\(tag.name)</a>"
}
result += "</div>"
}
@ -34,8 +35,9 @@ struct FeedEntry {
result += "<p>\(paragraph)</p>"
}
if let url = data.link {
result += "<div class=\"link-center\"><div class=\"link\">\(url.text)</div></div>"
result += "<div class='link-center'><div class='link'>\(url.text)</div></div>"
}
result += "</div></div>" // Closes card-content and card
return result
}
}

View File

@ -43,9 +43,19 @@ struct FeedEntryData {
struct Image {
let mainImageUrl: String
let altText: String
let avif1x: String
let avif2x: String
let webp1x: String
let webp2x: String
let jpg1x: String
let jpg2x: String
}
}

View File

@ -6,42 +6,64 @@ struct ImageGallery {
let images: [FeedEntryData.Image]
private var htmlSafeId: String {
ImageGallery.htmlSafe(id)
}
init(id: String, images: [FeedEntryData.Image]) {
self.id = id
self.images = images
}
private func imageCode(_ image: FeedEntryData.Image) -> String {
//return "<img src='\(image.mainImageUrl)' loading='lazy' alt='\(image.altText.htmlEscaped())'>"
var result = "<picture>"
result += "<source type='image/avif' srcset='\(image.avif1x) 1x, \(image.avif2x) 2x'/>"
result += "<source type='image/webp' srcset='\(image.webp1x) 1x, \(image.webp2x) 2x'/>"
result += "<img srcset='\(image.jpg2x) 2x' src='\(image.jpg1x)' loading='lazy' alt='\(image.altText.htmlEscaped())'/>"
result += "</picture>"
return result
}
func addContent(to result: inout String) {
guard !images.isEmpty else {
return
}
result += "<div id=\"s\(id)\" class=\"swiper\"><div class=\"swiper-wrapper\">"
result += "<div id='\(htmlSafeId)' class='swiper'><div class='swiper-wrapper'>"
guard images.count > 1 else {
let image = images[0]
result += "<div class=\"swiper-slide\"><img src=\(image.mainImageUrl) loading=\"lazy\" alt=\"\(image.altText.htmlEscaped())\"></div>"
result += imageCode(images[0])
result += "</div></div>" // Close swiper, swiper-wrapper
return
}
for image in images {
// TODO: Use different images based on device
result += "<div class=\"swiper-slide\">"
result += "<img src=\(image.mainImageUrl) loading=\"lazy\" alt=\"\(image.altText.htmlEscaped())\">"
result += "<div class=\"swiper-lazy-preloader swiper-lazy-preloader-white\"></div>"
result += "<div class='swiper-slide'>"
result += imageCode(image)
result += "<div class='swiper-lazy-preloader swiper-lazy-preloader-white'></div>"
result += "</div>" // Close swiper-slide
}
result += "<div class=\"swiper-button-next\"></div>"
result += "<div class=\"swiper-button-prev\"></div>"
result += "<div class=\"swiper-pagination\"></div>"
result += "</div></div>" // Close swiper, swiper-wrapper
result += "</div>" // Close swiper-wrapper
result += "<div class='swiper-button-next'></div>"
result += "<div class='swiper-button-prev'></div>"
result += "<div class='swiper-pagination'></div>"
result += "</div>" // Close swiper
}
private static func htmlSafe(_ id: String) -> String {
id.replacingOccurrences(of: "-", with: "_")
}
static func swiperInit(id: String) -> String {
"""
var swiper\(id) = new Swiper("#\(id)", {
let id = htmlSafe(id)
return """
var swiper_\(id) = new Swiper("#\(id)", {
loop: true,
slidesPerView: 1,
spaceBetween: 30,

View File

@ -42,6 +42,7 @@ struct NavigationBar {
result += "<a id=\"nav-image\" href=\"/\">"
result += "<img class=\"navbar-icon\" src=\"\(data.navigationIconPath)\" alt=\"\(data.iconDescription)\">"
result += "</a>"
for item in rightNavigationItems {
result += "<a class=\"nav-animate\" href=\"\(item.url)\">\(item.text)</a>"

View File

@ -12,14 +12,17 @@ struct GenericPage {
let additionalHeaders: String
let additionalFooter: String
let insertedContent: (inout String) -> Void
init(language: ContentLanguage, title: String, description: String, data: NavigationBarData, additionalHeaders: String, insertedContent: @escaping (inout String) -> Void) {
init(language: ContentLanguage, title: String, description: String, data: NavigationBarData, additionalHeaders: String, additionalFooter: String, insertedContent: @escaping (inout String) -> Void) {
self.language = language
self.title = title
self.description = description
self.data = data
self.additionalHeaders = additionalHeaders
self.additionalFooter = additionalFooter
self.insertedContent = insertedContent
}
var content: String {
@ -30,7 +33,9 @@ struct GenericPage {
result += NavigationBar(data: data).content
result += "<div class=\"content\"><div style=\"height: 70px;\"></div>"
insertedContent(&result)
result += "</div></body></html>" // Close content
result += "</div>"
result += additionalFooter
result += "</body></html>" // Close content
return result
}
}

View File

@ -33,20 +33,25 @@ struct PageInFeed {
}
var content: String {
GenericPage(language: language, title: title, description: description, data: navigationBarData, additionalHeaders: headers) { content in
let footer = swiperIsNeeded ? swiperInits : ""
return GenericPage(
language: language,
title: title,
description: description,
data: navigationBarData,
additionalHeaders: headers,
additionalFooter: footer) { content in
for post in posts {
FeedEntry(data: post)
.addContent(to: &content)
content += FeedEntry(data: post).content
}
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
if swiperIsNeeded {
addSwiperInits(to: &content)
}
}.content
}
private func addSwiperInits(to result: inout String) {
result += "<script src='\(swiperJsPath)'></script><script>"
private var swiperInits: String {
var result = "<script src='\(swiperJsPath)'></script><script>"
for post in posts {
guard post.images.count > 1 else {
continue
@ -54,5 +59,6 @@ struct PageInFeed {
result += ImageGallery.swiperInit(id: post.entryId)
}
result += "</script>"
return result
}
}

View File

@ -4,16 +4,21 @@ extension WebsiteGeneratorConfiguration {
static let english = WebsiteGeneratorConfiguration(
language: .english,
outputDirectory: URL(fileURLWithPath: ""),
postsPerPage: 20,
postFeedTitle: "Posts",
postFeedDescription: "The most recent posts on christophhagen.de",
postFeedUrlPrefix: "feed")
postFeedUrlPrefix: "feed",
navigationIconPath: "/assets/icons/ch.svg",
mainContentMaximumWidth: 600)
static let german = WebsiteGeneratorConfiguration(
language: .german,
outputDirectory: URL(fileURLWithPath: ""),
postsPerPage: 20,
postFeedTitle: "Beiträge",
postFeedDescription: "Die neusten Beiträge auf christophhagen.de",
postFeedUrlPrefix: "beiträge")
postFeedUrlPrefix: "beiträge",
navigationIconPath: "/assets/icons/ch.svg",
mainContentMaximumWidth: 600)
}

View File

@ -0,0 +1,224 @@
import Foundation
import AppKit
import SDWebImageAVIFCoder
import SDWebImageWebPCoder
private struct ImageJob {
let image: String
let version: String
let maximumWidth: CGFloat
let maximumHeight: CGFloat
let quality: CGFloat
let type: ImageType
}
final class ImageGenerator {
private let storage: Storage
private let inputImageFolder: URL
private let relativeImageOutputPath: String
private var generatedImages: [String : [String]] = [:]
private var jobs: [ImageJob] = []
init(storage: Storage, inputImageFolder: URL, relativeImageOutputPath: String) {
self.storage = storage
self.inputImageFolder = inputImageFolder
self.relativeImageOutputPath = relativeImageOutputPath
self.generatedImages = storage.loadListOfGeneratedImages()
}
func prepareForGeneration() -> Bool {
inOutputImagesFolder { imagesFolder in
do {
try imagesFolder.ensureFolderExistence()
return true
} catch {
print("Failed to create output images folder: \(error)")
return false
}
}
}
func runJobs() -> Bool {
for job in jobs {
print("Generating image \(job.version)")
guard generate(job: job) else {
return false
}
}
return true
}
func save() -> Bool {
storage.save(listOfGeneratedImages: generatedImages)
}
private func versionFileName(image: String, type: ImageType, width: CGFloat, height: CGFloat) -> String {
let fileName = image.fileNameAndExtension.fileName
let prefix = "\(fileName)@\(Int(width))x\(Int(height))"
return "\(prefix).\(type.fileExtension)"
}
func generateVersion(for image: String, type: ImageType, maximumWidth: CGFloat, maximumHeight: CGFloat) -> String {
let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight)
let fullPath = "/" + relativeImageOutputPath + "/" + version
if hasPreviouslyGenerated(version: version, for: image), exists(version) {
// Don't add job again
return fullPath
}
let job = ImageJob(
image: image,
version: version,
maximumWidth: maximumWidth,
maximumHeight: maximumHeight,
quality: 0.7,
type: type)
jobs.append(job)
return fullPath
}
private func hasPreviouslyGenerated(version: String, for image: String) -> Bool {
guard let versions = generatedImages[image] else {
return false
}
return versions.contains(version)
}
private func hasNowGenerated(version: String, for image: String) {
guard var versions = generatedImages[image] else {
generatedImages[image] = [version]
return
}
versions.append(version)
generatedImages[image] = versions
}
private func removeVersions(for image: String) {
generatedImages[image] = nil
}
// MARK: Image operations
private func generate(job: ImageJob) -> Bool {
if hasPreviouslyGenerated(version: job.version, for: job.image), exists(job.version) {
return true
}
let inputPath = inputImageFolder.appendingPathComponent(job.image)
#warning("TODO: Read through security scope")
guard inputPath.exists else {
print("Missing image \(inputPath.path())")
return false
}
let data: Data
do {
data = try Data(contentsOf: inputPath)
} catch {
print("Failed to load image \(inputPath.path()): \(error)")
return false
}
guard let originalImage = NSImage(data: data) else {
print("Failed to load image")
return false
}
let sourceRep = originalImage.representations[0]
let sourceSize = NSSize(width: sourceRep.pixelsWide, height: sourceRep.pixelsHigh)
let maximumSize = NSSize(width: job.maximumWidth, height: job.maximumHeight)
let destinationSize = sourceSize.scaledToFit(in: maximumSize)
// create NSBitmapRep manually, if using cgImage, the resulting size is wrong
let rep = NSBitmapImageRep(bitmapDataPlanes: nil,
pixelsWide: Int(destinationSize.width),
pixelsHigh: Int(destinationSize.height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: NSColorSpaceName.deviceRGB,
bytesPerRow: Int(destinationSize.width) * 4,
bitsPerPixel: 32)!
let ctx = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
originalImage.draw(in: NSMakeRect(0, 0, destinationSize.width, destinationSize.height))
ctx?.flushGraphics()
NSGraphicsContext.restoreGraphicsState()
guard let data = create(image: rep, type: job.type, quality: job.quality) else {
print("Failed to get data for type \(job.type)")
return false
}
let result = inOutputImagesFolder { folder in
let url = folder.appendingPathComponent(job.version)
do {
try data.write(to: url)
return true
} catch {
print("Failed to write image \(job.version): \(error)")
return false
}
}
guard result else {
return false
}
hasNowGenerated(version: job.version, for: job.image)
return true
}
private func exists(_ relativePath: String) -> Bool {
inOutputImagesFolder { folder in
folder.appendingPathComponent(relativePath).exists
}
}
private func inOutputImagesFolder(perform operation: (URL) -> Bool) -> Bool {
storage.write(in: .outputPath) { outputFolder in
let imagesFolder = outputFolder.appendingPathComponent(relativeImageOutputPath)
return operation(imagesFolder)
}
}
// MARK: Avif images
private func create(image: NSBitmapImageRep, type: ImageType, quality: CGFloat) -> Data? {
switch type {
case .jpg:
return image.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: quality)])
case .png:
return image.representation(using: .png, properties: [.compressionFactor: NSNumber(value: quality)])
case .avif:
return createAvif(image: image, quality: quality)
case .webp:
return createWebp(image: image, quality: quality)
case .gif:
return image.representation(using: .gif, properties: [.compressionFactor: NSNumber(value: quality)])
}
}
private func createAvif(image: NSBitmapImageRep, quality: CGFloat) -> Data? {
let newImage = NSImage(size: image.size)
newImage.addRepresentation(image)
return SDImageAVIFCoder.shared.encodedData(with: newImage, format: .AVIF, options: [.encodeCompressionQuality: quality])
}
private func createWebp(image: NSBitmapImageRep, quality: CGFloat) -> Data? {
let newImage = NSImage(size: image.size)
newImage.addRepresentation(image)
return SDImageWebPCoder.shared.encodedData(with: newImage, format: .webP, options: [.encodeCompressionQuality: quality])
}
}

View File

@ -9,7 +9,9 @@ struct FileOnDisk {
let name: String
init(image: String, url: URL) {
self.type = .image
let ext = image.fileExtension!
let type = ImageType(fileExtension: ext)!
self.type = .image(type)
self.url = url
self.name = image
}

View File

@ -1,7 +1,8 @@
import Foundation
enum FileType {
case image
case image(ImageType)
case file
case video
case resource
@ -9,8 +10,16 @@ enum FileType {
init(fileExtension: String) {
switch fileExtension.lowercased() {
case "jpg", "jpeg", "png", "gif":
self = .image
case "jpg", "jpeg":
self = .image(.jpg)
case "png":
self = .image(.png)
case "avif":
self = .image(.avif)
case "webp":
self = .image(.webp)
case "gif":
self = .image(.gif)
case "html", "stl", "f3d", "step", "f3z", "zip", "json", "conf", "css", "js", "cpp", "cddx", "svg", "glb", "mp3", "pdf", "swift":
self = .file
case "mp4":
@ -22,4 +31,19 @@ enum FileType {
self = .resource
}
}
var fileExtension: String {
switch self {
case .image(let imageType): return imageType.fileExtension
default:
return "" // TODO: Fix
}
}
var isImage: Bool {
if case .image = self {
return true
}
return false
}
}

View File

@ -1,5 +1,12 @@
import Foundation
enum SecurityScopeBookmark: String {
case outputPath = "outputPathBookmark"
case contentPath = "contentPathBookmark"
}
/**
A class that handles the storage of the website data.
@ -13,9 +20,6 @@ import Foundation
*/
final class Storage {
static let outputPathBookmarkKey = "outputPathBookmark"
static let contentPathBookmarkKey = "contentPathBookmark"
private(set) var baseFolder: URL
private let encoder = JSONEncoder()
@ -60,14 +64,9 @@ final class Storage {
// MARK: Folders
func update(baseFolder: URL, moveContent: Bool) throws {
let oldFolder = self.baseFolder
func update(baseFolder: URL) throws {
self.baseFolder = baseFolder
try createFolderStructure()
guard moveContent else {
return
}
// TODO: Move all files
}
private func create(folder: URL) throws {
@ -213,7 +212,7 @@ final class Storage {
// MARK: Files
/// The folder path where other files are stored (by their unique name)
private var filesFolder: URL { subFolder("files") }
var filesFolder: URL { subFolder("files") }
private func fileUrl(file: String) -> URL {
filesFolder.appending(path: file, directoryHint: .notDirectory)
@ -250,6 +249,70 @@ final class Storage {
write(websiteData, type: "Website Data", id: "-", to: websiteDataUrl)
}
// MARK: Image generation data
private var generatedImagesListUrl: URL {
baseFolder.appending(component: "generated-images.json", directoryHint: .notDirectory)
}
func loadListOfGeneratedImages() -> [String : [String]] {
let url = generatedImagesListUrl
guard url.exists else {
return [:]
}
do {
return try read(at: url)
} catch {
print("Failed to read list of generated images: \(error)")
return [:]
}
}
func save(listOfGeneratedImages: [String : [String]]) -> Bool {
write(listOfGeneratedImages, type: "generated images list", id: "-", to: generatedImagesListUrl)
}
// MARK: Folder access
func save(folderUrl url: URL, in bookmark: SecurityScopeBookmark) {
do {
let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
UserDefaults.standard.set(bookmarkData, forKey: bookmark.rawValue)
} catch {
print("Failed to create security-scoped bookmark: \(error)")
}
}
func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool {
guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else {
print("No bookmark data to access folder")
return false
}
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 false
}
if isStale {
print("Bookmark is stale, consider saving a new bookmark.")
}
// Start accessing the security-scoped resource
if folderURL.startAccessingSecurityScopedResource() {
let result = operation(folderURL)
folderURL.stopAccessingSecurityScopedResource()
return result
} else {
print("Failed to access folder: \(folderURL.path)")
return false
}
}
// MARK: Writing files
private func deleteFiles(in folder: URL, notIn fileSet: Set<String>) throws {

View File

@ -0,0 +1,183 @@
import Foundation
struct WebsiteGeneratorConfiguration {
let language: ContentLanguage
let outputDirectory: URL
let postsPerPage: Int
let postFeedTitle: String
let postFeedDescription: String
let postFeedUrlPrefix: String
let navigationIconPath: String
let mainContentMaximumWidth: CGFloat
}
final class WebsiteGenerator {
let language: ContentLanguage
let outputDirectory: URL
let postsPerPage: Int
let postFeedTitle: String
let postFeedDescription: String
let postFeedUrlPrefix: String
let navigationIconPath: String
let mainContentMaximumWidth: CGFloat
private let content: Content
private let imageGenerator: ImageGenerator
init(content: Content, configuration: WebsiteGeneratorConfiguration) {
self.language = configuration.language
self.outputDirectory = configuration.outputDirectory
self.postsPerPage = configuration.postsPerPage
self.postFeedTitle = configuration.postFeedTitle
self.postFeedDescription = configuration.postFeedDescription
self.postFeedUrlPrefix = configuration.postFeedUrlPrefix
self.navigationIconPath = configuration.navigationIconPath
self.mainContentMaximumWidth = configuration.mainContentMaximumWidth
self.content = content
self.imageGenerator = ImageGenerator(
storage: content.storage,
inputImageFolder: content.storage.filesFolder,
relativeImageOutputPath: "images")
}
func generateWebsite() -> Bool {
guard imageGenerator.prepareForGeneration() else {
return false
}
guard createPostFeedPages() else {
return false
}
guard imageGenerator.runJobs() else {
return false
}
return imageGenerator.save()
}
private func createPostFeedPages() -> Bool {
let totalCount = content.posts.count
guard totalCount > 0 else {
return true
}
let navBarData = createNavigationBarData()
let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up
for pageIndex in 1...numberOfPages {
let startIndex = (pageIndex - 1) * postsPerPage
let endIndex = min(pageIndex * postsPerPage, totalCount)
let postsOnPage = content.posts[startIndex..<endIndex]
guard createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navBarData) else {
return false
}
}
return true
}
private func createNavigationBarData() -> NavigationBarData {
let data = content.websiteData.localized(in: language)
let navigationItems: [NavigationBarLink] = content.websiteData.navigationTags.map {
let localized = $0.localized(in: language)
return .init(text: localized.name, url: localized.urlComponent)
}
return NavigationBarData(
navigationIconPath: navigationIconPath,
iconDescription: data.iconDescription,
navigationItems: navigationItems)
}
private func createImageSet(for image: ImageResource) -> FeedEntryData.Image {
let size1x = mainContentMaximumWidth
let size2x = mainContentMaximumWidth * 2
let avif1x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size1x, maximumHeight: size1x)
let avif2x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size2x, maximumHeight: size2x)
let webp1x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size1x, maximumHeight: size1x)
let webp2x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size2x, maximumHeight: size2x)
let jpg1x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size1x, maximumHeight: size1x)
let jpg2x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size2x, maximumHeight: size2x)
return FeedEntryData.Image(
altText: image.altText.getText(for: language),
avif1x: avif1x,
avif2x: avif2x,
webp1x: webp1x,
webp2x: webp2x,
jpg1x: jpg1x,
jpg2x: jpg2x)
}
private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice<Post>, bar: NavigationBarData) -> Bool {
let posts: [FeedEntryData] = posts.map { post in
let localized: LocalizedPost = post.localized(in: language)
let linkUrl = post.linkedPage.map {
FeedEntryData.Link(url: $0.localized(in: language).relativeUrl, text: "View")
}
return FeedEntryData(
entryId: "\(post.id)",
title: localized.title,
textAboveTitle: post.dateText(in: language),
link: linkUrl,
tags: post.tags.map { $0.data(in: language) },
text: [localized.content], // TODO: Convert from markdown to html
images: localized.images.map(createImageSet))
}
let feed = PageInFeed(
language: language,
title: postFeedTitle,
description: postFeedDescription,
navigationBarData: bar,
pageNumber: pageIndex,
totalPages: pageCount,
posts: posts)
let fileContent = feed.content
if pageIndex == 1 {
return save(fileContent, to: "\(postFeedUrlPrefix).html")
} else {
return save(fileContent, to: "\(postFeedUrlPrefix)-\(pageIndex).html")
}
}
private func save(_ content: String, to relativePath: String) -> Bool {
guard let data = content.data(using: .utf8) else {
print("Failed to create data for \(relativePath)")
return false
}
return save(data, to: relativePath)
}
private func save(_ data: Data, to relativePath: String) -> Bool {
self.content.storage.write(in: .outputPath) { folder in
let outputFile = folder.appendingPathComponent(relativePath, isDirectory: false)
do {
try data.write(to: outputFile)
return true
} catch {
print("Failed to save \(outputFile.path()): \(error)")
return false
}
}
}
}

View File

@ -15,10 +15,7 @@ struct SettingsView: View {
var content: Content
@State
private var isSelectingContentFolder = false
@State
private var showFileImporter = false
private var folderSelection: SecurityScopeBookmark = .contentPath
@State
private var showTagPicker = false
@ -70,6 +67,7 @@ struct SettingsView: View {
Button(action: generateFeed) {
Text("Generate")
}
.disabled(isGeneratingWebsite)
if isGeneratingWebsite {
ProgressView()
.progressViewStyle(.circular)
@ -80,10 +78,6 @@ struct SettingsView: View {
}
.padding()
}
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.folder],
onCompletion: didSelectContentFolder)
.sheet(isPresented: $showTagPicker) {
TagSelectionView(
presented: $showTagPicker,
@ -95,43 +89,21 @@ struct SettingsView: View {
// MARK: Folder selection
private func selectContentFolder() {
isSelectingContentFolder = true
//showFileImporter = true
guard let url = savePanelUsingOpenPanel(key: Storage.contentPathBookmarkKey) else {
folderSelection = .contentPath
guard let url = savePanelUsingOpenPanel() else {
return
}
self.contentPath = url.path()
}
private func selectOutputFolder() {
isSelectingContentFolder = false
guard let url = savePanelUsingOpenPanel(key: Storage.outputPathBookmarkKey) else {
folderSelection = .outputPath
guard let url = savePanelUsingOpenPanel() else {
return
}
self.outputPath = url.path()
}
private func didSelectContentFolder(_ result: Result<URL, any Error>) {
switch result {
case .success(let url):
didSelect(folder: url)
case .failure(let error):
print("Failed to select content folder: \(error)")
}
}
private func didSelect(folder: URL) {
let path = folder.absoluteString
.replacingOccurrences(of: "file://", with: "")
if isSelectingContentFolder {
self.contentPath = path
saveSecurityScopedBookmark(folder, key: Storage.contentPathBookmarkKey)
} else {
self.outputPath = path
saveSecurityScopedBookmark(folder, key: Storage.outputPathBookmarkKey)
}
}
// MARK: Feed
private func generateFeed() {
@ -150,7 +122,7 @@ struct SettingsView: View {
let generator = WebsiteGenerator(
content: content,
configuration: configuration)
generator.generateWebsite()
_ = generator.generateWebsite()
DispatchQueue.main.async {
isGeneratingWebsite = false
}
@ -158,13 +130,18 @@ struct SettingsView: View {
}
private var configuration: WebsiteGeneratorConfiguration {
switch language {
case .english: return .english
case .german: return .german
}
return .init(
language: language,
outputDirectory: URL(filePath: outputPath, directoryHint: .isDirectory),
postsPerPage: 20,
postFeedTitle: "Posts",
postFeedDescription: "The most recent posts on christophhagen.de",
postFeedUrlPrefix: "feed",
navigationIconPath: "/assets/icons/ch.svg",
mainContentMaximumWidth: 600)
}
func savePanelUsingOpenPanel(key: String) -> URL? {
func savePanelUsingOpenPanel() -> URL? {
let panel = NSOpenPanel()
// Sets up so user can only select a single directory
panel.canChooseFiles = false
@ -182,19 +159,9 @@ struct SettingsView: View {
guard let url = panel.url else {
return nil
}
saveSecurityScopedBookmark(url, key: key)
content.storage.save(folderUrl: url, in: folderSelection)
return url
}
func saveSecurityScopedBookmark(_ url: URL, key: String) {
do {
let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
UserDefaults.standard.set(bookmarkData, forKey: key)
print("Security-scoped bookmark saved.")
} catch {
print("Failed to create security-scoped bookmark: \(error)")
}
}
}
#Preview {