diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 67180c1..ef58a8b 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -45,6 +45,12 @@ E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA51C2CFF135B00AEF16D /* GenericPage.swift */; }; E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */; }; E25DA5212CFF1B9300AEF16D /* WebsiteGenerator+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5202CFF1B8900AEF16D /* WebsiteGenerator+Mock.swift */; }; + E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */; }; + E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */; }; + E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5262CFF745200AEF16D /* URL+Extensions.swift */; }; + E25DA5292CFFBFBB00AEF16D /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5282CFFBFB800AEF16D /* ImageType.swift */; }; + E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */; }; + E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */; }; E2A21C012CB16A820060935B /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C002CB16A820060935B /* PostView.swift */; }; E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; }; E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C042CB176670060935B /* LocalizedText.swift */; }; @@ -136,6 +142,10 @@ E25DA51C2CFF135B00AEF16D /* GenericPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericPage.swift; sourceTree = ""; }; E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = ""; }; E25DA5202CFF1B8900AEF16D /* WebsiteGenerator+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebsiteGenerator+Mock.swift"; sourceTree = ""; }; + E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGenerator.swift; sourceTree = ""; }; + E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSize+Scaling.swift"; sourceTree = ""; }; + E25DA5262CFF745200AEF16D /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = ""; }; + E25DA5282CFFBFB800AEF16D /* ImageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageType.swift; sourceTree = ""; }; E2A21C002CB16A820060935B /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = ""; }; E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = ""; }; E2A21C042CB176670060935B /* LocalizedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedText.swift; sourceTree = ""; }; @@ -194,6 +204,8 @@ buildActionMask = 2147483647; files = ( E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */, + E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */, + E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */, E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -278,7 +290,9 @@ isa = PBXGroup; children = ( E25DA5112CFF001900AEF16D /* Model */, + E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */, E2A37D0D2CE527040000979F /* Storage.swift */, + E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */, ); path = Storage; sourceTree = ""; @@ -298,23 +312,23 @@ E2B85F392C428F020047CD0C /* Model */ = { isa = PBXGroup; children = ( + E25DA5282CFFBFB800AEF16D /* ImageType.swift */, E21850322CFAFA200090B18B /* WebsiteData.swift */, E21850362CFCA5580090B18B /* LocalizedWebsiteData.swift */, E2E06DFA2CA4A6570019C2AF /* Content.swift */, E25DA5162CFF00F200AEF16D /* Content+Save.swift */, E25DA5142CFF00B900AEF16D /* Content+Load.swift */, - E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */, E21850302CFAF8840090B18B /* Content+Import.swift */, E24252092C52C9260029FF16 /* ContentLanguage.swift */, E2A21C502CBBD53C0060935B /* FileResource.swift */, E2A21C3A2CB9D9A50060935B /* ImageResource.swift */, E2A21C042CB176670060935B /* LocalizedText.swift */, - E25A0B882CE4021400F33674 /* LocalizedPage.swift */, E2B85F3A2C428F0D0047CD0C /* Post.swift */, E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */, E2581DEC2C75202400F1F079 /* Tag.swift */, E2A37D182CEA36A40000979F /* LocalizedTag.swift */, E2A9CB7D2C7BCF2A005C89CC /* Page.swift */, + E25A0B882CE4021400F33674 /* LocalizedPage.swift */, ); path = Model; sourceTree = ""; @@ -379,6 +393,8 @@ E2B85F552C4BD0AD0047CD0C /* Extensions */ = { isa = PBXGroup; children = ( + E25DA5262CFF745200AEF16D /* URL+Extensions.swift */, + E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */, E25DA5182CFF035200AEF16D /* Array+Split.swift */, E2A37D202CEA94E80000979F /* Sequence+Sorted.swift */, E2A37D1E2CEA94330000979F /* Optional+Extensions.swift */, @@ -458,6 +474,8 @@ packageProductDependencies = ( E2B85F352C426BEE0047CD0C /* SFSafeSymbols */, E24252002C50E0A40029FF16 /* HighlightedTextEditor */, + E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */, + E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */, ); productName = CHDataManagement; productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */; @@ -490,6 +508,8 @@ packageReferences = ( E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */, + E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */, + E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, ); productRefGroup = E2DD04712C276F31003BFF1F /* Products */; projectDirPath = ""; @@ -572,6 +592,7 @@ E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */, E2B85F412C4294790047CD0C /* PageHead.swift in Sources */, E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */, + E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */, E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */, E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */, E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */, @@ -579,6 +600,7 @@ E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */, E218503D2CFCFD910090B18B /* LocalizedSettingsView.swift in Sources */, E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */, + E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */, E21850372CFCA55F0090B18B /* LocalizedWebsiteData.swift in Sources */, E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */, E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */, @@ -586,6 +608,7 @@ E25DA50F2CFDD76B00AEF16D /* ImagesContentView.swift in Sources */, E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */, E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */, + E25DA5292CFFBFBB00AEF16D /* ImageType.swift in Sources */, E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */, E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */, E2A21C542CBBF87A0060935B /* FilesView.swift in Sources */, @@ -594,6 +617,7 @@ E218502D2CF791440090B18B /* PostImagesView.swift in Sources */, E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */, E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */, + E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */, E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */, E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */, E25DA5152CFF00C100AEF16D /* Content+Load.swift in Sources */, @@ -833,6 +857,22 @@ minimumVersion = 2.1.2; }; }; + E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageAVIFCoder.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.11.0; + }; + }; + E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.14.6; + }; + }; E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; @@ -849,6 +889,16 @@ package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */; productName = HighlightedTextEditor; }; + E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */ = { + isa = XCSwiftPackageProductDependency; + package = E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */; + productName = SDWebImageAVIFCoder; + }; + E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */ = { + isa = XCSwiftPackageProductDependency; + package = E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; + productName = SDWebImageWebPCoder; + }; E2B85F352C426BEE0047CD0C /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; package = E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; diff --git a/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 571a850..badfef9 100644 --- a/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a865991f5fa01ecfb2e7afd44ef74d1e86f52c8f7eec6be4e188382e4051b34c", + "originHash" : "fbe90465f57759d9e85fb24c88e821179f0610fa0fa1239083ea8ffab228185f", "pins" : [ { "identity" : "highlightedtexteditor", @@ -10,6 +10,69 @@ "version" : "2.1.2" } }, + { + "identity" : "libaom-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libaom-Xcode.git", + "state" : { + "revision" : "482cafbebbc5f32378b82339b7580761fab4fd23", + "version" : "2.0.2" + } + }, + { + "identity" : "libavif-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libavif-Xcode.git", + "state" : { + "revision" : "a158cb024166f8c599f88f8c91458c59922bcb0f", + "version" : "0.11.1" + } + }, + { + "identity" : "libvmaf-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libvmaf-Xcode.git", + "state" : { + "revision" : "41db5dc11d05c02d1aca7de0b572a068f528c37c", + "version" : "2.3.1" + } + }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", + "state" : { + "revision" : "b2b1d20a90b14d11f6ef4241da6b81c1d3f171e4", + "version" : "1.3.2" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "10d06f6a33bafae8c164fbfd1f03391f6d4692b3", + "version" : "5.20.0" + } + }, + { + "identity" : "sdwebimageavifcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageAVIFCoder.git", + "state" : { + "revision" : "715df4ace986e1fb332c8c28b45b73dba6a40e5a", + "version" : "0.11.0" + } + }, + { + "identity" : "sdwebimagewebpcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", + "state" : { + "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", + "version" : "0.14.6" + } + }, { "identity" : "sfsafesymbols", "kind" : "remoteSourceControl", diff --git a/CHDataManagement/Extensions/NSSize+Scaling.swift b/CHDataManagement/Extensions/NSSize+Scaling.swift new file mode 100644 index 0000000..e6e9cb9 --- /dev/null +++ b/CHDataManagement/Extensions/NSSize+Scaling.swift @@ -0,0 +1,44 @@ +import Foundation + +extension NSSize { + + /// Scales the current size to fit within the target size while maintaining the aspect ratio. + /// - Parameter targetSize: The size to fit into. + /// - Returns: A new `NSSize` that fits within the `targetSize`. + func scaledToFit(in targetSize: NSSize) -> NSSize { + guard self.width > 0 && self.height > 0 else { + return .zero // Avoid division by zero if the size is invalid. + } + + let widthScale = targetSize.width / self.width + let heightScale = targetSize.height / self.height + + let scale = min(widthScale, heightScale) + + return NSSize(width: self.width * scale, height: self.height * scale) + } + + func scaledDown(to desiredWidth: CGFloat) -> NSSize { + if width == desiredWidth { + return self + } + + if width < desiredWidth { + // Don't scale larger + return self + } + + let height = (height * desiredWidth / width).rounded(.down) + return NSSize(width: desiredWidth, height: height) + } +} + +extension NSSize { + + var ratio: CGFloat { + guard height != 0 else { + return 0 + } + return width / height + } +} diff --git a/CHDataManagement/Extensions/String+Extensions.swift b/CHDataManagement/Extensions/String+Extensions.swift index a1a5efe..7491179 100644 --- a/CHDataManagement/Extensions/String+Extensions.swift +++ b/CHDataManagement/Extensions/String+Extensions.swift @@ -13,3 +13,20 @@ extension String { isEmpty ? nil : self } } + +extension String { + + var fileExtension: String? { + let parts = components(separatedBy: ".") + guard parts.count > 1 else { return nil } + return parts.last + } + + var fileNameAndExtension: (fileName: String, fileExtension: String?) { + let parts = components(separatedBy: ".") + guard parts.count > 1 else { + return (self, nil) + } + return (fileName: parts.dropLast().joined(separator: "."), fileExtension: parts.last) + } +} diff --git a/CHDataManagement/Extensions/URL+Extensions.swift b/CHDataManagement/Extensions/URL+Extensions.swift new file mode 100644 index 0000000..ce0767a --- /dev/null +++ b/CHDataManagement/Extensions/URL+Extensions.swift @@ -0,0 +1,72 @@ +import Foundation + +extension URL { + + func ensureParentFolderExistence() throws { + try deletingLastPathComponent().ensureFolderExistence() + } + + func ensureFolderExistence() throws { + guard !exists else { + return + } + try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true) + } + + var isDirectory: Bool { + do { + let resources = try resourceValues(forKeys: [.isDirectoryKey]) + guard let isDirectory = resources.isDirectory else { + print("No isDirectory info for \(path)") + return false + } + return isDirectory + } catch { + print("Failed to get directory information from \(path): \(error)") + return false + } + } + + var exists: Bool { + FileManager.default.fileExists(atPath: path) + } + + /** + Delete the file at the url. + */ + func delete() throws { + try FileManager.default.removeItem(at: self) + } + + func copy(to url: URL) throws { + if url.exists { + try url.delete() + } + try url.ensureParentFolderExistence() + try FileManager.default.copyItem(at: self, to: url) + } + + var size: Int? { + let attributes = try? FileManager.default.attributesOfItem(atPath: path) + return (attributes?[.size] as? NSNumber)?.intValue + } + + func resolvingFolderTraversal() -> URL? { + var components = [String]() + absoluteString.components(separatedBy: "/").forEach { part in + if part == ".." { + if !components.isEmpty { + _ = components.popLast() + } else { + components.append("..") + } + return + } + if part == "." { + return + } + components.append(part) + } + return URL(string: components.joined(separator: "/")) + } +} diff --git a/CHDataManagement/Import/Importer.swift b/CHDataManagement/Import/Importer.swift index 34f65ec..66c19ca 100644 --- a/CHDataManagement/Import/Importer.swift +++ b/CHDataManagement/Import/Importer.swift @@ -46,7 +46,7 @@ final class Importer { let thumbnailUrl = folder.appending(path: "thumbnail.jpg", directoryHint: .notDirectory) var thumbnail: FileOnDisk? = nil if FileManager.default.fileExists(atPath: thumbnailUrl.path()) { - thumbnail = FileOnDisk(type: .image, url: thumbnailUrl, name: "\(name)-thumbnail.jpg") + thumbnail = FileOnDisk(type: .image(.jpg), url: thumbnailUrl, name: "\(name)-thumbnail.jpg") add(resource: thumbnail!) } @@ -104,7 +104,7 @@ final class Importer { } let type = FileType(fileExtension: fileExtension) - guard type != .resource else { + guard case .resource = type else { self.ignoredFiles.append(url) return nil } diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index 8cf3b23..1cb5168 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -85,47 +85,20 @@ final class Content: ObservableObject { return } - try? storage.update(baseFolder: URL(filePath: contentPath), moveContent: false) + try? storage.update(baseFolder: URL(filePath: contentPath)) observeContentPath() } private func observeContentPath() { $contentPath.sink { newValue in let url = URL(filePath: newValue) - try? self.storage.update(baseFolder: url, moveContent: true) + do { + try self.storage.update(baseFolder: url) + try self.loadFromDisk() + } catch { + print("Failed to switch content path: \(error)") + } } .store(in: &cancellables) } - - // MARK: Folder access - - static func accessFolderFromBookmark(key: String, operation: (URL) -> Void) { - guard let bookmarkData = UserDefaults.standard.data(forKey: key) else { - print("No bookmark data to access folder") - return - } - var isStale = false - let folderURL: URL - do { - // Resolve the bookmark to get the folder URL - folderURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) - } catch { - print("Failed to resolve bookmark: \(error)") - return - } - - if isStale { - print("Bookmark is stale, consider saving a new bookmark.") - } - - // Start accessing the security-scoped resource - if folderURL.startAccessingSecurityScopedResource() { - print("Accessing folder: \(folderURL.path)") - - operation(folderURL) - folderURL.stopAccessingSecurityScopedResource() - } else { - print("Failed to access folder: \(folderURL.path)") - } - } } diff --git a/CHDataManagement/Model/ImageResource.swift b/CHDataManagement/Model/ImageResource.swift index 3c95619..06fe220 100644 --- a/CHDataManagement/Model/ImageResource.swift +++ b/CHDataManagement/Model/ImageResource.swift @@ -93,10 +93,3 @@ extension ImageResource { } } - -extension ImageResource { - - func feedEntryImage(for language: ContentLanguage) -> FeedEntryData.Image { - .init(mainImageUrl: "images/\(id)", altText: altText.getText(for: language)) - } -} diff --git a/CHDataManagement/Model/ImageType.swift b/CHDataManagement/Model/ImageType.swift new file mode 100644 index 0000000..23c4952 --- /dev/null +++ b/CHDataManagement/Model/ImageType.swift @@ -0,0 +1,53 @@ +import Foundation +import AppKit + +enum ImageType { + case jpg + case png + case avif + case webp + case gif + + var fileExtension: String { + switch self { + case .jpg: return "jpg" + case .png: return "png" + case .avif: return "avif" + case .webp: return "webp" + case .gif: return "gif" + } + } + + var fileType: NSBitmapImageRep.FileType { + switch self { + case .jpg: + return .jpeg + case .png, .avif, .webp: + return .png + case .gif: + return .gif + } + } +} + +extension ImageType: CaseIterable { + +} + +extension ImageType { + + init?(fileExtension: String) { + switch fileExtension { + case "jpg", "jpeg": + self = .jpg + case "png": + self = .png + case "avif": + self = .avif + case "webp": + self = .webp + default: + return nil + } + } +} diff --git a/CHDataManagement/Model/LocalizedPage.swift b/CHDataManagement/Model/LocalizedPage.swift index 2d4e44c..ff90162 100644 --- a/CHDataManagement/Model/LocalizedPage.swift +++ b/CHDataManagement/Model/LocalizedPage.swift @@ -97,4 +97,8 @@ final class LocalizedPage: ObservableObject { } ) } + + var relativeUrl: String { + "/page/\(urlString)" + } } diff --git a/CHDataManagement/Model/Post.swift b/CHDataManagement/Model/Post.swift index d2d400c..cdf6d9f 100644 --- a/CHDataManagement/Model/Post.swift +++ b/CHDataManagement/Model/Post.swift @@ -153,29 +153,4 @@ extension Post { let endText = Post.dateString(for: endDate, in: language) return "\(datePrefixString(in: language)) - \(endText)" } - - private func paragraphs(in language: ContentLanguage) -> [String] { - localized(in: language).content - .components(separatedBy: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { $0 != "" } - } - - func linkToPageInFeed(for language: ContentLanguage) -> FeedEntryData.Link? { - nil //.init(url: <#T##String#>, text: <#T##String#>) - } - - func feedEntry(for language: ContentLanguage) -> FeedEntryData { - let post = localized(in: language) - return .init( - entryId: "\(id)", - title: post.title, - textAboveTitle: dateText(in: language), - link: linkToPageInFeed(for: language), - tags: tags.map { $0.data(in: language) }, - text: paragraphs(in: language), - images: post.images.map { - $0.feedEntryImage(for: language) - }) - } } diff --git a/CHDataManagement/Model/WebsiteGenerator.swift b/CHDataManagement/Model/WebsiteGenerator.swift deleted file mode 100644 index 4874514..0000000 --- a/CHDataManagement/Model/WebsiteGenerator.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation - -struct WebsiteGeneratorConfiguration { - - let language: ContentLanguage - - let postsPerPage: Int - - let postFeedTitle: String - - let postFeedDescription: String - - let postFeedUrlPrefix: String -} - -final class WebsiteGenerator { - - let language: ContentLanguage - - let content: Content - - let postsPerPage: Int - - let postFeedTitle: String - - let postFeedDescription: String - - let postFeedUrlPrefix: String - - init(content: Content, configuration: WebsiteGeneratorConfiguration) { - self.content = content - self.language = configuration.language - self.postsPerPage = configuration.postsPerPage - self.postFeedTitle = configuration.postFeedTitle - self.postFeedDescription = configuration.postFeedDescription - self.postFeedUrlPrefix = configuration.postFeedUrlPrefix - } - - func generateWebsite() { - createPostFeedPages() - } - - private func createPostFeedPages() { - let totalCount = content.posts.count - guard totalCount > 0 else { return } - - let navBarData = createNavigationBarData() - - let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up - for pageIndex in 1...numberOfPages { - let startIndex = (pageIndex - 1) * postsPerPage - let endIndex = min(pageIndex * postsPerPage, totalCount) - let postsOnPage = content.posts[startIndex.. NavigationBarData { - let data = content.websiteData.localized(in: language) - let navigationItems: [NavigationBarLink] = content.websiteData.navigationTags.map { - let localized = $0.localized(in: language) - return .init(text: localized.name, url: localized.urlComponent) - } - return NavigationBarData( - navigationIconPath: "/assets/icons/ch.svg", - iconDescription: data.iconDescription, - navigationItems: navigationItems) - } - - private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice, bar: NavigationBarData) { - let posts = posts.map { $0.feedEntry(for: language) } - - let feed = PageInFeed( - language: language, - title: postFeedTitle, - description: postFeedDescription, - navigationBarData: bar, - pageNumber: pageIndex, - totalPages: pageCount, - posts: posts) - let fileContent = feed.content - - if pageIndex == 1 { - save(fileContent, to: "\(postFeedUrlPrefix).html") - } else { - save(fileContent, to: "\(postFeedUrlPrefix)-\(pageIndex).html") - } - } - - private func save(_ content: String, to relativePath: String) { - Content.accessFolderFromBookmark(key: Storage.outputPathBookmarkKey) { folder in - let outputFile = folder.appendingPathComponent(relativePath, isDirectory: false) - do { - try content - .data(using: .utf8)! - .write(to: outputFile) - } catch { - print("Failed to save: \(error)") - } - } - } -} diff --git a/CHDataManagement/Page Elements/FeedEntry.swift b/CHDataManagement/Page Elements/FeedEntry.swift index 914d567..730c18e 100644 --- a/CHDataManagement/Page Elements/FeedEntry.swift +++ b/CHDataManagement/Page Elements/FeedEntry.swift @@ -8,25 +8,26 @@ struct FeedEntry { self.data = data } - func addContent(to result: inout String) { + var content: String { #warning("TODO: Select CSS classes based on existence of link (hover effects, mouse pointer") - result += "
" + var result = "
" ImageGallery(id: data.entryId, images: data.images) .addContent(to: &result) if let url = data.link?.url { - result += "
" + result += "
" } else { - result += "
" + result += "
" } result += "

\(data.textAboveTitle)

" if let title = data.title { result += "

\(title.htmlEscaped())

" } if !data.tags.isEmpty { - result += "
" + result += "
" for tag in data.tags { - result += "\(tag.name)" + result += "\(tag.name)" + //result += "\(tag.name)" } result += "
" } @@ -34,8 +35,9 @@ struct FeedEntry { result += "

\(paragraph)

" } if let url = data.link { - result += "" + result += "" } result += "
" // Closes card-content and card + return result } } diff --git a/CHDataManagement/Page Elements/FeedEntryData.swift b/CHDataManagement/Page Elements/FeedEntryData.swift index 1d97dfe..e10fc53 100644 --- a/CHDataManagement/Page Elements/FeedEntryData.swift +++ b/CHDataManagement/Page Elements/FeedEntryData.swift @@ -43,9 +43,19 @@ struct FeedEntryData { struct Image { - let mainImageUrl: String - let altText: String + let avif1x: String + + let avif2x: String + + let webp1x: String + + let webp2x: String + + let jpg1x: String + + let jpg2x: String + } } diff --git a/CHDataManagement/Page Elements/ImageGallery.swift b/CHDataManagement/Page Elements/ImageGallery.swift index 5e4405e..7393a49 100644 --- a/CHDataManagement/Page Elements/ImageGallery.swift +++ b/CHDataManagement/Page Elements/ImageGallery.swift @@ -6,42 +6,64 @@ struct ImageGallery { let images: [FeedEntryData.Image] + private var htmlSafeId: String { + ImageGallery.htmlSafe(id) + } + init(id: String, images: [FeedEntryData.Image]) { self.id = id self.images = images } + private func imageCode(_ image: FeedEntryData.Image) -> String { + //return "\(image.altText.htmlEscaped())" + var result = "" + result += "" + result += "" + result += "\(image.altText.htmlEscaped())" + result += "" + return result + } + func addContent(to result: inout String) { guard !images.isEmpty else { return } - result += "
" + result += "
" guard images.count > 1 else { - let image = images[0] - result += "
\"\(image.altText.htmlEscaped())\"
" + result += imageCode(images[0]) result += "
" // Close swiper, swiper-wrapper return } for image in images { // TODO: Use different images based on device - result += "
" - result += "\"\(image.altText.htmlEscaped())\"" - result += "
" + result += "
" + + result += imageCode(image) + + result += "
" + result += "
" // Close swiper-slide } - result += "
" - result += "
" - result += "
" - result += "
" // Close swiper, swiper-wrapper + result += "
" // Close swiper-wrapper + result += "
" + result += "
" + result += "
" + result += "
" // Close swiper + } + + private static func htmlSafe(_ id: String) -> String { + id.replacingOccurrences(of: "-", with: "_") } static func swiperInit(id: String) -> String { - """ - var swiper\(id) = new Swiper("#\(id)", { + let id = htmlSafe(id) + return """ + var swiper_\(id) = new Swiper("#\(id)", { loop: true, slidesPerView: 1, spaceBetween: 30, diff --git a/CHDataManagement/Page Elements/NavigationBar.swift b/CHDataManagement/Page Elements/NavigationBar.swift index f7f23da..cba3e77 100644 --- a/CHDataManagement/Page Elements/NavigationBar.swift +++ b/CHDataManagement/Page Elements/NavigationBar.swift @@ -42,6 +42,7 @@ struct NavigationBar { result += "" result += "\"\(data.iconDescription)\"" + result += "" for item in rightNavigationItems { result += "\(item.text)" diff --git a/CHDataManagement/Pages/GenericPage.swift b/CHDataManagement/Pages/GenericPage.swift index 4820eb6..22bd80f 100644 --- a/CHDataManagement/Pages/GenericPage.swift +++ b/CHDataManagement/Pages/GenericPage.swift @@ -12,14 +12,17 @@ struct GenericPage { let additionalHeaders: String + let additionalFooter: String + let insertedContent: (inout String) -> Void - init(language: ContentLanguage, title: String, description: String, data: NavigationBarData, additionalHeaders: String, insertedContent: @escaping (inout String) -> Void) { + init(language: ContentLanguage, title: String, description: String, data: NavigationBarData, additionalHeaders: String, additionalFooter: String, insertedContent: @escaping (inout String) -> Void) { self.language = language self.title = title self.description = description self.data = data self.additionalHeaders = additionalHeaders + self.additionalFooter = additionalFooter self.insertedContent = insertedContent } var content: String { @@ -30,7 +33,9 @@ struct GenericPage { result += NavigationBar(data: data).content result += "
" insertedContent(&result) - result += "
" // Close content + result += "
" + result += additionalFooter + result += "" // Close content return result } } diff --git a/CHDataManagement/Pages/PageInFeed.swift b/CHDataManagement/Pages/PageInFeed.swift index 60c503f..9e3367e 100644 --- a/CHDataManagement/Pages/PageInFeed.swift +++ b/CHDataManagement/Pages/PageInFeed.swift @@ -33,20 +33,25 @@ struct PageInFeed { } var content: String { - GenericPage(language: language, title: title, description: description, data: navigationBarData, additionalHeaders: headers) { content in + let footer = swiperIsNeeded ? swiperInits : "" + + return GenericPage( + language: language, + title: title, + description: description, + data: navigationBarData, + additionalHeaders: headers, + additionalFooter: footer) { content in for post in posts { - FeedEntry(data: post) - .addContent(to: &content) + content += FeedEntry(data: post).content } content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content - if swiperIsNeeded { - addSwiperInits(to: &content) - } + }.content } - private func addSwiperInits(to result: inout String) { - result += "" + return result } } diff --git a/CHDataManagement/Preview Content/WebsiteGenerator+Mock.swift b/CHDataManagement/Preview Content/WebsiteGenerator+Mock.swift index 27abf99..2820b72 100644 --- a/CHDataManagement/Preview Content/WebsiteGenerator+Mock.swift +++ b/CHDataManagement/Preview Content/WebsiteGenerator+Mock.swift @@ -4,16 +4,21 @@ extension WebsiteGeneratorConfiguration { static let english = WebsiteGeneratorConfiguration( language: .english, + outputDirectory: URL(fileURLWithPath: ""), postsPerPage: 20, postFeedTitle: "Posts", postFeedDescription: "The most recent posts on christophhagen.de", - postFeedUrlPrefix: "feed") + postFeedUrlPrefix: "feed", + navigationIconPath: "/assets/icons/ch.svg", + mainContentMaximumWidth: 600) static let german = WebsiteGeneratorConfiguration( language: .german, + outputDirectory: URL(fileURLWithPath: ""), postsPerPage: 20, postFeedTitle: "Beiträge", postFeedDescription: "Die neusten Beiträge auf christophhagen.de", - postFeedUrlPrefix: "beiträge") - + postFeedUrlPrefix: "beiträge", + navigationIconPath: "/assets/icons/ch.svg", + mainContentMaximumWidth: 600) } diff --git a/CHDataManagement/Storage/ImageGenerator.swift b/CHDataManagement/Storage/ImageGenerator.swift new file mode 100644 index 0000000..b7a452e --- /dev/null +++ b/CHDataManagement/Storage/ImageGenerator.swift @@ -0,0 +1,224 @@ +import Foundation +import AppKit +import SDWebImageAVIFCoder +import SDWebImageWebPCoder + +private struct ImageJob { + + let image: String + + let version: String + + let maximumWidth: CGFloat + + let maximumHeight: CGFloat + + let quality: CGFloat + + let type: ImageType +} + +final class ImageGenerator { + + private let storage: Storage + + private let inputImageFolder: URL + + private let relativeImageOutputPath: String + + private var generatedImages: [String : [String]] = [:] + + private var jobs: [ImageJob] = [] + + init(storage: Storage, inputImageFolder: URL, relativeImageOutputPath: String) { + self.storage = storage + self.inputImageFolder = inputImageFolder + self.relativeImageOutputPath = relativeImageOutputPath + self.generatedImages = storage.loadListOfGeneratedImages() + } + + func prepareForGeneration() -> Bool { + inOutputImagesFolder { imagesFolder in + do { + try imagesFolder.ensureFolderExistence() + return true + } catch { + print("Failed to create output images folder: \(error)") + return false + } + } + } + + func runJobs() -> Bool { + for job in jobs { + print("Generating image \(job.version)") + guard generate(job: job) else { + return false + } + } + return true + } + + func save() -> Bool { + storage.save(listOfGeneratedImages: generatedImages) + } + + private func versionFileName(image: String, type: ImageType, width: CGFloat, height: CGFloat) -> String { + let fileName = image.fileNameAndExtension.fileName + let prefix = "\(fileName)@\(Int(width))x\(Int(height))" + return "\(prefix).\(type.fileExtension)" + } + + func generateVersion(for image: String, type: ImageType, maximumWidth: CGFloat, maximumHeight: CGFloat) -> String { + let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight) + let fullPath = "/" + relativeImageOutputPath + "/" + version + if hasPreviouslyGenerated(version: version, for: image), exists(version) { + // Don't add job again + return fullPath + } + + let job = ImageJob( + image: image, + version: version, + maximumWidth: maximumWidth, + maximumHeight: maximumHeight, + quality: 0.7, + type: type) + + jobs.append(job) + return fullPath + } + + private func hasPreviouslyGenerated(version: String, for image: String) -> Bool { + guard let versions = generatedImages[image] else { + return false + } + return versions.contains(version) + } + + private func hasNowGenerated(version: String, for image: String) { + guard var versions = generatedImages[image] else { + generatedImages[image] = [version] + return + } + versions.append(version) + generatedImages[image] = versions + } + + private func removeVersions(for image: String) { + generatedImages[image] = nil + } + + // MARK: Image operations + + private func generate(job: ImageJob) -> Bool { + if hasPreviouslyGenerated(version: job.version, for: job.image), exists(job.version) { + return true + } + let inputPath = inputImageFolder.appendingPathComponent(job.image) + #warning("TODO: Read through security scope") + guard inputPath.exists else { + print("Missing image \(inputPath.path())") + return false + } + let data: Data + do { + data = try Data(contentsOf: inputPath) + } catch { + print("Failed to load image \(inputPath.path()): \(error)") + return false + } + + guard let originalImage = NSImage(data: data) else { + print("Failed to load image") + return false + } + + let sourceRep = originalImage.representations[0] + let sourceSize = NSSize(width: sourceRep.pixelsWide, height: sourceRep.pixelsHigh) + let maximumSize = NSSize(width: job.maximumWidth, height: job.maximumHeight) + let destinationSize = sourceSize.scaledToFit(in: maximumSize) + + // create NSBitmapRep manually, if using cgImage, the resulting size is wrong + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: Int(destinationSize.width), + pixelsHigh: Int(destinationSize.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: NSColorSpaceName.deviceRGB, + bytesPerRow: Int(destinationSize.width) * 4, + bitsPerPixel: 32)! + + let ctx = NSGraphicsContext(bitmapImageRep: rep) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = ctx + originalImage.draw(in: NSMakeRect(0, 0, destinationSize.width, destinationSize.height)) + ctx?.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + + guard let data = create(image: rep, type: job.type, quality: job.quality) else { + print("Failed to get data for type \(job.type)") + return false + } + + let result = inOutputImagesFolder { folder in + let url = folder.appendingPathComponent(job.version) + do { + try data.write(to: url) + return true + } catch { + print("Failed to write image \(job.version): \(error)") + return false + } + } + guard result else { + return false + } + hasNowGenerated(version: job.version, for: job.image) + return true + } + + private func exists(_ relativePath: String) -> Bool { + inOutputImagesFolder { folder in + folder.appendingPathComponent(relativePath).exists + } + } + + private func inOutputImagesFolder(perform operation: (URL) -> Bool) -> Bool { + storage.write(in: .outputPath) { outputFolder in + let imagesFolder = outputFolder.appendingPathComponent(relativeImageOutputPath) + return operation(imagesFolder) + } + } + + // MARK: Avif images + + private func create(image: NSBitmapImageRep, type: ImageType, quality: CGFloat) -> Data? { + switch type { + case .jpg: + return image.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: quality)]) + case .png: + return image.representation(using: .png, properties: [.compressionFactor: NSNumber(value: quality)]) + case .avif: + return createAvif(image: image, quality: quality) + case .webp: + return createWebp(image: image, quality: quality) + case .gif: + return image.representation(using: .gif, properties: [.compressionFactor: NSNumber(value: quality)]) + } + } + + private func createAvif(image: NSBitmapImageRep, quality: CGFloat) -> Data? { + let newImage = NSImage(size: image.size) + newImage.addRepresentation(image) + return SDImageAVIFCoder.shared.encodedData(with: newImage, format: .AVIF, options: [.encodeCompressionQuality: quality]) + } + + private func createWebp(image: NSBitmapImageRep, quality: CGFloat) -> Data? { + let newImage = NSImage(size: image.size) + newImage.addRepresentation(image) + return SDImageWebPCoder.shared.encodedData(with: newImage, format: .webP, options: [.encodeCompressionQuality: quality]) + } +} diff --git a/CHDataManagement/Storage/Model/FileOnDisk.swift b/CHDataManagement/Storage/Model/FileOnDisk.swift index f3f3ec0..7b44887 100644 --- a/CHDataManagement/Storage/Model/FileOnDisk.swift +++ b/CHDataManagement/Storage/Model/FileOnDisk.swift @@ -9,7 +9,9 @@ struct FileOnDisk { let name: String init(image: String, url: URL) { - self.type = .image + let ext = image.fileExtension! + let type = ImageType(fileExtension: ext)! + self.type = .image(type) self.url = url self.name = image } diff --git a/CHDataManagement/Storage/Model/FileType.swift b/CHDataManagement/Storage/Model/FileType.swift index 9cba02f..ba0200a 100644 --- a/CHDataManagement/Storage/Model/FileType.swift +++ b/CHDataManagement/Storage/Model/FileType.swift @@ -1,7 +1,8 @@ import Foundation enum FileType { - case image + case image(ImageType) + case file case video case resource @@ -9,8 +10,16 @@ enum FileType { init(fileExtension: String) { switch fileExtension.lowercased() { - case "jpg", "jpeg", "png", "gif": - self = .image + case "jpg", "jpeg": + self = .image(.jpg) + case "png": + self = .image(.png) + case "avif": + self = .image(.avif) + case "webp": + self = .image(.webp) + case "gif": + self = .image(.gif) case "html", "stl", "f3d", "step", "f3z", "zip", "json", "conf", "css", "js", "cpp", "cddx", "svg", "glb", "mp3", "pdf", "swift": self = .file case "mp4": @@ -22,4 +31,19 @@ enum FileType { self = .resource } } + + var fileExtension: String { + switch self { + case .image(let imageType): return imageType.fileExtension + default: + return "" // TODO: Fix + } + } + + var isImage: Bool { + if case .image = self { + return true + } + return false + } } diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 7a18d4d..b94f770 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -1,5 +1,12 @@ import Foundation +enum SecurityScopeBookmark: String { + + case outputPath = "outputPathBookmark" + + case contentPath = "contentPathBookmark" +} + /** A class that handles the storage of the website data. @@ -13,9 +20,6 @@ import Foundation */ final class Storage { - static let outputPathBookmarkKey = "outputPathBookmark" - static let contentPathBookmarkKey = "contentPathBookmark" - private(set) var baseFolder: URL private let encoder = JSONEncoder() @@ -60,14 +64,9 @@ final class Storage { // MARK: Folders - func update(baseFolder: URL, moveContent: Bool) throws { - let oldFolder = self.baseFolder + func update(baseFolder: URL) throws { self.baseFolder = baseFolder try createFolderStructure() - guard moveContent else { - return - } - // TODO: Move all files } private func create(folder: URL) throws { @@ -213,7 +212,7 @@ final class Storage { // MARK: Files /// The folder path where other files are stored (by their unique name) - private var filesFolder: URL { subFolder("files") } + var filesFolder: URL { subFolder("files") } private func fileUrl(file: String) -> URL { filesFolder.appending(path: file, directoryHint: .notDirectory) @@ -250,6 +249,70 @@ final class Storage { write(websiteData, type: "Website Data", id: "-", to: websiteDataUrl) } + // MARK: Image generation data + + private var generatedImagesListUrl: URL { + baseFolder.appending(component: "generated-images.json", directoryHint: .notDirectory) + } + + func loadListOfGeneratedImages() -> [String : [String]] { + let url = generatedImagesListUrl + guard url.exists else { + return [:] + } + do { + return try read(at: url) + } catch { + print("Failed to read list of generated images: \(error)") + return [:] + } + } + + func save(listOfGeneratedImages: [String : [String]]) -> Bool { + write(listOfGeneratedImages, type: "generated images list", id: "-", to: generatedImagesListUrl) + } + + // MARK: Folder access + + func save(folderUrl url: URL, in bookmark: SecurityScopeBookmark) { + do { + let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) + UserDefaults.standard.set(bookmarkData, forKey: bookmark.rawValue) + } catch { + print("Failed to create security-scoped bookmark: \(error)") + } + } + + func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool { + guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else { + print("No bookmark data to access folder") + return false + } + var isStale = false + let folderURL: URL + do { + // Resolve the bookmark to get the folder URL + folderURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) + } catch { + print("Failed to resolve bookmark: \(error)") + return false + } + + if isStale { + print("Bookmark is stale, consider saving a new bookmark.") + } + + // Start accessing the security-scoped resource + if folderURL.startAccessingSecurityScopedResource() { + let result = operation(folderURL) + folderURL.stopAccessingSecurityScopedResource() + return result + } else { + print("Failed to access folder: \(folderURL.path)") + return false + } + } + // MARK: Writing files private func deleteFiles(in folder: URL, notIn fileSet: Set) throws { diff --git a/CHDataManagement/Storage/WebsiteGenerator.swift b/CHDataManagement/Storage/WebsiteGenerator.swift new file mode 100644 index 0000000..1e46f85 --- /dev/null +++ b/CHDataManagement/Storage/WebsiteGenerator.swift @@ -0,0 +1,183 @@ +import Foundation + +struct WebsiteGeneratorConfiguration { + + let language: ContentLanguage + + let outputDirectory: URL + + let postsPerPage: Int + + let postFeedTitle: String + + let postFeedDescription: String + + let postFeedUrlPrefix: String + + let navigationIconPath: String + + let mainContentMaximumWidth: CGFloat +} + +final class WebsiteGenerator { + + let language: ContentLanguage + + let outputDirectory: URL + + let postsPerPage: Int + + let postFeedTitle: String + + let postFeedDescription: String + + let postFeedUrlPrefix: String + + let navigationIconPath: String + + let mainContentMaximumWidth: CGFloat + + private let content: Content + + private let imageGenerator: ImageGenerator + + init(content: Content, configuration: WebsiteGeneratorConfiguration) { + self.language = configuration.language + self.outputDirectory = configuration.outputDirectory + self.postsPerPage = configuration.postsPerPage + self.postFeedTitle = configuration.postFeedTitle + self.postFeedDescription = configuration.postFeedDescription + self.postFeedUrlPrefix = configuration.postFeedUrlPrefix + self.navigationIconPath = configuration.navigationIconPath + self.mainContentMaximumWidth = configuration.mainContentMaximumWidth + + self.content = content + self.imageGenerator = ImageGenerator( + storage: content.storage, + inputImageFolder: content.storage.filesFolder, + relativeImageOutputPath: "images") + } + + func generateWebsite() -> Bool { + guard imageGenerator.prepareForGeneration() else { + return false + } + guard createPostFeedPages() else { + return false + } + guard imageGenerator.runJobs() else { + return false + } + return imageGenerator.save() + } + + private func createPostFeedPages() -> Bool { + let totalCount = content.posts.count + guard totalCount > 0 else { + return true + } + + let navBarData = createNavigationBarData() + + let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up + for pageIndex in 1...numberOfPages { + let startIndex = (pageIndex - 1) * postsPerPage + let endIndex = min(pageIndex * postsPerPage, totalCount) + let postsOnPage = content.posts[startIndex.. NavigationBarData { + let data = content.websiteData.localized(in: language) + let navigationItems: [NavigationBarLink] = content.websiteData.navigationTags.map { + let localized = $0.localized(in: language) + return .init(text: localized.name, url: localized.urlComponent) + } + return NavigationBarData( + navigationIconPath: navigationIconPath, + iconDescription: data.iconDescription, + navigationItems: navigationItems) + } + + private func createImageSet(for image: ImageResource) -> FeedEntryData.Image { + let size1x = mainContentMaximumWidth + let size2x = mainContentMaximumWidth * 2 + + let avif1x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size1x, maximumHeight: size1x) + let avif2x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size2x, maximumHeight: size2x) + + let webp1x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size1x, maximumHeight: size1x) + let webp2x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size2x, maximumHeight: size2x) + + let jpg1x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size1x, maximumHeight: size1x) + let jpg2x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size2x, maximumHeight: size2x) + + return FeedEntryData.Image( + altText: image.altText.getText(for: language), + avif1x: avif1x, + avif2x: avif2x, + webp1x: webp1x, + webp2x: webp2x, + jpg1x: jpg1x, + jpg2x: jpg2x) + } + + private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice, bar: NavigationBarData) -> Bool { + let posts: [FeedEntryData] = posts.map { post in + let localized: LocalizedPost = post.localized(in: language) + + let linkUrl = post.linkedPage.map { + FeedEntryData.Link(url: $0.localized(in: language).relativeUrl, text: "View") + } + + return FeedEntryData( + entryId: "\(post.id)", + title: localized.title, + textAboveTitle: post.dateText(in: language), + link: linkUrl, + tags: post.tags.map { $0.data(in: language) }, + text: [localized.content], // TODO: Convert from markdown to html + images: localized.images.map(createImageSet)) + } + + let feed = PageInFeed( + language: language, + title: postFeedTitle, + description: postFeedDescription, + navigationBarData: bar, + pageNumber: pageIndex, + totalPages: pageCount, + posts: posts) + let fileContent = feed.content + if pageIndex == 1 { + return save(fileContent, to: "\(postFeedUrlPrefix).html") + } else { + return save(fileContent, to: "\(postFeedUrlPrefix)-\(pageIndex).html") + } + } + + private func save(_ content: String, to relativePath: String) -> Bool { + guard let data = content.data(using: .utf8) else { + print("Failed to create data for \(relativePath)") + return false + } + return save(data, to: relativePath) + } + + private func save(_ data: Data, to relativePath: String) -> Bool { + self.content.storage.write(in: .outputPath) { folder in + let outputFile = folder.appendingPathComponent(relativePath, isDirectory: false) + do { + try data.write(to: outputFile) + return true + } catch { + print("Failed to save \(outputFile.path()): \(error)") + return false + } + } + } +} diff --git a/CHDataManagement/Views/Settings/SettingsView.swift b/CHDataManagement/Views/Settings/SettingsView.swift index b450fa9..e08de64 100644 --- a/CHDataManagement/Views/Settings/SettingsView.swift +++ b/CHDataManagement/Views/Settings/SettingsView.swift @@ -15,10 +15,7 @@ struct SettingsView: View { var content: Content @State - private var isSelectingContentFolder = false - - @State - private var showFileImporter = false + private var folderSelection: SecurityScopeBookmark = .contentPath @State private var showTagPicker = false @@ -70,6 +67,7 @@ struct SettingsView: View { Button(action: generateFeed) { Text("Generate") } + .disabled(isGeneratingWebsite) if isGeneratingWebsite { ProgressView() .progressViewStyle(.circular) @@ -80,10 +78,6 @@ struct SettingsView: View { } .padding() } - .fileImporter( - isPresented: $showFileImporter, - allowedContentTypes: [.folder], - onCompletion: didSelectContentFolder) .sheet(isPresented: $showTagPicker) { TagSelectionView( presented: $showTagPicker, @@ -95,43 +89,21 @@ struct SettingsView: View { // MARK: Folder selection private func selectContentFolder() { - isSelectingContentFolder = true - //showFileImporter = true - guard let url = savePanelUsingOpenPanel(key: Storage.contentPathBookmarkKey) else { + folderSelection = .contentPath + guard let url = savePanelUsingOpenPanel() else { return } self.contentPath = url.path() } private func selectOutputFolder() { - isSelectingContentFolder = false - guard let url = savePanelUsingOpenPanel(key: Storage.outputPathBookmarkKey) else { + folderSelection = .outputPath + guard let url = savePanelUsingOpenPanel() else { return } self.outputPath = url.path() } - private func didSelectContentFolder(_ result: Result) { - switch result { - case .success(let url): - didSelect(folder: url) - case .failure(let error): - print("Failed to select content folder: \(error)") - } - } - - private func didSelect(folder: URL) { - let path = folder.absoluteString - .replacingOccurrences(of: "file://", with: "") - if isSelectingContentFolder { - self.contentPath = path - saveSecurityScopedBookmark(folder, key: Storage.contentPathBookmarkKey) - } else { - self.outputPath = path - saveSecurityScopedBookmark(folder, key: Storage.outputPathBookmarkKey) - } - } - // MARK: Feed private func generateFeed() { @@ -150,7 +122,7 @@ struct SettingsView: View { let generator = WebsiteGenerator( content: content, configuration: configuration) - generator.generateWebsite() + _ = generator.generateWebsite() DispatchQueue.main.async { isGeneratingWebsite = false } @@ -158,13 +130,18 @@ struct SettingsView: View { } private var configuration: WebsiteGeneratorConfiguration { - switch language { - case .english: return .english - case .german: return .german - } + return .init( + language: language, + outputDirectory: URL(filePath: outputPath, directoryHint: .isDirectory), + postsPerPage: 20, + postFeedTitle: "Posts", + postFeedDescription: "The most recent posts on christophhagen.de", + postFeedUrlPrefix: "feed", + navigationIconPath: "/assets/icons/ch.svg", + mainContentMaximumWidth: 600) } - func savePanelUsingOpenPanel(key: String) -> URL? { + func savePanelUsingOpenPanel() -> URL? { let panel = NSOpenPanel() // Sets up so user can only select a single directory panel.canChooseFiles = false @@ -182,19 +159,9 @@ struct SettingsView: View { guard let url = panel.url else { return nil } - saveSecurityScopedBookmark(url, key: key) + content.storage.save(folderUrl: url, in: folderSelection) return url } - - func saveSecurityScopedBookmark(_ url: URL, key: String) { - do { - let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) - UserDefaults.standard.set(bookmarkData, forKey: key) - print("Security-scoped bookmark saved.") - } catch { - print("Failed to create security-scoped bookmark: \(error)") - } - } } #Preview {