Generate first tag pages

This commit is contained in:
Christoph Hagen 2024-12-09 17:47:03 +01:00
parent 4f08526978
commit 8183bc4903
35 changed files with 719 additions and 1105 deletions

View File

@ -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 */,

View File

@ -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 {

View File

@ -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
}
}

View 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
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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 {
}

View File

@ -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 `![page](page_id)`
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 {
}

View File

@ -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 {
}

View File

@ -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)!
}
}

View File

@ -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() {

View File

@ -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

View File

@ -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)")
}
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -36,13 +36,3 @@ final class LocalizedTag: ObservableObject {
self.originalUrl = originalUrl
}
}
extension LocalizedTag {
func data() -> FeedEntryData.Tag {
.init(
name: name,
url: "tags/\(urlComponent).html"
)
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {
}

View File

@ -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 {

View File

@ -4,6 +4,7 @@ extension Page {
static var empty: Page {
.init(
content: .mock,
id: "my-id",
isDraft: true,
createdDate: Date(),

View File

@ -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,

View File

@ -12,3 +12,12 @@ struct LocalizedPostSettingsFile {
}
extension LocalizedPostSettingsFile: Codable { }
extension LocalizedPostSettingsFile {
static var `default`: LocalizedPostSettingsFile {
.init(feedTitle: "A title",
feedDescription: "A description",
feedUrlPrefix: "blog")
}
}

View File

@ -10,3 +10,11 @@ struct LocalizedSettingsFile {
extension LocalizedSettingsFile: Codable {
}
extension LocalizedSettingsFile {
static var `default`: LocalizedSettingsFile {
.init(navigationBarIconDescription: "An icon",
posts: .default)
}
}

View File

@ -10,3 +10,10 @@ struct NavigationBarSettingsFile {
extension NavigationBarSettingsFile: Codable { }
extension NavigationBarSettingsFile {
static var `default`: NavigationBarSettingsFile {
.init(navigationIconPath: "/assets/icons/icon.svg",
navigationTags: [])
}
}

View File

@ -9,3 +9,11 @@ struct PageSettingsFile {
extension PageSettingsFile: Codable {
}
extension PageSettingsFile {
static var `default`: PageSettingsFile {
.init(pageUrlPrefix: "page",
contentWidth: 600)
}
}

View File

@ -10,3 +10,11 @@ struct PostSettingsFile {
}
extension PostSettingsFile: Codable { }
extension PostSettingsFile {
static var `default`: PostSettingsFile {
.init(postsPerPage: 25,
contentWidth: 600)
}
}

View File

@ -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
)
}
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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

View File

@ -67,6 +67,7 @@ struct AddPageView: View {
private func addNewPage() {
let page = Page(
content: content,
id: newPageId,
isDraft: true,
createdDate: .now,

View File

@ -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)")
}
}
}

View File

@ -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"

View File

@ -67,6 +67,7 @@ struct AddPostView: View {
private func addNewPost() {
let post = Post(
content: content,
id: newPostId,
isDraft: true,
createdDate: .now,

View File

@ -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"