diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 17b0045..f8b63d5 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -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 = ""; }; E218502C2CF791440090B18B /* PostImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostImagesView.swift; sourceTree = ""; }; E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteGenerator.swift; sourceTree = ""; }; - E21850302CFAF8840090B18B /* Content+Import.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Import.swift"; sourceTree = ""; }; E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; E21850342CFAFA570090B18B /* SettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFile.swift; sourceTree = ""; }; E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = ""; }; E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebsiteData+Mock.swift"; sourceTree = ""; }; E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostFeedSettingsView.swift; sourceTree = ""; }; - E24252022C5163CF0029FF16 /* Importer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Importer.swift; sourceTree = ""; }; - E24252052C51684E0029FF16 /* GenericMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericMetadata.swift; sourceTree = ""; }; - E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GenericMetadata+Localized.swift"; sourceTree = ""; }; E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = ""; }; E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; - E2581DF02C7523F400F1F079 /* ImportableTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportableTag.swift; sourceTree = ""; }; E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = ""; }; E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = ""; }; E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTagAssignmentView.swift; sourceTree = ""; }; @@ -258,6 +249,7 @@ E29D31602D06D9570051B7F4 /* ResourceFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceFileType.swift; sourceTree = ""; }; E29D31622D06E95A0051B7F4 /* NavigationIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationIcon.swift; sourceTree = ""; }; E29D31682D0702670051B7F4 /* TextFileContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFileContentView.swift; sourceTree = ""; }; + E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostListPageGenerator.swift; sourceTree = ""; }; E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = ""; }; E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; }; E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = ""; }; @@ -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 = ""; - }; 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 = ""; @@ -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 */, diff --git a/CHDataManagement/Generator/ImageGenerator.swift b/CHDataManagement/Generator/ImageGenerator.swift index a58f06d..7b0e8e4 100644 --- a/CHDataManagement/Generator/ImageGenerator.swift +++ b/CHDataManagement/Generator/ImageGenerator.swift @@ -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 { diff --git a/CHDataManagement/Generator/PageGenerator.swift b/CHDataManagement/Generator/PageGenerator.swift index a46937b..e477a67 100644 --- a/CHDataManagement/Generator/PageGenerator.swift +++ b/CHDataManagement/Generator/PageGenerator.swift @@ -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 } - } diff --git a/CHDataManagement/Generator/PostListPageGenerator.swift b/CHDataManagement/Generator/PostListPageGenerator.swift new file mode 100644 index 0000000..0774c2e --- /dev/null +++ b/CHDataManagement/Generator/PostListPageGenerator.swift @@ -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.., 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 + } + } +} diff --git a/CHDataManagement/Generator/WebsiteGenerator.swift b/CHDataManagement/Generator/WebsiteGenerator.swift index 70aa825..5c14655 100644 --- a/CHDataManagement/Generator/WebsiteGenerator.swift +++ b/CHDataManagement/Generator/WebsiteGenerator.swift @@ -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.. 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, 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 - } - } } } diff --git a/CHDataManagement/Import/GenericMetadata+Localized.swift b/CHDataManagement/Import/GenericMetadata+Localized.swift deleted file mode 100644 index 21d3945..0000000 --- a/CHDataManagement/Import/GenericMetadata+Localized.swift +++ /dev/null @@ -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 { - -} diff --git a/CHDataManagement/Import/GenericMetadata.swift b/CHDataManagement/Import/GenericMetadata.swift deleted file mode 100644 index a06b662..0000000 --- a/CHDataManagement/Import/GenericMetadata.swift +++ /dev/null @@ -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? - - /** - 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? - - /** - Additional images required by the element. - - These images are specified as: `source_name destination_name width (height)`. - */ - let images: Set? - - /** - 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 { - -} diff --git a/CHDataManagement/Import/ImportableTag.swift b/CHDataManagement/Import/ImportableTag.swift deleted file mode 100644 index 2ce5a75..0000000 --- a/CHDataManagement/Import/ImportableTag.swift +++ /dev/null @@ -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 { - -} diff --git a/CHDataManagement/Import/Importer.swift b/CHDataManagement/Import/Importer.swift deleted file mode 100644 index c32f34a..0000000 --- a/CHDataManagement/Import/Importer.swift +++ /dev/null @@ -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)! - } -} diff --git a/CHDataManagement/Main/MainView.swift b/CHDataManagement/Main/MainView.swift index be2b1aa..0c608a8 100644 --- a/CHDataManagement/Main/MainView.swift +++ b/CHDataManagement/Main/MainView.swift @@ -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() { diff --git a/CHDataManagement/Model/Content+Generation.swift b/CHDataManagement/Model/Content+Generation.swift index 740777d..51e418f 100644 --- a/CHDataManagement/Model/Content+Generation.swift +++ b/CHDataManagement/Model/Content+Generation.swift @@ -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 diff --git a/CHDataManagement/Model/Content+Import.swift b/CHDataManagement/Model/Content+Import.swift deleted file mode 100644 index 315d388..0000000 --- a/CHDataManagement/Model/Content+Import.swift +++ /dev/null @@ -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)") - } - } -} diff --git a/CHDataManagement/Model/Content+Load.swift b/CHDataManagement/Model/Content+Load.swift index afd109d..704edf0 100644 --- a/CHDataManagement/Model/Content+Load.swift +++ b/CHDataManagement/Model/Content+Load.swift @@ -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, diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index 8fee01c..e0403c4 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -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 { diff --git a/CHDataManagement/Model/LocalizedTag.swift b/CHDataManagement/Model/LocalizedTag.swift index 0d42bb2..48bc24b 100644 --- a/CHDataManagement/Model/LocalizedTag.swift +++ b/CHDataManagement/Model/LocalizedTag.swift @@ -36,13 +36,3 @@ final class LocalizedTag: ObservableObject { self.originalUrl = originalUrl } } - -extension LocalizedTag { - - func data() -> FeedEntryData.Tag { - .init( - name: name, - url: "tags/\(urlComponent).html" - ) - } -} diff --git a/CHDataManagement/Model/Page.swift b/CHDataManagement/Model/Page.swift index ca8964a..5299cf1 100644 --- a/CHDataManagement/Model/Page.swift +++ b/CHDataManagement/Model/Page.swift @@ -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 = [] - 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 { diff --git a/CHDataManagement/Model/Post.swift b/CHDataManagement/Model/Post.swift index 5216a35..2c2bde4 100644 --- a/CHDataManagement/Model/Post.swift +++ b/CHDataManagement/Model/Post.swift @@ -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 { diff --git a/CHDataManagement/Model/Tag.swift b/CHDataManagement/Model/Tag.swift index e91b5d5..74318c5 100644 --- a/CHDataManagement/Model/Tag.swift +++ b/CHDataManagement/Model/Tag.swift @@ -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 { } diff --git a/CHDataManagement/Pages/PageInFeed.swift b/CHDataManagement/Pages/PageInFeed.swift index 9e3367e..6869b92 100644 --- a/CHDataManagement/Pages/PageInFeed.swift +++ b/CHDataManagement/Pages/PageInFeed.swift @@ -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 += "

\(title)

" + } + 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 { diff --git a/CHDataManagement/Preview Content/Page+Mock.swift b/CHDataManagement/Preview Content/Page+Mock.swift index 208c681..f7bee41 100644 --- a/CHDataManagement/Preview Content/Page+Mock.swift +++ b/CHDataManagement/Preview Content/Page+Mock.swift @@ -4,6 +4,7 @@ extension Page { static var empty: Page { .init( + content: .mock, id: "my-id", isDraft: true, createdDate: Date(), diff --git a/CHDataManagement/Preview Content/Post+Mock.swift b/CHDataManagement/Preview Content/Post+Mock.swift index 0a3415b..84f58da 100644 --- a/CHDataManagement/Preview Content/Post+Mock.swift +++ b/CHDataManagement/Preview Content/Post+Mock.swift @@ -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, diff --git a/CHDataManagement/Storage/Model/Settings/LocalizedPostSettingsFile.swift b/CHDataManagement/Storage/Model/Settings/LocalizedPostSettingsFile.swift index 902c51e..cf6d1b2 100644 --- a/CHDataManagement/Storage/Model/Settings/LocalizedPostSettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/LocalizedPostSettingsFile.swift @@ -12,3 +12,12 @@ struct LocalizedPostSettingsFile { } extension LocalizedPostSettingsFile: Codable { } + +extension LocalizedPostSettingsFile { + + static var `default`: LocalizedPostSettingsFile { + .init(feedTitle: "A title", + feedDescription: "A description", + feedUrlPrefix: "blog") + } +} diff --git a/CHDataManagement/Storage/Model/Settings/LocalizedSettingsFile.swift b/CHDataManagement/Storage/Model/Settings/LocalizedSettingsFile.swift index 39c9e0c..5d9db6a 100644 --- a/CHDataManagement/Storage/Model/Settings/LocalizedSettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/LocalizedSettingsFile.swift @@ -10,3 +10,11 @@ struct LocalizedSettingsFile { extension LocalizedSettingsFile: Codable { } + +extension LocalizedSettingsFile { + + static var `default`: LocalizedSettingsFile { + .init(navigationBarIconDescription: "An icon", + posts: .default) + } +} diff --git a/CHDataManagement/Storage/Model/Settings/NavigationBarSettingsFile.swift b/CHDataManagement/Storage/Model/Settings/NavigationBarSettingsFile.swift index b63c2e7..042168d 100644 --- a/CHDataManagement/Storage/Model/Settings/NavigationBarSettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/NavigationBarSettingsFile.swift @@ -10,3 +10,10 @@ struct NavigationBarSettingsFile { extension NavigationBarSettingsFile: Codable { } +extension NavigationBarSettingsFile { + + static var `default`: NavigationBarSettingsFile { + .init(navigationIconPath: "/assets/icons/icon.svg", + navigationTags: []) + } +} diff --git a/CHDataManagement/Storage/Model/Settings/PageSettingsFile.swift b/CHDataManagement/Storage/Model/Settings/PageSettingsFile.swift index ef9f41f..0b578d2 100644 --- a/CHDataManagement/Storage/Model/Settings/PageSettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/PageSettingsFile.swift @@ -9,3 +9,11 @@ struct PageSettingsFile { extension PageSettingsFile: Codable { } + +extension PageSettingsFile { + + static var `default`: PageSettingsFile { + .init(pageUrlPrefix: "page", + contentWidth: 600) + } +} diff --git a/CHDataManagement/Storage/Model/Settings/PostSettingsFile.swift b/CHDataManagement/Storage/Model/Settings/PostSettingsFile.swift index b22b1b3..022abe1 100644 --- a/CHDataManagement/Storage/Model/Settings/PostSettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/PostSettingsFile.swift @@ -10,3 +10,11 @@ struct PostSettingsFile { } extension PostSettingsFile: Codable { } + +extension PostSettingsFile { + + static var `default`: PostSettingsFile { + .init(postsPerPage: 25, + contentWidth: 600) + } +} diff --git a/CHDataManagement/Storage/Model/Settings/SettingsFile.swift b/CHDataManagement/Storage/Model/Settings/SettingsFile.swift index e2ceb0e..2349a55 100644 --- a/CHDataManagement/Storage/Model/Settings/SettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/SettingsFile.swift @@ -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 + ) + } +} diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 140385d..98611bb 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -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(file relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T { + try withScopedContent(relativePath, in: scope, directoryHint: .notDirectory, operation) + } + + private func withScopedContent(folder relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T { + try withScopedContent(relativePath, in: scope, directoryHint: .isDirectory, operation) + } + + private func withScopedContent(_ 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(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) 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(_ 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(_ 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(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(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(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(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(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) } } } diff --git a/CHDataManagement/Views/Files/AddFileView.swift b/CHDataManagement/Views/Files/AddFileView.swift index 36b6f1e..701de08 100644 --- a/CHDataManagement/Views/Files/AddFileView.swift +++ b/CHDataManagement/Views/Files/AddFileView.swift @@ -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 } diff --git a/CHDataManagement/Views/Files/FileDetailView.swift b/CHDataManagement/Views/Files/FileDetailView.swift index 68a70f2..23a4af4 100644 --- a/CHDataManagement/Views/Files/FileDetailView.swift +++ b/CHDataManagement/Views/Files/FileDetailView.swift @@ -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 diff --git a/CHDataManagement/Views/Pages/AddPageView.swift b/CHDataManagement/Views/Pages/AddPageView.swift index ea13b32..7e6e7ff 100644 --- a/CHDataManagement/Views/Pages/AddPageView.swift +++ b/CHDataManagement/Views/Pages/AddPageView.swift @@ -67,6 +67,7 @@ struct AddPageView: View { private func addNewPage() { let page = Page( + content: content, id: newPageId, isDraft: true, createdDate: .now, diff --git a/CHDataManagement/Views/Pages/LocalizedPageContentView.swift b/CHDataManagement/Views/Pages/LocalizedPageContentView.swift index 1a149d9..90b73b8 100644 --- a/CHDataManagement/Views/Pages/LocalizedPageContentView.swift +++ b/CHDataManagement/Views/Pages/LocalizedPageContentView.swift @@ -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)") + } } } diff --git a/CHDataManagement/Views/Pages/PageDetailView.swift b/CHDataManagement/Views/Pages/PageDetailView.swift index 5409e03..52b5513 100644 --- a/CHDataManagement/Views/Pages/PageDetailView.swift +++ b/CHDataManagement/Views/Pages/PageDetailView.swift @@ -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" diff --git a/CHDataManagement/Views/Posts/AddPostView.swift b/CHDataManagement/Views/Posts/AddPostView.swift index 11769dd..d9ab4da 100644 --- a/CHDataManagement/Views/Posts/AddPostView.swift +++ b/CHDataManagement/Views/Posts/AddPostView.swift @@ -67,6 +67,7 @@ struct AddPostView: View { private func addNewPost() { let post = Post( + content: content, id: newPostId, isDraft: true, createdDate: .now, diff --git a/CHDataManagement/Views/Posts/PostDetailView.swift b/CHDataManagement/Views/Posts/PostDetailView.swift index d27a413..3f2ed83 100644 --- a/CHDataManagement/Views/Posts/PostDetailView.swift +++ b/CHDataManagement/Views/Posts/PostDetailView.swift @@ -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"