Generate first tag pages
This commit is contained in:
parent
4f08526978
commit
8183bc4903
@ -20,19 +20,14 @@
|
||||
E218502B2CF790B30090B18B /* PostContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502A2CF790AC0090B18B /* PostContentView.swift */; };
|
||||
E218502D2CF791440090B18B /* PostImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502C2CF791440090B18B /* PostImagesView.swift */; };
|
||||
E218502F2CFAF69C0090B18B /* WebsiteGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */; };
|
||||
E21850312CFAF8880090B18B /* Content+Import.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850302CFAF8840090B18B /* Content+Import.swift */; };
|
||||
E21850332CFAFA2F0090B18B /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850322CFAFA200090B18B /* Settings.swift */; };
|
||||
E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850342CFAFA570090B18B /* SettingsFile.swift */; };
|
||||
E21850372CFCA55F0090B18B /* LocalizedPostSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */; };
|
||||
E21850392CFCA6C00090B18B /* WebsiteData+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */; };
|
||||
E218503D2CFCFD910090B18B /* LocalizedPostFeedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */; };
|
||||
E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = E24252002C50E0A40029FF16 /* HighlightedTextEditor */; };
|
||||
E24252032C5163CF0029FF16 /* Importer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252022C5163CF0029FF16 /* Importer.swift */; };
|
||||
E24252062C51684E0029FF16 /* GenericMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252052C51684E0029FF16 /* GenericMetadata.swift */; };
|
||||
E24252082C5168750029FF16 /* GenericMetadata+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */; };
|
||||
E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252092C52C9260029FF16 /* ContentLanguage.swift */; };
|
||||
E2581DED2C75202400F1F079 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEC2C75202400F1F079 /* Tag.swift */; };
|
||||
E2581DF12C7523F400F1F079 /* ImportableTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DF02C7523F400F1F079 /* ImportableTag.swift */; };
|
||||
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5082CFD964E00AEF16D /* TagContentView.swift */; };
|
||||
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */; };
|
||||
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50C2CFD9B9C00AEF16D /* PostTagAssignmentView.swift */; };
|
||||
@ -113,6 +108,7 @@
|
||||
E29D31612D06D95C0051B7F4 /* ResourceFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31602D06D9570051B7F4 /* ResourceFileType.swift */; };
|
||||
E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31622D06E95A0051B7F4 /* NavigationIcon.swift */; };
|
||||
E29D31692D0702700051B7F4 /* TextFileContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31682D0702670051B7F4 /* TextFileContentView.swift */; };
|
||||
E29D316B2D07488B0051B7F4 /* PostListPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */; };
|
||||
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; };
|
||||
E2A21C082CB17B870060935B /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C072CB17B810060935B /* TagView.swift */; };
|
||||
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0D2CB189D70060935B /* Color+RGB.swift */; };
|
||||
@ -169,18 +165,13 @@
|
||||
E218502A2CF790AC0090B18B /* PostContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostContentView.swift; sourceTree = "<group>"; };
|
||||
E218502C2CF791440090B18B /* PostImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostImagesView.swift; sourceTree = "<group>"; };
|
||||
E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteGenerator.swift; sourceTree = "<group>"; };
|
||||
E21850302CFAF8840090B18B /* Content+Import.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Import.swift"; sourceTree = "<group>"; };
|
||||
E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
|
||||
E21850342CFAFA570090B18B /* SettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFile.swift; sourceTree = "<group>"; };
|
||||
E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = "<group>"; };
|
||||
E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebsiteData+Mock.swift"; sourceTree = "<group>"; };
|
||||
E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostFeedSettingsView.swift; sourceTree = "<group>"; };
|
||||
E24252022C5163CF0029FF16 /* Importer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Importer.swift; sourceTree = "<group>"; };
|
||||
E24252052C51684E0029FF16 /* GenericMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericMetadata.swift; sourceTree = "<group>"; };
|
||||
E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GenericMetadata+Localized.swift"; sourceTree = "<group>"; };
|
||||
E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = "<group>"; };
|
||||
E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
||||
E2581DF02C7523F400F1F079 /* ImportableTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportableTag.swift; sourceTree = "<group>"; };
|
||||
E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = "<group>"; };
|
||||
E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = "<group>"; };
|
||||
E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTagAssignmentView.swift; sourceTree = "<group>"; };
|
||||
@ -258,6 +249,7 @@
|
||||
E29D31602D06D9570051B7F4 /* ResourceFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceFileType.swift; sourceTree = "<group>"; };
|
||||
E29D31622D06E95A0051B7F4 /* NavigationIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationIcon.swift; sourceTree = "<group>"; };
|
||||
E29D31682D0702670051B7F4 /* TextFileContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFileContentView.swift; sourceTree = "<group>"; };
|
||||
E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostListPageGenerator.swift; sourceTree = "<group>"; };
|
||||
E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = "<group>"; };
|
||||
E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = "<group>"; };
|
||||
E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = "<group>"; };
|
||||
@ -317,17 +309,6 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
E24252042C5168430029FF16 /* Import */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E24252022C5163CF0029FF16 /* Importer.swift */,
|
||||
E2581DF02C7523F400F1F079 /* ImportableTag.swift */,
|
||||
E24252052C51684E0029FF16 /* GenericMetadata.swift */,
|
||||
E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */,
|
||||
);
|
||||
path = Import;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E25DA5112CFF001900AEF16D /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -371,6 +352,7 @@
|
||||
E25DA5782D01C56200AEF16D /* Generator */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */,
|
||||
E29D31252D0370A50051B7F4 /* VideoOption.swift */,
|
||||
E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */,
|
||||
E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */,
|
||||
@ -517,7 +499,6 @@
|
||||
E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */,
|
||||
E25DA5162CFF00F200AEF16D /* Content+Save.swift */,
|
||||
E25DA5142CFF00B900AEF16D /* Content+Load.swift */,
|
||||
E21850302CFAF8840090B18B /* Content+Import.swift */,
|
||||
E24252092C52C9260029FF16 /* ContentLanguage.swift */,
|
||||
E25DA59A2D024A2900AEF16D /* DateItem.swift */,
|
||||
E2A21C502CBBD53C0060935B /* FileResource.swift */,
|
||||
@ -637,7 +618,6 @@
|
||||
E2DD047B2C276F32003BFF1F /* CHDataManagement.entitlements */,
|
||||
E2B85F552C4BD0AD0047CD0C /* Extensions */,
|
||||
E2DD047C2C276F32003BFF1F /* Preview Content */,
|
||||
E24252042C5168430029FF16 /* Import */,
|
||||
);
|
||||
path = CHDataManagement;
|
||||
sourceTree = "<group>";
|
||||
@ -753,9 +733,7 @@
|
||||
E29D31692D0702700051B7F4 /* TextFileContentView.swift in Sources */,
|
||||
E25DA5412D00446C00AEF16D /* PostSettings.swift in Sources */,
|
||||
E2A37D192CEA36A90000979F /* LocalizedTag.swift in Sources */,
|
||||
E24252062C51684E0029FF16 /* GenericMetadata.swift in Sources */,
|
||||
E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */,
|
||||
E21850312CFAF8880090B18B /* Content+Import.swift in Sources */,
|
||||
E25DA5172CFF00F500AEF16D /* Content+Save.swift in Sources */,
|
||||
E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */,
|
||||
E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */,
|
||||
@ -779,7 +757,6 @@
|
||||
E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */,
|
||||
E25DA5832D01C7A400AEF16D /* VideoFileType.swift in Sources */,
|
||||
E29D31512D06168E0051B7F4 /* PostListView.swift in Sources */,
|
||||
E2581DF12C7523F400F1F079 /* ImportableTag.swift in Sources */,
|
||||
E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */,
|
||||
E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */,
|
||||
E25DA58D2D021BA400AEF16D /* WebsiteImage.swift in Sources */,
|
||||
@ -792,7 +769,6 @@
|
||||
E29D31492D0489BB0051B7F4 /* AddFileView.swift in Sources */,
|
||||
E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */,
|
||||
E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */,
|
||||
E24252082C5168750029FF16 /* GenericMetadata+Localized.swift in Sources */,
|
||||
E2581DED2C75202400F1F079 /* Tag.swift in Sources */,
|
||||
E29D315B2D06D63E0051B7F4 /* ModelFileType.swift in Sources */,
|
||||
E29D31302D03A2C50051B7F4 /* DescriptionField.swift in Sources */,
|
||||
@ -807,7 +783,6 @@
|
||||
E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */,
|
||||
E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */,
|
||||
E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */,
|
||||
E24252032C5163CF0029FF16 /* Importer.swift in Sources */,
|
||||
E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */,
|
||||
E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */,
|
||||
E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */,
|
||||
@ -826,6 +801,7 @@
|
||||
E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */,
|
||||
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
|
||||
E2B85F412C4294790047CD0C /* PageHead.swift in Sources */,
|
||||
E29D316B2D07488B0051B7F4 /* PostListPageGenerator.swift in Sources */,
|
||||
E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */,
|
||||
E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */,
|
||||
E25DA5432D0094A900AEF16D /* SettingsSidebar.swift in Sources */,
|
||||
|
@ -33,7 +33,12 @@ final class ImageGenerator {
|
||||
init(storage: Storage, relativeImageOutputPath: String) {
|
||||
self.storage = storage
|
||||
self.relativeImageOutputPath = relativeImageOutputPath
|
||||
self.generatedImages = storage.loadListOfGeneratedImages()
|
||||
do {
|
||||
self.generatedImages = try storage.loadListOfGeneratedImages()
|
||||
} catch {
|
||||
print("Failed to load list of previously generated images: \(error)")
|
||||
self.generatedImages = [:]
|
||||
}
|
||||
}
|
||||
|
||||
func prepareForGeneration() -> Bool {
|
||||
@ -60,7 +65,13 @@ final class ImageGenerator {
|
||||
}
|
||||
|
||||
func save() -> Bool {
|
||||
storage.save(listOfGeneratedImages: generatedImages)
|
||||
do {
|
||||
try storage.save(listOfGeneratedImages: generatedImages)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to save list of generated images: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func versionFileName(image: String, type: ImageFileType, width: CGFloat, height: CGFloat) -> String {
|
||||
|
@ -14,7 +14,7 @@ final class PageGenerator {
|
||||
self.navigationBarData = navigationBarData
|
||||
}
|
||||
|
||||
func generate(page: Page, language: ContentLanguage) -> String {
|
||||
func generate(page: Page, language: ContentLanguage) throws -> String {
|
||||
let contentGenerator = PageContentParser(
|
||||
page: page,
|
||||
content: content,
|
||||
@ -22,22 +22,26 @@ final class PageGenerator {
|
||||
results: results,
|
||||
imageGenerator: imageGenerator)
|
||||
|
||||
let rawPageContent = content.storage.pageContent(for: page.id, language: language)
|
||||
let rawPageContent = try content.storage.pageContent(for: page.id, language: language)
|
||||
|
||||
let pageContent = contentGenerator.generatePage(from: rawPageContent)
|
||||
|
||||
let localized = page.localized(in: language)
|
||||
|
||||
let tags: [FeedEntryData.Tag] = page.tags.map { tag in
|
||||
.init(name: tag.localized(in: language).name,
|
||||
url: content.tagLink(tag, language: language))
|
||||
}
|
||||
|
||||
return ContentPage(
|
||||
language: language,
|
||||
dateString: page.dateText(in: language),
|
||||
title: localized.title,
|
||||
tags: page.tags.map { $0.data(in: language) },
|
||||
tags: tags,
|
||||
linkTitle: localized.linkPreviewTitle ?? localized.title,
|
||||
description: localized.linkPreviewDescription ?? "",
|
||||
navigationBarData: navigationBarData,
|
||||
pageContent: pageContent)
|
||||
.content
|
||||
}
|
||||
|
||||
}
|
||||
|
118
CHDataManagement/Generator/PostListPageGenerator.swift
Normal file
118
CHDataManagement/Generator/PostListPageGenerator.swift
Normal file
@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
|
||||
final class PostListPageGenerator {
|
||||
|
||||
private let language: ContentLanguage
|
||||
|
||||
private let content: Content
|
||||
|
||||
private let imageGenerator: ImageGenerator
|
||||
|
||||
private let navigationBarData: NavigationBarData
|
||||
|
||||
private let showTitle: Bool
|
||||
|
||||
private let pageTitle: String
|
||||
|
||||
private let pageDescription: String
|
||||
|
||||
/// The url of the page, excluding the extension
|
||||
private let pageUrlPrefix: String
|
||||
|
||||
init(language: ContentLanguage, content: Content, imageGenerator: ImageGenerator, navigationBarData: NavigationBarData, showTitle: Bool, pageTitle: String, pageDescription: String, pageUrlPrefix: String) {
|
||||
self.language = language
|
||||
self.content = content
|
||||
self.imageGenerator = imageGenerator
|
||||
self.navigationBarData = navigationBarData
|
||||
self.showTitle = showTitle
|
||||
self.pageTitle = pageTitle
|
||||
self.pageDescription = pageDescription
|
||||
self.pageUrlPrefix = pageUrlPrefix
|
||||
}
|
||||
|
||||
private var mainContentMaximumWidth: CGFloat {
|
||||
CGFloat(content.settings.posts.contentWidth)
|
||||
}
|
||||
|
||||
private var postsPerPage: Int {
|
||||
content.settings.posts.postsPerPage
|
||||
}
|
||||
|
||||
func createPages(for posts: [Post]) -> Bool {
|
||||
let totalCount = posts.count
|
||||
guard totalCount > 0 else {
|
||||
return true
|
||||
}
|
||||
|
||||
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 = posts[startIndex..<endIndex]
|
||||
guard createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navigationBarData) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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: content.pageLink($0, language: language),
|
||||
text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings
|
||||
}
|
||||
|
||||
let tags: [FeedEntryData.Tag] = post.tags.map { tag in
|
||||
.init(name: tag.localized(in: language).name,
|
||||
url: content.tagLink(tag, language: language))
|
||||
}
|
||||
|
||||
return FeedEntryData(
|
||||
entryId: "\(post.id)",
|
||||
title: localized.title,
|
||||
textAboveTitle: post.dateText(in: language),
|
||||
link: linkUrl,
|
||||
tags: tags,
|
||||
text: [localized.content], // TODO: Convert from markdown to html
|
||||
images: localized.images.map(createImageSet))
|
||||
}
|
||||
|
||||
let feed = PageInFeed(
|
||||
language: language,
|
||||
title: pageTitle,
|
||||
showTitle: showTitle,
|
||||
description: pageDescription,
|
||||
navigationBarData: bar,
|
||||
pageNumber: pageIndex,
|
||||
totalPages: pageCount,
|
||||
posts: posts)
|
||||
let fileContent = feed.content
|
||||
if pageIndex == 1 {
|
||||
return save(fileContent, to: "\(pageUrlPrefix).html")
|
||||
} else {
|
||||
return save(fileContent, to: "\(pageUrlPrefix)-\(pageIndex).html")
|
||||
}
|
||||
}
|
||||
|
||||
private func createImageSet(for image: FileResource) -> FeedEntryData.Image {
|
||||
imageGenerator.generateImageSet(
|
||||
for: image.id,
|
||||
maxWidth: mainContentMaximumWidth,
|
||||
maxHeight: mainContentMaximumWidth,
|
||||
altText: image.getDescription(for: language))
|
||||
}
|
||||
|
||||
private func save(_ content: String, to relativePath: String) -> Bool {
|
||||
do {
|
||||
try self.content.storage.write(content: content, to: relativePath)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to write page \(relativePath)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
@ -14,18 +14,6 @@ final class WebsiteGenerator {
|
||||
content.settings.posts.postsPerPage
|
||||
}
|
||||
|
||||
private var postFeedTitle: String {
|
||||
localizedSettings.posts.title
|
||||
}
|
||||
|
||||
private var postFeedDescription: String {
|
||||
localizedSettings.posts.description
|
||||
}
|
||||
|
||||
private var postFeedUrlPrefix: String {
|
||||
localizedSettings.posts.feedUrlPrefix
|
||||
}
|
||||
|
||||
private var navigationIconPath: String {
|
||||
content.settings.navigationBar.iconPath
|
||||
}
|
||||
@ -57,7 +45,10 @@ final class WebsiteGenerator {
|
||||
guard imageGenerator.prepareForGeneration() else {
|
||||
return false
|
||||
}
|
||||
guard createPostFeedPages() else {
|
||||
guard createMainPostFeedPages() else {
|
||||
return false
|
||||
}
|
||||
guard generateTagPages() else {
|
||||
return false
|
||||
}
|
||||
guard imageGenerator.runJobs(callback: callback) else {
|
||||
@ -66,18 +57,37 @@ final class WebsiteGenerator {
|
||||
return imageGenerator.save()
|
||||
}
|
||||
|
||||
private func createPostFeedPages() -> Bool {
|
||||
let totalCount = content.posts.count
|
||||
guard totalCount > 0 else {
|
||||
return true
|
||||
}
|
||||
private func createMainPostFeedPages() -> Bool {
|
||||
let generator = PostListPageGenerator(
|
||||
language: language,
|
||||
content: content,
|
||||
imageGenerator: imageGenerator,
|
||||
navigationBarData: navigationBarData,
|
||||
showTitle: false,
|
||||
pageTitle: localizedSettings.posts.title,
|
||||
pageDescription: localizedSettings.posts.description,
|
||||
pageUrlPrefix: localizedSettings.posts.feedUrlPrefix)
|
||||
return generator.createPages(for: content.posts)
|
||||
}
|
||||
|
||||
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: navigationBarData) else {
|
||||
private func generateTagPages() -> Bool {
|
||||
for tag in content.tags {
|
||||
let posts = content.posts.filter { $0.tags.contains(tag) }
|
||||
guard posts.count > 0 else { continue }
|
||||
|
||||
let localized = tag.localized(in: language)
|
||||
|
||||
#warning("Get tag url prefix from settings")
|
||||
let generator = PostListPageGenerator(
|
||||
language: language,
|
||||
content: content,
|
||||
imageGenerator: imageGenerator,
|
||||
navigationBarData: navigationBarData,
|
||||
showTitle: true,
|
||||
pageTitle: localized.name,
|
||||
pageDescription: localized.description ?? "",
|
||||
pageUrlPrefix: "tags/\(localized.urlComponent)")
|
||||
guard generator.createPages(for: posts) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -95,50 +105,6 @@ final class WebsiteGenerator {
|
||||
navigationItems: navigationItems)
|
||||
}
|
||||
|
||||
private func createImageSet(for image: FileResource) -> FeedEntryData.Image {
|
||||
imageGenerator.generateImageSet(
|
||||
for: image.id,
|
||||
maxWidth: mainContentMaximumWidth,
|
||||
maxHeight: mainContentMaximumWidth,
|
||||
altText: image.getDescription(for: language))
|
||||
}
|
||||
|
||||
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: content.pageLink($0, language: language),
|
||||
text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings
|
||||
}
|
||||
|
||||
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 generatePagesFolderIfNeeded() -> Bool {
|
||||
let relativePath = content.settings.pages.pageUrlPrefix
|
||||
|
||||
@ -159,7 +125,14 @@ final class WebsiteGenerator {
|
||||
return false
|
||||
}
|
||||
let pageGenerator = PageGenerator(content: content, imageGenerator: imageGenerator, navigationBarData: navigationBarData)
|
||||
let content = pageGenerator.generate(page: page, language: language)
|
||||
|
||||
let content: String
|
||||
do {
|
||||
content = try pageGenerator.generate(page: page, language: language)
|
||||
} catch {
|
||||
print("Failed to generate page \(page.id) in language \(language): \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
let path = self.content.pageLink(page, language: language) + ".html"
|
||||
guard save(content, to: path) else {
|
||||
@ -181,8 +154,10 @@ final class WebsiteGenerator {
|
||||
guard let outputPath = content.pathToFile(fileId) else {
|
||||
return false
|
||||
}
|
||||
guard content.storage.copy(file: fileId, to: outputPath) else {
|
||||
print("Failed to copy video file to output folder")
|
||||
do {
|
||||
try content.storage.copy(file: fileId, to: outputPath)
|
||||
} catch {
|
||||
print("Failed to copy video file: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -190,23 +165,12 @@ final class WebsiteGenerator {
|
||||
}
|
||||
|
||||
private func save(_ content: String, to relativePath: String) -> Bool {
|
||||
guard let data = content.data(using: .utf8) else {
|
||||
print("Failed to create data for \(relativePath)")
|
||||
do {
|
||||
try self.content.storage.write(content: content, to: relativePath)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to write page \(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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,153 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension GenericMetadata {
|
||||
|
||||
/**
|
||||
Metadata localized for a specific language.
|
||||
*/
|
||||
struct LocalizedMetadata {
|
||||
|
||||
/**
|
||||
The language for which the content is specified.
|
||||
- Note: This field is mandatory
|
||||
*/
|
||||
let language: String?
|
||||
|
||||
/**
|
||||
The title used in the page header.
|
||||
- Note: This field is mandatory
|
||||
*/
|
||||
let title: String?
|
||||
|
||||
/**
|
||||
The subtitle used in the page header.
|
||||
*/
|
||||
let subtitle: String?
|
||||
|
||||
/**
|
||||
The description text used in the page header
|
||||
*/
|
||||
let description: String?
|
||||
|
||||
/**
|
||||
The title to use for the link preview.
|
||||
|
||||
If `nil` is specified, then the localized element `title` is used.
|
||||
*/
|
||||
let linkPreviewTitle: String?
|
||||
|
||||
/**
|
||||
The file name of the link preview image.
|
||||
- Note: The image must be located in the element folder.
|
||||
- Note: If `nil` is specified, then the (localized) thumbnail is used.
|
||||
*/
|
||||
let linkPreviewImage: String?
|
||||
|
||||
/**
|
||||
The description text for the link preview.
|
||||
- Note: If `nil` is specified, then first the (localized) element `subtitle` is used.
|
||||
If this is `nil` too, then the localized `description` of the element is used.
|
||||
*/
|
||||
let linkPreviewDescription: String?
|
||||
|
||||
/**
|
||||
The text on the link to show the section page when previewing multiple sections on an overview page.
|
||||
- Note: If this value is inherited from the parent, if it is not defined. There must be at least one
|
||||
element in the path that defines this property.
|
||||
*/
|
||||
let moreLinkText: String?
|
||||
|
||||
/**
|
||||
The text on the back navigation link of **contained** elements.
|
||||
|
||||
This text does not appear on the section page, but on the pages contained within the section.
|
||||
- Note: If this property is not specified, then the root `backLinkText` is used.
|
||||
- Note: The root element must specify this property.
|
||||
*/
|
||||
let backLinkText: String?
|
||||
|
||||
/**
|
||||
The text to show as a title for placeholder boxes
|
||||
|
||||
Placeholders are included in missing pages.
|
||||
- Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property.
|
||||
*/
|
||||
let placeholderTitle: String?
|
||||
|
||||
/**
|
||||
The text to show as a description for placeholder boxes
|
||||
|
||||
Placeholders are included in missing pages.
|
||||
- Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property.
|
||||
*/
|
||||
let placeholderText: String?
|
||||
|
||||
/**
|
||||
An optional suffix to add to the title on a page.
|
||||
|
||||
This can be useful to express a different author, project grouping, etc.
|
||||
*/
|
||||
let titleSuffix: String?
|
||||
|
||||
/**
|
||||
An optional suffix to add to the thumbnail title of a page.
|
||||
|
||||
This can be useful to express a different author, project grouping, etc.
|
||||
*/
|
||||
let thumbnailSuffix: String?
|
||||
|
||||
/**
|
||||
A text to place in the top right corner of a large thumbnail.
|
||||
|
||||
The text should be a very short string to fit into the corner, like `soon`, or `draft`
|
||||
|
||||
- Note: This property is ignored if `thumbnailStyle` is not `large`.
|
||||
*/
|
||||
let cornerText: String?
|
||||
|
||||
/**
|
||||
The external url to use instead of automatically generating the page.
|
||||
|
||||
This property can be used for links to other parts of the site, like additional services.
|
||||
It can also be set to manually write a page.
|
||||
*/
|
||||
let externalUrl: String?
|
||||
|
||||
/**
|
||||
The text to display for content related to the current page.
|
||||
|
||||
This property is mandatory at root level, and is propagated to child elements.
|
||||
*/
|
||||
let relatedContentText: String?
|
||||
|
||||
/**
|
||||
The text to display on a navigation element pointing to this element as the previous page.
|
||||
|
||||
This property is mandatory at root level, and is propagated to child elements.
|
||||
*/
|
||||
let navigationTextAsPreviousPage: String?
|
||||
|
||||
/**
|
||||
The text to display on the navigation element pointing to this element as the next page.
|
||||
|
||||
This property is mandatory at root level, and is propagated to child elements.
|
||||
*/
|
||||
let navigationTextAsNextPage: String?
|
||||
|
||||
/**
|
||||
The text to display above a slideshow for most recent items.
|
||||
Only used for elements that define `showMostRecentSection = true`
|
||||
*/
|
||||
let mostRecentTitle: String?
|
||||
|
||||
/**
|
||||
The text to display above a slideshow for featured items.
|
||||
Only used for elements that define `showFeaturedSection = true`
|
||||
*/
|
||||
let featuredTitle: String?
|
||||
}
|
||||
}
|
||||
|
||||
extension GenericMetadata.LocalizedMetadata: Codable {
|
||||
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
The metadata for all site elements.
|
||||
*/
|
||||
struct GenericMetadata {
|
||||
|
||||
/**
|
||||
A custom id to uniquely identify the element on the site.
|
||||
|
||||
The id is used for short-hand links to pages, in the form of ``
|
||||
for thumbnail previews or `[text](page:page_id)` for simple links.
|
||||
|
||||
If no custom id is set, then the name of the element folder is used.
|
||||
*/
|
||||
let customId: String?
|
||||
|
||||
/**
|
||||
The author of the content.
|
||||
|
||||
If no author is set, then the author from the parent element is used.
|
||||
*/
|
||||
let author: String?
|
||||
|
||||
/**
|
||||
The (start) date of the element.
|
||||
|
||||
The date is printed on content pages and may also used for sorting elements,
|
||||
depending on the `useManualSorting` property of the parent.
|
||||
*/
|
||||
let date: String?
|
||||
|
||||
/**
|
||||
The end date of the element.
|
||||
|
||||
This property can be used to specify a date range for a content page.
|
||||
*/
|
||||
let endDate: String?
|
||||
|
||||
/**
|
||||
The deployment state of the page.
|
||||
|
||||
- Note: This property defaults to ``PageState.standard`
|
||||
*/
|
||||
let state: String?
|
||||
|
||||
/**
|
||||
The sort index of the page for manual sorting.
|
||||
|
||||
- Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set.
|
||||
*/
|
||||
let sortIndex: Int?
|
||||
|
||||
/**
|
||||
All files which may occur in content but is stored externally.
|
||||
|
||||
Missing files which would otherwise produce a warning are ignored when included here.
|
||||
- Note: This property defaults to an empty set.
|
||||
*/
|
||||
let externalFiles: Set<String>?
|
||||
|
||||
/**
|
||||
Specifies additional files which should be copied to the destination when generating the content.
|
||||
- Note: This property defaults to an empty set.
|
||||
*/
|
||||
let requiredFiles: Set<String>?
|
||||
|
||||
/**
|
||||
Additional images required by the element.
|
||||
|
||||
These images are specified as: `source_name destination_name width (height)`.
|
||||
*/
|
||||
let images: Set<String>?
|
||||
|
||||
/**
|
||||
The path to the thumbnail file.
|
||||
|
||||
This property is optional, and defaults to ``Element.defaultThumbnailName``.
|
||||
Note: The generator first looks for localized versions of the thumbnail by appending `-[lang]` to the file name,
|
||||
e.g. `customThumb-en.jpg`. If no file is found, then the specified file is tried.
|
||||
*/
|
||||
let thumbnailPath: String?
|
||||
|
||||
/**
|
||||
The style of thumbnail to use when generating overviews.
|
||||
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property is inherited from the parent if not specified.
|
||||
*/
|
||||
let thumbnailStyle: String?
|
||||
|
||||
/**
|
||||
Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`.
|
||||
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property defaults to `false`
|
||||
*/
|
||||
let useManualSorting: Bool?
|
||||
|
||||
/**
|
||||
The number of items to show when generating overviews of this element.
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property is inherited from the parent if not specified.
|
||||
*/
|
||||
let overviewItemCount: Int?
|
||||
|
||||
/**
|
||||
Indicate the header type to be generated automatically.
|
||||
|
||||
If this option is set to `none`, then custom header code should be present in the page source files
|
||||
- Note: If not specified, this property defaults to `left`.
|
||||
- Note: Overview pages are always using `center`.
|
||||
*/
|
||||
let headerType: String?
|
||||
|
||||
/**
|
||||
Indicate that the overview section should contain a `Newest Content` section before the other sections.
|
||||
- Note: If not specified, this property defaults to `false`
|
||||
*/
|
||||
let showMostRecentSection: Bool?
|
||||
|
||||
/**
|
||||
Indicate that the overview section should contain a `Featured Content` section before the other sections.
|
||||
The elements are the page ids of the elements contained in the feature.
|
||||
- Note: If not specified, this property defaults to `false`
|
||||
*/
|
||||
let featuredPages: [String]?
|
||||
|
||||
/**
|
||||
The localized metadata for each language.
|
||||
*/
|
||||
let languages: [LocalizedMetadata]?
|
||||
}
|
||||
|
||||
extension GenericMetadata: Codable {
|
||||
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct ImportableTag {
|
||||
|
||||
let languages: [TagLanguage]
|
||||
|
||||
func info(for language: ContentLanguage) -> TagLanguage? {
|
||||
languages.first { $0.language == language.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension ImportableTag: Codable {
|
||||
|
||||
}
|
||||
|
||||
struct TagLanguage {
|
||||
|
||||
let language: String
|
||||
|
||||
let title: String
|
||||
|
||||
let subtitle: String?
|
||||
|
||||
let description: String?
|
||||
|
||||
let moreLinkText: String?
|
||||
|
||||
let backLinkText: String?
|
||||
}
|
||||
|
||||
extension TagLanguage: Codable {
|
||||
|
||||
}
|
@ -1,290 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
final class Importer {
|
||||
|
||||
var posts: [String : PostFile] = [:]
|
||||
|
||||
var pages: [String : PageOnDisk] = [:]
|
||||
|
||||
var tags: [String : TagFile] = [:]
|
||||
|
||||
var files: [String : FileOnDisk] = [:]
|
||||
|
||||
var ignoredFiles: [URL] = []
|
||||
|
||||
var foldersToSearch: [(path: String, tag: String)] = [
|
||||
("/Users/ch/Downloads/Website/projects/electronics", "electronics"),
|
||||
("/Users/ch/Downloads/Website/projects/endeavor", "endeavor"),
|
||||
("/Users/ch/Downloads/Website/projects/furniture", "furniture"),
|
||||
("/Users/ch/Downloads/Website/projects/lighting", "lighting"),
|
||||
("/Users/ch/Downloads/Website/projects/other", "other"),
|
||||
("/Users/ch/Downloads/Website/projects/sewing", "sewing"),
|
||||
("/Users/ch/Downloads/Website/projects/software", "software"),
|
||||
("/Users/ch/Downloads/Website/articles", "articles"),
|
||||
("/Users/ch/Downloads/Website/photography", "photography"),
|
||||
("/Users/ch/Downloads/Website/travel", "travel")
|
||||
]
|
||||
|
||||
func importContent() throws {
|
||||
for (path, name) in foldersToSearch {
|
||||
let folder = URL(filePath: path)
|
||||
let pageFolders = try findPageFolders(in: folder)
|
||||
|
||||
let tag = try importTag(name: name, folder: folder)
|
||||
|
||||
for pageFolder in pageFolders {
|
||||
try importEntry(at: pageFolder, tag: tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func importTag(name: String, folder: URL) throws -> String {
|
||||
let metadataUrl = folder.appending(path: "metadata.json", directoryHint: .notDirectory)
|
||||
let data = try Data(contentsOf: metadataUrl)
|
||||
let meta = try JSONDecoder().decode(ImportableTag.self, from: data)
|
||||
|
||||
let thumbnailUrl = folder.appending(path: "thumbnail.jpg", directoryHint: .notDirectory)
|
||||
var thumbnail: FileOnDisk? = nil
|
||||
if FileManager.default.fileExists(atPath: thumbnailUrl.path()) {
|
||||
thumbnail = FileOnDisk(type: .image(.jpg), url: thumbnailUrl, name: "\(name)-thumbnail.jpg")
|
||||
add(resource: thumbnail!)
|
||||
}
|
||||
|
||||
func makeTag(metadata: TagLanguage) throws -> LocalizedTagFile {
|
||||
let language = ContentLanguage(rawValue: metadata.language)!
|
||||
let originalUrl = folder
|
||||
.appendingPathComponent("\(language.rawValue).html", isDirectory: false)
|
||||
.path()
|
||||
.replacingOccurrences(of: "/Users/ch/Downloads/Website", with: "")
|
||||
|
||||
return LocalizedTagFile(
|
||||
urlComponent: metadata.title.lowercased().replacingOccurrences(of: " ", with: "-"),
|
||||
name: metadata.title,
|
||||
subtitle: metadata.subtitle,
|
||||
description: metadata.description,
|
||||
thumbnail: thumbnail?.name,
|
||||
originalURL: originalUrl)
|
||||
}
|
||||
|
||||
let en = meta.info(for: .english)!
|
||||
let de = meta.info(for: .german)!
|
||||
|
||||
let tagId = en.title.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||
|
||||
let enTag = try makeTag(metadata: en)
|
||||
let deTag = try makeTag(metadata: de)
|
||||
|
||||
let tag = TagFile(
|
||||
id: enTag.urlComponent,
|
||||
isVisible: true,
|
||||
german: deTag,
|
||||
english: enTag)
|
||||
tags[tagId] = tag
|
||||
return tagId
|
||||
}
|
||||
|
||||
private func findPageFolders(in folder: URL) throws -> [URL] {
|
||||
try FileManager.default
|
||||
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
|
||||
.filter { $0.hasDirectoryPath }
|
||||
}
|
||||
|
||||
private func findResources(in folder: URL, pageId: String) throws -> [FileOnDisk] {
|
||||
try FileManager.default
|
||||
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
|
||||
.filter { !$0.hasDirectoryPath }
|
||||
.compactMap { url in
|
||||
let fileName = url.lastPathComponent
|
||||
let fileExtension = url.pathExtension
|
||||
|
||||
guard fileName != "metadata.json",
|
||||
fileName != "de.md",
|
||||
fileName != "en.md" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let type = FileType(fileExtension: fileExtension)
|
||||
guard case .other = type else {
|
||||
self.ignoredFiles.append(url)
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = pageId + "-" + fileName
|
||||
|
||||
return FileOnDisk(type: type, url: url, name: name)
|
||||
}
|
||||
}
|
||||
|
||||
private func importEntry(at url: URL, tag: String) throws {
|
||||
let metadataUrl = url.appending(path: "metadata.json", directoryHint: .notDirectory)
|
||||
guard FileManager.default.fileExists(atPath: metadataUrl.path()) else {
|
||||
print("No entry at \(url.path())")
|
||||
return
|
||||
}
|
||||
let data = try Data(contentsOf: metadataUrl)
|
||||
let meta = try JSONDecoder().decode(GenericMetadata.self, from: data)
|
||||
|
||||
let pageId = meta.customId ?? url.lastPathComponent
|
||||
|
||||
let resources = try findResources(in: url, pageId: pageId)
|
||||
|
||||
guard let languages = meta.languages else {
|
||||
print("No languages for \(url.path())")
|
||||
return
|
||||
}
|
||||
|
||||
let externalFiles = meta.externalFiles ?? []
|
||||
let requiredFiles = meta.requiredFiles ?? []
|
||||
|
||||
let date = meta.date!.toDate()
|
||||
let endDate = meta.endDate?.toDate()
|
||||
|
||||
let de = languages.first { $0.language == "de" }!
|
||||
let en = languages.first { $0.language == "en" }!
|
||||
|
||||
@discardableResult
|
||||
func makePage(_ content: GenericMetadata.LocalizedMetadata) throws -> (LocalizedPageFile, URL, LocalizedPostFile) {
|
||||
let language = ContentLanguage(rawValue: content.language!)!
|
||||
|
||||
let id: String
|
||||
if language == .english {
|
||||
id = pageId
|
||||
} else {
|
||||
id = pageId + "-" + language.rawValue
|
||||
}
|
||||
|
||||
let originalUrl = url
|
||||
.appendingPathComponent("\(language.rawValue).html", isDirectory: false)
|
||||
.path()
|
||||
.replacingOccurrences(of: "/Users/ch/Downloads/Website", with: "")
|
||||
|
||||
var pageFiles = Set(resources.map { $0.name })
|
||||
let thumbnail = try determineThumbnail(in: resources, folder: url, customPath: meta.thumbnailPath, pageId: id, language: language.rawValue)
|
||||
if let thumbnail {
|
||||
pageFiles.insert(thumbnail.name)
|
||||
}
|
||||
let page = LocalizedPageFile(
|
||||
url: id,
|
||||
files: pageFiles.sorted(),
|
||||
externalFiles: externalFiles.sorted(),
|
||||
requiredFiles: requiredFiles.sorted(),
|
||||
title: content.title!,
|
||||
linkPreviewImage: thumbnail?.name,
|
||||
linkPreviewTitle: content.linkPreviewTitle,
|
||||
linkPreviewDescription: content.linkPreviewDescription,
|
||||
lastModifiedDate: nil,
|
||||
originalURL: originalUrl)
|
||||
let contentUrl = url.appendingPathComponent("\(content.language!).md", isDirectory: false)
|
||||
|
||||
let postContent = content.linkPreviewDescription ?? content.description ?? ""
|
||||
|
||||
let post = createPost(page: page, content: postContent)
|
||||
|
||||
return (page, contentUrl, post)
|
||||
}
|
||||
let (dePage, deUrl, dePost) = try makePage(de)
|
||||
let (enPage, enUrl, enPost) = try makePage(en)
|
||||
|
||||
let page = PageFile(
|
||||
isDraft: meta.state == "draft",
|
||||
tags: [tag],
|
||||
createdDate: date,
|
||||
startDate: date,
|
||||
endDate: endDate,
|
||||
german: dePage,
|
||||
english: enPage)
|
||||
|
||||
if pages[pageId] != nil {
|
||||
print("Conflicting page id \(pageId)")
|
||||
}
|
||||
|
||||
pages[pageId] = .init(page: page, deContentUrl: deUrl, enContentUrl: enUrl)
|
||||
|
||||
|
||||
for resource in resources {
|
||||
add(resource: resource)
|
||||
}
|
||||
|
||||
let post = PostFile(
|
||||
isDraft: page.isDraft || meta.state == "hidden",
|
||||
createdDate: page.createdDate,
|
||||
startDate: page.startDate,
|
||||
endDate: page.endDate,
|
||||
tags: page.tags,
|
||||
german: dePost,
|
||||
english: enPost,
|
||||
linkedPageId: pageId)
|
||||
|
||||
posts[pageId] = post
|
||||
}
|
||||
|
||||
private func add(resource: FileOnDisk) {
|
||||
guard let existingFile = files[resource.name] else {
|
||||
files[resource.name] = resource
|
||||
return
|
||||
}
|
||||
|
||||
guard existingFile.url != resource.url else {
|
||||
return
|
||||
}
|
||||
print("Conflicting name for file \(resource.name)")
|
||||
}
|
||||
|
||||
private func determineThumbnail(in resources: [FileOnDisk], folder: URL, customPath: String?, pageId: String, language: String) throws -> FileOnDisk? {
|
||||
guard let thumbnailUrl = findThumbnailUrl(in: folder, customPath: customPath, language: language) else {
|
||||
return nil
|
||||
}
|
||||
return resources.first { $0.url == thumbnailUrl }
|
||||
}
|
||||
|
||||
private func determineThumbnail(in folder: URL, customPath: String?, pageId: String, language: String) throws -> FileOnDisk? {
|
||||
guard let thumbnailUrl = findThumbnailUrl(in: folder, customPath: customPath, language: language) else {
|
||||
return nil
|
||||
}
|
||||
let id = pageId + "-" + thumbnailUrl.lastPathComponent
|
||||
return FileOnDisk(image: id, url: thumbnailUrl)
|
||||
}
|
||||
|
||||
private func findThumbnailUrl(in folder: URL, customPath: String?, language: String) -> URL? {
|
||||
if let customPath {
|
||||
return folder.appending(path: customPath, directoryHint: .notDirectory)
|
||||
}
|
||||
let thumbnailImageUrl = folder.appending(path: "thumbnail.jpg", directoryHint: .notDirectory)
|
||||
if FileManager.default.fileExists(atPath: thumbnailImageUrl.path()) {
|
||||
return thumbnailImageUrl
|
||||
}
|
||||
|
||||
let localizedThumbnail = folder.appending(path: "thumbnail-\(language).jpg", directoryHint: .notDirectory)
|
||||
if FileManager.default.fileExists(atPath: localizedThumbnail.path()) {
|
||||
return localizedThumbnail
|
||||
}
|
||||
print("No thumbnail found in \(folder.path())")
|
||||
return nil
|
||||
}
|
||||
|
||||
private func createPost(page: LocalizedPageFile, content: String) -> LocalizedPostFile {
|
||||
let images = page.linkPreviewImage.map { [$0] } ?? []
|
||||
|
||||
return LocalizedPostFile(
|
||||
images: images.sorted(),
|
||||
title: page.linkPreviewTitle ?? page.title,
|
||||
content: content,
|
||||
lastModifiedDate: nil,
|
||||
linkPreviewImage: nil,
|
||||
linkPreviewTitle: nil,
|
||||
linkPreviewDescription: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
|
||||
private static let metadataDate: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "dd.MM.yy"
|
||||
return df
|
||||
}()
|
||||
|
||||
func toDate() -> Date {
|
||||
String.metadataDate.date(from: self)!
|
||||
}
|
||||
}
|
@ -1,6 +1,14 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/*
|
||||
Page: One page -> One post with overview
|
||||
Post: One post -> No page
|
||||
Page update: One page -> Multiple posts
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
#warning("Consolidate images and files")
|
||||
#warning("Allow selection of pages as navigation bar items")
|
||||
@ -171,7 +179,11 @@ struct MainView: App {
|
||||
|
||||
private func save() {
|
||||
// Save all changed files
|
||||
content.saveToDisk()
|
||||
do {
|
||||
try content.saveToDisk()
|
||||
} catch {
|
||||
print("Failed to save content: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContent() {
|
||||
|
@ -1,5 +1,10 @@
|
||||
extension Content {
|
||||
|
||||
#warning("Get tag url prefix from settings")
|
||||
func tagLink(_ tag: Tag, language: ContentLanguage) -> String {
|
||||
"/tags/\(tag.localized(in: language).urlComponent).html"
|
||||
}
|
||||
|
||||
func pageLink(_ page: Page, language: ContentLanguage) -> String {
|
||||
// TODO: Record link to trace connections between pages
|
||||
var prefix = settings.pages.pageUrlPrefix
|
||||
|
@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Content {
|
||||
|
||||
func importOldContent() {
|
||||
|
||||
let importer = Importer()
|
||||
do {
|
||||
try importer.importContent()
|
||||
} catch {
|
||||
print(error)
|
||||
return
|
||||
}
|
||||
for (_, file) in importer.files.sorted(by: { $0.key < $1.key }) {
|
||||
storage.copyFile(at: file.url, fileId: file.name)
|
||||
// TODO: Store alt text for image and videos
|
||||
}
|
||||
var missingPages: [String] = []
|
||||
for (pageId, page) in importer.pages.sorted(by: { $0.key < $1.key }) {
|
||||
storage.save(pageMetadata: page.page, for: pageId)
|
||||
|
||||
if FileManager.default.fileExists(atPath: page.deContentUrl.path()) {
|
||||
storage.copyPageContent(from: page.deContentUrl, for: pageId, language: .german)
|
||||
} else {
|
||||
missingPages.append(pageId + " (DE)")
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: page.enContentUrl.path()) {
|
||||
storage.copyPageContent(from: page.enContentUrl, for: pageId, language: .english)
|
||||
} else {
|
||||
missingPages.append(pageId + " (EN)")
|
||||
}
|
||||
}
|
||||
|
||||
for (tagId, tag) in importer.tags {
|
||||
storage.save(tagMetadata: tag, for: tagId)
|
||||
}
|
||||
|
||||
for (postId, post) in importer.posts {
|
||||
storage.save(post: post, for: postId)
|
||||
}
|
||||
|
||||
let ignoredFiles = importer.ignoredFiles
|
||||
.map { $0.path() }
|
||||
.sorted()
|
||||
|
||||
print("Ignored files:")
|
||||
for file in ignoredFiles {
|
||||
print(file)
|
||||
}
|
||||
|
||||
print("Missing pages:")
|
||||
for page in missingPages {
|
||||
print(page)
|
||||
}
|
||||
|
||||
do {
|
||||
try loadFromDisk()
|
||||
} catch {
|
||||
print("Failed to load from disk: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
@ -49,7 +49,7 @@ extension Content {
|
||||
let storage = Storage(baseFolder: URL(filePath: contentPath))
|
||||
|
||||
let settings = try storage.loadSettings()
|
||||
let imageDescriptions = storage.loadFileDescriptions().reduce(into: [:]) { descriptions, description in
|
||||
let imageDescriptions = try storage.loadFileDescriptions().reduce(into: [:]) { descriptions, description in
|
||||
descriptions[description.fileId] = description
|
||||
}
|
||||
|
||||
@ -84,6 +84,7 @@ extension Content {
|
||||
let english = convert(post.english, images: images)
|
||||
|
||||
return Post(
|
||||
content: self,
|
||||
id: postId,
|
||||
isDraft: post.isDraft,
|
||||
createdDate: post.createdDate,
|
||||
@ -129,6 +130,7 @@ extension Content {
|
||||
pagesData.reduce(into: [:]) { pages, data in
|
||||
let (pageId, page) = data
|
||||
pages[pageId] = Page(
|
||||
content: self,
|
||||
id: pageId,
|
||||
isDraft: page.isDraft,
|
||||
createdDate: page.createdDate,
|
||||
|
@ -2,20 +2,20 @@ import Foundation
|
||||
|
||||
extension Content {
|
||||
|
||||
func saveToDisk() {
|
||||
func saveToDisk() throws {
|
||||
//print("Starting save")
|
||||
for page in pages {
|
||||
storage.save(pageMetadata: page.pageFile, for: page.id)
|
||||
try storage.save(pageMetadata: page.pageFile, for: page.id)
|
||||
}
|
||||
|
||||
for post in posts {
|
||||
storage.save(post: post.postFile, for: post.id)
|
||||
try storage.save(post: post.postFile, for: post.id)
|
||||
}
|
||||
|
||||
for tag in tags {
|
||||
storage.save(tagMetadata: tag.tagFile, for: tag.id)
|
||||
try storage.save(tagMetadata: tag.tagFile, for: tag.id)
|
||||
}
|
||||
storage.save(settings: settings.file)
|
||||
try storage.save(settings: settings.file)
|
||||
|
||||
let fileDescriptions: [FileDescriptions] = files.sorted().compactMap { file in
|
||||
guard !file.englishDescription.isEmpty || !file.germanDescription.isEmpty else {
|
||||
@ -27,22 +27,19 @@ extension Content {
|
||||
english: file.englishDescription.nonEmpty)
|
||||
}
|
||||
|
||||
storage.save(fileDescriptions: fileDescriptions)
|
||||
try storage.save(fileDescriptions: fileDescriptions)
|
||||
|
||||
do {
|
||||
try storage.deletePostFiles(notIn: posts.map { $0.id })
|
||||
try storage.deletePageFiles(notIn: pages.map { $0.id })
|
||||
try storage.deleteTagFiles(notIn: tags.map { $0.id })
|
||||
try storage.deleteFiles(notIn: files.map { $0.id })
|
||||
try storage.deleteFileResources(notIn: files.map { $0.id })
|
||||
} catch {
|
||||
print("Failed to remove unused files: \(error)")
|
||||
}
|
||||
// TODO: Remove all files that are no longer in use (they belong to deleted items)
|
||||
//print("Finished save")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private extension Page {
|
||||
|
||||
var pageFile: PageFile {
|
||||
|
@ -36,13 +36,3 @@ final class LocalizedTag: ObservableObject {
|
||||
self.originalUrl = originalUrl
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizedTag {
|
||||
|
||||
func data() -> FeedEntryData.Tag {
|
||||
.init(
|
||||
name: name,
|
||||
url: "tags/\(urlComponent).html"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
final class Page: ObservableObject {
|
||||
|
||||
|
||||
unowned let content: Content
|
||||
|
||||
/**
|
||||
The unique id of the entry
|
||||
*/
|
||||
@ -40,7 +42,8 @@ final class Page: ObservableObject {
|
||||
@Published
|
||||
var images: Set<String> = []
|
||||
|
||||
init(id: String,
|
||||
init(content: Content,
|
||||
id: String,
|
||||
isDraft: Bool,
|
||||
createdDate: Date,
|
||||
startDate: Date,
|
||||
@ -48,6 +51,7 @@ final class Page: ObservableObject {
|
||||
german: LocalizedPage,
|
||||
english: LocalizedPage,
|
||||
tags: [Tag]) {
|
||||
self.content = content
|
||||
self.id = id
|
||||
self.isDraft = isDraft
|
||||
self.createdDate = createdDate
|
||||
@ -65,6 +69,15 @@ final class Page: ObservableObject {
|
||||
case .english: return english
|
||||
}
|
||||
}
|
||||
|
||||
func update(id newId: String) -> Bool {
|
||||
guard content.storage.move(page: id, to: newId) else {
|
||||
print("Failed to move file of page \(id)")
|
||||
return false
|
||||
}
|
||||
id = newId
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension Page: Identifiable {
|
||||
|
@ -2,6 +2,8 @@ import Foundation
|
||||
|
||||
final class Post: ObservableObject {
|
||||
|
||||
unowned let content: Content
|
||||
|
||||
@Published
|
||||
var id: String
|
||||
|
||||
@ -33,7 +35,8 @@ final class Post: ObservableObject {
|
||||
@Published
|
||||
var linkedPage: Page?
|
||||
|
||||
init(id: String,
|
||||
init(content: Content,
|
||||
id: String,
|
||||
isDraft: Bool,
|
||||
createdDate: Date,
|
||||
startDate: Date,
|
||||
@ -42,6 +45,7 @@ final class Post: ObservableObject {
|
||||
german: LocalizedPost,
|
||||
english: LocalizedPost,
|
||||
linkedPage: Page? = nil) {
|
||||
self.content = content
|
||||
self.id = id
|
||||
self.isDraft = isDraft
|
||||
self.createdDate = createdDate
|
||||
@ -60,6 +64,17 @@ final class Post: ObservableObject {
|
||||
case .german: return german
|
||||
}
|
||||
}
|
||||
|
||||
func update(id newId: String) -> Bool {
|
||||
do {
|
||||
try content.storage.move(post: id, to: newId)
|
||||
} catch {
|
||||
print("Failed to move file of post \(id)")
|
||||
return false
|
||||
}
|
||||
id = newId
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension Post: Identifiable {
|
||||
|
@ -37,18 +37,6 @@ final class Tag: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
extension Tag {
|
||||
|
||||
func data(in language: ContentLanguage) -> FeedEntryData.Tag {
|
||||
switch language {
|
||||
case .english:
|
||||
return english.data()
|
||||
case .german:
|
||||
return german.data()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Tag: Identifiable {
|
||||
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ struct PageInFeed {
|
||||
|
||||
let title: String
|
||||
|
||||
let showTitle: Bool
|
||||
|
||||
let description: String
|
||||
|
||||
let navigationBarData: NavigationBarData
|
||||
@ -42,12 +44,17 @@ struct PageInFeed {
|
||||
data: navigationBarData,
|
||||
additionalHeaders: headers,
|
||||
additionalFooter: footer) { content in
|
||||
for post in posts {
|
||||
content += FeedEntry(data: post).content
|
||||
}
|
||||
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
|
||||
if showTitle {
|
||||
content += "<h1>\(title)</h1>"
|
||||
}
|
||||
for post in posts {
|
||||
content += FeedEntry(data: post).content
|
||||
}
|
||||
if totalPages > 1 {
|
||||
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
|
||||
}
|
||||
|
||||
}.content
|
||||
}.content
|
||||
}
|
||||
|
||||
private var swiperInits: String {
|
||||
|
@ -4,6 +4,7 @@ extension Page {
|
||||
|
||||
static var empty: Page {
|
||||
.init(
|
||||
content: .mock,
|
||||
id: "my-id",
|
||||
isDraft: true,
|
||||
createdDate: Date(),
|
||||
|
@ -2,7 +2,8 @@
|
||||
extension Post {
|
||||
|
||||
static var empty: Post {
|
||||
.init(id: "empty",
|
||||
.init(content: Content.mock,
|
||||
id: "empty",
|
||||
isDraft: true,
|
||||
createdDate: .now,
|
||||
startDate: .now,
|
||||
@ -15,6 +16,7 @@ extension Post {
|
||||
|
||||
static var mock: Post {
|
||||
Post(
|
||||
content: Content.mock,
|
||||
id: "mock",
|
||||
isDraft: false,
|
||||
createdDate: .now,
|
||||
@ -28,6 +30,7 @@ extension Post {
|
||||
|
||||
static var fullMock: Post {
|
||||
.init(
|
||||
content: Content.mock,
|
||||
id: "full",
|
||||
isDraft: true,
|
||||
createdDate: .now,
|
||||
|
@ -12,3 +12,12 @@ struct LocalizedPostSettingsFile {
|
||||
}
|
||||
|
||||
extension LocalizedPostSettingsFile: Codable { }
|
||||
|
||||
extension LocalizedPostSettingsFile {
|
||||
|
||||
static var `default`: LocalizedPostSettingsFile {
|
||||
.init(feedTitle: "A title",
|
||||
feedDescription: "A description",
|
||||
feedUrlPrefix: "blog")
|
||||
}
|
||||
}
|
||||
|
@ -10,3 +10,11 @@ struct LocalizedSettingsFile {
|
||||
extension LocalizedSettingsFile: Codable {
|
||||
|
||||
}
|
||||
|
||||
extension LocalizedSettingsFile {
|
||||
|
||||
static var `default`: LocalizedSettingsFile {
|
||||
.init(navigationBarIconDescription: "An icon",
|
||||
posts: .default)
|
||||
}
|
||||
}
|
||||
|
@ -10,3 +10,10 @@ struct NavigationBarSettingsFile {
|
||||
|
||||
extension NavigationBarSettingsFile: Codable { }
|
||||
|
||||
extension NavigationBarSettingsFile {
|
||||
|
||||
static var `default`: NavigationBarSettingsFile {
|
||||
.init(navigationIconPath: "/assets/icons/icon.svg",
|
||||
navigationTags: [])
|
||||
}
|
||||
}
|
||||
|
@ -9,3 +9,11 @@ struct PageSettingsFile {
|
||||
extension PageSettingsFile: Codable {
|
||||
|
||||
}
|
||||
|
||||
extension PageSettingsFile {
|
||||
|
||||
static var `default`: PageSettingsFile {
|
||||
.init(pageUrlPrefix: "page",
|
||||
contentWidth: 600)
|
||||
}
|
||||
}
|
||||
|
@ -10,3 +10,11 @@ struct PostSettingsFile {
|
||||
}
|
||||
|
||||
extension PostSettingsFile: Codable { }
|
||||
|
||||
extension PostSettingsFile {
|
||||
|
||||
static var `default`: PostSettingsFile {
|
||||
.init(postsPerPage: 25,
|
||||
contentWidth: 600)
|
||||
}
|
||||
}
|
||||
|
@ -17,3 +17,17 @@ struct SettingsFile {
|
||||
}
|
||||
|
||||
extension SettingsFile: Codable { }
|
||||
|
||||
extension SettingsFile {
|
||||
|
||||
static var `default`: SettingsFile {
|
||||
.init(
|
||||
outputDirectoryPath: "",
|
||||
navigationBar: .default,
|
||||
posts: .default,
|
||||
pages: .default,
|
||||
german: .default,
|
||||
english: .default
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,10 @@ enum StorageAccessError: Error {
|
||||
|
||||
case folderAccessFailed(URL)
|
||||
|
||||
case stringConversionFailed
|
||||
|
||||
case fileNotFound(String)
|
||||
|
||||
}
|
||||
|
||||
extension StorageAccessError: CustomStringConvertible {
|
||||
@ -27,6 +31,10 @@ extension StorageAccessError: CustomStringConvertible {
|
||||
return "Failed to resolve bookmark: \(error)"
|
||||
case .folderAccessFailed(let url):
|
||||
return "Failed to access folder: \(url.path())"
|
||||
case .stringConversionFailed:
|
||||
return "Failed to convert string to data"
|
||||
case .fileNotFound(let path):
|
||||
return "File not found: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -102,10 +110,10 @@ final class Storage {
|
||||
|
||||
func createFolderStructure() throws {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
try create(folder: pagesFolder)
|
||||
try create(folder: pagesFolder(in: contentPath))
|
||||
try create(folder: filesFolder(in: contentPath))
|
||||
try create(folder: postsFolder)
|
||||
try create(folder: tagsFolder)
|
||||
try create(folder: postsFolder(in: contentPath))
|
||||
try create(folder: tagsFolder(in: contentPath))
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,60 +122,59 @@ final class Storage {
|
||||
private let pagesFolderName = "pages"
|
||||
|
||||
/// The folder path where the markdown and metadata files of the pages are stored (by their id/url component)
|
||||
private var pagesFolder: URL { subFolder(pagesFolderName) }
|
||||
private func pagesFolder(in folder: URL) -> URL {
|
||||
folder.appending(path: pagesFolderName, directoryHint: .isDirectory)
|
||||
}
|
||||
|
||||
private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String {
|
||||
"\(id)-\(language.rawValue).md"
|
||||
}
|
||||
|
||||
private func pageContentPath(page pageId: String, language: ContentLanguage) -> String {
|
||||
pagesFolderName + "/" + pageContentFileName(pageId, language)
|
||||
}
|
||||
|
||||
private func pageMetadataPath(page pageId: String) -> String {
|
||||
pagesFolderName + "/" + pageId + ".json"
|
||||
}
|
||||
|
||||
private func pageFileName(_ id: String) -> String {
|
||||
id + ".json"
|
||||
}
|
||||
|
||||
private func pageContentUrl(pageId: String, language: ContentLanguage) -> URL {
|
||||
pagesFolder.appending(path: pageContentFileName(pageId, language), directoryHint: .notDirectory)
|
||||
private func pageContentUrl(page pageId: String, language: ContentLanguage, in folder: URL) -> URL {
|
||||
let fileName = pageContentFileName(pageId, language)
|
||||
return pagesFolder(in: folder).appending(path: fileName, directoryHint: .notDirectory)
|
||||
}
|
||||
|
||||
private func pageMetadataUrl(pageId: String) -> URL {
|
||||
pagesFolder.appending(path: pageFileName(pageId), directoryHint: .notDirectory)
|
||||
private func pageMetadataUrl(page pageId: String, in folder: URL) -> URL {
|
||||
let fileName = pageFileName(pageId)
|
||||
return pagesFolder(in: folder).appending(path: fileName, directoryHint: .notDirectory)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(pageContent: String, for pageId: String, language: ContentLanguage) -> Bool {
|
||||
let contentUrl = pageContentUrl(pageId: pageId, language: language)
|
||||
return write(content: pageContent, to: contentUrl, type: "page", id: pageId)
|
||||
func save(pageContent: String, for pageId: String, language: ContentLanguage) throws {
|
||||
let path = pageContentPath(page: pageId, language: language)
|
||||
try writeIfChanged(content: pageContent, to: path)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(pageMetadata: PageFile, for pageId: String) -> Bool {
|
||||
let contentUrl = pageMetadataUrl(pageId: pageId)
|
||||
return write(pageMetadata, type: "page", id: pageId, to: contentUrl)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func copyPageContent(from url: URL, for pageId: String, language: ContentLanguage) -> Bool {
|
||||
let contentUrl = pageContentUrl(pageId: pageId, language: language)
|
||||
return copy(file: url, to: contentUrl, type: "page content", id: pageId)
|
||||
func save(pageMetadata: PageFile, for pageId: String) throws {
|
||||
let path = pageMetadataPath(page: pageId)
|
||||
try writeIfChanged(pageMetadata, to: path)
|
||||
}
|
||||
|
||||
func loadAllPages() throws -> [String : PageFile] {
|
||||
try loadAll(in: pagesFolder)
|
||||
try decodeAllFromJson(in: pagesFolderName)
|
||||
}
|
||||
|
||||
func pageContent(for pageId: String, language: ContentLanguage) -> String {
|
||||
let contentUrl = pageContentUrl(pageId: pageId, language: language)
|
||||
guard fm.fileExists(atPath: contentUrl.path()) else {
|
||||
print("No file at \(contentUrl.path())")
|
||||
return ""
|
||||
}
|
||||
do {
|
||||
return try String(contentsOf: contentUrl, encoding: .utf8)
|
||||
} catch {
|
||||
print("Failed to load page content for \(pageId) (\(language)): \(error)")
|
||||
return error.localizedDescription
|
||||
}
|
||||
func pageContent(for pageId: String, language: ContentLanguage) throws -> String {
|
||||
let path = pageContentPath(page: pageId, language: language)
|
||||
return try readString(at: path, defaultValue: "")
|
||||
}
|
||||
|
||||
/**
|
||||
Delete all files associated with pages that are not in the given set
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
func deletePageFiles(notIn pages: [String]) throws {
|
||||
var files = Set(pages.map(pageFileName))
|
||||
for language in ContentLanguage.allCases {
|
||||
@ -176,66 +183,112 @@ final class Storage {
|
||||
try deleteFiles(in: pagesFolderName, notIn: files)
|
||||
}
|
||||
|
||||
func move(page pageId: String, to newFile: String) -> Bool {
|
||||
do {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
// Move the metadata file
|
||||
let source = pageMetadataUrl(page: pageId, in: contentPath)
|
||||
let destination = pageMetadataUrl(page: newFile, in: contentPath)
|
||||
try fm.moveItem(at: source, to: destination)
|
||||
|
||||
// Move the existing content files
|
||||
for language in ContentLanguage.allCases {
|
||||
let source = pageContentUrl(page: pageId, language: language, in: contentPath)
|
||||
guard source.exists else { continue }
|
||||
let destination = pageContentUrl(page: newFile, language: language, in: contentPath)
|
||||
try fm.moveItem(at: source, to: destination)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to move page file \(pageId) to \(newFile): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Posts
|
||||
|
||||
private let postsFolderName = "posts"
|
||||
|
||||
/// The folder path where the markdown files of the posts are stored (by their unique id/url component)
|
||||
private var postsFolder: URL { subFolder(postsFolderName) }
|
||||
|
||||
private func postFileUrl(postId: String) -> URL {
|
||||
postsFolder.appending(path: postId, directoryHint: .notDirectory).appendingPathExtension("json")
|
||||
private func postFileName(_ postId: String) -> String {
|
||||
postId + ".json"
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(post: PostFile, for postId: String) -> Bool {
|
||||
let contentUrl = postFileUrl(postId: postId)
|
||||
return write(post, type: "post", id: postId, to: contentUrl)
|
||||
/// The folder path where the markdown files of the posts are stored (by their unique id/url component)
|
||||
private func postsFolder(in folder: URL) -> URL {
|
||||
folder.appending(path: postsFolderName, directoryHint: .isDirectory)
|
||||
}
|
||||
|
||||
private func postFileUrl(post postId: String, in folder: URL) -> URL {
|
||||
let path = postFilePath(post: postId)
|
||||
return folder.appending(path: path, directoryHint: .notDirectory)
|
||||
}
|
||||
|
||||
private func postFilePath(post postId: String) -> String {
|
||||
postsFolderName + "/" + postFileName(postId)
|
||||
}
|
||||
|
||||
func save(post: PostFile, for postId: String) throws {
|
||||
let path = postFilePath(post: postId)
|
||||
try writeIfChanged(post, to: path)
|
||||
}
|
||||
|
||||
func loadAllPosts() throws -> [String : PostFile] {
|
||||
try loadAll(in: postsFolder)
|
||||
}
|
||||
|
||||
private func post(at url: URL) throws -> PostFile {
|
||||
try read(at: url)
|
||||
try decodeAllFromJson(in: postsFolderName)
|
||||
}
|
||||
|
||||
private func postContent(for postId: String) throws -> PostFile {
|
||||
let url = postFileUrl(postId: postId)
|
||||
return try post(at: url)
|
||||
let path = postFilePath(post: postId)
|
||||
return try read(at: path)
|
||||
}
|
||||
|
||||
/**
|
||||
Delete all files associated with posts that are not in the given set
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
func deletePostFiles(notIn posts: [String]) throws {
|
||||
let files = Set(posts.map { $0 + ".json" })
|
||||
let files = Set(posts.map(postFileName))
|
||||
try deleteFiles(in: postsFolderName, notIn: files)
|
||||
}
|
||||
|
||||
func move(post postId: String, to newFile: String) throws {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
let source = postFileUrl(post: postId, in: contentPath)
|
||||
let destination = postFileUrl(post: newFile, in: contentPath)
|
||||
try fm.moveItem(at: source, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Tags
|
||||
|
||||
private let tagsFolderName = "tags"
|
||||
|
||||
private func tagFileName(tagId: String) -> String {
|
||||
tagId + ".json"
|
||||
}
|
||||
|
||||
/// The folder path where the source images are stored (by their unique name)
|
||||
private var tagsFolder: URL { subFolder(tagsFolderName) }
|
||||
|
||||
private func tagFileUrl(tagId: String) -> URL {
|
||||
tagsFolder.appending(path: tagId, directoryHint: .notDirectory)
|
||||
private func tagsFolder(in folder: URL) -> URL {
|
||||
folder.appending(path: tagsFolderName)
|
||||
}
|
||||
|
||||
private func tagMetadataUrl(tagId: String) -> URL {
|
||||
tagFileUrl(tagId: tagId).appendingPathExtension("json")
|
||||
private func relativeTagFilePath(tagId: String) -> String {
|
||||
tagsFolderName + "/" + tagFileName(tagId: tagId)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(tagMetadata: TagFile, for tagId: String) -> Bool {
|
||||
let contentUrl = tagMetadataUrl(tagId: tagId)
|
||||
return write(tagMetadata, type: "tag", id: tagId, to: contentUrl)
|
||||
func save(tagMetadata: TagFile, for tagId: String) throws {
|
||||
let path = relativeTagFilePath(tagId: tagId)
|
||||
try writeIfChanged(tagMetadata, to: path)
|
||||
}
|
||||
|
||||
func loadAllTags() throws -> [String : TagFile] {
|
||||
try loadAll(in: tagsFolder)
|
||||
try decodeAllFromJson(in: tagsFolderName)
|
||||
}
|
||||
|
||||
/**
|
||||
Delete all files associated with tags that are not in the given set
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
func deleteTagFiles(notIn tags: [String]) throws {
|
||||
let files = Set(tags.map { $0 + ".json" })
|
||||
try deleteFiles(in: tagsFolderName, notIn: files)
|
||||
@ -245,32 +298,24 @@ final class Storage {
|
||||
|
||||
private let fileDescriptionFilename = "file-descriptions.json"
|
||||
|
||||
func loadFileDescriptions() -> [FileDescriptions] {
|
||||
do {
|
||||
return try read(relativePath: fileDescriptionFilename)
|
||||
} catch {
|
||||
print("Failed to read file descriptions: \(error)")
|
||||
return []
|
||||
}
|
||||
func loadFileDescriptions() throws -> [FileDescriptions] {
|
||||
try read(at: fileDescriptionFilename, defaultValue: [])
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(fileDescriptions: [FileDescriptions]) -> Bool {
|
||||
do {
|
||||
try writeIfChanged(fileDescriptions, to: fileDescriptionFilename)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to write file descriptions: \(error)")
|
||||
return false
|
||||
}
|
||||
func save(fileDescriptions: [FileDescriptions]) throws {
|
||||
try writeIfChanged(fileDescriptions, to: fileDescriptionFilename)
|
||||
}
|
||||
|
||||
// MARK: Files
|
||||
|
||||
private let filesFolderName = "files"
|
||||
|
||||
private func filePath(file fileId: String) -> String {
|
||||
filesFolderName + "/" + fileId
|
||||
}
|
||||
|
||||
/// The folder path where other files are stored (by their unique name)
|
||||
func filesFolder(in folder: URL) -> URL {
|
||||
private func filesFolder(in folder: URL) -> URL {
|
||||
folder.appending(path: filesFolderName, directoryHint: .isDirectory)
|
||||
}
|
||||
|
||||
@ -281,120 +326,87 @@ final class Storage {
|
||||
/**
|
||||
Copy an external file to the content folder
|
||||
*/
|
||||
@discardableResult
|
||||
func copyFile(at url: URL, fileId: String) -> Bool {
|
||||
do {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
let destination = fileUrl(file: fileId, in: contentPath)
|
||||
try fm.copyItem(at: url, to: destination)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to copy external file \(url.path()) to \(fileId): \(error)")
|
||||
return false
|
||||
func copyFile(at url: URL, fileId: String) throws {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
let destination = fileUrl(file: fileId, in: contentPath)
|
||||
try fm.copyItem(at: url, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
func move(file fileId: String, to newFile: String) -> Bool {
|
||||
do {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
let source = fileUrl(file: fileId, in: contentPath)
|
||||
let destination = fileUrl(file: newFile, in: contentPath)
|
||||
try fm.moveItem(at: source, to: destination)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to move file \(fileId) to \(newFile): \(error)")
|
||||
return false
|
||||
func move(file fileId: String, to newFile: String) throws {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
let source = fileUrl(file: fileId, in: contentPath)
|
||||
let destination = fileUrl(file: newFile, in: contentPath)
|
||||
try fm.moveItem(at: source, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
func copy(file fileId: String, to relativeOutputPath: String) -> Bool {
|
||||
do {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
try operate(in: .outputPath) { outputPath in
|
||||
let output = outputPath.appending(path: relativeOutputPath, directoryHint: .notDirectory)
|
||||
if output.exists {
|
||||
return
|
||||
}
|
||||
try output.ensureParentFolderExistence()
|
||||
|
||||
let input = fileUrl(file: fileId, in: contentPath)
|
||||
try FileManager.default.copyItem(at: input, to: output)
|
||||
func copy(file fileId: String, to relativeOutputPath: String) throws {
|
||||
let path = filePath(file: fileId)
|
||||
try withScopedContent(file: path) { input in
|
||||
try operate(in: .outputPath) { outputPath in
|
||||
let output = outputPath.appending(path: relativeOutputPath, directoryHint: .notDirectory)
|
||||
if output.exists {
|
||||
return
|
||||
}
|
||||
try output.ensureParentFolderExistence()
|
||||
|
||||
try FileManager.default.copyItem(at: input, to: output)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to copy file \(fileId) to output folder: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func loadAllFiles() throws -> [String] {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
let folder = filesFolder(in: contentPath)
|
||||
return try files(in: folder).map { $0.lastPathComponent }
|
||||
}
|
||||
try self.existingFiles(in: filesFolderName)
|
||||
.map { $0.lastPathComponent }
|
||||
}
|
||||
|
||||
func deleteFiles(notIn fileSet: [String]) throws {
|
||||
/**
|
||||
Delete all file resources that are not in the given set
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
func deleteFileResources(notIn fileSet: [String]) throws {
|
||||
try deleteFiles(in: filesFolderName, notIn: Set(fileSet))
|
||||
}
|
||||
|
||||
func fileContent(for file: String) throws -> String {
|
||||
try operate(in: .contentPath) { folder in
|
||||
let fileUrl = folder
|
||||
.appending(path: "files", directoryHint: .isDirectory)
|
||||
.appending(path: file, directoryHint: .notDirectory)
|
||||
return try String(contentsOf: fileUrl, encoding: .utf8)
|
||||
}
|
||||
func fileContent(for fileId: String) throws -> String {
|
||||
let path = filePath(file: fileId)
|
||||
return try readString(at: path)
|
||||
}
|
||||
|
||||
func fileData(for file: String) throws -> Data {
|
||||
try operate(in: .contentPath) { folder in
|
||||
let fileUrl = folder
|
||||
.appending(path: "files", directoryHint: .isDirectory)
|
||||
.appending(path: file, directoryHint: .notDirectory)
|
||||
return try Data(contentsOf: fileUrl)
|
||||
}
|
||||
func fileData(for fileId: String) throws -> Data {
|
||||
let path = filePath(file: fileId)
|
||||
return try readExistingFile(at: path)
|
||||
}
|
||||
|
||||
// MARK: Website data
|
||||
|
||||
private var settingsDataUrl: URL {
|
||||
baseFolder.appending(path: "settings.json", directoryHint: .notDirectory)
|
||||
}
|
||||
private let settingsDataFileName: String = "settings.json"
|
||||
|
||||
func loadSettings() throws -> SettingsFile {
|
||||
try read(at: settingsDataUrl)
|
||||
try read(at: settingsDataFileName, defaultValue: .default)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(settings: SettingsFile) -> Bool {
|
||||
write(settings, type: "Settings", id: "-", to: settingsDataUrl)
|
||||
func save(settings: SettingsFile) throws {
|
||||
try writeIfChanged(settings, to: settingsDataFileName)
|
||||
}
|
||||
|
||||
// MARK: Image generation data
|
||||
|
||||
private var generatedImagesListUrl: URL {
|
||||
baseFolder.appending(component: "generated-images.json", directoryHint: .notDirectory)
|
||||
private let generatedImagesListName = "generated-images.json"
|
||||
|
||||
func loadListOfGeneratedImages() throws -> [String : [String]] {
|
||||
try read(at: generatedImagesListName, defaultValue: [:])
|
||||
}
|
||||
|
||||
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]]) throws {
|
||||
try writeIfChanged(listOfGeneratedImages, to: generatedImagesListName)
|
||||
}
|
||||
|
||||
func save(listOfGeneratedImages: [String : [String]]) -> Bool {
|
||||
write(listOfGeneratedImages, type: "generated images list", id: "-", to: generatedImagesListUrl)
|
||||
// MARK: Output files
|
||||
|
||||
func write(content: String, to relativeOutputPath: String) throws {
|
||||
try writeIfChanged(content: content, to: relativeOutputPath, in: .outputPath)
|
||||
}
|
||||
|
||||
// MARK: Folder access
|
||||
@ -417,6 +429,21 @@ final class Storage {
|
||||
}
|
||||
}
|
||||
|
||||
private func withScopedContent<T>(file relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T {
|
||||
try withScopedContent(relativePath, in: scope, directoryHint: .notDirectory, operation)
|
||||
}
|
||||
|
||||
private func withScopedContent<T>(folder relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T {
|
||||
try withScopedContent(relativePath, in: scope, directoryHint: .isDirectory, operation)
|
||||
}
|
||||
|
||||
private func withScopedContent<T>(_ relativePath: String, in scope: SecurityScopeBookmark, directoryHint: URL.DirectoryHint, _ operation: (URL) throws -> T) throws -> T {
|
||||
try operate(in: scope) {
|
||||
let url = $0.appending(path: relativePath, directoryHint: directoryHint)
|
||||
return try operation(url)
|
||||
}
|
||||
}
|
||||
|
||||
func operate<T>(in scope: SecurityScopeBookmark, operation: (URL) throws -> T) throws -> T {
|
||||
guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else {
|
||||
throw StorageAccessError.noBookmarkData
|
||||
@ -444,10 +471,13 @@ final class Storage {
|
||||
|
||||
// MARK: Writing files
|
||||
|
||||
/**
|
||||
Delete files in a subPath of the content folder which are not in the given set of files
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
private func deleteFiles(in folder: String, notIn fileSet: Set<String>) throws {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
let subFolder = contentPath.appending(path: folder, directoryHint: .isDirectory)
|
||||
let filesToDelete = try files(in: subFolder)
|
||||
try withScopedContent(folder: folder) { folderUrl in
|
||||
let filesToDelete = try files(in: folderUrl)
|
||||
.filter { !fileSet.contains($0.lastPathComponent) }
|
||||
|
||||
for file in filesToDelete {
|
||||
@ -457,10 +487,33 @@ final class Storage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Write the data of an encodable value to a relative path in the content folder
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
private func writeIfChanged<T>(_ value: T, to relativePath: String) throws where T: Encodable {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
let url = contentPath.appending(path: relativePath, directoryHint: .notDirectory)
|
||||
let data = try encoder.encode(value)
|
||||
let data = try encoder.encode(value)
|
||||
try writeIfChanged(data: data, to: relativePath)
|
||||
}
|
||||
|
||||
/**
|
||||
Write the data of a string to a relative path in the content folder
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
private func writeIfChanged(content: String, to relativePath: String, in scope: SecurityScopeBookmark = .contentPath) throws {
|
||||
guard let data = content.data(using: .utf8) else {
|
||||
print("Failed to convert string to data for file at \(relativePath)")
|
||||
throw StorageAccessError.stringConversionFailed
|
||||
}
|
||||
try writeIfChanged(data: data, to: relativePath, in: scope)
|
||||
}
|
||||
|
||||
/**
|
||||
Write the data to a relative path in the content folder
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
private func writeIfChanged(data: Data, to relativePath: String, in scope: SecurityScopeBookmark = .contentPath) throws {
|
||||
try withScopedContent(file: relativePath, in: scope) { url in
|
||||
if fm.fileExists(atPath: url.path()) {
|
||||
// Check if content is the same, to prevent unnecessary writes
|
||||
do {
|
||||
@ -475,6 +528,7 @@ final class Storage {
|
||||
}
|
||||
} else {
|
||||
print("Writing new file \(url.path())")
|
||||
try url.ensureParentFolderExistence()
|
||||
}
|
||||
try data.write(to: url)
|
||||
print("Saved file \(url.path())")
|
||||
@ -482,84 +536,88 @@ final class Storage {
|
||||
}
|
||||
|
||||
/**
|
||||
Encode a value and write it to a file, if the content changed
|
||||
*/
|
||||
private func write<T>(_ value: T, type: String, id: String, to file: URL) -> Bool where T: Encodable {
|
||||
let content: Data
|
||||
do {
|
||||
content = try encoder.encode(value)
|
||||
} catch {
|
||||
print("Failed to encode content of \(type) '\(id)': \(error)")
|
||||
return false
|
||||
}
|
||||
return write(data: content, type: type, id: id, to: file)
|
||||
}
|
||||
|
||||
/**
|
||||
Write data to a file if the content changed
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
private func write(data: Data, type: String, id: String, to file: URL) -> Bool {
|
||||
if fm.fileExists(atPath: file.path()) {
|
||||
// Check if content is the same, to prevent unnecessary writes
|
||||
do {
|
||||
let oldData = try Data(contentsOf: file)
|
||||
if data == oldData {
|
||||
// File is the same, don't write
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
print("Failed to read file \(file.path()) for equality check: \(error)")
|
||||
// No check possible, write file
|
||||
private func read<T>(at relativePath: String, defaultValue: T? = nil) throws -> T where T: Decodable {
|
||||
guard let data = try readData(at: relativePath) else {
|
||||
guard let defaultValue else {
|
||||
throw StorageAccessError.fileNotFound(relativePath)
|
||||
}
|
||||
} else {
|
||||
print("Writing new file \(file.path())")
|
||||
return defaultValue
|
||||
}
|
||||
do {
|
||||
try data.write(to: file, options: .atomic)
|
||||
print("Saved file \(file.path())")
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to save content for \(type) '\(id)': \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func copy(file: URL, to destination: URL, type: String, id: String) -> Bool {
|
||||
do {
|
||||
try fm.copyItem(at: file, to: destination)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to copy content file for \(type) '\(id)': \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func write(content: String, to file: URL, type: String, id: String) -> Bool {
|
||||
guard let data = content.data(using: .utf8) else {
|
||||
print("Failed to convert string to data for \(type) '\(id)'")
|
||||
return false
|
||||
}
|
||||
return write(data: data, type: type, id: id, to: file)
|
||||
}
|
||||
|
||||
private func read<T>(relativePath: String) throws -> T where T: Decodable {
|
||||
try operate(in: .contentPath) { baseFolder in
|
||||
let url = baseFolder.appending(path: relativePath, directoryHint: .notDirectory)
|
||||
let data = try Data(contentsOf: url)
|
||||
return try decoder.decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private func read<T>(at url: URL) throws -> T where T: Decodable {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try decoder.decode(T.self, from: data)
|
||||
}
|
||||
|
||||
private func loadAll<T>(in folder: URL) throws -> [String : T] where T: Decodable {
|
||||
try files(in: folder, type: "json").reduce(into: [:]) { items, url in
|
||||
let id = url.deletingPathExtension().lastPathComponent
|
||||
let item: T = try read(at: url)
|
||||
items[id] = item
|
||||
/**
|
||||
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
private func readString(at relativePath: String, defaultValue: String? = nil) throws -> String {
|
||||
try withScopedContent(file: relativePath) { url in
|
||||
guard url.exists else {
|
||||
guard let defaultValue else {
|
||||
throw StorageAccessError.fileNotFound(relativePath)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private func readExistingFile(at relativePath: String) throws -> Data {
|
||||
guard let data = try readData(at: relativePath) else {
|
||||
throw StorageAccessError.fileNotFound(relativePath)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
private func readData(at relativePath: String) throws -> Data? {
|
||||
try withScopedContent(file: relativePath) { url in
|
||||
guard url.exists else {
|
||||
return nil
|
||||
}
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func getFiles(in folder: URL) throws -> [URL] {
|
||||
try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
|
||||
.filter { !$0.hasDirectoryPath }
|
||||
}
|
||||
|
||||
private func existingFiles(in folder: String) throws -> [URL] {
|
||||
try withScopedContent(folder: folder, getFiles)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
private func decodeAllFromJson<T>(in folder: String) throws -> [String : T] where T: Decodable {
|
||||
try withScopedContent(folder: folder) { folderUrl in
|
||||
try getFiles(in: folderUrl)
|
||||
.filter { $0.pathExtension.lowercased() == "json" }
|
||||
.reduce(into: [:]) { items, url in
|
||||
let id = url.deletingPathExtension().lastPathComponent
|
||||
let data = try Data(contentsOf: url)
|
||||
items[id] = try decoder.decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
private func copy(file: URL, to relativePath: String) throws {
|
||||
try withScopedContent(file: relativePath) { destination in
|
||||
try destination.ensureParentFolderExistence()
|
||||
try fm.copyItem(at: file, to: destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -85,9 +85,10 @@ struct AddFileView: View {
|
||||
print("Skipping existing file \(file.uniqueId)")
|
||||
continue
|
||||
}
|
||||
|
||||
guard content.storage.copyFile(at: file.url, fileId: file.uniqueId) else {
|
||||
print("Failed to import file '\(file.uniqueId)' at \(file.url.path())")
|
||||
do {
|
||||
try content.storage.copyFile(at: file.url, fileId: file.uniqueId)
|
||||
} catch {
|
||||
print("Failed to import file '\(file.uniqueId)' at \(file.url.path()): \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -55,7 +55,9 @@ struct FileDetailView: View {
|
||||
}
|
||||
|
||||
private func setNewId() {
|
||||
guard file.content.storage.move(file: file.id, to: newId) else {
|
||||
do {
|
||||
try file.content.storage.move(file: file.id, to: newId)
|
||||
} catch {
|
||||
print("Failed to move file \(file.id)")
|
||||
newId = file.id
|
||||
return
|
||||
|
@ -67,6 +67,7 @@ struct AddPageView: View {
|
||||
|
||||
private func addNewPage() {
|
||||
let page = Page(
|
||||
content: content,
|
||||
id: newPageId,
|
||||
isDraft: true,
|
||||
createdDate: .now,
|
||||
|
@ -20,6 +20,9 @@ struct LocalizedPageContentView: View {
|
||||
@State
|
||||
private var pageContent: String = ""
|
||||
|
||||
@State
|
||||
private var didLoadContent = false
|
||||
|
||||
init(pageId: String, page: LocalizedPage) {
|
||||
self.pageId = pageId
|
||||
self.page = page
|
||||
@ -50,18 +53,32 @@ struct LocalizedPageContentView: View {
|
||||
}
|
||||
|
||||
private func loadContent() {
|
||||
let content = content.storage.pageContent(for: pageId, language: language)
|
||||
guard content != "" else {
|
||||
pageContent = "New file"
|
||||
return
|
||||
do {
|
||||
let content = try content.storage.pageContent(for: pageId, language: language)
|
||||
|
||||
guard content != "" else {
|
||||
pageContent = "New file"
|
||||
didLoadContent = false
|
||||
return
|
||||
}
|
||||
pageContent = content
|
||||
didLoadContent = true
|
||||
|
||||
} catch {
|
||||
print("Failed to load page content: \(error)")
|
||||
pageContent = "Failed to load"
|
||||
}
|
||||
pageContent = content
|
||||
|
||||
}
|
||||
|
||||
private func saveContent() {
|
||||
guard pageContent != "", pageContent != "New file" else {
|
||||
guard didLoadContent else {
|
||||
return
|
||||
}
|
||||
content.storage.save(pageContent: pageContent, for: pageId, language: language)
|
||||
do {
|
||||
try content.storage.save(pageContent: pageContent, for: pageId, language: language)
|
||||
} catch {
|
||||
print("Failed to save content: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,8 +14,22 @@ struct PageDetailView: View {
|
||||
@State
|
||||
private var isGeneratingWebsite = false
|
||||
|
||||
@State
|
||||
private var newId: String
|
||||
|
||||
init(page: Page) {
|
||||
self.page = page
|
||||
self.newId = page.id
|
||||
}
|
||||
|
||||
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
|
||||
|
||||
private var idExists: Bool {
|
||||
page.content.pages.contains { $0.id == newId }
|
||||
}
|
||||
|
||||
private var containsInvalidCharacters: Bool {
|
||||
newId.rangeOfCharacter(from: allowedCharactersInPostId) != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -25,11 +39,13 @@ struct PageDetailView: View {
|
||||
Text("Generate")
|
||||
}
|
||||
.disabled(isGeneratingWebsite)
|
||||
Text("ID")
|
||||
.font(.headline)
|
||||
TextField("", text: $page.id)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(.bottom)
|
||||
HStack {
|
||||
TextField("", text: $newId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Update", action: setNewId)
|
||||
.disabled(newId.isEmpty || containsInvalidCharacters || idExists)
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
HStack {
|
||||
Text("Draft")
|
||||
@ -102,12 +118,20 @@ struct PageDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setNewId() {
|
||||
guard page.update(id: newId) else {
|
||||
newId = page.id
|
||||
return
|
||||
}
|
||||
page.id = newId
|
||||
}
|
||||
}
|
||||
|
||||
extension PageDetailView: MainContentView {
|
||||
|
||||
init(item: Page) {
|
||||
self.page = item
|
||||
self.init(page: item)
|
||||
}
|
||||
|
||||
static let itemDescription = "a page"
|
||||
|
@ -67,6 +67,7 @@ struct AddPostView: View {
|
||||
|
||||
private func addNewPost() {
|
||||
let post = Post(
|
||||
content: content,
|
||||
id: newPostId,
|
||||
isDraft: true,
|
||||
createdDate: .now,
|
||||
|
@ -34,10 +34,24 @@ struct PostDetailView: View {
|
||||
private var language
|
||||
|
||||
@ObservedObject
|
||||
private var item: Post
|
||||
private var post: Post
|
||||
|
||||
@State
|
||||
private var newId: String
|
||||
|
||||
init(post: Post) {
|
||||
self.item = post
|
||||
self.post = post
|
||||
self.newId = post.id
|
||||
}
|
||||
|
||||
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
|
||||
|
||||
private var idExists: Bool {
|
||||
post.content.posts.contains { $0.id == newId }
|
||||
}
|
||||
|
||||
private var containsInvalidCharacters: Bool {
|
||||
newId.rangeOfCharacter(from: allowedCharactersInPostId) != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -45,15 +59,19 @@ struct PostDetailView: View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("ID")
|
||||
.font(.headline)
|
||||
TextField("", text: $item.id)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(.bottom)
|
||||
HStack {
|
||||
TextField("", text: $newId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Update", action: setNewId)
|
||||
.disabled(newId.isEmpty || containsInvalidCharacters || idExists)
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
HStack {
|
||||
Text("Draft")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Toggle("", isOn: $item.isDraft)
|
||||
Toggle("", isOn: $post.isDraft)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
.padding(.bottom)
|
||||
@ -62,7 +80,7 @@ struct PostDetailView: View {
|
||||
Text("Start")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
DatePicker("", selection: $item.startDate, displayedComponents: .date)
|
||||
DatePicker("", selection: $post.startDate, displayedComponents: .date)
|
||||
.datePickerStyle(.compact)
|
||||
.padding(.bottom)
|
||||
}
|
||||
@ -71,34 +89,42 @@ struct PostDetailView: View {
|
||||
Text("Has end date")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Toggle("", isOn: $item.hasEndDate)
|
||||
Toggle("", isOn: $post.hasEndDate)
|
||||
.toggleStyle(.switch)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
if item.hasEndDate {
|
||||
if post.hasEndDate {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("End date")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
DatePicker("", selection: $item.endDate, displayedComponents: .date)
|
||||
DatePicker("", selection: $post.endDate, displayedComponents: .date)
|
||||
.datePickerStyle(.compact)
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
LocalizedPostDetailView(post: item.localized(in: language))
|
||||
LocalizedPostDetailView(post: post.localized(in: language))
|
||||
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func setNewId() {
|
||||
guard post.update(id: newId) else {
|
||||
newId = post.id
|
||||
return
|
||||
}
|
||||
post.id = newId
|
||||
}
|
||||
}
|
||||
|
||||
extension PostDetailView: MainContentView {
|
||||
|
||||
init(item: Post) {
|
||||
self.item = item
|
||||
self.init(post: item)
|
||||
}
|
||||
|
||||
static let itemDescription = "a post"
|
||||
|
Loading…
x
Reference in New Issue
Block a user