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 */; };
|
E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA51C2CFF135B00AEF16D /* GenericPage.swift */; };
|
||||||
E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA51E2CFF15C100AEF16D /* NavigationBar.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 */; };
|
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 */; };
|
E2A21C012CB16A820060935B /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C002CB16A820060935B /* PostView.swift */; };
|
||||||
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
E2A21C042CB176670060935B /* LocalizedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedText.swift; sourceTree = "<group>"; };
|
||||||
@ -194,6 +204,8 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
|
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
|
||||||
|
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */,
|
||||||
|
E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */,
|
||||||
E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */,
|
E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@ -278,7 +290,9 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E25DA5112CFF001900AEF16D /* Model */,
|
E25DA5112CFF001900AEF16D /* Model */,
|
||||||
|
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
|
||||||
E2A37D0D2CE527040000979F /* Storage.swift */,
|
E2A37D0D2CE527040000979F /* Storage.swift */,
|
||||||
|
E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */,
|
||||||
);
|
);
|
||||||
path = Storage;
|
path = Storage;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -298,23 +312,23 @@
|
|||||||
E2B85F392C428F020047CD0C /* Model */ = {
|
E2B85F392C428F020047CD0C /* Model */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E25DA5282CFFBFB800AEF16D /* ImageType.swift */,
|
||||||
E21850322CFAFA200090B18B /* WebsiteData.swift */,
|
E21850322CFAFA200090B18B /* WebsiteData.swift */,
|
||||||
E21850362CFCA5580090B18B /* LocalizedWebsiteData.swift */,
|
E21850362CFCA5580090B18B /* LocalizedWebsiteData.swift */,
|
||||||
E2E06DFA2CA4A6570019C2AF /* Content.swift */,
|
E2E06DFA2CA4A6570019C2AF /* Content.swift */,
|
||||||
E25DA5162CFF00F200AEF16D /* Content+Save.swift */,
|
E25DA5162CFF00F200AEF16D /* Content+Save.swift */,
|
||||||
E25DA5142CFF00B900AEF16D /* Content+Load.swift */,
|
E25DA5142CFF00B900AEF16D /* Content+Load.swift */,
|
||||||
E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */,
|
|
||||||
E21850302CFAF8840090B18B /* Content+Import.swift */,
|
E21850302CFAF8840090B18B /* Content+Import.swift */,
|
||||||
E24252092C52C9260029FF16 /* ContentLanguage.swift */,
|
E24252092C52C9260029FF16 /* ContentLanguage.swift */,
|
||||||
E2A21C502CBBD53C0060935B /* FileResource.swift */,
|
E2A21C502CBBD53C0060935B /* FileResource.swift */,
|
||||||
E2A21C3A2CB9D9A50060935B /* ImageResource.swift */,
|
E2A21C3A2CB9D9A50060935B /* ImageResource.swift */,
|
||||||
E2A21C042CB176670060935B /* LocalizedText.swift */,
|
E2A21C042CB176670060935B /* LocalizedText.swift */,
|
||||||
E25A0B882CE4021400F33674 /* LocalizedPage.swift */,
|
|
||||||
E2B85F3A2C428F0D0047CD0C /* Post.swift */,
|
E2B85F3A2C428F0D0047CD0C /* Post.swift */,
|
||||||
E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */,
|
E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */,
|
||||||
E2581DEC2C75202400F1F079 /* Tag.swift */,
|
E2581DEC2C75202400F1F079 /* Tag.swift */,
|
||||||
E2A37D182CEA36A40000979F /* LocalizedTag.swift */,
|
E2A37D182CEA36A40000979F /* LocalizedTag.swift */,
|
||||||
E2A9CB7D2C7BCF2A005C89CC /* Page.swift */,
|
E2A9CB7D2C7BCF2A005C89CC /* Page.swift */,
|
||||||
|
E25A0B882CE4021400F33674 /* LocalizedPage.swift */,
|
||||||
);
|
);
|
||||||
path = Model;
|
path = Model;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -379,6 +393,8 @@
|
|||||||
E2B85F552C4BD0AD0047CD0C /* Extensions */ = {
|
E2B85F552C4BD0AD0047CD0C /* Extensions */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E25DA5262CFF745200AEF16D /* URL+Extensions.swift */,
|
||||||
|
E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */,
|
||||||
E25DA5182CFF035200AEF16D /* Array+Split.swift */,
|
E25DA5182CFF035200AEF16D /* Array+Split.swift */,
|
||||||
E2A37D202CEA94E80000979F /* Sequence+Sorted.swift */,
|
E2A37D202CEA94E80000979F /* Sequence+Sorted.swift */,
|
||||||
E2A37D1E2CEA94330000979F /* Optional+Extensions.swift */,
|
E2A37D1E2CEA94330000979F /* Optional+Extensions.swift */,
|
||||||
@ -458,6 +474,8 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
E2B85F352C426BEE0047CD0C /* SFSafeSymbols */,
|
E2B85F352C426BEE0047CD0C /* SFSafeSymbols */,
|
||||||
E24252002C50E0A40029FF16 /* HighlightedTextEditor */,
|
E24252002C50E0A40029FF16 /* HighlightedTextEditor */,
|
||||||
|
E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */,
|
||||||
|
E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */,
|
||||||
);
|
);
|
||||||
productName = CHDataManagement;
|
productName = CHDataManagement;
|
||||||
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
|
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
|
||||||
@ -490,6 +508,8 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
|
E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
|
||||||
E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */,
|
E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */,
|
||||||
|
E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */,
|
||||||
|
E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */,
|
||||||
);
|
);
|
||||||
productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
|
productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@ -572,6 +592,7 @@
|
|||||||
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
|
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
|
||||||
E2B85F412C4294790047CD0C /* PageHead.swift in Sources */,
|
E2B85F412C4294790047CD0C /* PageHead.swift in Sources */,
|
||||||
E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */,
|
E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */,
|
||||||
|
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
|
||||||
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,
|
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,
|
||||||
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */,
|
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */,
|
||||||
E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */,
|
E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */,
|
||||||
@ -579,6 +600,7 @@
|
|||||||
E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */,
|
E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */,
|
||||||
E218503D2CFCFD910090B18B /* LocalizedSettingsView.swift in Sources */,
|
E218503D2CFCFD910090B18B /* LocalizedSettingsView.swift in Sources */,
|
||||||
E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */,
|
E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */,
|
||||||
|
E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */,
|
||||||
E21850372CFCA55F0090B18B /* LocalizedWebsiteData.swift in Sources */,
|
E21850372CFCA55F0090B18B /* LocalizedWebsiteData.swift in Sources */,
|
||||||
E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */,
|
E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */,
|
||||||
E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */,
|
E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */,
|
||||||
@ -586,6 +608,7 @@
|
|||||||
E25DA50F2CFDD76B00AEF16D /* ImagesContentView.swift in Sources */,
|
E25DA50F2CFDD76B00AEF16D /* ImagesContentView.swift in Sources */,
|
||||||
E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */,
|
E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */,
|
||||||
E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */,
|
E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */,
|
||||||
|
E25DA5292CFFBFBB00AEF16D /* ImageType.swift in Sources */,
|
||||||
E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */,
|
E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */,
|
||||||
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
|
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
|
||||||
E2A21C542CBBF87A0060935B /* FilesView.swift in Sources */,
|
E2A21C542CBBF87A0060935B /* FilesView.swift in Sources */,
|
||||||
@ -594,6 +617,7 @@
|
|||||||
E218502D2CF791440090B18B /* PostImagesView.swift in Sources */,
|
E218502D2CF791440090B18B /* PostImagesView.swift in Sources */,
|
||||||
E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */,
|
E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */,
|
||||||
E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */,
|
E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */,
|
||||||
|
E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */,
|
||||||
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */,
|
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */,
|
||||||
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */,
|
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */,
|
||||||
E25DA5152CFF00C100AEF16D /* Content+Load.swift in Sources */,
|
E25DA5152CFF00C100AEF16D /* Content+Load.swift in Sources */,
|
||||||
@ -833,6 +857,22 @@
|
|||||||
minimumVersion = 2.1.2;
|
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" */ = {
|
E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
|
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
|
||||||
@ -849,6 +889,16 @@
|
|||||||
package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */;
|
package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */;
|
||||||
productName = 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 */ = {
|
E2B85F352C426BEE0047CD0C /* SFSafeSymbols */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;
|
package = E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "a865991f5fa01ecfb2e7afd44ef74d1e86f52c8f7eec6be4e188382e4051b34c",
|
"originHash" : "fbe90465f57759d9e85fb24c88e821179f0610fa0fa1239083ea8ffab228185f",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "highlightedtexteditor",
|
"identity" : "highlightedtexteditor",
|
||||||
@ -10,6 +10,69 @@
|
|||||||
"version" : "2.1.2"
|
"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",
|
"identity" : "sfsafesymbols",
|
||||||
"kind" : "remoteSourceControl",
|
"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
|
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)
|
let thumbnailUrl = folder.appending(path: "thumbnail.jpg", directoryHint: .notDirectory)
|
||||||
var thumbnail: FileOnDisk? = nil
|
var thumbnail: FileOnDisk? = nil
|
||||||
if FileManager.default.fileExists(atPath: thumbnailUrl.path()) {
|
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!)
|
add(resource: thumbnail!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,7 +104,7 @@ final class Importer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let type = FileType(fileExtension: fileExtension)
|
let type = FileType(fileExtension: fileExtension)
|
||||||
guard type != .resource else {
|
guard case .resource = type else {
|
||||||
self.ignoredFiles.append(url)
|
self.ignoredFiles.append(url)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -85,47 +85,20 @@ final class Content: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try? storage.update(baseFolder: URL(filePath: contentPath), moveContent: false)
|
try? storage.update(baseFolder: URL(filePath: contentPath))
|
||||||
observeContentPath()
|
observeContentPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func observeContentPath() {
|
private func observeContentPath() {
|
||||||
$contentPath.sink { newValue in
|
$contentPath.sink { newValue in
|
||||||
let url = URL(filePath: newValue)
|
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)
|
.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)
|
let endText = Post.dateString(for: endDate, in: language)
|
||||||
return "\(datePrefixString(in: language)) - \(endText)"
|
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
|
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")
|
#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)
|
ImageGallery(id: data.entryId, images: data.images)
|
||||||
.addContent(to: &result)
|
.addContent(to: &result)
|
||||||
|
|
||||||
if let url = data.link?.url {
|
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 {
|
} else {
|
||||||
result += "<div class=\"card-content\">"
|
result += "<div class='card-content'>"
|
||||||
}
|
}
|
||||||
result += "<h3>\(data.textAboveTitle)</h3>"
|
result += "<h3>\(data.textAboveTitle)</h3>"
|
||||||
if let title = data.title {
|
if let title = data.title {
|
||||||
result += "<h2>\(title.htmlEscaped())</h2>"
|
result += "<h2>\(title.htmlEscaped())</h2>"
|
||||||
}
|
}
|
||||||
if !data.tags.isEmpty {
|
if !data.tags.isEmpty {
|
||||||
result += "<div class=\"tags\">"
|
result += "<div class='tags'>"
|
||||||
for tag in data.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>"
|
result += "</div>"
|
||||||
}
|
}
|
||||||
@ -34,8 +35,9 @@ struct FeedEntry {
|
|||||||
result += "<p>\(paragraph)</p>"
|
result += "<p>\(paragraph)</p>"
|
||||||
}
|
}
|
||||||
if let url = data.link {
|
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
|
result += "</div></div>" // Closes card-content and card
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,9 +43,19 @@ struct FeedEntryData {
|
|||||||
|
|
||||||
struct Image {
|
struct Image {
|
||||||
|
|
||||||
let mainImageUrl: String
|
|
||||||
|
|
||||||
let altText: 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]
|
let images: [FeedEntryData.Image]
|
||||||
|
|
||||||
|
private var htmlSafeId: String {
|
||||||
|
ImageGallery.htmlSafe(id)
|
||||||
|
}
|
||||||
|
|
||||||
init(id: String, images: [FeedEntryData.Image]) {
|
init(id: String, images: [FeedEntryData.Image]) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.images = images
|
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) {
|
func addContent(to result: inout String) {
|
||||||
guard !images.isEmpty else {
|
guard !images.isEmpty else {
|
||||||
return
|
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 {
|
guard images.count > 1 else {
|
||||||
let image = images[0]
|
result += imageCode(images[0])
|
||||||
result += "<div class=\"swiper-slide\"><img src=\(image.mainImageUrl) loading=\"lazy\" alt=\"\(image.altText.htmlEscaped())\"></div>"
|
|
||||||
result += "</div></div>" // Close swiper, swiper-wrapper
|
result += "</div></div>" // Close swiper, swiper-wrapper
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for image in images {
|
for image in images {
|
||||||
// TODO: Use different images based on device
|
// TODO: Use different images based on device
|
||||||
result += "<div class=\"swiper-slide\">"
|
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 += imageCode(image)
|
||||||
|
|
||||||
|
result += "<div class='swiper-lazy-preloader swiper-lazy-preloader-white'></div>"
|
||||||
|
|
||||||
result += "</div>" // Close swiper-slide
|
result += "</div>" // Close swiper-slide
|
||||||
}
|
}
|
||||||
|
|
||||||
result += "<div class=\"swiper-button-next\"></div>"
|
result += "</div>" // Close swiper-wrapper
|
||||||
result += "<div class=\"swiper-button-prev\"></div>"
|
result += "<div class='swiper-button-next'></div>"
|
||||||
result += "<div class=\"swiper-pagination\"></div>"
|
result += "<div class='swiper-button-prev'></div>"
|
||||||
result += "</div></div>" // Close swiper, swiper-wrapper
|
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 {
|
static func swiperInit(id: String) -> String {
|
||||||
"""
|
let id = htmlSafe(id)
|
||||||
var swiper\(id) = new Swiper("#\(id)", {
|
return """
|
||||||
|
var swiper_\(id) = new Swiper("#\(id)", {
|
||||||
loop: true,
|
loop: true,
|
||||||
slidesPerView: 1,
|
slidesPerView: 1,
|
||||||
spaceBetween: 30,
|
spaceBetween: 30,
|
||||||
|
@ -42,6 +42,7 @@ struct NavigationBar {
|
|||||||
|
|
||||||
result += "<a id=\"nav-image\" href=\"/\">"
|
result += "<a id=\"nav-image\" href=\"/\">"
|
||||||
result += "<img class=\"navbar-icon\" src=\"\(data.navigationIconPath)\" alt=\"\(data.iconDescription)\">"
|
result += "<img class=\"navbar-icon\" src=\"\(data.navigationIconPath)\" alt=\"\(data.iconDescription)\">"
|
||||||
|
result += "</a>"
|
||||||
|
|
||||||
for item in rightNavigationItems {
|
for item in rightNavigationItems {
|
||||||
result += "<a class=\"nav-animate\" href=\"\(item.url)\">\(item.text)</a>"
|
result += "<a class=\"nav-animate\" href=\"\(item.url)\">\(item.text)</a>"
|
||||||
|
@ -12,14 +12,17 @@ struct GenericPage {
|
|||||||
|
|
||||||
let additionalHeaders: String
|
let additionalHeaders: String
|
||||||
|
|
||||||
|
let additionalFooter: String
|
||||||
|
|
||||||
let insertedContent: (inout String) -> Void
|
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.language = language
|
||||||
self.title = title
|
self.title = title
|
||||||
self.description = description
|
self.description = description
|
||||||
self.data = data
|
self.data = data
|
||||||
self.additionalHeaders = additionalHeaders
|
self.additionalHeaders = additionalHeaders
|
||||||
|
self.additionalFooter = additionalFooter
|
||||||
self.insertedContent = insertedContent
|
self.insertedContent = insertedContent
|
||||||
}
|
}
|
||||||
var content: String {
|
var content: String {
|
||||||
@ -30,7 +33,9 @@ struct GenericPage {
|
|||||||
result += NavigationBar(data: data).content
|
result += NavigationBar(data: data).content
|
||||||
result += "<div class=\"content\"><div style=\"height: 70px;\"></div>"
|
result += "<div class=\"content\"><div style=\"height: 70px;\"></div>"
|
||||||
insertedContent(&result)
|
insertedContent(&result)
|
||||||
result += "</div></body></html>" // Close content
|
result += "</div>"
|
||||||
|
result += additionalFooter
|
||||||
|
result += "</body></html>" // Close content
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,20 +33,25 @@ struct PageInFeed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var content: String {
|
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 {
|
for post in posts {
|
||||||
FeedEntry(data: post)
|
content += FeedEntry(data: post).content
|
||||||
.addContent(to: &content)
|
|
||||||
}
|
}
|
||||||
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
|
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
|
||||||
if swiperIsNeeded {
|
|
||||||
addSwiperInits(to: &content)
|
|
||||||
}
|
|
||||||
}.content
|
}.content
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addSwiperInits(to result: inout String) {
|
private var swiperInits: String {
|
||||||
result += "<script src='\(swiperJsPath)'></script><script>"
|
var result = "<script src='\(swiperJsPath)'></script><script>"
|
||||||
for post in posts {
|
for post in posts {
|
||||||
guard post.images.count > 1 else {
|
guard post.images.count > 1 else {
|
||||||
continue
|
continue
|
||||||
@ -54,5 +59,6 @@ struct PageInFeed {
|
|||||||
result += ImageGallery.swiperInit(id: post.entryId)
|
result += ImageGallery.swiperInit(id: post.entryId)
|
||||||
}
|
}
|
||||||
result += "</script>"
|
result += "</script>"
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,16 +4,21 @@ extension WebsiteGeneratorConfiguration {
|
|||||||
|
|
||||||
static let english = WebsiteGeneratorConfiguration(
|
static let english = WebsiteGeneratorConfiguration(
|
||||||
language: .english,
|
language: .english,
|
||||||
|
outputDirectory: URL(fileURLWithPath: ""),
|
||||||
postsPerPage: 20,
|
postsPerPage: 20,
|
||||||
postFeedTitle: "Posts",
|
postFeedTitle: "Posts",
|
||||||
postFeedDescription: "The most recent posts on christophhagen.de",
|
postFeedDescription: "The most recent posts on christophhagen.de",
|
||||||
postFeedUrlPrefix: "feed")
|
postFeedUrlPrefix: "feed",
|
||||||
|
navigationIconPath: "/assets/icons/ch.svg",
|
||||||
|
mainContentMaximumWidth: 600)
|
||||||
|
|
||||||
static let german = WebsiteGeneratorConfiguration(
|
static let german = WebsiteGeneratorConfiguration(
|
||||||
language: .german,
|
language: .german,
|
||||||
|
outputDirectory: URL(fileURLWithPath: ""),
|
||||||
postsPerPage: 20,
|
postsPerPage: 20,
|
||||||
postFeedTitle: "Beiträge",
|
postFeedTitle: "Beiträge",
|
||||||
postFeedDescription: "Die neusten Beiträge auf christophhagen.de",
|
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
|
let name: String
|
||||||
|
|
||||||
init(image: String, url: URL) {
|
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.url = url
|
||||||
self.name = image
|
self.name = image
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum FileType {
|
enum FileType {
|
||||||
case image
|
case image(ImageType)
|
||||||
|
|
||||||
case file
|
case file
|
||||||
case video
|
case video
|
||||||
case resource
|
case resource
|
||||||
@ -9,8 +10,16 @@ enum FileType {
|
|||||||
|
|
||||||
init(fileExtension: String) {
|
init(fileExtension: String) {
|
||||||
switch fileExtension.lowercased() {
|
switch fileExtension.lowercased() {
|
||||||
case "jpg", "jpeg", "png", "gif":
|
case "jpg", "jpeg":
|
||||||
self = .image
|
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":
|
case "html", "stl", "f3d", "step", "f3z", "zip", "json", "conf", "css", "js", "cpp", "cddx", "svg", "glb", "mp3", "pdf", "swift":
|
||||||
self = .file
|
self = .file
|
||||||
case "mp4":
|
case "mp4":
|
||||||
@ -22,4 +31,19 @@ enum FileType {
|
|||||||
self = .resource
|
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
|
import Foundation
|
||||||
|
|
||||||
|
enum SecurityScopeBookmark: String {
|
||||||
|
|
||||||
|
case outputPath = "outputPathBookmark"
|
||||||
|
|
||||||
|
case contentPath = "contentPathBookmark"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
A class that handles the storage of the website data.
|
A class that handles the storage of the website data.
|
||||||
|
|
||||||
@ -13,9 +20,6 @@ import Foundation
|
|||||||
*/
|
*/
|
||||||
final class Storage {
|
final class Storage {
|
||||||
|
|
||||||
static let outputPathBookmarkKey = "outputPathBookmark"
|
|
||||||
static let contentPathBookmarkKey = "contentPathBookmark"
|
|
||||||
|
|
||||||
private(set) var baseFolder: URL
|
private(set) var baseFolder: URL
|
||||||
|
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
@ -60,14 +64,9 @@ final class Storage {
|
|||||||
|
|
||||||
// MARK: Folders
|
// MARK: Folders
|
||||||
|
|
||||||
func update(baseFolder: URL, moveContent: Bool) throws {
|
func update(baseFolder: URL) throws {
|
||||||
let oldFolder = self.baseFolder
|
|
||||||
self.baseFolder = baseFolder
|
self.baseFolder = baseFolder
|
||||||
try createFolderStructure()
|
try createFolderStructure()
|
||||||
guard moveContent else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// TODO: Move all files
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func create(folder: URL) throws {
|
private func create(folder: URL) throws {
|
||||||
@ -213,7 +212,7 @@ final class Storage {
|
|||||||
// MARK: Files
|
// MARK: Files
|
||||||
|
|
||||||
/// The folder path where other files are stored (by their unique name)
|
/// 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 {
|
private func fileUrl(file: String) -> URL {
|
||||||
filesFolder.appending(path: file, directoryHint: .notDirectory)
|
filesFolder.appending(path: file, directoryHint: .notDirectory)
|
||||||
@ -250,6 +249,70 @@ final class Storage {
|
|||||||
write(websiteData, type: "Website Data", id: "-", to: websiteDataUrl)
|
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
|
// MARK: Writing files
|
||||||
|
|
||||||
private func deleteFiles(in folder: URL, notIn fileSet: Set<String>) throws {
|
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
|
var content: Content
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var isSelectingContentFolder = false
|
private var folderSelection: SecurityScopeBookmark = .contentPath
|
||||||
|
|
||||||
@State
|
|
||||||
private var showFileImporter = false
|
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var showTagPicker = false
|
private var showTagPicker = false
|
||||||
@ -70,6 +67,7 @@ struct SettingsView: View {
|
|||||||
Button(action: generateFeed) {
|
Button(action: generateFeed) {
|
||||||
Text("Generate")
|
Text("Generate")
|
||||||
}
|
}
|
||||||
|
.disabled(isGeneratingWebsite)
|
||||||
if isGeneratingWebsite {
|
if isGeneratingWebsite {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
@ -80,10 +78,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.fileImporter(
|
|
||||||
isPresented: $showFileImporter,
|
|
||||||
allowedContentTypes: [.folder],
|
|
||||||
onCompletion: didSelectContentFolder)
|
|
||||||
.sheet(isPresented: $showTagPicker) {
|
.sheet(isPresented: $showTagPicker) {
|
||||||
TagSelectionView(
|
TagSelectionView(
|
||||||
presented: $showTagPicker,
|
presented: $showTagPicker,
|
||||||
@ -95,43 +89,21 @@ struct SettingsView: View {
|
|||||||
// MARK: Folder selection
|
// MARK: Folder selection
|
||||||
|
|
||||||
private func selectContentFolder() {
|
private func selectContentFolder() {
|
||||||
isSelectingContentFolder = true
|
folderSelection = .contentPath
|
||||||
//showFileImporter = true
|
guard let url = savePanelUsingOpenPanel() else {
|
||||||
guard let url = savePanelUsingOpenPanel(key: Storage.contentPathBookmarkKey) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.contentPath = url.path()
|
self.contentPath = url.path()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectOutputFolder() {
|
private func selectOutputFolder() {
|
||||||
isSelectingContentFolder = false
|
folderSelection = .outputPath
|
||||||
guard let url = savePanelUsingOpenPanel(key: Storage.outputPathBookmarkKey) else {
|
guard let url = savePanelUsingOpenPanel() else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.outputPath = url.path()
|
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
|
// MARK: Feed
|
||||||
|
|
||||||
private func generateFeed() {
|
private func generateFeed() {
|
||||||
@ -150,7 +122,7 @@ struct SettingsView: View {
|
|||||||
let generator = WebsiteGenerator(
|
let generator = WebsiteGenerator(
|
||||||
content: content,
|
content: content,
|
||||||
configuration: configuration)
|
configuration: configuration)
|
||||||
generator.generateWebsite()
|
_ = generator.generateWebsite()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
isGeneratingWebsite = false
|
isGeneratingWebsite = false
|
||||||
}
|
}
|
||||||
@ -158,13 +130,18 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var configuration: WebsiteGeneratorConfiguration {
|
private var configuration: WebsiteGeneratorConfiguration {
|
||||||
switch language {
|
return .init(
|
||||||
case .english: return .english
|
language: language,
|
||||||
case .german: return .german
|
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()
|
let panel = NSOpenPanel()
|
||||||
// Sets up so user can only select a single directory
|
// Sets up so user can only select a single directory
|
||||||
panel.canChooseFiles = false
|
panel.canChooseFiles = false
|
||||||
@ -182,19 +159,9 @@ struct SettingsView: View {
|
|||||||
guard let url = panel.url else {
|
guard let url = panel.url else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
saveSecurityScopedBookmark(url, key: key)
|
content.storage.save(folderUrl: url, in: folderSelection)
|
||||||
return url
|
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 {
|
#Preview {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user