Simplify images, tag overview

This commit is contained in:
Christoph Hagen 2025-01-04 08:44:26 +01:00
parent 4d4275e072
commit 22e7d9a05a
49 changed files with 603 additions and 509 deletions

View File

@ -41,7 +41,7 @@
E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903B2D0F8A74009F8D77 /* OptionalTextFieldPropertyView.swift */; };
E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903D2D0F8EFD009F8D77 /* StringPropertyView.swift */; };
E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */; };
E22990422D107A95009F8D77 /* ImageJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990412D107A94009F8D77 /* ImageJob.swift */; };
E22990422D107A95009F8D77 /* ImageVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990412D107A94009F8D77 /* ImageVersion.swift */; };
E22990462D10B7A7009F8D77 /* SecurityScopeStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990452D10B7A6009F8D77 /* SecurityScopeStatus.swift */; };
E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990472D10B7B7009F8D77 /* StorageAccessError.swift */; };
E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */; };
@ -80,7 +80,6 @@
E25DA5852D01C92700AEF16D /* ShorthandMarkdownKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */; };
E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */; };
E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58A2D020C9200AEF16D /* PageImage.swift */; };
E25DA58D2D021BA400AEF16D /* WebsiteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */; };
E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58E2D02368A00AEF16D /* PageSettings.swift */; };
E25DA5912D023A8400AEF16D /* IntegerField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5902D023A7E00AEF16D /* IntegerField.swift */; };
E25DA5932D023B3C00AEF16D /* PageSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5922D023B3600AEF16D /* PageSettingsFile.swift */; };
@ -209,6 +208,9 @@
E2FE0F112D268E7E002963B7 /* PageCodeProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F102D268E78002963B7 /* PageCodeProcessor.swift */; };
E2FE0F152D26918F002963B7 /* PageHtmlProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F142D269188002963B7 /* PageHtmlProcessor.swift */; };
E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */; };
E2FE0F192D2723E3002963B7 /* ImageSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F182D2723E3002963B7 /* ImageSet.swift */; };
E2FE0F1B2D274FDF002963B7 /* LinkPreviewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F1A2D274FDA002963B7 /* LinkPreviewItem.swift */; };
E2FE0F1E2D281AE1002963B7 /* TagOverviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F1D2D281ACE002963B7 /* TagOverviewGenerator.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -246,7 +248,7 @@
E229903B2D0F8A74009F8D77 /* OptionalTextFieldPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextFieldPropertyView.swift; sourceTree = "<group>"; };
E229903D2D0F8EFD009F8D77 /* StringPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringPropertyView.swift; sourceTree = "<group>"; };
E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderOnDiskPropertyView.swift; sourceTree = "<group>"; };
E22990412D107A94009F8D77 /* ImageJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageJob.swift; sourceTree = "<group>"; };
E22990412D107A94009F8D77 /* ImageVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageVersion.swift; sourceTree = "<group>"; };
E22990452D10B7A6009F8D77 /* SecurityScopeStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityScopeStatus.swift; sourceTree = "<group>"; };
E22990472D10B7B7009F8D77 /* StorageAccessError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageAccessError.swift; sourceTree = "<group>"; };
E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityScopeBookmark.swift; sourceTree = "<group>"; };
@ -281,7 +283,6 @@
E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShorthandMarkdownKey.swift; sourceTree = "<group>"; };
E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Generation.swift"; sourceTree = "<group>"; };
E25DA58A2D020C9200AEF16D /* PageImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImage.swift; sourceTree = "<group>"; };
E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteImage.swift; sourceTree = "<group>"; };
E25DA58E2D02368A00AEF16D /* PageSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettings.swift; sourceTree = "<group>"; };
E25DA5902D023A7E00AEF16D /* IntegerField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegerField.swift; sourceTree = "<group>"; };
E25DA5922D023B3600AEF16D /* PageSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsFile.swift; sourceTree = "<group>"; };
@ -409,6 +410,9 @@
E2FE0F102D268E78002963B7 /* PageCodeProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageCodeProcessor.swift; sourceTree = "<group>"; };
E2FE0F142D269188002963B7 /* PageHtmlProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHtmlProcessor.swift; sourceTree = "<group>"; };
E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPageId.swift; sourceTree = "<group>"; };
E2FE0F182D2723E3002963B7 /* ImageSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSet.swift; sourceTree = "<group>"; };
E2FE0F1A2D274FDA002963B7 /* LinkPreviewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewItem.swift; sourceTree = "<group>"; };
E2FE0F1D2D281ACE002963B7 /* TagOverviewGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagOverviewGenerator.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -432,6 +436,7 @@
E229901A2D0E3F09009F8D77 /* Item */ = {
isa = PBXGroup;
children = (
E2FE0F1A2D274FDA002963B7 /* LinkPreviewItem.swift */,
E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */,
E229902B2D0F6FC0009F8D77 /* ItemId.swift */,
E229901D2D0E4362009F8D77 /* LocalizedItem.swift */,
@ -488,15 +493,15 @@
children = (
E2FE0F072D2689DC002963B7 /* Post Lists */,
E29D31B62D0DAC030051B7F4 /* Page Content */,
E2FE0F1C2D281A7B002963B7 /* Page Generators */,
E22990232D0EDBD0009F8D77 /* HeaderElement.swift */,
E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */,
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
E22990412D107A94009F8D77 /* ImageJob.swift */,
E22990412D107A94009F8D77 /* ImageVersion.swift */,
E2FE0F182D2723E3002963B7 /* ImageSet.swift */,
E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */,
E29D316C2D07A4FF0051B7F4 /* PageGenerationResults.swift */,
E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */,
E25DA5982D02401A00AEF16D /* PageGenerator.swift */,
E2B85F3C2C4293F80047CD0C /* FeedPageGenerator.swift */,
E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */,
E29D31252D0370A50051B7F4 /* VideoOption.swift */,
E29D318F2D0B34870051B7F4 /* GenerationAnomaly.swift */,
@ -726,7 +731,6 @@
isa = PBXGroup;
children = (
E29D311E2D0320D90051B7F4 /* ContentElements */,
E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */,
E25DA58A2D020C9200AEF16D /* PageImage.swift */,
E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */,
E2FE0EF92D25AFB5002963B7 /* PageHeader.swift */,
@ -848,6 +852,16 @@
path = "Post Lists";
sourceTree = "<group>";
};
E2FE0F1C2D281A7B002963B7 /* Page Generators */ = {
isa = PBXGroup;
children = (
E2FE0F1D2D281ACE002963B7 /* TagOverviewGenerator.swift */,
E25DA5982D02401A00AEF16D /* PageGenerator.swift */,
E2B85F3C2C4293F80047CD0C /* FeedPageGenerator.swift */,
);
path = "Page Generators";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -972,7 +986,7 @@
E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */,
E29D31852D0AE8EE0051B7F4 /* KnownHeaderElement.swift in Sources */,
E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */,
E22990422D107A95009F8D77 /* ImageJob.swift in Sources */,
E22990422D107A95009F8D77 /* ImageVersion.swift in Sources */,
E29D317F2D086F4C0051B7F4 /* StatisticsIcons.swift in Sources */,
E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */,
E2A21C082CB17B870060935B /* TagView.swift in Sources */,
@ -987,7 +1001,6 @@
E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */,
E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */,
E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */,
E25DA58D2D021BA400AEF16D /* WebsiteImage.swift in Sources */,
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */,
E25DA5382D00420E00AEF16D /* LocalizedPostSettingsFile.swift in Sources */,
E2B85F3D2C4293F80047CD0C /* FeedPageGenerator.swift in Sources */,
@ -1043,6 +1056,7 @@
E29D316D2D07A5050051B7F4 /* PageGenerationResults.swift in Sources */,
E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */,
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
E2FE0F1B2D274FDF002963B7 /* LinkPreviewItem.swift in Sources */,
E2FE0F062D267350002963B7 /* TextFieldPropertyView.swift in Sources */,
E29D31B82D0DAC250051B7F4 /* ButtonCommand.swift in Sources */,
E29D31962D0C186E0051B7F4 /* PathSettings.swift in Sources */,
@ -1122,8 +1136,10 @@
E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */,
E25DA5932D023B3C00AEF16D /* PageSettingsFile.swift in Sources */,
E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */,
E2FE0F192D2723E3002963B7 /* ImageSet.swift in Sources */,
E2A21C362CB9A3D70060935B /* PathSettingsView.swift in Sources */,
E29D31362D0435430051B7F4 /* TabSelection.swift in Sources */,
E2FE0F1E2D281AE1002963B7 /* TagOverviewGenerator.swift in Sources */,
E29D31572D06D38B0051B7F4 /* AddTagView.swift in Sources */,
E25DA5362D0041EB00AEF16D /* PostSettingsFile.swift in Sources */,
E29D31792D083DE50051B7F4 /* PageContentResultsView.swift in Sources */,

View File

@ -26,7 +26,7 @@ final class GenerationResults: ObservableObject {
var requiredFiles: Set<FileResource> = []
@Published
var imagesToGenerate: Set<ImageGenerationJob> = []
var imagesToGenerate: Set<ImageVersion> = []
@Published
var invalidCommands: Set<String> = []
@ -38,7 +38,7 @@ final class GenerationResults: ObservableObject {
var unsavedOutputFiles: Set<String> = []
@Published
var failedImages: Set<ImageGenerationJob> = []
var failedImages: Set<ImageVersion> = []
@Published
var emptyPages: Set<LocalizedPageId> = []
@ -151,11 +151,11 @@ final class GenerationResults: ObservableObject {
update { self.requiredFiles.formUnion(files) }
}
func generate(_ image: ImageGenerationJob) {
func generate(_ image: ImageVersion) {
update { self.imagesToGenerate.insert(image) }
}
func generate<S>(_ images: S) where S: Sequence, S.Element == ImageGenerationJob {
func generate<S>(_ images: S) where S: Sequence, S.Element == ImageVersion {
update { self.imagesToGenerate.formUnion(images) }
}
@ -167,7 +167,7 @@ final class GenerationResults: ObservableObject {
update { self.warnings.insert(warning) }
}
func failed(image: ImageGenerationJob) {
func failed(image: ImageVersion) {
update { self.failedImages.insert(image) }
}

View File

@ -15,20 +15,33 @@ final class ImageGenerator {
self.storage = storage
self.settings = settings
self.generatedImages = storage.loadListOfGeneratedImages() ?? [:]
print("ImageGenerator: Loaded list of \(totalImageCount) already generated images")
}
private var outputFolder: String {
settings.paths.imagesOutputFolderPath
}
private var totalImageCount: Int {
generatedImages.values.reduce(0) { $0 + $1.count }
}
@discardableResult
func save() -> Bool {
guard storage.save(listOfGeneratedImages: generatedImages) else {
print("Failed to save list of generated images")
print("ImageGenerator: Failed to save list of generated images")
return false
}
print("ImageGenerator: Saved list of \(totalImageCount) images")
return true
}
private var avifCommands: Set<String> = []
func printAvifCommands() {
avifCommands.sorted().forEach { print($0) }
}
/**
Remove all versions of an image, so that they will be recreated on the next run.
@ -44,26 +57,27 @@ final class ImageGenerator {
print("Image generator: \(generatedImages.count)/\(images.count) images (\(versionCount) versions)")
}
private func needsToGenerate(version: String, for image: String) -> Bool {
if exists(version) {
private func hasPreviouslyGenerated(_ version: ImageVersion) -> Bool {
guard let versions = generatedImages[version.image.id] else {
return false
}
guard let versions = generatedImages[image] else {
return true
}
guard versions.contains(version) else {
return true
}
return !exists(version)
return versions.contains(version.versionId)
}
private func hasNowGenerated(version: String, for image: String) {
guard var versions = generatedImages[image] else {
generatedImages[image] = [version]
return
private func needsToGenerate(_ version: ImageVersion) -> Bool {
if hasPreviouslyGenerated(version) {
return false
}
versions.insert(version)
generatedImages[image] = versions
if exists(version) {
// Mark as already generated
hasNowGenerated(version)
return false
}
return true
}
private func hasNowGenerated(_ version: ImageVersion) {
generatedImages[version.image.id, default: []].insert(version.versionId)
}
private func removeVersions(for image: String) {
@ -72,53 +86,59 @@ final class ImageGenerator {
// MARK: Files
private func exists(_ image: String) -> Bool {
storage.hasFileInOutputFolder(relativePath(for: image))
private func exists(_ version: ImageVersion) -> Bool {
storage.hasFileInOutputFolder(version.outputPath)
}
private func relativePath(for image: String) -> String {
outputFolder + "/" + image
}
private func write(imageData data: Data, version: String) -> Bool {
return storage.write(data, to: relativePath(for: version))
private func write(imageData data: Data, of version: ImageVersion) -> Bool {
return storage.write(data, to: version.outputPath)
}
// MARK: Image operations
func generate(job: ImageGenerationJob) -> Bool {
guard needsToGenerate(version: job.version, for: job.image) else {
func generate(version: ImageVersion) -> Bool {
guard needsToGenerate(version) else {
return true
}
guard let data = storage.fileData(for: job.image) else {
print("Failed to load image \(job.image)")
guard let data = version.image.dataContent() else {
print("ImageGenerator: Failed to load data for image \(version.image.id)")
return false
}
guard let originalImage = NSImage(data: data) else {
print("Failed to load image")
print("ImageGenerator: Failed to load image \(version.image.id)")
return false
}
let representation = create(image: originalImage, width: CGFloat(job.maximumWidth), height: CGFloat(job.maximumHeight))
let representation = create(image: originalImage, width: CGFloat(version.maximumWidth), height: CGFloat(version.maximumHeight))
guard let data = create(image: representation, type: job.type, quality: job.quality) else {
print("Failed to get data for type \(job.type)")
guard let data = create(image: representation, type: version.type, quality: version.quality) else {
print("ImageGenerator: Failed to get data for type \(version.type) of image \(version.image.id)")
return false
}
if job.type == .avif {
let input = job.version.fileNameAndExtension.fileName + "." + job.image.fileExtension!
print("avifenc -q 70 \(input) \(job.version)")
hasNowGenerated(version: job.version, for: job.image)
if version.type == .avif {
// AVIF conversion is very slow, so we save bash commands
// for the conversion instead
let baseVersion = ImageVersion(
image: version.image,
type: version.image.type,
maximumWidth: version.maximumWidth,
maximumHeight: version.maximumHeight)
let originalImagePath = storage.outputPath(to: baseVersion.outputPath)!.path()
let generatedImagePath = storage.outputPath(to: version.outputPath)!.path()
let quality = Int(version.quality * 100)
avifCommands.insert("avifenc -q \(quality) '\(originalImagePath)' '\(generatedImagePath)'")
// hasNowGenerated(version)
return true
}
guard write(imageData: data, version: job.version) else {
guard write(imageData: data, of: version) else {
return false
}
hasNowGenerated(version: job.version, for: job.image)
hasNowGenerated(version)
return true
}

View File

@ -1,73 +0,0 @@
import Foundation
struct ImageGenerationJob {
let image: String
let type: FileType
let maximumWidth: Int
let maximumHeight: Int
let quality: CGFloat
init(image: String, type: FileType, maximumWidth: CGFloat, maximumHeight: CGFloat, quality: CGFloat = 0.7) {
self.image = image
self.type = type
self.maximumWidth = Int(maximumWidth)
self.maximumHeight = Int(maximumHeight)
self.quality = quality
}
init(image: String, type: FileType, maximumWidth: Int, maximumHeight: Int, quality: CGFloat = 0.7) {
self.image = image
self.type = type
self.maximumWidth = maximumWidth
self.maximumHeight = maximumHeight
self.quality = quality
}
var version: String {
let fileName = image.fileNameAndExtension.fileName
let prefix = "\(fileName)@\(maximumWidth)x\(maximumHeight)"
return "\(prefix).\(type.fileExtension)"
}
static func imageSet(for image: String, maxWidth: Int, maxHeight: Int, quality: CGFloat = 0.7) -> [ImageGenerationJob] {
let type = FileType(fileExtension: image.fileExtension)
let width2x = maxWidth * 2
let height2x = maxHeight * 2
return [
.init(image: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x, quality: quality),
.init(image: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x, quality: quality),
.init(image: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: type, maximumWidth: width2x, maximumHeight: height2x, quality: quality)
]
}
}
extension ImageGenerationJob: Equatable {
static func == (lhs: ImageGenerationJob, rhs: ImageGenerationJob) -> Bool {
lhs.version == rhs.version
}
}
extension ImageGenerationJob: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(version)
}
}
extension ImageGenerationJob: Comparable {
static func < (lhs: ImageGenerationJob, rhs: ImageGenerationJob) -> Bool {
lhs.version < rhs.version
}
}

View File

@ -0,0 +1,52 @@
import Foundation
struct ImageSet {
let image: FileResource
let maxWidth: Int
let maxHeight: Int
let quality: CGFloat
let description: String
init(image: FileResource, maxWidth: Int, maxHeight: Int, description: String, quality: CGFloat = 0.7) {
self.image = image
self.maxWidth = maxWidth
self.maxHeight = maxHeight
self.description = description
self.quality = quality
}
var jobs: [ImageVersion] {
let type = image.type
let width2x = maxWidth * 2
let height2x = maxHeight * 2
return [
.init(image: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x, quality: quality),
.init(image: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x, quality: quality),
.init(image: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: type, maximumWidth: width2x, maximumHeight: height2x, quality: quality)
]
}
var content: String {
let fileExtension = image.type.fileExtension.map { "." + $0 } ?? ""
let prefix1x = "/\(image.outputImageFolder)/\(maxWidth)x\(maxHeight)"
let prefix2x = "/\(image.outputImageFolder)/\(maxWidth*2)x\(maxHeight*2)"
var result = "<picture>"
result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>"
result += "<source type='image/webp' srcset='\(prefix1x).webm 1x, \(prefix1x).webm 2x'/>"
result += "<img srcset='\(prefix2x)\(fileExtension) 2x' src='\(prefix1x)\(fileExtension)' loading='lazy' alt='\(description.htmlEscaped())'/>"
result += "</picture>"
return result
}
}

View File

@ -0,0 +1,78 @@
import Foundation
/**
A version of an image with a specific size and possible different image type.
*/
struct ImageVersion {
/// The name of the image file to convert
let image: FileResource
let type: FileType
let maximumWidth: Int
let maximumHeight: Int
let quality: CGFloat
init(image: FileResource, type: FileType, maximumWidth: CGFloat, maximumHeight: CGFloat, quality: CGFloat = 0.7) {
self.image = image
self.type = type
self.maximumWidth = Int(maximumWidth)
self.maximumHeight = Int(maximumHeight)
self.quality = quality
}
init(image: FileResource, type: FileType, maximumWidth: Int, maximumHeight: Int, quality: CGFloat = 0.7) {
self.image = image
self.type = type
self.maximumWidth = maximumWidth
self.maximumHeight = maximumHeight
self.quality = quality
}
/// A unique id of the version for this image (not unique across images)
var versionId: String {
"\(maximumWidth)-\(maximumHeight)-\(type.fileExtension!)"
}
/// The path of the generated image version in the output folder (without leading slash)
var outputPath: String {
image.outputPath(width: maximumWidth, height: maximumHeight, type: type)
}
}
extension ImageVersion: Identifiable {
var id: String {
image.id + "-" + versionId
}
}
extension ImageVersion: Equatable {
static func == (lhs: ImageVersion, rhs: ImageVersion) -> Bool {
lhs.image.id == rhs.image.id &&
lhs.maximumWidth == rhs.maximumWidth &&
lhs.maximumHeight == rhs.maximumHeight &&
lhs.type == rhs.type
}
}
extension ImageVersion: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(image.id)
hasher.combine(maximumWidth)
hasher.combine(maximumHeight)
hasher.combine(type)
}
}
extension ImageVersion: Comparable {
static func < (lhs: ImageVersion, rhs: ImageVersion) -> Bool {
lhs.id < rhs.id
}
}

View File

@ -39,8 +39,6 @@ extension KnownHeaderElement: Comparable {
static func < (lhs: KnownHeaderElement, rhs: KnownHeaderElement) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
extension KnownHeaderElement: CustomStringConvertible {

View File

@ -50,12 +50,19 @@ struct AudioPlayerCommandProcessor: CommandProcessor {
results.missing(file: song.cover, containedIn: file)
continue
}
guard image.type.isImage else {
results.warning("Cover '\(song.cover)' in file \(fileId) is not an image file")
continue
}
guard let audioFile = content.file(song.file) else {
results.missing(file: song.cover, containedIn: file)
continue
}
#warning("Check if file is audio")
guard audioFile.type.isAudio else {
results.warning("Song '\(song.file)' in file \(fileId) is not an audio file")
continue
}
let coverUrl = image.absoluteUrl
let playlistItem = AudioPlayer.PlaylistItem(

View File

@ -106,7 +106,8 @@ struct PageHtmlProcessor: CommandProcessor {
results.warning("Failed to find <source> elements in inline HTML: \(error)")
return
}
checkSourceSetAttributes(sources: sources)
checkSourceAttributes(sources: sources)
}
private func checkSourceSetAttributes(sources: [Element]) {

View File

@ -29,7 +29,8 @@ final class FeedPageGenerator {
showTitle: Bool,
pageNumber: Int,
totalPages: Int,
languageButtonUrl: String) -> String {
languageButtonUrl: String,
linkPrefix: String) -> String {
var headers = content.defaultPageHeaders
var footer = ""
if posts.contains(where: { $0.images.count > 1 }) {
@ -63,7 +64,10 @@ final class FeedPageGenerator {
content += FeedEntry(data: post).content
}
if totalPages > 1 {
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
content += PostFeedPageNavigation(
linkPrefix: linkPrefix,
currentPage: pageNumber,
numberOfPages: totalPages).content
}
}
return page.content

View File

@ -6,12 +6,11 @@ final class PageGenerator {
self.content = content
}
private func makeHeaders(requiredItems: Set<KnownHeaderElement>) -> Set<HeaderElement> {
private func makeHeaders(requiredItems: Set<KnownHeaderElement>, results: PageGenerationResults) -> Set<HeaderElement> {
var result = content.defaultPageHeaders
for item in requiredItems {
guard let header = item.header(content: content) else {
print("Missing header \(item)")
#warning("Add warning on missing file assignment")
results.warning("Header \(item) not configured in settings")
continue
}
result.insert(header)
@ -20,6 +19,7 @@ final class PageGenerator {
}
private func makeEmptyPageContent(in language: ContentLanguage) -> String {
#warning("Configure empty page text in settings")
switch language {
case .english:
return ContentBox(
@ -56,7 +56,7 @@ final class PageGenerator {
url: tag.absoluteUrl(in: language))
}
let headers = makeHeaders(requiredItems: results.requiredHeaders)
let headers = makeHeaders(requiredItems: results.requiredHeaders, results: results)
results.require(files: headers.compactMap { $0.file })
let iconUrl = content.settings.navigation.localized(in: language).rootUrl

View File

@ -0,0 +1,74 @@
final class TagOverviewGenerator {
let content: Content
let language: ContentLanguage
let results: PageGenerationResults
init(content: Content, language: ContentLanguage, results: PageGenerationResults) {
self.content = content
self.language = language
self.results = results
}
func generatePage(tags: [Tag], overview: TagOverviewPage) {
let iconUrl = content.settings.navigation.localized(in: language).rootUrl
let languageUrl = overview.absoluteUrl(in: language.next)
let languageButton = NavigationBar.Link(
text: language.next.rawValue,
url: languageUrl)
let localized = overview.localized(in: language)
let pageHeader = PageHeader(
language: language,
title: localized.linkPreviewTitle ?? localized.title,
description: localized.linkPreviewDescription,
iconUrl: iconUrl,
languageButton: languageButton,
links: content.navigationBar(in: language),
headers: content.defaultPageHeaders,
icons: [])
let page = GenericPage(
header: pageHeader,
additionalFooter: "") { content in
content += "<h1>\(localized.title)</h1>"
for tag in tags {
let localized = tag.localized(in: self.language)
let url = tag.absoluteUrl(in: self.language)
let title = localized.name
let description = localized.description ?? ""
let image = self.makePageImage(item: localized)
content += RelatedPageLink(
title: title,
description: description,
url: url,
image: image)
.content
}
// if totalPages > 1 {
// content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
// }
}
let fileContent = page.content
let url = overview.absoluteUrl(in: language) + ".html"
guard content.storage.write(fileContent, to: url) else {
results.unsavedOutput(url, source: .tagOverview)
return
}
}
private func makePageImage(item: LinkPreviewItem) -> ImageSet? {
item.linkPreviewImage.map { image in
let size = content.settings.pages.pageLinkImageSize
let imageSet = image.imageSet(width: size, height: size, language: language)
results.require(imageSet: imageSet)
return imageSet
}
}
}

View File

@ -160,20 +160,11 @@ final class PageContentParser {
guard !image.type.isSvg else {
return SvgImage(imagePath: path, altText: altText).content
}
let thumbnail = image.imageSet(width: thumbnailWidth, height: thumbnailWidth, language: language)
results.require(imageSet: thumbnail)
let thumbnail = FeedEntryData.Image(
rawImagePath: path,
width: thumbnailWidth,
height: thumbnailWidth,
altText: altText)
results.requireImageSet(for: image, size: thumbnailWidth)
let largeImage = FeedEntryData.Image(
rawImagePath: path,
width: largeImageWidth,
height: largeImageWidth,
altText: altText)
results.requireImageSet(for: image, size: largeImageWidth)
let largeImage = image.imageSet(width: largeImageWidth, height: largeImageWidth, language: language)
results.require(imageSet: largeImage)
return PageImage(
imageId: imageId.replacingOccurrences(of: ".", with: "-"),
@ -221,18 +212,18 @@ final class PageContentParser {
results.invalid(command: .video, markdown)
return nil
}
if case let .poster(imageId) = option {
switch option {
case .poster(let imageId):
if let image = content.image(imageId) {
results.used(file: image)
let width = 2*thumbnailWidth
let fullLink = WebsiteImage.imagePath(source: image.absoluteUrl, width: width, height: width)
return .poster(image: fullLink)
let version = image.imageVersion(width: width, height: width, type: .jpg)
results.require(image: version)
return .poster(image: version.outputPath)
} else {
results.missing(file: imageId, source: "Video command poster")
return nil // Image file not present, so skip the option
}
}
if case let .src(videoId) = option {
case .src(let videoId):
if let video = content.video(videoId) {
results.used(file: video)
let link = video.absoluteUrl
@ -241,8 +232,9 @@ final class PageContentParser {
results.missing(file: videoId, source: "Video command source")
return nil // Video file not present, so skip the option
}
default:
return option
}
return option
}
/**
@ -270,17 +262,7 @@ final class PageContentParser {
let url = page.absoluteUrl(in: language)
let title = localized.linkPreviewTitle ?? localized.title
let description = localized.linkPreviewDescription ?? ""
let image = localized.linkPreviewImage.map { image in
let size = content.settings.pages.pageLinkImageSize
results.used(file: image)
results.requireImageSet(for: image, size: size)
return RelatedPageLink.Image(
url: image.absoluteUrl,
description: image.localized(in: language),
size: size)
}
let image = makePageImage(item: localized)
return RelatedPageLink(
title: title,
@ -309,16 +291,7 @@ final class PageContentParser {
let url = tag.absoluteUrl(in: language)
let title = localized.name
let description = localized.description ?? ""
let image = localized.linkPreviewImage.map { image in
let size = content.settings.pages.pageLinkImageSize
results.requireImageSet(for: image, size: size)
return RelatedPageLink.Image(
url: image.absoluteUrl,
description: image.localized(in: language),
size: size)
}
let image = makePageImage(item: localized)
return RelatedPageLink(
title: title,
@ -328,6 +301,15 @@ final class PageContentParser {
.content
}
private func makePageImage(item: LinkPreviewItem) -> ImageSet? {
item.linkPreviewImage.map { image in
let size = content.settings.pages.pageLinkImageSize
let imageSet = image.imageSet(width: size, height: size, language: language)
results.require(imageSet: imageSet)
return imageSet
}
}
/**
Format: `![model](<file>)`
*/

View File

@ -64,7 +64,7 @@ final class PageGenerationResults: ObservableObject {
private(set) var requiredFiles: Set<FileResource>
/// The image versions required for this page
private(set) var imagesToGenerate: Set<ImageGenerationJob>
private(set) var imagesToGenerate: Set<ImageVersion>
private(set) var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)] = []
@ -127,10 +127,16 @@ final class PageGenerationResults: ObservableObject {
delegate.missing(file: file)
}
func requireImageSet(for image: FileResource, size: Int) {
let jobs = ImageGenerationJob.imageSet(for: image.id, maxWidth: size, maxHeight: size)
func require(image: ImageVersion) {
imagesToGenerate.insert(image)
used(file: image.image)
delegate.generate(image)
}
func require(imageSet: ImageSet) {
let jobs = imageSet.jobs
imagesToGenerate.formUnion(jobs)
used(file: image)
used(file: imageSet.image)
delegate.generate(jobs)
}

View File

@ -11,6 +11,10 @@ struct FeedGeneratorSource: PostListPageGeneratorSource {
false
}
var postsPerPage: Int {
content.settings.posts.postsPerPage
}
var pageTitle: String {
content.settings.localized(in: language).title
}

View File

@ -16,12 +16,8 @@ final class PostListPageGenerator {
source.content.settings.posts.contentWidth
}
private var postsPerPage: Int {
source.content.settings.posts.postsPerPage
}
private func pageUrl(in language: ContentLanguage, pageNumber: Int) -> String {
"\(source.pageUrlPrefix(for: language))/\(pageNumber).html"
"\(source.pageUrlPrefix(for: language))/\(pageNumber)"
}
func createPages(for posts: [Post]) {
@ -29,6 +25,7 @@ final class PostListPageGenerator {
guard totalCount > 0 else {
return
}
let postsPerPage = source.postsPerPage
let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up
for pageIndex in 1...numberOfPages {
@ -43,10 +40,11 @@ final class PostListPageGenerator {
let posts: [FeedEntryData] = posts.map { post in
let localized: LocalizedPost = post.localized(in: language)
#warning("Add post link text to settings or to each post")
let linkUrl = post.linkedPage.map {
FeedEntryData.Link(
url: $0.absoluteUrl(in: language),
text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings
text: language == .english ? "View" : "Anzeigen")
}
let tags: [FeedEntryData.Tag] = post.tags.filter { $0.isVisible }.map { tag in
@ -54,7 +52,10 @@ final class PostListPageGenerator {
url: tag.absoluteUrl(in: language))
}
let images = localized.images.map(createFeedImage)
let images = localized.images.map { image in
image.imageSet(width: mainContentMaximumWidth, height: mainContentMaximumWidth, language: language)
}
images.forEach(source.results.require)
return FeedEntryData(
entryId: post.id,
@ -68,7 +69,7 @@ final class PostListPageGenerator {
let feedPageGenerator = FeedPageGenerator(content: source.content, results: source.results)
let languageButtonUrl = pageUrl(in: language.next, pageNumber: pageIndex)
let languageButtonUrl = "/" + pageUrl(in: language.next, pageNumber: pageIndex)
let fileContent = feedPageGenerator.generatePage(
language: language,
@ -78,23 +79,15 @@ final class PostListPageGenerator {
showTitle: source.showTitle,
pageNumber: pageIndex,
totalPages: pageCount,
languageButtonUrl: languageButtonUrl)
let filePath = pageUrl(in: language, pageNumber: pageIndex)
languageButtonUrl: languageButtonUrl,
linkPrefix: "/" + source.pageUrlPrefix(for: language) + "/")
let filePath = pageUrl(in: language, pageNumber: pageIndex) + ".html"
guard save(fileContent, to: filePath) else {
source.results.unsavedOutput(filePath, source: .feed)
return
}
}
private func createFeedImage(for image: FileResource) -> FeedEntryData.Image {
source.results.requireImageSet(for: image, size: mainContentMaximumWidth)
return .init(
rawImagePath: image.absoluteUrl,
width: mainContentMaximumWidth,
height: mainContentMaximumWidth,
altText: image.localized(in: language))
}
private func save(_ content: String, to relativePath: String) -> Bool {
source.content.storage.write(content, to: relativePath)
}

View File

@ -14,4 +14,6 @@ protocol PostListPageGeneratorSource {
var pageDescription: String { get }
func pageUrlPrefix(for language: ContentLanguage) -> String
var postsPerPage: Int { get }
}

View File

@ -13,6 +13,10 @@ struct TagPageGeneratorSource: PostListPageGeneratorSource {
true
}
var postsPerPage: Int {
content.settings.posts.postsPerPage
}
var pageTitle: String {
tag.localized(in: language).name
}

View File

@ -96,7 +96,7 @@ enum VideoOption {
case .height(let height): return "height='\(height)'"
case .width(let width): return "width='\(width)'"
case .preload(let option): return "preload='\(option)'"
case .poster(let image): return "poster='\(image)'"
case .poster(let image): return "poster='/\(image)'"
case .src(let url): return "src='\(url)'"
}
}

View File

@ -4,11 +4,11 @@ import SFSafeSymbols
#warning("Fix podcast")
#warning("Fix CV")
#warning("Fix endeavor basics (image compare)")
#warning("Fix cap mosaic GIF")
#warning("Add custom url string to external files (optional)")
#warning("Show all warnings on page content")
#warning("Button to delete file")
#warning("Add link to other language")
#warning("Button to delete file, show in finder, replace, mark changed (-> images)")
#warning("Transfer images of posts to other language")
#warning("Show tag selection view for pages")
#warning("Button to replace files")
@ -16,12 +16,13 @@ import SFSafeSymbols
#warning("Calculate file sizes")
#warning("Specify image aspect ratio to prevent page jumps")
#warning("Add version and source url properties to file resources")
#warning("Consolidate all errors in Content")
#warning("Show errors during loading of content")
#warning("Generate pages for posts")
#warning("Clean up mock content")
#warning("Show posts linking to a page")
#warning("Add author to settings and page headers")
#warning("Mark changed images for generation")
#warning("Check for files in output folder not generated by app")
#warning("Fix GIFs: Don't rescale, don't use image set")
@main
struct MainView: App {

View File

@ -74,12 +74,14 @@ extension Content {
completed += 1
status("Generating required images: \(completed) / \(count)")
}
if imageGenerator.generate(job: image) {
if imageGenerator.generate(version: image) {
continue
}
results.failed(image: image)
}
imageGenerator.save()
imageGenerator.printAvifCommands()
//let images = Set(self.images.map { $0.id })
//imageGenerator.recalculateGeneratedImages(by: images)
}
@ -244,11 +246,16 @@ extension Content {
- Note: Run on background thread
*/
private func generateTagOverviewPagesInternal() {
guard let tagOverview else {
print("Generator: No tag overview page to generate")
return
}
status("Generating tag overview page")
for language in ContentLanguage.allCases {
guard shouldGenerateWebsite else { return }
let results = results.makeResults(for: .tagOverview, in: language)
#warning("Create layout for tag overview page")
let generator = TagOverviewGenerator(content: self, language: language, results: results)
generator.generatePage(tags: tags, overview: tagOverview)
}
}

View File

@ -146,7 +146,27 @@ extension Content {
self.files = files.values.sorted { $0.id }
self.posts = posts.values.sorted(ascending: false) { $0.startDate }
self.tagOverview = tagOverview
self.settings = .init(file: settings, tags: tags, pages: pages, files: files, posts: posts, tagOverview: tagOverview)
self.settings = .init(file: settings, files: files) { raw in
#warning("Notify about missing links")
guard let type = ItemType(rawValue: raw, posts: posts, pages: pages, tags: tags) else {
return nil
}
switch type {
case .general:
return nil
case .post(let post):
return post
case .feed:
return nil // TODO: Provide feed object
case .page(let page):
return page
case .tagPage(let tag):
return tag
case .tagOverview:
return tagOverview
}
}
print("Content loaded")
}

View File

@ -34,6 +34,7 @@ final class Content: ObservableObject {
@Published
private(set) var isGeneratingWebsite = false
@Published
private(set) var shouldGenerateWebsite = false
init(settings: Settings,

View File

@ -76,6 +76,33 @@ final class FileResource: Item {
Image(systemSymbol: .exclamationmarkTriangle)
}
/// The path to the output folder where image versions are stored (no leading slash)
var outputImageFolder: String {
"\(content.settings.paths.imagesOutputFolderPath)/\(id.fileNameWithoutExtension)"
}
func outputPath(width: Int, height: Int, type: FileType?) -> String {
let prefix = "/\(outputImageFolder)/\(width)x\(height)"
guard let ext = type?.fileExtension else {
return prefix
}
return prefix + "." + ext
}
func imageSet(width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7) -> ImageSet {
let description = self.localized(in: language)
return .init(
image: self,
maxWidth: width,
maxHeight: height,
description: description,
quality: quality)
}
func imageVersion(width: Int, height: Int, type: FileType) -> ImageVersion {
.init(image: self, type: type, maximumWidth: width, maximumHeight: height)
}
// MARK: Paths
/**

View File

@ -103,6 +103,10 @@ enum FileType: String {
case aac
case m4b
case m4a
// MARK: Other
case noExtension
@ -143,7 +147,7 @@ enum FileType: String {
return .image
case .mp4, .m4v, .webm:
return .video
case .mp3, .aac:
case .mp3, .aac, .m4b, .m4a:
return .audio
case .js, .css, .ttf:
return .asset
@ -160,65 +164,38 @@ enum FileType: String {
}
}
var fileExtension: String {
var fileExtension: String? {
switch self {
case .noExtension, .unknown: return ""
case .noExtension, .unknown: return nil
default:
return rawValue
}
}
var isImage: Bool {
switch self {
case .jpg, .png, .avif, .webp, .gif, .svg, .tiff:
return true
default:
return false
}
category == .image
}
var isVideo: Bool {
switch self {
case .mp4, .m4v, .webm:
return true
default:
return false
}
category == .video
}
var isAudio: Bool {
switch self {
case .mp3, .aac:
return true
default:
return false
}
category == .audio
}
var isAsset: Bool {
switch self {
case .js, .css, .ttf:
return true
default:
return false
}
category == .asset
}
var isTextFile: Bool {
switch self {
case .html, .cpp, .swift, .css, .js, .json, .conf, .yaml:
return true
default:
return false
switch category {
case .text, .code: return true
default: break
}
}
var isOtherFile: Bool {
switch self {
case .noExtension, .zip, .cddx, .pdf, .key, .psd:
return true
default:
return false
case .css, .js: return true
default: return false
}
}

View File

@ -0,0 +1,9 @@
protocol LinkPreviewItem {
var linkPreviewImage: FileResource? { get }
var linkPreviewTitle: String? { get }
var linkPreviewDescription: String? { get }
}

View File

@ -71,3 +71,7 @@ final class LocalizedPage: ObservableObject {
!content.containsPage(withUrlComponent: urlComponent)
}
}
extension LocalizedPage: LinkPreviewItem {
}

View File

@ -44,3 +44,7 @@ final class LocalizedPost: ObservableObject {
self.linkPreviewDescription = linkPreviewDescription
}
}
extension LocalizedPost: LinkPreviewItem {
}

View File

@ -45,3 +45,14 @@ final class LocalizedTag: ObservableObject {
!content.containsTag(withUrlComponent: urlComponent)
}
}
extension LocalizedTag: LinkPreviewItem {
var linkPreviewTitle: String? {
self.name
}
var linkPreviewDescription: String? {
description
}
}

View File

@ -12,39 +12,16 @@ final class NavigationSettings: ObservableObject {
@Published
var english: LocalizedNavigationSettings
init(navigationItems: [Item], german: LocalizedNavigationSettings, english: LocalizedNavigationSettings) {
init(navigationItems: [Item],
german: LocalizedNavigationSettings,
english: LocalizedNavigationSettings) {
self.navigationItems = navigationItems
self.german = german
self.english = english
}
init(file: NavigationSettingsFile,
tags: [String : Tag],
pages: [String : Page],
files: [String : FileResource],
posts: [String : Post],
tagOverview: TagOverviewPage?) {
#warning("Notify about missing links")
self.navigationItems = file.navigationItems.compactMap { raw in
guard let type = ItemType(rawValue: raw, posts: posts, pages: pages, tags: tags) else {
return nil
}
switch type {
case .general:
return nil
case .post(let post):
return post
case .feed:
return nil // TODO: Provide feed object
case .page(let page):
return page
case .tagPage(let tag):
return tag
case .tagOverview:
return tagOverview
}
}
init(file: NavigationSettingsFile, map: (String) -> Item?) {
self.navigationItems = file.navigationItems.compactMap(map)
self.german = LocalizedNavigationSettings(file: file.german)
self.english = LocalizedNavigationSettings(file: file.english)
}

View File

@ -37,20 +37,8 @@ final class Settings: ObservableObject {
}
}
init(file: SettingsFile,
tags: [String : Tag],
pages: [String : Page],
files: [String : FileResource],
posts: [String : Post],
tagOverview: TagOverviewPage?) {
self.navigation = NavigationSettings(
file: file.navigation,
tags: tags,
pages: pages,
files: files,
posts: posts,
tagOverview: tagOverview)
init(file: SettingsFile, files: [String : FileResource], map: (String) -> Item?) {
self.navigation = NavigationSettings(file: file.navigation, map: map)
self.posts = PostSettings(file: file.posts, files: files)
self.pages = PageSettings(file: file.pages, files: files)

View File

@ -1,39 +1,32 @@
struct RelatedPageLink {
/**
An element showing a box with info about a related page.
struct Image {
let url: String
let description: String
let size: Int
}
Contains an optional thumbnail image, a title and a description.
*/
struct RelatedPageLink: HtmlProducer {
/// The title of the linked page
let title: String
/// A short description of the linked page
let description: String
/// The url to the linked page
let url: String
let image: Image?
/// The optional thumbnail image to display
let image: ImageSet?
var content: String {
var result = ""
func populate(_ result: inout String) {
result += "<a href='\(url)' class='related-box-wrapper'>"
result += "<div class='related-box'>"
if let image {
result += WebsiteImage(
rawImagePath: image.url,
width: image.size,
height: image.size,
altText: image.description)
.content
result += image.content
}
result += "<div class='related-content'>"
result += "<h3>\(title)</h3>"
result += "<p>\(description)</p>"
result += "</div></div></a>" // Close related-box-wrapper, related-box
return result
}
}

View File

@ -14,8 +14,7 @@ struct FeedEntry {
var content: String {
var result = "<article><div class='card\(cardLinkClassText)'>"
ImageGallery(id: data.entryId, images: data.images)
.addContent(to: &result)
ImageGallery(id: data.entryId, images: data.images).populate(&result)
if let url = data.link?.url {
result += "<div class='card-content' onclick=\"window.location.href='\(url)'\">"

View File

@ -13,9 +13,9 @@ struct FeedEntryData {
let text: [String]
let images: [Image]
let images: [ImageSet]
init(entryId: String, title: String?, textAboveTitle: String, link: Link?, tags: [Tag], text: [String], images: [Image]) {
init(entryId: String, title: String?, textAboveTitle: String, link: Link?, tags: [Tag], text: [String], images: [ImageSet]) {
self.entryId = entryId
self.title = title
self.textAboveTitle = textAboveTitle
@ -40,16 +40,4 @@ struct FeedEntryData {
let url: String
}
struct Image {
let rawImagePath: String
let width: Int
let height: Int
let altText: String
}
}

View File

@ -1,49 +1,50 @@
import Foundation
struct ImageGallery {
/**
An element showing a selection of images one by one using navigation buttons.
*/
struct ImageGallery: HtmlProducer {
/// The unique id to distinguish different galleries in HTML and JavaScript
let id: String
let images: [FeedEntryData.Image]
/// The images to display
let images: [ImageSet]
/// A version of the id that is safe to use in HTML and JavaScript
private var htmlSafeId: String {
ImageGallery.htmlSafe(id)
}
init(id: String, images: [FeedEntryData.Image]) {
init(id: String, images: [ImageSet]) {
self.id = id
self.images = images
}
func addContent(to result: inout String) {
func populate(_ result: inout String) {
guard !images.isEmpty else {
return
}
result += "<div id='\(htmlSafeId)' class='swiper'><div class='swiper-wrapper'>"
guard images.count > 1 else {
result += "<div class='swiper-slide'>"
result += WebsiteImage(image: images[0]).content
result += "</div></div></div>" // Close swiper-slide, swiper, swiper-wrapper
return
}
let needsPagination = images.count > 1
for image in images {
// TODO: Use different images based on device
result += "<div class='swiper-slide'>"
result += WebsiteImage(image: image).content
result += "<div class='swiper-lazy-preloader swiper-lazy-preloader-white'></div>"
result += image.content
if needsPagination {
result += "<div class='swiper-lazy-preloader swiper-lazy-preloader-white'></div>"
}
result += "</div>" // Close swiper-slide
}
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>"
if needsPagination {
result += "<div class='swiper-button-next'></div>"
result += "<div class='swiper-button-prev'></div>"
result += "<div class='swiper-pagination'></div>"
}
result += "</div>" // Close swiper
}

View File

@ -1,27 +1,34 @@
import Foundation
struct PageImage {
/**
An image that is part of the page content.
A tap/click on the image shows a fullscreen version of the image, including an optional caption.
*/
struct PageImage: HtmlProducer {
/// The HTML id attribute used to enable fullscreen images
let imageId: String
let thumbnail: FeedEntryData.Image
/// The small version of the image visible on the page
let thumbnail: ImageSet
let largeImage: FeedEntryData.Image
/// The large version of the image for fullscreen view
let largeImage: ImageSet
/// The optional caption text below the fullscreen image
let caption: String?
var content: String {
var result = ""
func populate(_ result: inout String) {
result += "<div class='content-image' onclick=\"document.getElementById('\(imageId)').classList.add('active')\">"
result += WebsiteImage(image: thumbnail).content
result += thumbnail.content
result += "</div>"
result += "<div id='\(imageId)' class='fullscreen-image' onclick=\"document.getElementById('\(imageId)').classList.remove('active')\">"
result += WebsiteImage(image: largeImage).content
result += largeImage.content
if let caption {
result += "<div class='caption'>\(caption)</div>"
}
result += "<div class='close'></div>"
result += "</div>"
return result
}
}

View File

@ -2,30 +2,20 @@ import Foundation
struct PostFeedPageNavigation {
let language: ContentLanguage
let linkPrefix: String
let currentPage: Int
let numberOfPages: Int
init(currentPage: Int, numberOfPages: Int, language: ContentLanguage) {
init(linkPrefix: String, currentPage: Int, numberOfPages: Int) {
self.linkPrefix = linkPrefix
self.currentPage = currentPage
self.numberOfPages = numberOfPages
self.language = language
}
private func pageLink(_ page: Int) -> String {
guard page > 1 else { return "href='/feed'" }
return "href='/feed-\(page)'"
}
private func previousText() -> String {
switch language {
case .english:
return "Previous"
case .german:
return "Zurück"
}
"href='\(linkPrefix)\(page)'"
}
private func addPreviousButton(to result: inout String) {

View File

@ -1,48 +0,0 @@
struct WebsiteImage {
static func imagePath(prefix: String, width: Int, height: Int) -> String {
"\(prefix)@\(width)x\(height)"
}
static func imagePath(prefix: String, extension fileExtension: String, width: Int, height: Int) -> String {
"\(prefix)@\(width)x\(height).\(fileExtension)"
}
static func imagePath(source: String, width: Int, height: Int) -> String {
let (prefix, ext) = source.fileNameAndExtension
return imagePath(prefix: prefix, extension: ext ?? ".jpg", width: width, height: height)
}
private let prefix1x: String
private let prefix2x: String
private let altText: String
private let ext: String
init(image: FeedEntryData.Image) {
self.init(rawImagePath: image.rawImagePath,
width: image.width,
height: image.height,
altText: image.altText)
}
init(rawImagePath: String, width: Int, height: Int, altText: String) {
let (prefix, ext) = rawImagePath.fileNameAndExtension
self.prefix1x = WebsiteImage.imagePath(prefix: prefix, width: width, height: height)
self.prefix2x = WebsiteImage.imagePath(prefix: prefix, width: 2*width, height: 2*height)
self.altText = altText.htmlEscaped()
self.ext = ext ?? "jpg"
}
var content: String {
var result = "<picture>"
result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>"
result += "<source type='image/webp' srcset='\(prefix1x).webm 1x, \(prefix1x).webm 2x'/>"
result += "<img srcset='\(prefix2x).\(ext) 2x' src='\(prefix1x).\(ext)' loading='lazy' alt='\(altText)'/>"
result += "</picture>"
return result
}
}

View File

@ -28,6 +28,10 @@ struct SecurityBookmark {
// MARK: Write
func fullPath(to relativePath: String) -> URL {
url.appending(path: relativePath, directoryHint: .notDirectory)
}
/**
Write the data of an encodable value to a relative path in the content folder,
or delete the file if nil is passed.
@ -68,7 +72,7 @@ struct SecurityBookmark {
createParentFolder: Bool = true,
ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool {
perform { url in
let file = url.appending(path: relativePath, directoryHint: .notDirectory)
let file = fullPath(to: relativePath)
if exists(file) {
switch overwrite {

View File

@ -56,7 +56,6 @@ final class Storage: ObservableObject {
// MARK: Pages
private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String {
"\(id)-\(language.rawValue).md"
}
@ -236,6 +235,23 @@ final class Storage: ObservableObject {
// MARK: Files
/**
The full path to a resource file in the content folder
- Parameter file: The filename of the file
- Note: Only for resource files, since path is relative to files folder
*/
func path(toFile file: String) -> URL? {
contentScope?.fullPath(to: filePath(file: file))
}
/**
The full file path to a file in the output folder
- Parameter relativePath: The path of the file relative to the output folder
*/
func outputPath(to relativePath: String) -> URL? {
outputScope?.fullPath(to: relativePath)
}
private func filePath(file fileId: String) -> String {
filesFolderName + "/" + fileId
}
@ -337,6 +353,7 @@ final class Storage: ObservableObject {
}
print("Found \(allImages.count) generated images")
let images = Set(allImages)
#warning("TODO: Fix counting generated images")
return imageSet.reduce(into: [:]) { result, imageName in
let prefix = imageName.fileNameWithoutExtension + "@"
let versions = images.filter { $0.hasPrefix(prefix) }

View File

@ -1,34 +1,5 @@
import SwiftUI
enum FileFilterType: String, Hashable, CaseIterable, Identifiable {
case images
case text
case videos
case other
var text: String {
switch self {
case .images: return "Image"
case .text: return "Text"
case .videos: return "Video"
case .other: return "Other"
}
}
var id: String {
rawValue
}
func matches(_ type: FileType) -> Bool {
switch self {
case .images: return type.isImage
case .text: return type.isTextFile
case .videos: return type.isVideo
case .other: return type.isOtherFile
}
}
}
struct FileListView: View {
@EnvironmentObject
@ -38,21 +9,23 @@ struct FileListView: View {
var selectedFile: FileResource?
@State
private var selectedFileType: FileFilterType
private var selectedFileType: FileTypeCategory? = nil
@State
private var searchString = ""
let allowedType: FileFilterType?
let allowedType: FileTypeCategory?
init(selectedFile: Binding<FileResource?>, allowedType: FileFilterType? = nil) {
init(selectedFile: Binding<FileResource?>, allowedType: FileTypeCategory? = nil) {
self._selectedFile = selectedFile
self.allowedType = allowedType
self.selectedFileType = allowedType ?? .images
}
var filesBySelectedType: [FileResource] {
content.files.filter { selectedFileType.matches($0.type) }
guard let selectedFileType else {
return content.files
}
return content.files.filter { selectedFileType == $0.type.category }
}
var filteredFiles: [FileResource] {
@ -64,14 +37,17 @@ struct FileListView: View {
var body: some View {
VStack(alignment: .center) {
Picker("", selection: $selectedFileType) {
ForEach(FileFilterType.allCases) { type in
Text(type.text).tag(type)
if allowedType == nil {
Picker("", selection: $selectedFileType) {
let all: FileTypeCategory? = nil
Text("All").tag(all)
ForEach(FileTypeCategory.allCases) { type in
Text(type.text).tag(type)
}
}
.pickerStyle(.menu)
.padding(.trailing, 7)
}
.pickerStyle(.segmented)
.padding(.trailing, 7)
.disabled(allowedType != nil)
TextField("", text: $searchString, prompt: Text("Search"))
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 8)
@ -93,7 +69,7 @@ struct FileListView: View {
return
}
if newValue.matches(selectedFile.type) {
if newValue == selectedFile.type.category {
return
}
DispatchQueue.main.async {
@ -102,10 +78,11 @@ struct FileListView: View {
}
}
.onAppear {
if let allowedType {
selectedFileType = allowedType
}
if selectedFile == nil {
DispatchQueue.main.async {
selectedFile = content.files.first
}
selectedFile = content.files.first
}
}
}

View File

@ -8,9 +8,9 @@ struct FileSelectionView: View {
@Binding
private var selectedFile: FileResource?
let allowedType: FileFilterType?
let allowedType: FileTypeCategory?
init(selectedFile: Binding<FileResource?>, allowedType: FileFilterType? = nil) {
init(selectedFile: Binding<FileResource?>, allowedType: FileTypeCategory? = nil) {
self._selectedFile = selectedFile
self.newSelection = selectedFile.wrappedValue
self.allowedType = allowedType

View File

@ -11,12 +11,12 @@ struct MultiFileSelectionView: View {
@Binding
private var selectedFiles: [FileResource]
let allowedType: FileFilterType?
let allowedType: FileTypeCategory?
let insertSorted: Bool
@State
private var selectedFileType: FileFilterType
private var selectedFileType: FileTypeCategory?
@State
private var searchString = ""
@ -24,16 +24,19 @@ struct MultiFileSelectionView: View {
@State
private var newSelection: [FileResource]
init(selectedFiles: Binding<[FileResource]>, allowedType: FileFilterType? = nil, insertSorted: Bool = false) {
init(selectedFiles: Binding<[FileResource]>, allowedType: FileTypeCategory? = nil, insertSorted: Bool = false) {
self._selectedFiles = selectedFiles
self.newSelection = selectedFiles.wrappedValue
self.allowedType = allowedType
self.selectedFileType = allowedType ?? .images
self.selectedFileType = allowedType ?? .image
self.insertSorted = insertSorted
}
private var filesBySelectedType: [FileResource] {
content.files.filter { selectedFileType.matches($0.type) }
guard let selectedFileType else {
return content.files
}
return content.files.filter { selectedFileType == $0.type.category }
}
private var filteredFiles: [FileResource] {
@ -75,7 +78,9 @@ struct MultiFileSelectionView: View {
}
VStack {
Picker("", selection: $selectedFileType) {
ForEach(FileFilterType.allCases) { type in
let all: FileTypeCategory? = nil
Text("All").tag(all)
ForEach(FileTypeCategory.allCases) { type in
Text(type.text).tag(type)
}
}

View File

@ -9,9 +9,9 @@ struct FilePropertyView: View {
@Binding
var selectedFile: FileResource?
let allowedType: FileFilterType?
let allowedType: FileTypeCategory?
init(title: LocalizedStringKey, footer: LocalizedStringKey, selectedFile: Binding<FileResource?>, allowedType: FileFilterType? = nil) {
init(title: LocalizedStringKey, footer: LocalizedStringKey, selectedFile: Binding<FileResource?>, allowedType: FileTypeCategory? = nil) {
self.title = title
self.footer = footer
self._selectedFile = selectedFile

View File

@ -30,7 +30,7 @@ struct OptionalImagePropertyView: View {
}
}
.sheet(isPresented: $showSelectionSheet) {
FileSelectionView(selectedFile: $selectedImage, allowedType: .images)
FileSelectionView(selectedFile: $selectedImage, allowedType: .image)
}
}
}

View File

@ -51,7 +51,7 @@ struct PostImagesView: View {
}
}
.sheet(isPresented: $showImagePicker) {
MultiFileSelectionView(selectedFiles: $post.images, allowedType: .images)
MultiFileSelectionView(selectedFiles: $post.images, allowedType: .image)
}
}

View File

@ -39,11 +39,12 @@ struct GenerationContentView: View {
if content.isGeneratingWebsite {
content.endCurrentGeneration()
} else {
generateFullWebsite()
content.generateWebsiteInAllLanguages()
}
} label: {
Text(content.isGeneratingWebsite ? "Cancel" : "Generate")
}
.disabled(content.isGeneratingWebsite != content.shouldGenerateWebsite)
if content.isGeneratingWebsite {
ProgressView()
.progressViewStyle(.circular)
@ -108,39 +109,6 @@ struct GenerationContentView: View {
}
}.padding()
}
private func generateFullWebsite() {
DispatchQueue.main.async {
content.generateWebsiteInAllLanguages()
}
#warning("Update feed generation")
/*
guard let url = content.storage.outputPath else {
print("Invalid output path")
return
}
guard FileManager.default.fileExists(atPath: url.path) else {
print("Missing output folder")
return
}
isGeneratingWebsite = true
DispatchQueue.global(qos: .userInitiated).async {
let generator = LocalizedWebsiteGenerator(
content: content,
language: language)
_ = generator.generateWebsite { text in
DispatchQueue.main.async {
self.generatorText = text
}
}
DispatchQueue.main.async {
isGeneratingWebsite = false
self.generatorText = "Generation complete"
}
}
*/
}
}
#Preview {

View File

@ -2,8 +2,6 @@ import SFSafeSymbols
enum SettingsSection: String {
//case generation = "Generation"
case folders = "Folders"
case navigationBar = "Navigation Bar"
@ -21,7 +19,7 @@ extension SettingsSection {
var icon: SFSymbol {
switch self {
case .folders: return .folder
case .navigationBar: return .menubarRectangle
case .navigationBar: return .menubarArrowUpRectangle
case .postFeed: return .rectangleGrid1x2
case .pages: return .docRichtext
case .tagOverview: return .tag

View File

@ -17,10 +17,12 @@ struct TagOverviewDetailView: View {
if let page = content.tagOverview?.localized(in: language) {
TagOverviewDetails(page: page)
.id(language)
} else {
Button("Create", action: createTagOverviewPage)
}
}
.padding()
}
}
@ -70,6 +72,5 @@ private struct TagOverviewDetails: View {
text: $page.linkPreviewDescription,
footer: "The description to show in previews of the page")
}
.padding()
}
}