Simplify images, tag overview
This commit is contained in:
parent
4d4275e072
commit
22e7d9a05a
@ -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 */,
|
||||
|
@ -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) }
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
52
CHDataManagement/Generator/ImageSet.swift
Normal file
52
CHDataManagement/Generator/ImageSet.swift
Normal 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
|
||||
}
|
||||
}
|
78
CHDataManagement/Generator/ImageVersion.swift
Normal file
78
CHDataManagement/Generator/ImageVersion.swift
Normal 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
|
||||
}
|
||||
}
|
@ -39,8 +39,6 @@ extension KnownHeaderElement: Comparable {
|
||||
static func < (lhs: KnownHeaderElement, rhs: KnownHeaderElement) -> Bool {
|
||||
lhs.rawValue < rhs.rawValue
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
extension KnownHeaderElement: CustomStringConvertible {
|
||||
|
@ -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(
|
||||
|
@ -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]) {
|
||||
|
@ -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
|
@ -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
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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: ``
|
||||
*/
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,10 @@ struct FeedGeneratorSource: PostListPageGeneratorSource {
|
||||
false
|
||||
}
|
||||
|
||||
var postsPerPage: Int {
|
||||
content.settings.posts.postsPerPage
|
||||
}
|
||||
|
||||
var pageTitle: String {
|
||||
content.settings.localized(in: language).title
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -14,4 +14,6 @@ protocol PostListPageGeneratorSource {
|
||||
var pageDescription: String { get }
|
||||
|
||||
func pageUrlPrefix(for language: ContentLanguage) -> String
|
||||
|
||||
var postsPerPage: Int { get }
|
||||
}
|
||||
|
@ -13,6 +13,10 @@ struct TagPageGeneratorSource: PostListPageGeneratorSource {
|
||||
true
|
||||
}
|
||||
|
||||
var postsPerPage: Int {
|
||||
content.settings.posts.postsPerPage
|
||||
}
|
||||
|
||||
var pageTitle: String {
|
||||
tag.localized(in: language).name
|
||||
}
|
||||
|
@ -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)'"
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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")
|
||||
}
|
||||
|
||||
|
@ -34,6 +34,7 @@ final class Content: ObservableObject {
|
||||
@Published
|
||||
private(set) var isGeneratingWebsite = false
|
||||
|
||||
@Published
|
||||
private(set) var shouldGenerateWebsite = false
|
||||
|
||||
init(settings: Settings,
|
||||
|
@ -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
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
9
CHDataManagement/Model/Item/LinkPreviewItem.swift
Normal file
9
CHDataManagement/Model/Item/LinkPreviewItem.swift
Normal file
@ -0,0 +1,9 @@
|
||||
|
||||
protocol LinkPreviewItem {
|
||||
|
||||
var linkPreviewImage: FileResource? { get }
|
||||
|
||||
var linkPreviewTitle: String? { get }
|
||||
|
||||
var linkPreviewDescription: String? { get }
|
||||
}
|
@ -71,3 +71,7 @@ final class LocalizedPage: ObservableObject {
|
||||
!content.containsPage(withUrlComponent: urlComponent)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizedPage: LinkPreviewItem {
|
||||
|
||||
}
|
||||
|
@ -44,3 +44,7 @@ final class LocalizedPost: ObservableObject {
|
||||
self.linkPreviewDescription = linkPreviewDescription
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizedPost: LinkPreviewItem {
|
||||
|
||||
}
|
||||
|
@ -45,3 +45,14 @@ final class LocalizedTag: ObservableObject {
|
||||
!content.containsTag(withUrlComponent: urlComponent)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizedTag: LinkPreviewItem {
|
||||
|
||||
var linkPreviewTitle: String? {
|
||||
self.name
|
||||
}
|
||||
|
||||
var linkPreviewDescription: String? {
|
||||
description
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)'\">"
|
||||
|
@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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) }
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -30,7 +30,7 @@ struct OptionalImagePropertyView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSelectionSheet) {
|
||||
FileSelectionView(selectedFile: $selectedImage, allowedType: .images)
|
||||
FileSelectionView(selectedFile: $selectedImage, allowedType: .image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ struct PostImagesView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
MultiFileSelectionView(selectedFiles: $post.images, allowedType: .images)
|
||||
MultiFileSelectionView(selectedFiles: $post.images, allowedType: .image)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user