Generate first feed pages, images
This commit is contained in:
224
CHDataManagement/Storage/ImageGenerator.swift
Normal file
224
CHDataManagement/Storage/ImageGenerator.swift
Normal 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])
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
183
CHDataManagement/Storage/WebsiteGenerator.swift
Normal file
183
CHDataManagement/Storage/WebsiteGenerator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user