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

@ -45,6 +45,12 @@
E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA51C2CFF135B00AEF16D /* GenericPage.swift */; };
E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */; };
E25DA5212CFF1B9300AEF16D /* WebsiteGenerator+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5202CFF1B8900AEF16D /* WebsiteGenerator+Mock.swift */; };
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */; };
E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */; };
E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5262CFF745200AEF16D /* URL+Extensions.swift */; };
E25DA5292CFFBFBB00AEF16D /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5282CFFBFB800AEF16D /* ImageType.swift */; };
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */; };
E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */; };
E2A21C012CB16A820060935B /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C002CB16A820060935B /* PostView.swift */; };
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; };
E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C042CB176670060935B /* LocalizedText.swift */; };
@ -136,6 +142,10 @@
E25DA51C2CFF135B00AEF16D /* GenericPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericPage.swift; sourceTree = "<group>"; };
E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = "<group>"; };
E25DA5202CFF1B8900AEF16D /* WebsiteGenerator+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebsiteGenerator+Mock.swift"; sourceTree = "<group>"; };
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGenerator.swift; sourceTree = "<group>"; };
E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSize+Scaling.swift"; sourceTree = "<group>"; };
E25DA5262CFF745200AEF16D /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; };
E25DA5282CFFBFB800AEF16D /* ImageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageType.swift; sourceTree = "<group>"; };
E2A21C002CB16A820060935B /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = "<group>"; };
E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = "<group>"; };
E2A21C042CB176670060935B /* LocalizedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedText.swift; sourceTree = "<group>"; };
@ -194,6 +204,8 @@
buildActionMask = 2147483647;
files = (
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */,
E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */,
E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -278,7 +290,9 @@
isa = PBXGroup;
children = (
E25DA5112CFF001900AEF16D /* Model */,
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
E2A37D0D2CE527040000979F /* Storage.swift */,
E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */,
);
path = Storage;
sourceTree = "<group>";
@ -298,23 +312,23 @@
E2B85F392C428F020047CD0C /* Model */ = {
isa = PBXGroup;
children = (
E25DA5282CFFBFB800AEF16D /* ImageType.swift */,
E21850322CFAFA200090B18B /* WebsiteData.swift */,
E21850362CFCA5580090B18B /* LocalizedWebsiteData.swift */,
E2E06DFA2CA4A6570019C2AF /* Content.swift */,
E25DA5162CFF00F200AEF16D /* Content+Save.swift */,
E25DA5142CFF00B900AEF16D /* Content+Load.swift */,
E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */,
E21850302CFAF8840090B18B /* Content+Import.swift */,
E24252092C52C9260029FF16 /* ContentLanguage.swift */,
E2A21C502CBBD53C0060935B /* FileResource.swift */,
E2A21C3A2CB9D9A50060935B /* ImageResource.swift */,
E2A21C042CB176670060935B /* LocalizedText.swift */,
E25A0B882CE4021400F33674 /* LocalizedPage.swift */,
E2B85F3A2C428F0D0047CD0C /* Post.swift */,
E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */,
E2581DEC2C75202400F1F079 /* Tag.swift */,
E2A37D182CEA36A40000979F /* LocalizedTag.swift */,
E2A9CB7D2C7BCF2A005C89CC /* Page.swift */,
E25A0B882CE4021400F33674 /* LocalizedPage.swift */,
);
path = Model;
sourceTree = "<group>";
@ -379,6 +393,8 @@
E2B85F552C4BD0AD0047CD0C /* Extensions */ = {
isa = PBXGroup;
children = (
E25DA5262CFF745200AEF16D /* URL+Extensions.swift */,
E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */,
E25DA5182CFF035200AEF16D /* Array+Split.swift */,
E2A37D202CEA94E80000979F /* Sequence+Sorted.swift */,
E2A37D1E2CEA94330000979F /* Optional+Extensions.swift */,
@ -458,6 +474,8 @@
packageProductDependencies = (
E2B85F352C426BEE0047CD0C /* SFSafeSymbols */,
E24252002C50E0A40029FF16 /* HighlightedTextEditor */,
E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */,
E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */,
);
productName = CHDataManagement;
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
@ -490,6 +508,8 @@
packageReferences = (
E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */,
E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */,
E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */,
);
productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
projectDirPath = "";
@ -572,6 +592,7 @@
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
E2B85F412C4294790047CD0C /* PageHead.swift in Sources */,
E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */,
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */,
E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */,
@ -579,6 +600,7 @@
E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */,
E218503D2CFCFD910090B18B /* LocalizedSettingsView.swift in Sources */,
E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */,
E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */,
E21850372CFCA55F0090B18B /* LocalizedWebsiteData.swift in Sources */,
E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */,
E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */,
@ -586,6 +608,7 @@
E25DA50F2CFDD76B00AEF16D /* ImagesContentView.swift in Sources */,
E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */,
E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */,
E25DA5292CFFBFBB00AEF16D /* ImageType.swift in Sources */,
E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */,
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
E2A21C542CBBF87A0060935B /* FilesView.swift in Sources */,
@ -594,6 +617,7 @@
E218502D2CF791440090B18B /* PostImagesView.swift in Sources */,
E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */,
E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */,
E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */,
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */,
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */,
E25DA5152CFF00C100AEF16D /* Content+Load.swift in Sources */,
@ -833,6 +857,22 @@
minimumVersion = 2.1.2;
};
};
E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SDWebImage/SDWebImageAVIFCoder.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.11.0;
};
};
E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.14.6;
};
};
E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
@ -849,6 +889,16 @@
package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */;
productName = HighlightedTextEditor;
};
E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */ = {
isa = XCSwiftPackageProductDependency;
package = E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */;
productName = SDWebImageAVIFCoder;
};
E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */ = {
isa = XCSwiftPackageProductDependency;
package = E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */;
productName = SDWebImageWebPCoder;
};
E2B85F352C426BEE0047CD0C /* SFSafeSymbols */ = {
isa = XCSwiftPackageProductDependency;
package = E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;

View File

@ -1,5 +1,5 @@
{
"originHash" : "a865991f5fa01ecfb2e7afd44ef74d1e86f52c8f7eec6be4e188382e4051b34c",
"originHash" : "fbe90465f57759d9e85fb24c88e821179f0610fa0fa1239083ea8ffab228185f",
"pins" : [
{
"identity" : "highlightedtexteditor",
@ -10,6 +10,69 @@
"version" : "2.1.2"
}
},
{
"identity" : "libaom-xcode",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/libaom-Xcode.git",
"state" : {
"revision" : "482cafbebbc5f32378b82339b7580761fab4fd23",
"version" : "2.0.2"
}
},
{
"identity" : "libavif-xcode",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/libavif-Xcode.git",
"state" : {
"revision" : "a158cb024166f8c599f88f8c91458c59922bcb0f",
"version" : "0.11.1"
}
},
{
"identity" : "libvmaf-xcode",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/libvmaf-Xcode.git",
"state" : {
"revision" : "41db5dc11d05c02d1aca7de0b572a068f528c37c",
"version" : "2.3.1"
}
},
{
"identity" : "libwebp-xcode",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/libwebp-Xcode.git",
"state" : {
"revision" : "b2b1d20a90b14d11f6ef4241da6b81c1d3f171e4",
"version" : "1.3.2"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage.git",
"state" : {
"revision" : "10d06f6a33bafae8c164fbfd1f03391f6d4692b3",
"version" : "5.20.0"
}
},
{
"identity" : "sdwebimageavifcoder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImageAVIFCoder.git",
"state" : {
"revision" : "715df4ace986e1fb332c8c28b45b73dba6a40e5a",
"version" : "0.11.0"
}
},
{
"identity" : "sdwebimagewebpcoder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImageWebPCoder",
"state" : {
"revision" : "f534cfe830a7807ecc3d0332127a502426cfa067",
"version" : "0.14.6"
}
},
{
"identity" : "sfsafesymbols",
"kind" : "remoteSourceControl",

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 {