Generate first feed pages, images
This commit is contained in:
parent
dc7b7a0e90
commit
b3cc4a57db
@ -45,6 +45,12 @@
|
||||
E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA51C2CFF135B00AEF16D /* GenericPage.swift */; };
|
||||
E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */; };
|
||||
E25DA5212CFF1B9300AEF16D /* WebsiteGenerator+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5202CFF1B8900AEF16D /* WebsiteGenerator+Mock.swift */; };
|
||||
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */; };
|
||||
E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */; };
|
||||
E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5262CFF745200AEF16D /* URL+Extensions.swift */; };
|
||||
E25DA5292CFFBFBB00AEF16D /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5282CFFBFB800AEF16D /* ImageType.swift */; };
|
||||
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */; };
|
||||
E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */; };
|
||||
E2A21C012CB16A820060935B /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C002CB16A820060935B /* PostView.swift */; };
|
||||
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; };
|
||||
E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C042CB176670060935B /* LocalizedText.swift */; };
|
||||
@ -136,6 +142,10 @@
|
||||
E25DA51C2CFF135B00AEF16D /* GenericPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericPage.swift; sourceTree = "<group>"; };
|
||||
E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = "<group>"; };
|
||||
E25DA5202CFF1B8900AEF16D /* WebsiteGenerator+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebsiteGenerator+Mock.swift"; sourceTree = "<group>"; };
|
||||
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGenerator.swift; sourceTree = "<group>"; };
|
||||
E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSize+Scaling.swift"; sourceTree = "<group>"; };
|
||||
E25DA5262CFF745200AEF16D /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E25DA5282CFFBFB800AEF16D /* ImageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageType.swift; sourceTree = "<group>"; };
|
||||
E2A21C002CB16A820060935B /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = "<group>"; };
|
||||
E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = "<group>"; };
|
||||
E2A21C042CB176670060935B /* LocalizedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedText.swift; sourceTree = "<group>"; };
|
||||
@ -194,6 +204,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
|
||||
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */,
|
||||
E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */,
|
||||
E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@ -278,7 +290,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E25DA5112CFF001900AEF16D /* Model */,
|
||||
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
|
||||
E2A37D0D2CE527040000979F /* Storage.swift */,
|
||||
E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */,
|
||||
);
|
||||
path = Storage;
|
||||
sourceTree = "<group>";
|
||||
@ -298,23 +312,23 @@
|
||||
E2B85F392C428F020047CD0C /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E25DA5282CFFBFB800AEF16D /* ImageType.swift */,
|
||||
E21850322CFAFA200090B18B /* WebsiteData.swift */,
|
||||
E21850362CFCA5580090B18B /* LocalizedWebsiteData.swift */,
|
||||
E2E06DFA2CA4A6570019C2AF /* Content.swift */,
|
||||
E25DA5162CFF00F200AEF16D /* Content+Save.swift */,
|
||||
E25DA5142CFF00B900AEF16D /* Content+Load.swift */,
|
||||
E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */,
|
||||
E21850302CFAF8840090B18B /* Content+Import.swift */,
|
||||
E24252092C52C9260029FF16 /* ContentLanguage.swift */,
|
||||
E2A21C502CBBD53C0060935B /* FileResource.swift */,
|
||||
E2A21C3A2CB9D9A50060935B /* ImageResource.swift */,
|
||||
E2A21C042CB176670060935B /* LocalizedText.swift */,
|
||||
E25A0B882CE4021400F33674 /* LocalizedPage.swift */,
|
||||
E2B85F3A2C428F0D0047CD0C /* Post.swift */,
|
||||
E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */,
|
||||
E2581DEC2C75202400F1F079 /* Tag.swift */,
|
||||
E2A37D182CEA36A40000979F /* LocalizedTag.swift */,
|
||||
E2A9CB7D2C7BCF2A005C89CC /* Page.swift */,
|
||||
E25A0B882CE4021400F33674 /* LocalizedPage.swift */,
|
||||
);
|
||||
path = Model;
|
||||
sourceTree = "<group>";
|
||||
@ -379,6 +393,8 @@
|
||||
E2B85F552C4BD0AD0047CD0C /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E25DA5262CFF745200AEF16D /* URL+Extensions.swift */,
|
||||
E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */,
|
||||
E25DA5182CFF035200AEF16D /* Array+Split.swift */,
|
||||
E2A37D202CEA94E80000979F /* Sequence+Sorted.swift */,
|
||||
E2A37D1E2CEA94330000979F /* Optional+Extensions.swift */,
|
||||
@ -458,6 +474,8 @@
|
||||
packageProductDependencies = (
|
||||
E2B85F352C426BEE0047CD0C /* SFSafeSymbols */,
|
||||
E24252002C50E0A40029FF16 /* HighlightedTextEditor */,
|
||||
E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */,
|
||||
E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */,
|
||||
);
|
||||
productName = CHDataManagement;
|
||||
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
|
||||
@ -490,6 +508,8 @@
|
||||
packageReferences = (
|
||||
E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
|
||||
E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */,
|
||||
E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */,
|
||||
E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */,
|
||||
);
|
||||
productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
|
||||
projectDirPath = "";
|
||||
@ -572,6 +592,7 @@
|
||||
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
|
||||
E2B85F412C4294790047CD0C /* PageHead.swift in Sources */,
|
||||
E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */,
|
||||
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
|
||||
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,
|
||||
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */,
|
||||
E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */,
|
||||
@ -579,6 +600,7 @@
|
||||
E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */,
|
||||
E218503D2CFCFD910090B18B /* LocalizedSettingsView.swift in Sources */,
|
||||
E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */,
|
||||
E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */,
|
||||
E21850372CFCA55F0090B18B /* LocalizedWebsiteData.swift in Sources */,
|
||||
E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */,
|
||||
E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */,
|
||||
@ -586,6 +608,7 @@
|
||||
E25DA50F2CFDD76B00AEF16D /* ImagesContentView.swift in Sources */,
|
||||
E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */,
|
||||
E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */,
|
||||
E25DA5292CFFBFBB00AEF16D /* ImageType.swift in Sources */,
|
||||
E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */,
|
||||
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
|
||||
E2A21C542CBBF87A0060935B /* FilesView.swift in Sources */,
|
||||
@ -594,6 +617,7 @@
|
||||
E218502D2CF791440090B18B /* PostImagesView.swift in Sources */,
|
||||
E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */,
|
||||
E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */,
|
||||
E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */,
|
||||
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */,
|
||||
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */,
|
||||
E25DA5152CFF00C100AEF16D /* Content+Load.swift in Sources */,
|
||||
@ -833,6 +857,22 @@
|
||||
minimumVersion = 2.1.2;
|
||||
};
|
||||
};
|
||||
E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/SDWebImage/SDWebImageAVIFCoder.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.11.0;
|
||||
};
|
||||
};
|
||||
E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.14.6;
|
||||
};
|
||||
};
|
||||
E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
|
||||
@ -849,6 +889,16 @@
|
||||
package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */;
|
||||
productName = HighlightedTextEditor;
|
||||
};
|
||||
E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */;
|
||||
productName = SDWebImageAVIFCoder;
|
||||
};
|
||||
E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */;
|
||||
productName = SDWebImageWebPCoder;
|
||||
};
|
||||
E2B85F352C426BEE0047CD0C /* SFSafeSymbols */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;
|
||||
|
@ -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",
|
||||
|
44
CHDataManagement/Extensions/NSSize+Scaling.swift
Normal file
44
CHDataManagement/Extensions/NSSize+Scaling.swift
Normal file
@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
extension NSSize {
|
||||
|
||||
/// Scales the current size to fit within the target size while maintaining the aspect ratio.
|
||||
/// - Parameter targetSize: The size to fit into.
|
||||
/// - Returns: A new `NSSize` that fits within the `targetSize`.
|
||||
func scaledToFit(in targetSize: NSSize) -> NSSize {
|
||||
guard self.width > 0 && self.height > 0 else {
|
||||
return .zero // Avoid division by zero if the size is invalid.
|
||||
}
|
||||
|
||||
let widthScale = targetSize.width / self.width
|
||||
let heightScale = targetSize.height / self.height
|
||||
|
||||
let scale = min(widthScale, heightScale)
|
||||
|
||||
return NSSize(width: self.width * scale, height: self.height * scale)
|
||||
}
|
||||
|
||||
func scaledDown(to desiredWidth: CGFloat) -> NSSize {
|
||||
if width == desiredWidth {
|
||||
return self
|
||||
}
|
||||
|
||||
if width < desiredWidth {
|
||||
// Don't scale larger
|
||||
return self
|
||||
}
|
||||
|
||||
let height = (height * desiredWidth / width).rounded(.down)
|
||||
return NSSize(width: desiredWidth, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSSize {
|
||||
|
||||
var ratio: CGFloat {
|
||||
guard height != 0 else {
|
||||
return 0
|
||||
}
|
||||
return width / height
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
72
CHDataManagement/Extensions/URL+Extensions.swift
Normal file
72
CHDataManagement/Extensions/URL+Extensions.swift
Normal file
@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
|
||||
func ensureParentFolderExistence() throws {
|
||||
try deletingLastPathComponent().ensureFolderExistence()
|
||||
}
|
||||
|
||||
func ensureFolderExistence() throws {
|
||||
guard !exists else {
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
var isDirectory: Bool {
|
||||
do {
|
||||
let resources = try resourceValues(forKeys: [.isDirectoryKey])
|
||||
guard let isDirectory = resources.isDirectory else {
|
||||
print("No isDirectory info for \(path)")
|
||||
return false
|
||||
}
|
||||
return isDirectory
|
||||
} catch {
|
||||
print("Failed to get directory information from \(path): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var exists: Bool {
|
||||
FileManager.default.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
/**
|
||||
Delete the file at the url.
|
||||
*/
|
||||
func delete() throws {
|
||||
try FileManager.default.removeItem(at: self)
|
||||
}
|
||||
|
||||
func copy(to url: URL) throws {
|
||||
if url.exists {
|
||||
try url.delete()
|
||||
}
|
||||
try url.ensureParentFolderExistence()
|
||||
try FileManager.default.copyItem(at: self, to: url)
|
||||
}
|
||||
|
||||
var size: Int? {
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
|
||||
return (attributes?[.size] as? NSNumber)?.intValue
|
||||
}
|
||||
|
||||
func resolvingFolderTraversal() -> URL? {
|
||||
var components = [String]()
|
||||
absoluteString.components(separatedBy: "/").forEach { part in
|
||||
if part == ".." {
|
||||
if !components.isEmpty {
|
||||
_ = components.popLast()
|
||||
} else {
|
||||
components.append("..")
|
||||
}
|
||||
return
|
||||
}
|
||||
if part == "." {
|
||||
return
|
||||
}
|
||||
components.append(part)
|
||||
}
|
||||
return URL(string: components.joined(separator: "/"))
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -93,10 +93,3 @@ extension ImageResource {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ImageResource {
|
||||
|
||||
func feedEntryImage(for language: ContentLanguage) -> FeedEntryData.Image {
|
||||
.init(mainImageUrl: "images/\(id)", altText: altText.getText(for: language))
|
||||
}
|
||||
}
|
||||
|
53
CHDataManagement/Model/ImageType.swift
Normal file
53
CHDataManagement/Model/ImageType.swift
Normal file
@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
enum ImageType {
|
||||
case jpg
|
||||
case png
|
||||
case avif
|
||||
case webp
|
||||
case gif
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .jpg: return "jpg"
|
||||
case .png: return "png"
|
||||
case .avif: return "avif"
|
||||
case .webp: return "webp"
|
||||
case .gif: return "gif"
|
||||
}
|
||||
}
|
||||
|
||||
var fileType: NSBitmapImageRep.FileType {
|
||||
switch self {
|
||||
case .jpg:
|
||||
return .jpeg
|
||||
case .png, .avif, .webp:
|
||||
return .png
|
||||
case .gif:
|
||||
return .gif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageType: CaseIterable {
|
||||
|
||||
}
|
||||
|
||||
extension ImageType {
|
||||
|
||||
init?(fileExtension: String) {
|
||||
switch fileExtension {
|
||||
case "jpg", "jpeg":
|
||||
self = .jpg
|
||||
case "png":
|
||||
self = .png
|
||||
case "avif":
|
||||
self = .avif
|
||||
case "webp":
|
||||
self = .webp
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
@ -97,4 +97,8 @@ final class LocalizedPage: ObservableObject {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var relativeUrl: String {
|
||||
"/page/\(urlString)"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -1,102 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct WebsiteGeneratorConfiguration {
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
let postsPerPage: Int
|
||||
|
||||
let postFeedTitle: String
|
||||
|
||||
let postFeedDescription: String
|
||||
|
||||
let postFeedUrlPrefix: String
|
||||
}
|
||||
|
||||
final class WebsiteGenerator {
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
let content: Content
|
||||
|
||||
let postsPerPage: Int
|
||||
|
||||
let postFeedTitle: String
|
||||
|
||||
let postFeedDescription: String
|
||||
|
||||
let postFeedUrlPrefix: String
|
||||
|
||||
init(content: Content, configuration: WebsiteGeneratorConfiguration) {
|
||||
self.content = content
|
||||
self.language = configuration.language
|
||||
self.postsPerPage = configuration.postsPerPage
|
||||
self.postFeedTitle = configuration.postFeedTitle
|
||||
self.postFeedDescription = configuration.postFeedDescription
|
||||
self.postFeedUrlPrefix = configuration.postFeedUrlPrefix
|
||||
}
|
||||
|
||||
func generateWebsite() {
|
||||
createPostFeedPages()
|
||||
}
|
||||
|
||||
private func createPostFeedPages() {
|
||||
let totalCount = content.posts.count
|
||||
guard totalCount > 0 else { return }
|
||||
|
||||
let navBarData = createNavigationBarData()
|
||||
|
||||
let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up
|
||||
for pageIndex in 1...numberOfPages {
|
||||
let startIndex = (pageIndex - 1) * postsPerPage
|
||||
let endIndex = min(pageIndex * postsPerPage, totalCount)
|
||||
let postsOnPage = content.posts[startIndex..<endIndex]
|
||||
createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navBarData)
|
||||
}
|
||||
}
|
||||
|
||||
private func createNavigationBarData() -> NavigationBarData {
|
||||
let data = content.websiteData.localized(in: language)
|
||||
let navigationItems: [NavigationBarLink] = content.websiteData.navigationTags.map {
|
||||
let localized = $0.localized(in: language)
|
||||
return .init(text: localized.name, url: localized.urlComponent)
|
||||
}
|
||||
return NavigationBarData(
|
||||
navigationIconPath: "/assets/icons/ch.svg",
|
||||
iconDescription: data.iconDescription,
|
||||
navigationItems: navigationItems)
|
||||
}
|
||||
|
||||
private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice<Post>, bar: NavigationBarData) {
|
||||
let posts = posts.map { $0.feedEntry(for: language) }
|
||||
|
||||
let feed = PageInFeed(
|
||||
language: language,
|
||||
title: postFeedTitle,
|
||||
description: postFeedDescription,
|
||||
navigationBarData: bar,
|
||||
pageNumber: pageIndex,
|
||||
totalPages: pageCount,
|
||||
posts: posts)
|
||||
let fileContent = feed.content
|
||||
|
||||
if pageIndex == 1 {
|
||||
save(fileContent, to: "\(postFeedUrlPrefix).html")
|
||||
} else {
|
||||
save(fileContent, to: "\(postFeedUrlPrefix)-\(pageIndex).html")
|
||||
}
|
||||
}
|
||||
|
||||
private func save(_ content: String, to relativePath: String) {
|
||||
Content.accessFolderFromBookmark(key: Storage.outputPathBookmarkKey) { folder in
|
||||
let outputFile = folder.appendingPathComponent(relativePath, isDirectory: false)
|
||||
do {
|
||||
try content
|
||||
.data(using: .utf8)!
|
||||
.write(to: outputFile)
|
||||
} catch {
|
||||
print("Failed to save: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -8,25 +8,26 @@ struct FeedEntry {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
func addContent(to result: inout String) {
|
||||
var content: String {
|
||||
#warning("TODO: Select CSS classes based on existence of link (hover effects, mouse pointer")
|
||||
result += "<div class='card'>"
|
||||
var result = "<div class='card'>"
|
||||
ImageGallery(id: data.entryId, images: data.images)
|
||||
.addContent(to: &result)
|
||||
|
||||
if let url = data.link?.url {
|
||||
result += "<div class=\"card-content\" onclick=\"window.location.href='\(url)'\">"
|
||||
result += "<div class='card-content' onclick=\"window.location.href='\(url)'\">"
|
||||
} else {
|
||||
result += "<div class=\"card-content\">"
|
||||
result += "<div class='card-content'>"
|
||||
}
|
||||
result += "<h3>\(data.textAboveTitle)</h3>"
|
||||
if let title = data.title {
|
||||
result += "<h2>\(title.htmlEscaped())</h2>"
|
||||
}
|
||||
if !data.tags.isEmpty {
|
||||
result += "<div class=\"tags\">"
|
||||
result += "<div class='tags'>"
|
||||
for tag in data.tags {
|
||||
result += "<a class=\"tag\" href=\"\(tag.url)\">\(tag.name)</a>"
|
||||
result += "<span class='tag' onclick=\"location.href='\(tag.url)'; event.stopPropagation();\">\(tag.name)</span>"
|
||||
//result += "<a class='tag' href='\(tag.url)'>\(tag.name)</a>"
|
||||
}
|
||||
result += "</div>"
|
||||
}
|
||||
@ -34,8 +35,9 @@ struct FeedEntry {
|
||||
result += "<p>\(paragraph)</p>"
|
||||
}
|
||||
if let url = data.link {
|
||||
result += "<div class=\"link-center\"><div class=\"link\">\(url.text)</div></div>"
|
||||
result += "<div class='link-center'><div class='link'>\(url.text)</div></div>"
|
||||
}
|
||||
result += "</div></div>" // Closes card-content and card
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -6,42 +6,64 @@ struct ImageGallery {
|
||||
|
||||
let images: [FeedEntryData.Image]
|
||||
|
||||
private var htmlSafeId: String {
|
||||
ImageGallery.htmlSafe(id)
|
||||
}
|
||||
|
||||
init(id: String, images: [FeedEntryData.Image]) {
|
||||
self.id = id
|
||||
self.images = images
|
||||
}
|
||||
|
||||
private func imageCode(_ image: FeedEntryData.Image) -> String {
|
||||
//return "<img src='\(image.mainImageUrl)' loading='lazy' alt='\(image.altText.htmlEscaped())'>"
|
||||
var result = "<picture>"
|
||||
result += "<source type='image/avif' srcset='\(image.avif1x) 1x, \(image.avif2x) 2x'/>"
|
||||
result += "<source type='image/webp' srcset='\(image.webp1x) 1x, \(image.webp2x) 2x'/>"
|
||||
result += "<img srcset='\(image.jpg2x) 2x' src='\(image.jpg1x)' loading='lazy' alt='\(image.altText.htmlEscaped())'/>"
|
||||
result += "</picture>"
|
||||
return result
|
||||
}
|
||||
|
||||
func addContent(to result: inout String) {
|
||||
guard !images.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
result += "<div id=\"s\(id)\" class=\"swiper\"><div class=\"swiper-wrapper\">"
|
||||
result += "<div id='\(htmlSafeId)' class='swiper'><div class='swiper-wrapper'>"
|
||||
|
||||
guard images.count > 1 else {
|
||||
let image = images[0]
|
||||
result += "<div class=\"swiper-slide\"><img src=\(image.mainImageUrl) loading=\"lazy\" alt=\"\(image.altText.htmlEscaped())\"></div>"
|
||||
result += imageCode(images[0])
|
||||
result += "</div></div>" // Close swiper, swiper-wrapper
|
||||
return
|
||||
}
|
||||
|
||||
for image in images {
|
||||
// TODO: Use different images based on device
|
||||
result += "<div class=\"swiper-slide\">"
|
||||
result += "<img src=\(image.mainImageUrl) loading=\"lazy\" alt=\"\(image.altText.htmlEscaped())\">"
|
||||
result += "<div class=\"swiper-lazy-preloader swiper-lazy-preloader-white\"></div>"
|
||||
result += "<div class='swiper-slide'>"
|
||||
|
||||
result += imageCode(image)
|
||||
|
||||
result += "<div class='swiper-lazy-preloader swiper-lazy-preloader-white'></div>"
|
||||
|
||||
result += "</div>" // Close swiper-slide
|
||||
}
|
||||
|
||||
result += "<div class=\"swiper-button-next\"></div>"
|
||||
result += "<div class=\"swiper-button-prev\"></div>"
|
||||
result += "<div class=\"swiper-pagination\"></div>"
|
||||
result += "</div></div>" // Close swiper, swiper-wrapper
|
||||
result += "</div>" // Close swiper-wrapper
|
||||
result += "<div class='swiper-button-next'></div>"
|
||||
result += "<div class='swiper-button-prev'></div>"
|
||||
result += "<div class='swiper-pagination'></div>"
|
||||
result += "</div>" // Close swiper
|
||||
}
|
||||
|
||||
private static func htmlSafe(_ id: String) -> String {
|
||||
id.replacingOccurrences(of: "-", with: "_")
|
||||
}
|
||||
|
||||
static func swiperInit(id: String) -> String {
|
||||
"""
|
||||
var swiper\(id) = new Swiper("#\(id)", {
|
||||
let id = htmlSafe(id)
|
||||
return """
|
||||
var swiper_\(id) = new Swiper("#\(id)", {
|
||||
loop: true,
|
||||
slidesPerView: 1,
|
||||
spaceBetween: 30,
|
||||
|
@ -42,6 +42,7 @@ struct NavigationBar {
|
||||
|
||||
result += "<a id=\"nav-image\" href=\"/\">"
|
||||
result += "<img class=\"navbar-icon\" src=\"\(data.navigationIconPath)\" alt=\"\(data.iconDescription)\">"
|
||||
result += "</a>"
|
||||
|
||||
for item in rightNavigationItems {
|
||||
result += "<a class=\"nav-animate\" href=\"\(item.url)\">\(item.text)</a>"
|
||||
|
@ -12,14 +12,17 @@ struct GenericPage {
|
||||
|
||||
let additionalHeaders: String
|
||||
|
||||
let additionalFooter: String
|
||||
|
||||
let insertedContent: (inout String) -> Void
|
||||
|
||||
init(language: ContentLanguage, title: String, description: String, data: NavigationBarData, additionalHeaders: String, insertedContent: @escaping (inout String) -> Void) {
|
||||
init(language: ContentLanguage, title: String, description: String, data: NavigationBarData, additionalHeaders: String, additionalFooter: String, insertedContent: @escaping (inout String) -> Void) {
|
||||
self.language = language
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.data = data
|
||||
self.additionalHeaders = additionalHeaders
|
||||
self.additionalFooter = additionalFooter
|
||||
self.insertedContent = insertedContent
|
||||
}
|
||||
var content: String {
|
||||
@ -30,7 +33,9 @@ struct GenericPage {
|
||||
result += NavigationBar(data: data).content
|
||||
result += "<div class=\"content\"><div style=\"height: 70px;\"></div>"
|
||||
insertedContent(&result)
|
||||
result += "</div></body></html>" // Close content
|
||||
result += "</div>"
|
||||
result += additionalFooter
|
||||
result += "</body></html>" // Close content
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
@ -33,20 +33,25 @@ struct PageInFeed {
|
||||
}
|
||||
|
||||
var content: String {
|
||||
GenericPage(language: language, title: title, description: description, data: navigationBarData, additionalHeaders: headers) { content in
|
||||
let footer = swiperIsNeeded ? swiperInits : ""
|
||||
|
||||
return GenericPage(
|
||||
language: language,
|
||||
title: title,
|
||||
description: description,
|
||||
data: navigationBarData,
|
||||
additionalHeaders: headers,
|
||||
additionalFooter: footer) { content in
|
||||
for post in posts {
|
||||
FeedEntry(data: post)
|
||||
.addContent(to: &content)
|
||||
content += FeedEntry(data: post).content
|
||||
}
|
||||
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
|
||||
if swiperIsNeeded {
|
||||
addSwiperInits(to: &content)
|
||||
}
|
||||
|
||||
}.content
|
||||
}
|
||||
|
||||
private func addSwiperInits(to result: inout String) {
|
||||
result += "<script src='\(swiperJsPath)'></script><script>"
|
||||
private var swiperInits: String {
|
||||
var result = "<script src='\(swiperJsPath)'></script><script>"
|
||||
for post in posts {
|
||||
guard post.images.count > 1 else {
|
||||
continue
|
||||
@ -54,5 +59,6 @@ struct PageInFeed {
|
||||
result += ImageGallery.swiperInit(id: post.entryId)
|
||||
}
|
||||
result += "</script>"
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
224
CHDataManagement/Storage/ImageGenerator.swift
Normal file
224
CHDataManagement/Storage/ImageGenerator.swift
Normal file
@ -0,0 +1,224 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import SDWebImageAVIFCoder
|
||||
import SDWebImageWebPCoder
|
||||
|
||||
private struct ImageJob {
|
||||
|
||||
let image: String
|
||||
|
||||
let version: String
|
||||
|
||||
let maximumWidth: CGFloat
|
||||
|
||||
let maximumHeight: CGFloat
|
||||
|
||||
let quality: CGFloat
|
||||
|
||||
let type: ImageType
|
||||
}
|
||||
|
||||
final class ImageGenerator {
|
||||
|
||||
private let storage: Storage
|
||||
|
||||
private let inputImageFolder: URL
|
||||
|
||||
private let relativeImageOutputPath: String
|
||||
|
||||
private var generatedImages: [String : [String]] = [:]
|
||||
|
||||
private var jobs: [ImageJob] = []
|
||||
|
||||
init(storage: Storage, inputImageFolder: URL, relativeImageOutputPath: String) {
|
||||
self.storage = storage
|
||||
self.inputImageFolder = inputImageFolder
|
||||
self.relativeImageOutputPath = relativeImageOutputPath
|
||||
self.generatedImages = storage.loadListOfGeneratedImages()
|
||||
}
|
||||
|
||||
func prepareForGeneration() -> Bool {
|
||||
inOutputImagesFolder { imagesFolder in
|
||||
do {
|
||||
try imagesFolder.ensureFolderExistence()
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to create output images folder: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runJobs() -> Bool {
|
||||
for job in jobs {
|
||||
print("Generating image \(job.version)")
|
||||
guard generate(job: job) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func save() -> Bool {
|
||||
storage.save(listOfGeneratedImages: generatedImages)
|
||||
}
|
||||
|
||||
private func versionFileName(image: String, type: ImageType, width: CGFloat, height: CGFloat) -> String {
|
||||
let fileName = image.fileNameAndExtension.fileName
|
||||
let prefix = "\(fileName)@\(Int(width))x\(Int(height))"
|
||||
return "\(prefix).\(type.fileExtension)"
|
||||
}
|
||||
|
||||
func generateVersion(for image: String, type: ImageType, maximumWidth: CGFloat, maximumHeight: CGFloat) -> String {
|
||||
let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight)
|
||||
let fullPath = "/" + relativeImageOutputPath + "/" + version
|
||||
if hasPreviouslyGenerated(version: version, for: image), exists(version) {
|
||||
// Don't add job again
|
||||
return fullPath
|
||||
}
|
||||
|
||||
let job = ImageJob(
|
||||
image: image,
|
||||
version: version,
|
||||
maximumWidth: maximumWidth,
|
||||
maximumHeight: maximumHeight,
|
||||
quality: 0.7,
|
||||
type: type)
|
||||
|
||||
jobs.append(job)
|
||||
return fullPath
|
||||
}
|
||||
|
||||
private func hasPreviouslyGenerated(version: String, for image: String) -> Bool {
|
||||
guard let versions = generatedImages[image] else {
|
||||
return false
|
||||
}
|
||||
return versions.contains(version)
|
||||
}
|
||||
|
||||
private func hasNowGenerated(version: String, for image: String) {
|
||||
guard var versions = generatedImages[image] else {
|
||||
generatedImages[image] = [version]
|
||||
return
|
||||
}
|
||||
versions.append(version)
|
||||
generatedImages[image] = versions
|
||||
}
|
||||
|
||||
private func removeVersions(for image: String) {
|
||||
generatedImages[image] = nil
|
||||
}
|
||||
|
||||
// MARK: Image operations
|
||||
|
||||
private func generate(job: ImageJob) -> Bool {
|
||||
if hasPreviouslyGenerated(version: job.version, for: job.image), exists(job.version) {
|
||||
return true
|
||||
}
|
||||
let inputPath = inputImageFolder.appendingPathComponent(job.image)
|
||||
#warning("TODO: Read through security scope")
|
||||
guard inputPath.exists else {
|
||||
print("Missing image \(inputPath.path())")
|
||||
return false
|
||||
}
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: inputPath)
|
||||
} catch {
|
||||
print("Failed to load image \(inputPath.path()): \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
guard let originalImage = NSImage(data: data) else {
|
||||
print("Failed to load image")
|
||||
return false
|
||||
}
|
||||
|
||||
let sourceRep = originalImage.representations[0]
|
||||
let sourceSize = NSSize(width: sourceRep.pixelsWide, height: sourceRep.pixelsHigh)
|
||||
let maximumSize = NSSize(width: job.maximumWidth, height: job.maximumHeight)
|
||||
let destinationSize = sourceSize.scaledToFit(in: maximumSize)
|
||||
|
||||
// create NSBitmapRep manually, if using cgImage, the resulting size is wrong
|
||||
let rep = NSBitmapImageRep(bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(destinationSize.width),
|
||||
pixelsHigh: Int(destinationSize.height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: NSColorSpaceName.deviceRGB,
|
||||
bytesPerRow: Int(destinationSize.width) * 4,
|
||||
bitsPerPixel: 32)!
|
||||
|
||||
let ctx = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = ctx
|
||||
originalImage.draw(in: NSMakeRect(0, 0, destinationSize.width, destinationSize.height))
|
||||
ctx?.flushGraphics()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let data = create(image: rep, type: job.type, quality: job.quality) else {
|
||||
print("Failed to get data for type \(job.type)")
|
||||
return false
|
||||
}
|
||||
|
||||
let result = inOutputImagesFolder { folder in
|
||||
let url = folder.appendingPathComponent(job.version)
|
||||
do {
|
||||
try data.write(to: url)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to write image \(job.version): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
guard result else {
|
||||
return false
|
||||
}
|
||||
hasNowGenerated(version: job.version, for: job.image)
|
||||
return true
|
||||
}
|
||||
|
||||
private func exists(_ relativePath: String) -> Bool {
|
||||
inOutputImagesFolder { folder in
|
||||
folder.appendingPathComponent(relativePath).exists
|
||||
}
|
||||
}
|
||||
|
||||
private func inOutputImagesFolder(perform operation: (URL) -> Bool) -> Bool {
|
||||
storage.write(in: .outputPath) { outputFolder in
|
||||
let imagesFolder = outputFolder.appendingPathComponent(relativeImageOutputPath)
|
||||
return operation(imagesFolder)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Avif images
|
||||
|
||||
private func create(image: NSBitmapImageRep, type: ImageType, quality: CGFloat) -> Data? {
|
||||
switch type {
|
||||
case .jpg:
|
||||
return image.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: quality)])
|
||||
case .png:
|
||||
return image.representation(using: .png, properties: [.compressionFactor: NSNumber(value: quality)])
|
||||
case .avif:
|
||||
return createAvif(image: image, quality: quality)
|
||||
case .webp:
|
||||
return createWebp(image: image, quality: quality)
|
||||
case .gif:
|
||||
return image.representation(using: .gif, properties: [.compressionFactor: NSNumber(value: quality)])
|
||||
}
|
||||
}
|
||||
|
||||
private func createAvif(image: NSBitmapImageRep, quality: CGFloat) -> Data? {
|
||||
let newImage = NSImage(size: image.size)
|
||||
newImage.addRepresentation(image)
|
||||
return SDImageAVIFCoder.shared.encodedData(with: newImage, format: .AVIF, options: [.encodeCompressionQuality: quality])
|
||||
}
|
||||
|
||||
private func createWebp(image: NSBitmapImageRep, quality: CGFloat) -> Data? {
|
||||
let newImage = NSImage(size: image.size)
|
||||
newImage.addRepresentation(image)
|
||||
return SDImageWebPCoder.shared.encodedData(with: newImage, format: .webP, options: [.encodeCompressionQuality: quality])
|
||||
}
|
||||
}
|
@ -9,7 +9,9 @@ struct FileOnDisk {
|
||||
let name: String
|
||||
|
||||
init(image: String, url: URL) {
|
||||
self.type = .image
|
||||
let ext = image.fileExtension!
|
||||
let type = ImageType(fileExtension: ext)!
|
||||
self.type = .image(type)
|
||||
self.url = url
|
||||
self.name = image
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
enum FileType {
|
||||
case image
|
||||
case image(ImageType)
|
||||
|
||||
case file
|
||||
case video
|
||||
case resource
|
||||
@ -9,8 +10,16 @@ enum FileType {
|
||||
|
||||
init(fileExtension: String) {
|
||||
switch fileExtension.lowercased() {
|
||||
case "jpg", "jpeg", "png", "gif":
|
||||
self = .image
|
||||
case "jpg", "jpeg":
|
||||
self = .image(.jpg)
|
||||
case "png":
|
||||
self = .image(.png)
|
||||
case "avif":
|
||||
self = .image(.avif)
|
||||
case "webp":
|
||||
self = .image(.webp)
|
||||
case "gif":
|
||||
self = .image(.gif)
|
||||
case "html", "stl", "f3d", "step", "f3z", "zip", "json", "conf", "css", "js", "cpp", "cddx", "svg", "glb", "mp3", "pdf", "swift":
|
||||
self = .file
|
||||
case "mp4":
|
||||
@ -22,4 +31,19 @@ enum FileType {
|
||||
self = .resource
|
||||
}
|
||||
}
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .image(let imageType): return imageType.fileExtension
|
||||
default:
|
||||
return "" // TODO: Fix
|
||||
}
|
||||
}
|
||||
|
||||
var isImage: Bool {
|
||||
if case .image = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum SecurityScopeBookmark: String {
|
||||
|
||||
case outputPath = "outputPathBookmark"
|
||||
|
||||
case contentPath = "contentPathBookmark"
|
||||
}
|
||||
|
||||
/**
|
||||
A class that handles the storage of the website data.
|
||||
|
||||
@ -13,9 +20,6 @@ import Foundation
|
||||
*/
|
||||
final class Storage {
|
||||
|
||||
static let outputPathBookmarkKey = "outputPathBookmark"
|
||||
static let contentPathBookmarkKey = "contentPathBookmark"
|
||||
|
||||
private(set) var baseFolder: URL
|
||||
|
||||
private let encoder = JSONEncoder()
|
||||
@ -60,14 +64,9 @@ final class Storage {
|
||||
|
||||
// MARK: Folders
|
||||
|
||||
func update(baseFolder: URL, moveContent: Bool) throws {
|
||||
let oldFolder = self.baseFolder
|
||||
func update(baseFolder: URL) throws {
|
||||
self.baseFolder = baseFolder
|
||||
try createFolderStructure()
|
||||
guard moveContent else {
|
||||
return
|
||||
}
|
||||
// TODO: Move all files
|
||||
}
|
||||
|
||||
private func create(folder: URL) throws {
|
||||
@ -213,7 +212,7 @@ final class Storage {
|
||||
// MARK: Files
|
||||
|
||||
/// The folder path where other files are stored (by their unique name)
|
||||
private var filesFolder: URL { subFolder("files") }
|
||||
var filesFolder: URL { subFolder("files") }
|
||||
|
||||
private func fileUrl(file: String) -> URL {
|
||||
filesFolder.appending(path: file, directoryHint: .notDirectory)
|
||||
@ -250,6 +249,70 @@ final class Storage {
|
||||
write(websiteData, type: "Website Data", id: "-", to: websiteDataUrl)
|
||||
}
|
||||
|
||||
// MARK: Image generation data
|
||||
|
||||
private var generatedImagesListUrl: URL {
|
||||
baseFolder.appending(component: "generated-images.json", directoryHint: .notDirectory)
|
||||
}
|
||||
|
||||
func loadListOfGeneratedImages() -> [String : [String]] {
|
||||
let url = generatedImagesListUrl
|
||||
guard url.exists else {
|
||||
return [:]
|
||||
}
|
||||
do {
|
||||
return try read(at: url)
|
||||
} catch {
|
||||
print("Failed to read list of generated images: \(error)")
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
func save(listOfGeneratedImages: [String : [String]]) -> Bool {
|
||||
write(listOfGeneratedImages, type: "generated images list", id: "-", to: generatedImagesListUrl)
|
||||
}
|
||||
|
||||
// MARK: Folder access
|
||||
|
||||
func save(folderUrl url: URL, in bookmark: SecurityScopeBookmark) {
|
||||
do {
|
||||
let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
|
||||
UserDefaults.standard.set(bookmarkData, forKey: bookmark.rawValue)
|
||||
} catch {
|
||||
print("Failed to create security-scoped bookmark: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool {
|
||||
guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else {
|
||||
print("No bookmark data to access folder")
|
||||
return false
|
||||
}
|
||||
var isStale = false
|
||||
let folderURL: URL
|
||||
do {
|
||||
// Resolve the bookmark to get the folder URL
|
||||
folderURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
|
||||
} catch {
|
||||
print("Failed to resolve bookmark: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
if isStale {
|
||||
print("Bookmark is stale, consider saving a new bookmark.")
|
||||
}
|
||||
|
||||
// Start accessing the security-scoped resource
|
||||
if folderURL.startAccessingSecurityScopedResource() {
|
||||
let result = operation(folderURL)
|
||||
folderURL.stopAccessingSecurityScopedResource()
|
||||
return result
|
||||
} else {
|
||||
print("Failed to access folder: \(folderURL.path)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Writing files
|
||||
|
||||
private func deleteFiles(in folder: URL, notIn fileSet: Set<String>) throws {
|
||||
|
183
CHDataManagement/Storage/WebsiteGenerator.swift
Normal file
183
CHDataManagement/Storage/WebsiteGenerator.swift
Normal file
@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
|
||||
struct WebsiteGeneratorConfiguration {
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
let outputDirectory: URL
|
||||
|
||||
let postsPerPage: Int
|
||||
|
||||
let postFeedTitle: String
|
||||
|
||||
let postFeedDescription: String
|
||||
|
||||
let postFeedUrlPrefix: String
|
||||
|
||||
let navigationIconPath: String
|
||||
|
||||
let mainContentMaximumWidth: CGFloat
|
||||
}
|
||||
|
||||
final class WebsiteGenerator {
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
let outputDirectory: URL
|
||||
|
||||
let postsPerPage: Int
|
||||
|
||||
let postFeedTitle: String
|
||||
|
||||
let postFeedDescription: String
|
||||
|
||||
let postFeedUrlPrefix: String
|
||||
|
||||
let navigationIconPath: String
|
||||
|
||||
let mainContentMaximumWidth: CGFloat
|
||||
|
||||
private let content: Content
|
||||
|
||||
private let imageGenerator: ImageGenerator
|
||||
|
||||
init(content: Content, configuration: WebsiteGeneratorConfiguration) {
|
||||
self.language = configuration.language
|
||||
self.outputDirectory = configuration.outputDirectory
|
||||
self.postsPerPage = configuration.postsPerPage
|
||||
self.postFeedTitle = configuration.postFeedTitle
|
||||
self.postFeedDescription = configuration.postFeedDescription
|
||||
self.postFeedUrlPrefix = configuration.postFeedUrlPrefix
|
||||
self.navigationIconPath = configuration.navigationIconPath
|
||||
self.mainContentMaximumWidth = configuration.mainContentMaximumWidth
|
||||
|
||||
self.content = content
|
||||
self.imageGenerator = ImageGenerator(
|
||||
storage: content.storage,
|
||||
inputImageFolder: content.storage.filesFolder,
|
||||
relativeImageOutputPath: "images")
|
||||
}
|
||||
|
||||
func generateWebsite() -> Bool {
|
||||
guard imageGenerator.prepareForGeneration() else {
|
||||
return false
|
||||
}
|
||||
guard createPostFeedPages() else {
|
||||
return false
|
||||
}
|
||||
guard imageGenerator.runJobs() else {
|
||||
return false
|
||||
}
|
||||
return imageGenerator.save()
|
||||
}
|
||||
|
||||
private func createPostFeedPages() -> Bool {
|
||||
let totalCount = content.posts.count
|
||||
guard totalCount > 0 else {
|
||||
return true
|
||||
}
|
||||
|
||||
let navBarData = createNavigationBarData()
|
||||
|
||||
let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up
|
||||
for pageIndex in 1...numberOfPages {
|
||||
let startIndex = (pageIndex - 1) * postsPerPage
|
||||
let endIndex = min(pageIndex * postsPerPage, totalCount)
|
||||
let postsOnPage = content.posts[startIndex..<endIndex]
|
||||
guard createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navBarData) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func createNavigationBarData() -> NavigationBarData {
|
||||
let data = content.websiteData.localized(in: language)
|
||||
let navigationItems: [NavigationBarLink] = content.websiteData.navigationTags.map {
|
||||
let localized = $0.localized(in: language)
|
||||
return .init(text: localized.name, url: localized.urlComponent)
|
||||
}
|
||||
return NavigationBarData(
|
||||
navigationIconPath: navigationIconPath,
|
||||
iconDescription: data.iconDescription,
|
||||
navigationItems: navigationItems)
|
||||
}
|
||||
|
||||
private func createImageSet(for image: ImageResource) -> FeedEntryData.Image {
|
||||
let size1x = mainContentMaximumWidth
|
||||
let size2x = mainContentMaximumWidth * 2
|
||||
|
||||
let avif1x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size1x, maximumHeight: size1x)
|
||||
let avif2x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size2x, maximumHeight: size2x)
|
||||
|
||||
let webp1x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size1x, maximumHeight: size1x)
|
||||
let webp2x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size2x, maximumHeight: size2x)
|
||||
|
||||
let jpg1x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size1x, maximumHeight: size1x)
|
||||
let jpg2x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size2x, maximumHeight: size2x)
|
||||
|
||||
return FeedEntryData.Image(
|
||||
altText: image.altText.getText(for: language),
|
||||
avif1x: avif1x,
|
||||
avif2x: avif2x,
|
||||
webp1x: webp1x,
|
||||
webp2x: webp2x,
|
||||
jpg1x: jpg1x,
|
||||
jpg2x: jpg2x)
|
||||
}
|
||||
|
||||
private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice<Post>, bar: NavigationBarData) -> Bool {
|
||||
let posts: [FeedEntryData] = posts.map { post in
|
||||
let localized: LocalizedPost = post.localized(in: language)
|
||||
|
||||
let linkUrl = post.linkedPage.map {
|
||||
FeedEntryData.Link(url: $0.localized(in: language).relativeUrl, text: "View")
|
||||
}
|
||||
|
||||
return FeedEntryData(
|
||||
entryId: "\(post.id)",
|
||||
title: localized.title,
|
||||
textAboveTitle: post.dateText(in: language),
|
||||
link: linkUrl,
|
||||
tags: post.tags.map { $0.data(in: language) },
|
||||
text: [localized.content], // TODO: Convert from markdown to html
|
||||
images: localized.images.map(createImageSet))
|
||||
}
|
||||
|
||||
let feed = PageInFeed(
|
||||
language: language,
|
||||
title: postFeedTitle,
|
||||
description: postFeedDescription,
|
||||
navigationBarData: bar,
|
||||
pageNumber: pageIndex,
|
||||
totalPages: pageCount,
|
||||
posts: posts)
|
||||
let fileContent = feed.content
|
||||
if pageIndex == 1 {
|
||||
return save(fileContent, to: "\(postFeedUrlPrefix).html")
|
||||
} else {
|
||||
return save(fileContent, to: "\(postFeedUrlPrefix)-\(pageIndex).html")
|
||||
}
|
||||
}
|
||||
|
||||
private func save(_ content: String, to relativePath: String) -> Bool {
|
||||
guard let data = content.data(using: .utf8) else {
|
||||
print("Failed to create data for \(relativePath)")
|
||||
return false
|
||||
}
|
||||
return save(data, to: relativePath)
|
||||
}
|
||||
|
||||
private func save(_ data: Data, to relativePath: String) -> Bool {
|
||||
self.content.storage.write(in: .outputPath) { folder in
|
||||
let outputFile = folder.appendingPathComponent(relativePath, isDirectory: false)
|
||||
do {
|
||||
try data.write(to: outputFile)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to save \(outputFile.path()): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -15,10 +15,7 @@ struct SettingsView: View {
|
||||
var content: Content
|
||||
|
||||
@State
|
||||
private var isSelectingContentFolder = false
|
||||
|
||||
@State
|
||||
private var showFileImporter = false
|
||||
private var folderSelection: SecurityScopeBookmark = .contentPath
|
||||
|
||||
@State
|
||||
private var showTagPicker = false
|
||||
@ -70,6 +67,7 @@ struct SettingsView: View {
|
||||
Button(action: generateFeed) {
|
||||
Text("Generate")
|
||||
}
|
||||
.disabled(isGeneratingWebsite)
|
||||
if isGeneratingWebsite {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
@ -80,10 +78,6 @@ struct SettingsView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showFileImporter,
|
||||
allowedContentTypes: [.folder],
|
||||
onCompletion: didSelectContentFolder)
|
||||
.sheet(isPresented: $showTagPicker) {
|
||||
TagSelectionView(
|
||||
presented: $showTagPicker,
|
||||
@ -95,43 +89,21 @@ struct SettingsView: View {
|
||||
// MARK: Folder selection
|
||||
|
||||
private func selectContentFolder() {
|
||||
isSelectingContentFolder = true
|
||||
//showFileImporter = true
|
||||
guard let url = savePanelUsingOpenPanel(key: Storage.contentPathBookmarkKey) else {
|
||||
folderSelection = .contentPath
|
||||
guard let url = savePanelUsingOpenPanel() else {
|
||||
return
|
||||
}
|
||||
self.contentPath = url.path()
|
||||
}
|
||||
|
||||
private func selectOutputFolder() {
|
||||
isSelectingContentFolder = false
|
||||
guard let url = savePanelUsingOpenPanel(key: Storage.outputPathBookmarkKey) else {
|
||||
folderSelection = .outputPath
|
||||
guard let url = savePanelUsingOpenPanel() else {
|
||||
return
|
||||
}
|
||||
self.outputPath = url.path()
|
||||
}
|
||||
|
||||
private func didSelectContentFolder(_ result: Result<URL, any Error>) {
|
||||
switch result {
|
||||
case .success(let url):
|
||||
didSelect(folder: url)
|
||||
case .failure(let error):
|
||||
print("Failed to select content folder: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func didSelect(folder: URL) {
|
||||
let path = folder.absoluteString
|
||||
.replacingOccurrences(of: "file://", with: "")
|
||||
if isSelectingContentFolder {
|
||||
self.contentPath = path
|
||||
saveSecurityScopedBookmark(folder, key: Storage.contentPathBookmarkKey)
|
||||
} else {
|
||||
self.outputPath = path
|
||||
saveSecurityScopedBookmark(folder, key: Storage.outputPathBookmarkKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Feed
|
||||
|
||||
private func generateFeed() {
|
||||
@ -150,7 +122,7 @@ struct SettingsView: View {
|
||||
let generator = WebsiteGenerator(
|
||||
content: content,
|
||||
configuration: configuration)
|
||||
generator.generateWebsite()
|
||||
_ = generator.generateWebsite()
|
||||
DispatchQueue.main.async {
|
||||
isGeneratingWebsite = false
|
||||
}
|
||||
@ -158,13 +130,18 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
private var configuration: WebsiteGeneratorConfiguration {
|
||||
switch language {
|
||||
case .english: return .english
|
||||
case .german: return .german
|
||||
}
|
||||
return .init(
|
||||
language: language,
|
||||
outputDirectory: URL(filePath: outputPath, directoryHint: .isDirectory),
|
||||
postsPerPage: 20,
|
||||
postFeedTitle: "Posts",
|
||||
postFeedDescription: "The most recent posts on christophhagen.de",
|
||||
postFeedUrlPrefix: "feed",
|
||||
navigationIconPath: "/assets/icons/ch.svg",
|
||||
mainContentMaximumWidth: 600)
|
||||
}
|
||||
|
||||
func savePanelUsingOpenPanel(key: String) -> URL? {
|
||||
func savePanelUsingOpenPanel() -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
// Sets up so user can only select a single directory
|
||||
panel.canChooseFiles = false
|
||||
@ -182,19 +159,9 @@ struct SettingsView: View {
|
||||
guard let url = panel.url else {
|
||||
return nil
|
||||
}
|
||||
saveSecurityScopedBookmark(url, key: key)
|
||||
content.storage.save(folderUrl: url, in: folderSelection)
|
||||
return url
|
||||
}
|
||||
|
||||
func saveSecurityScopedBookmark(_ url: URL, key: String) {
|
||||
do {
|
||||
let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
|
||||
UserDefaults.standard.set(bookmarkData, forKey: key)
|
||||
print("Security-scoped bookmark saved.")
|
||||
} catch {
|
||||
print("Failed to create security-scoped bookmark: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
Loading…
x
Reference in New Issue
Block a user