From 943d8d962b5858bb53f340dff8272647ea2a4378 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Mon, 18 Nov 2024 20:19:20 +0100 Subject: [PATCH] Import old content, load from disk --- CHDataManagement.xcodeproj/project.pbxproj | 48 +++ CHDataManagement/CHDataManagementApp.swift | 7 +- .../Extensions/Optional+Extensions.swift | 9 + .../Extensions/Sequence+Sorted.swift | 11 + CHDataManagement/Import/Importer.swift | 335 ++++++++++++++---- CHDataManagement/Model/Content.swift | 180 +++++++++- CHDataManagement/Model/ContentLanguage.swift | 4 + CHDataManagement/Model/LocalizedPage.swift | 72 ++++ CHDataManagement/Model/LocalizedPost.swift | 54 +++ CHDataManagement/Model/LocalizedTag.swift | 48 +++ CHDataManagement/Model/Page.swift | 71 ++-- CHDataManagement/Model/Post.swift | 58 +-- CHDataManagement/Model/Tag.swift | 33 +- .../Preview Content/Page+Mock.swift | 33 +- .../Preview Content/Post+Mock.swift | 52 ++- .../Preview Content/Tag+Mock.swift | 47 +++ CHDataManagement/Storage/PageFile.swift | 62 ++++ CHDataManagement/Storage/PostFile.swift | 42 +++ CHDataManagement/Storage/Storage.swift | 283 +++++++++++++++ CHDataManagement/Storage/TagFile.swift | 39 ++ .../Views/Pages/PageDetailView.swift | 2 +- CHDataManagement/Views/Posts/PostList.swift | 10 +- CHDataManagement/Views/Posts/PostView.swift | 19 +- .../Views/Settings/SettingsView.swift | 17 +- 24 files changed, 1326 insertions(+), 210 deletions(-) create mode 100644 CHDataManagement/Extensions/Optional+Extensions.swift create mode 100644 CHDataManagement/Extensions/Sequence+Sorted.swift create mode 100644 CHDataManagement/Model/LocalizedPage.swift create mode 100644 CHDataManagement/Model/LocalizedPost.swift create mode 100644 CHDataManagement/Model/LocalizedTag.swift create mode 100644 CHDataManagement/Preview Content/Tag+Mock.swift create mode 100644 CHDataManagement/Storage/PageFile.swift create mode 100644 CHDataManagement/Storage/PostFile.swift create mode 100644 CHDataManagement/Storage/Storage.swift create mode 100644 CHDataManagement/Storage/TagFile.swift diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index cca0114..e385e74 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -37,6 +37,16 @@ E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C502CBBD53C0060935B /* FileResource.swift */; }; E2A21C542CBBF87A0060935B /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C532CBBF87A0060935B /* FilesView.swift */; }; E2A21C562CBBF9880060935B /* FlexibleColumnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C552CBBF9880060935B /* FlexibleColumnView.swift */; }; + E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25A0B882CE4021400F33674 /* LocalizedPage.swift */; }; + E2A37D0E2CE527070000979F /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D0D2CE527040000979F /* Storage.swift */; }; + E2A37D112CE537800000979F /* PageFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D102CE537670000979F /* PageFile.swift */; }; + E2A37D152CE68BEC0000979F /* PostFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D142CE68BEA0000979F /* PostFile.swift */; }; + E2A37D172CE73F1A0000979F /* TagFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D162CE73F170000979F /* TagFile.swift */; }; + E2A37D192CEA36A90000979F /* LocalizedTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D182CEA36A40000979F /* LocalizedTag.swift */; }; + E2A37D1B2CEA45560000979F /* Tag+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D1A2CEA45530000979F /* Tag+Mock.swift */; }; + E2A37D1D2CEA922D0000979F /* LocalizedPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */; }; + E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D1E2CEA94330000979F /* Optional+Extensions.swift */; }; + E2A37D212CEA94EC0000979F /* Sequence+Sorted.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D202CEA94E80000979F /* Sequence+Sorted.swift */; }; E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A9CB7D2C7BCF2A005C89CC /* Page.swift */; }; E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2B85F352C426BEE0047CD0C /* SFSafeSymbols */; }; E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F3A2C428F0D0047CD0C /* Post.swift */; }; @@ -59,6 +69,7 @@ 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 = ""; }; E2A21C002CB16A820060935B /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = ""; }; E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = ""; }; E2A21C042CB176670060935B /* LocalizedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedText.swift; sourceTree = ""; }; @@ -82,6 +93,15 @@ E2A21C502CBBD53C0060935B /* FileResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileResource.swift; sourceTree = ""; }; E2A21C532CBBF87A0060935B /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = ""; }; E2A21C552CBBF9880060935B /* FlexibleColumnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlexibleColumnView.swift; sourceTree = ""; }; + E2A37D0D2CE527040000979F /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; + E2A37D102CE537670000979F /* PageFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageFile.swift; sourceTree = ""; }; + E2A37D142CE68BEA0000979F /* PostFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFile.swift; sourceTree = ""; }; + E2A37D162CE73F170000979F /* TagFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagFile.swift; sourceTree = ""; }; + E2A37D182CEA36A40000979F /* LocalizedTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedTag.swift; sourceTree = ""; }; + E2A37D1A2CEA45530000979F /* Tag+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+Mock.swift"; sourceTree = ""; }; + E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPost.swift; sourceTree = ""; }; + E2A37D1E2CEA94330000979F /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = ""; }; + E2A37D202CEA94E80000979F /* Sequence+Sorted.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Sorted.swift"; sourceTree = ""; }; E2A9CB7D2C7BCF2A005C89CC /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = ""; }; E2B85F3A2C428F0D0047CD0C /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; E2B85F3C2C4293F80047CD0C /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; @@ -165,6 +185,17 @@ path = Files; sourceTree = ""; }; + E2A37D0F2CE5375E0000979F /* Storage */ = { + isa = PBXGroup; + children = ( + E2A37D162CE73F170000979F /* TagFile.swift */, + E2A37D102CE537670000979F /* PageFile.swift */, + E2A37D142CE68BEA0000979F /* PostFile.swift */, + E2A37D0D2CE527040000979F /* Storage.swift */, + ); + path = Storage; + sourceTree = ""; + }; E2A9CB7F2C7E686C005C89CC /* Tags */ = { isa = PBXGroup; children = ( @@ -179,10 +210,13 @@ E24252092C52C9260029FF16 /* ContentLanguage.swift */, E2A21C502CBBD53C0060935B /* FileResource.swift */, E2A21C3A2CB9D9A50060935B /* ImageResource.swift */, + E25A0B882CE4021400F33674 /* LocalizedPage.swift */, E2A21C042CB176670060935B /* LocalizedText.swift */, E2A9CB7D2C7BCF2A005C89CC /* Page.swift */, E2B85F3A2C428F0D0047CD0C /* Post.swift */, + E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */, E2581DEC2C75202400F1F079 /* Tag.swift */, + E2A37D182CEA36A40000979F /* LocalizedTag.swift */, ); path = Model; sourceTree = ""; @@ -236,6 +270,8 @@ E2B85F552C4BD0AD0047CD0C /* Extensions */ = { isa = PBXGroup; children = ( + E2A37D202CEA94E80000979F /* Sequence+Sorted.swift */, + E2A37D1E2CEA94330000979F /* Optional+Extensions.swift */, E2A21C472CBAF8830060935B /* String+Extensions.swift */, E2A21C0D2CB189D70060935B /* Color+RGB.swift */, E2B85F562C4BD0BB0047CD0C /* Binding+Extension.swift */, @@ -263,6 +299,7 @@ E2DD04722C276F31003BFF1F /* CHDataManagement */ = { isa = PBXGroup; children = ( + E2A37D0F2CE5375E0000979F /* Storage */, E2DD04732C276F31003BFF1F /* CHDataManagementApp.swift */, E2B85F392C428F020047CD0C /* Model */, E2B85F462C42C7CA0047CD0C /* Views */, @@ -280,6 +317,7 @@ E2DD047C2C276F32003BFF1F /* Preview Content */ = { isa = PBXGroup; children = ( + E2A37D1A2CEA45530000979F /* Tag+Mock.swift */, E2A21C1F2CB28ED20060935B /* MockImage.swift */, E2A21C292CB2AA4C0060935B /* Post+Mock.swift */, E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */, @@ -367,22 +405,29 @@ buildActionMask = 2147483647; files = ( E2A21C162CB1A3C90060935B /* PostImageGalleryView.swift in Sources */, + E2A37D212CEA94EC0000979F /* Sequence+Sorted.swift in Sources */, E2A21C562CBBF9880060935B /* FlexibleColumnView.swift in Sources */, + E2A37D192CEA36A90000979F /* LocalizedTag.swift in Sources */, E24252062C51684E0029FF16 /* GenericMetadata.swift in Sources */, E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */, + E2A37D1D2CEA922D0000979F /* LocalizedPost.swift in Sources */, + E2A37D112CE537800000979F /* PageFile.swift in Sources */, E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */, E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */, E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */, E2A21C082CB17B870060935B /* TagView.swift in Sources */, E2A21C282CB29B2A0060935B /* FeedEntryData.swift in Sources */, E2581DF12C7523F400F1F079 /* ImportableTag.swift in Sources */, + E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */, E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */, E2B85F3D2C4293F80047CD0C /* Feed.swift in Sources */, E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */, E24252082C5168750029FF16 /* GenericMetadata+Localized.swift in Sources */, E2581DED2C75202400F1F079 /* Tag.swift in Sources */, E2A21C4F2CBB29E50060935B /* ImageDetailsView.swift in Sources */, + E2A37D152CE68BEC0000979F /* PostFile.swift in Sources */, E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */, + E2A37D172CE73F1A0000979F /* TagFile.swift in Sources */, E24252032C5163CF0029FF16 /* Importer.swift in Sources */, E2A21C332CB5BCAC0060935B /* PageDetailView.swift in Sources */, E2A21C122CB18D560060935B /* DatePickerView.swift in Sources */, @@ -395,9 +440,12 @@ E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */, E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */, E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */, + E2A37D1B2CEA45560000979F /* Tag+Mock.swift in Sources */, E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */, + E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */, E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */, E2A21C542CBBF87A0060935B /* FilesView.swift in Sources */, + E2A37D0E2CE527070000979F /* Storage.swift in Sources */, E2E06E002CA4A8F00019C2AF /* Page+Mock.swift in Sources */, E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */, E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */, diff --git a/CHDataManagement/CHDataManagementApp.swift b/CHDataManagement/CHDataManagementApp.swift index c8bb0dd..b959f7d 100644 --- a/CHDataManagement/CHDataManagementApp.swift +++ b/CHDataManagement/CHDataManagementApp.swift @@ -66,6 +66,11 @@ struct CHDataManagementApp: App { } private func importOldContent() { - content.importOldContent() + do { + try content.loadFromDisk() + //content.importOldContent() + } catch { + print("Failed to load content: \(error.localizedDescription)") + } } } diff --git a/CHDataManagement/Extensions/Optional+Extensions.swift b/CHDataManagement/Extensions/Optional+Extensions.swift new file mode 100644 index 0000000..b41426c --- /dev/null +++ b/CHDataManagement/Extensions/Optional+Extensions.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Optional { + + func map(_ transform: (Wrapped) throws -> T?) rethrows -> T? { + guard let self else { return nil } + return try transform(self) + } +} diff --git a/CHDataManagement/Extensions/Sequence+Sorted.swift b/CHDataManagement/Extensions/Sequence+Sorted.swift new file mode 100644 index 0000000..a2ff71b --- /dev/null +++ b/CHDataManagement/Extensions/Sequence+Sorted.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Sequence { + + func sorted(ascending: Bool = true, using conversion: (Element) -> T) -> [Element] where T: Comparable { + guard ascending else { + return sorted { conversion($0) > conversion($1) } + } + return sorted { conversion($0) < conversion($1) } + } +} diff --git a/CHDataManagement/Import/Importer.swift b/CHDataManagement/Import/Importer.swift index 73e027f..f2bd7f0 100644 --- a/CHDataManagement/Import/Importer.swift +++ b/CHDataManagement/Import/Importer.swift @@ -1,21 +1,71 @@ import Foundation -struct ImportedContent { +enum FileType { + case image + case file + case video + case resource - let posts: [Post] - let categories: [Tag] + init(fileExtension: String) { + switch fileExtension.lowercased() { + case "jpg", "jpeg", "png", "gif": + self = .image + case "html", "stl", "f3d", "step", "f3z", "zip", "json", "conf", "css", "js", "cpp", "cddx", "svg", "glb", "mp3", "pdf", "swift": + self = .file + case "mp4": + self = .video + case "key", "psd": + self = .resource + default: + print("Unhandled file type: \(fileExtension)") + self = .resource + } + } +} + +struct ImportedPage { + + let page: PageFile + + let deContentUrl: URL + + let enContentUrl: URL +} + + +struct FileResource { + + let type: FileType + + let url: URL + + let name: String + + init(image: String, url: URL) { + self.type = .image + self.url = url + self.name = image + } + + init(type: FileType, url: URL, name: String) { + self.type = type + self.url = url + self.name = name + } } final class Importer { - var posts: [Post] = [] + var posts: [String : PostFile] = [:] - var pages: [Page] = [] + var pages: [String : ImportedPage] = [:] - var tags: [Tag] = [] + var tags: [String : TagFile] = [:] - var images: [ImageResource] = [] + var files: [String : FileResource] = [:] + + var ignoredFiles: [URL] = [] var foldersToSearch: [(path: String, tag: String)] = [ ("/Users/ch/Downloads/Website/projects/electronics", "electronics"), @@ -30,93 +80,250 @@ final class Importer { ("/Users/ch/Downloads/Website/travel", "travel") ] - func importOldContent() throws { - for (folder, tagName) in foldersToSearch { - let url = URL(filePath: folder) - let tag = try importTag(name: tagName, folder: url) - try importEntries(in: url, tag: tag) - tags.append(tag) + 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) + } } - posts.sort { $0.startDate > $1.startDate } - //pages.sort { $0.startDate > $1.startDate } - tags.sort() } - private func importTag(name: String, folder: URL) throws -> 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) - return .init( - en: meta.info(for: .english)!.title, - de: meta.info(for: .german)!.title) + let thumbnailUrl = folder.appending(path: "thumbnail.jpg", directoryHint: .notDirectory) + var thumbnail: FileResource? = nil + if FileManager.default.fileExists(atPath: thumbnailUrl.path()) { + thumbnail = FileResource(type: .image, 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, + german: deTag, + english: enTag) + tags[tagId] = tag + return tagId } - private func importEntries(in folder: URL, tag: Tag) throws { + private func findPageFolders(in folder: URL) throws -> [URL] { try FileManager.default .contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey]) .filter { $0.hasDirectoryPath } - .forEach { try importEntry(at: $0, tag: tag) } } - private func importEntry(at url: URL, tag: Tag) throws { + private func findResources(in folder: URL, pageId: String) throws -> [FileResource] { + 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 type != .resource else { + self.ignoredFiles.append(url) + return nil + } + + let name = pageId + "-" + fileName + + return FileResource(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())") + print("No entry at \(url.path())") return } let data = try Data(contentsOf: metadataUrl) let meta = try JSONDecoder().decode(GenericMetadata.self, from: data) - let page = Page( - id: meta.customId ?? url.lastPathComponent, - isDraft: meta.state == "draft", - metadata: meta.languages!.map(convertPageContent), - externalFiles: meta.externalFiles ?? [], - requiredFiles: meta.requiredFiles ?? [], - images: meta.images ?? []) - pages.append(page) + let pageId = meta.customId ?? url.lastPathComponent - let de = meta.languages!.first { $0.language == "de" }! - let en = meta.languages!.first { $0.language == "en" }! + let resources = try findResources(in: url, pageId: pageId) - let thumbnailImageName = meta.thumbnailPath ?? "thumbnail.jpg" - let thumbnailImageUrl = url.appending(path: thumbnailImageName, directoryHint: .notDirectory) - var images: [ImageResource] = [] - if tag.id != "articles" { - if FileManager.default.fileExists(atPath: thumbnailImageUrl.path()) { - let thumbnail = ImageResource( - uniqueId: meta.customId ?? url.lastPathComponent, - altText: .init(en: "An image about \(en.title!)", de: "Ein Bild zu \(de.title!)"), - fileUrl: thumbnailImageUrl) - images.append(thumbnail) - self.images.append(thumbnail) - } else { - print("Thumbnail \(thumbnailImageUrl.path()) not found") - } + guard let languages = meta.languages else { + print("No languages for \(url.path())") + return } - let lastPostId = posts.last?.id ?? 0 + let externalFiles = meta.externalFiles ?? [] + let requiredFiles = meta.requiredFiles ?? [] - let post = Post( - id: lastPostId + 1, - isDraft: meta.state == "draft" || meta.state == "hidden", - startDate: meta.date!.toDate(), - endDate: meta.endDate?.toDate(), - title: .init(en: en.linkPreviewTitle ?? en.title!, - de: de.linkPreviewTitle ?? de.title!), - text: .init(en: en.linkPreviewDescription ?? en.description ?? "No description", - de: de.linkPreviewDescription ?? de.description ?? "Keine Beschreibung"), + 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, + externalFiles: externalFiles, + requiredFiles: requiredFiles, + 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], - images: images) + createdDate: date, + startDate: date, + endDate: endDate, + german: dePage, + english: enPage) - posts.append(post) + 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 convertPageContent(_ meta: GenericMetadata.LocalizedMetadata) -> LocalizedPage { - .init(language: ContentLanguage(rawValue: meta.language!)!, - urlString: nil, - headline: meta.title!) + private func add(resource: FileResource) { + 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: [FileResource], folder: URL, customPath: String?, pageId: String, language: String) throws -> FileResource? { + 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 -> FileResource? { + guard let thumbnailUrl = findThumbnailUrl(in: folder, customPath: customPath, language: language) else { + return nil + } + let id = pageId + "-" + thumbnailUrl.lastPathComponent + return FileResource(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: Set(images), + title: page.linkPreviewTitle ?? page.title, + content: content, + lastModifiedDate: nil) } } diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index 122a171..22e7c62 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI final class Content: ObservableObject { @@ -17,6 +18,9 @@ final class Content: ObservableObject { @Published var files: [FileResources] = [] + @AppStorage("contentPath") + var contentPath: String = "" + func generateFeed(for language: ContentLanguage, bookmarkKey: String) { let posts = posts.map { $0.feedEntry(for: language) } DispatchQueue.global(qos: .userInitiated).async { @@ -56,19 +60,178 @@ final class Content: ObservableObject { } func importOldContent() { - let importer = Importer() + let storage = Storage(baseFolder: URL(filePath: "/Users/ch/Downloads/Content")) do { - try importer.importOldContent() + try storage.createFolderStructure() } catch { print(error) return } - self.posts = importer.posts - self.tags = importer.tags - #warning("TODO: Copy page sources to data folder") - self.pages = importer.pages - self.images = importer.images - #warning("TODO: Copy images to data folder") + + 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)") + } + } + + private func convert(_ tag: LocalizedTagFile) -> LocalizedTag { + LocalizedTag( + urlComponent: tag.urlComponent, + name: tag.name, + subtitle: tag.subtitle, + description: tag.description, + thumbnail: tag.thumbnail, + originalUrl: tag.originalURL) + } + + func loadFromDisk() throws { + let storage = Storage(baseFolder: URL(filePath: contentPath)) + + let tagData = try storage.loadAllTags() + let pagesData = try storage.loadAllPages() + let postsData = try storage.loadAllPosts() + let filesData = try storage.loadAllFiles() + + let tags = tagData.reduce(into: [:]) { (tags, data) in + tags[data.key] = Tag(german: convert(data.value.german), + english: convert(data.value.english)) + } + + let pages: [String : Page] = loadPages(pagesData, tags: tags) + + let images: [String : ImageResource] = filesData.reduce(into: [:]) { dict, item in + let (file, url) = item + let ext = file.components(separatedBy: ".").last!.lowercased() + let type = FileType(fileExtension: ext) + guard type == .image else { return } + dict[file] = ImageResource(uniqueId: file, altText: .init(en: "", de: ""), fileUrl: url) + } + + let files: [FileResources] = filesData.compactMap { file, url in + let ext = file.components(separatedBy: ".").last!.lowercased() + let type = FileType(fileExtension: ext) + guard type == .file else { + return nil + } + return FileResources(uniqueId: file, description: "") + } + + let posts = postsData.map { postId, post in + let linkedPage = post.linkedPageId.map { pages[$0] } + + let german = LocalizedPost( + title: post.german.title, + content: post.german.content, + lastModified: post.german.lastModifiedDate, + images: post.german.images.compactMap { images[$0] }) + + let english = LocalizedPost( + title: post.english.title, + content: post.english.content, + lastModified: post.english.lastModifiedDate, + images: post.english.images.compactMap { images[$0] }) + + return Post( + id: postId, + isDraft: post.isDraft, + createdDate: post.createdDate, + startDate: post.startDate, + endDate: post.endDate, + tags: post.tags.map { tags[$0]! }, + german: german, + english: english, + linkedPage: linkedPage) + } + + self.tags = tags.values.sorted() + self.pages = pages.values.sorted(ascending: false) { $0.startDate } + self.files = files.sorted { $0.uniqueId } + self.images = images.values.sorted { $0.id } + self.posts = posts.sorted(ascending: false) { $0.startDate } + } + + private func loadPages(_ pagesData: [String : PageFile], tags: [String : Tag]) -> [String : Page] { + pagesData.reduce(into: [:]) { pages, data in + let (pageId, page) = data + let germanPage = LocalizedPage( + urlString: page.german.url, + title: page.german.title, + lastModified: page.german.lastModifiedDate, + originalUrl: page.german.originalURL, + files: page.german.files, + externalFiles: page.german.externalFiles, + requiredFiles: page.german.requiredFiles) + + let englishPage = LocalizedPage( + urlString: page.english.url, + title: page.english.title, + lastModified: page.english.lastModifiedDate, + originalUrl: page.english.originalURL, + files: page.english.files, + externalFiles: page.english.externalFiles, + requiredFiles: page.english.requiredFiles) + + pages[pageId] = Page( + id: pageId, + isDraft: page.isDraft, + createdDate: page.createdDate, + startDate: page.startDate, + endDate: page.endDate, + german: germanPage, + english: englishPage, + tags: page.tags.map { tags[$0]! }) + } } static func accessFolderFromBookmark(key: String, operation: (URL) -> Void) { @@ -100,5 +263,4 @@ final class Content: ObservableObject { print("Failed to access folder: \(folderURL.path)") } } - } diff --git a/CHDataManagement/Model/ContentLanguage.swift b/CHDataManagement/Model/ContentLanguage.swift index 168e9e9..2fbfb93 100644 --- a/CHDataManagement/Model/ContentLanguage.swift +++ b/CHDataManagement/Model/ContentLanguage.swift @@ -6,3 +6,7 @@ enum ContentLanguage: String { case german = "de" } + +extension ContentLanguage: Codable { + +} diff --git a/CHDataManagement/Model/LocalizedPage.swift b/CHDataManagement/Model/LocalizedPage.swift new file mode 100644 index 0000000..970a456 --- /dev/null +++ b/CHDataManagement/Model/LocalizedPage.swift @@ -0,0 +1,72 @@ +import Foundation + +/** + A localized page contains the page content of a single language, + including the title, url path and required resources + + */ +final class LocalizedPage: ObservableObject { + + /** + The string to use when creating the url for the page. + + Defaults to ``id`` if unset. + */ + @Published + var urlString: String + + /** + The headline to use when showing the entry on it's own page + */ + @Published + var title: String + + @Published + var lastModified: Date? + + /** + The url used on the old version of the website. + + Needed to redirect links to their new locations. + */ + let originalUrl: String? + + /** + All files which occur in the content and are stored. + - Note: This property defaults to an empty set. + */ + @Published + var files: Set = [] + + /** + All files which may occur in the content but are stored externally. + + Missing files which would otherwise produce a warning are ignored when included here. + - Note: This property defaults to an empty set. + */ + @Published + var externalFiles: Set = [] + + /** + Specifies additional files which should be copied to the destination when generating the content. + - Note: This property defaults to an empty set. + */ + @Published + var requiredFiles: Set = [] + + init(urlString: String, + title: String, + lastModified: Date? = nil, + originalUrl: String? = nil, + files: Set = [], + externalFiles: Set = [], + requiredFiles: Set = []) { + self.urlString = urlString + self.title = title + self.lastModified = lastModified + self.originalUrl = originalUrl + self.files = files + self.externalFiles = externalFiles + self.requiredFiles = requiredFiles + } +} diff --git a/CHDataManagement/Model/LocalizedPost.swift b/CHDataManagement/Model/LocalizedPost.swift new file mode 100644 index 0000000..5ff149a --- /dev/null +++ b/CHDataManagement/Model/LocalizedPost.swift @@ -0,0 +1,54 @@ +import Foundation +import SwiftUI + +final class LocalizedPost: ObservableObject { + + @Published + var title: String + + @Published + var content: String + + @Published + var lastModified: Date? + + @Published + var images: [ImageResource] + + init(title: String? = nil, + content: String, + lastModified: Date? = nil, + images: [ImageResource] = []) { + self.title = title ?? "" + self.content = content + self.lastModified = lastModified + self.images = images + } + + var displayImages: [Image] { + images.map { $0.imageToDisplay } + } + + @MainActor + func editableTitle() -> Binding { + Binding( + get: { + self.title + }, + set: { newValue in + self.title = newValue + } + ) + } + + func editableContent() -> Binding { + Binding( + get: { + self.content + }, + set: { newValue in + self.content = newValue + } + ) + } +} diff --git a/CHDataManagement/Model/LocalizedTag.swift b/CHDataManagement/Model/LocalizedTag.swift new file mode 100644 index 0000000..3d09c1e --- /dev/null +++ b/CHDataManagement/Model/LocalizedTag.swift @@ -0,0 +1,48 @@ +import Foundation + +final class LocalizedTag: ObservableObject { + + @Published + var urlComponent: String + + /// A custom name, different from the tag id + @Published + var name: String + + @Published + var subtitle: String? + + @Published + var description: String? + + /// The image id of the thumbnail + @Published + var thumbnail: String? + + /// The original url in the previous site layout + let originalUrl: String? + + init(urlComponent: String, + name: String, + subtitle: String? = nil, + description: String? = nil, + thumbnail: String? = nil, + originalUrl: String? = nil) { + self.urlComponent = urlComponent + self.name = name + self.subtitle = subtitle + self.description = description + self.thumbnail = thumbnail + 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 861c530..b04c901 100644 --- a/CHDataManagement/Model/Page.swift +++ b/CHDataManagement/Model/Page.swift @@ -12,23 +12,25 @@ final class Page: ObservableObject { var isDraft: Bool @Published - var metadata: [LocalizedPage] + var createdDate: Date - /** - 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. - */ @Published - var externalFiles: Set = [] + var startDate: Date - /** - Specifies additional files which should be copied to the destination when generating the content. - - Note: This property defaults to an empty set. - */ @Published - var requiredFiles: Set = [] + var hasEndDate: Bool + + @Published + var endDate: Date + + @Published + var german: LocalizedPage + + @Published + var english: LocalizedPage + + @Published + var tags: [Tag] /** Additional images required by the element. @@ -38,36 +40,31 @@ final class Page: ObservableObject { @Published var images: Set = [] - init(id: String, isDraft: Bool, metadata: [LocalizedPage], externalFiles: Set = [], requiredFiles: Set = [], images: Set = []) { + init(id: String, + isDraft: Bool, + createdDate: Date, + startDate: Date, + endDate: Date?, + german: LocalizedPage, + english: LocalizedPage, + tags: [Tag]) { self.id = id self.isDraft = isDraft - self.metadata = metadata - self.externalFiles = externalFiles - self.requiredFiles = requiredFiles - self.images = images + self.createdDate = createdDate + self.startDate = startDate + self.hasEndDate = endDate != nil + self.endDate = endDate ?? startDate + self.german = german + self.english = english + self.tags = tags } func metadata(for language: ContentLanguage) -> LocalizedPage? { - metadata.first { $0.language == language } + switch language { + case .german: return german + case .english: return english + } } - -} - -struct LocalizedPage { - - let language: ContentLanguage - - /** - The string to use when creating the url for the page. - - Defaults to ``id`` if unset. - */ - var urlString: String? - - /** - The headline to use when showing the entry on it's own page - */ - var headline: String } extension Page: Identifiable { diff --git a/CHDataManagement/Model/Post.swift b/CHDataManagement/Model/Post.swift index d701748..c0f962b 100644 --- a/CHDataManagement/Model/Post.swift +++ b/CHDataManagement/Model/Post.swift @@ -1,12 +1,15 @@ -import SwiftUI +import Foundation final class Post: ObservableObject { - let id: Int + let id: String @Published var isDraft: Bool + @Published + var createdDate: Date + @Published var startDate: Date @@ -19,33 +22,42 @@ final class Post: ObservableObject { @Published var tags: [Tag] - let title: LocalizedText + @Published + var german: LocalizedPost - let text: LocalizedText - - var images: [ImageResource] + @Published + var english: LocalizedPost /// The page linked to by this post @Published var linkedPage: Page? - init(id: Int, - isDraft: Bool = false, + init(id: String, + isDraft: Bool, + createdDate: Date, startDate: Date, - endDate: Date? = nil, - title: LocalizedText, - text: LocalizedText, + endDate: Date?, tags: [Tag], - images: [ImageResource]) { + german: LocalizedPost, + english: LocalizedPost, + linkedPage: Page? = nil) { self.id = id self.isDraft = isDraft + self.createdDate = createdDate self.startDate = startDate self.hasEndDate = endDate != nil self.endDate = endDate ?? startDate - self.title = title - self.text = text self.tags = tags - self.images = images + self.german = german + self.english = english + self.linkedPage = linkedPage + } + + func localized(in language: ContentLanguage) -> LocalizedPost { + switch language { + case .english: return english + case .german: return german + } } } @@ -142,8 +154,7 @@ extension Post { } private func paragraphs(in language: ContentLanguage) -> [String] { - text - .getText(for: language) + localized(in: language).content .components(separatedBy: "\n") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { $0 != "" } @@ -154,17 +165,16 @@ extension Post { } func feedEntry(for language: ContentLanguage) -> FeedEntryData { - .init( + let post = localized(in: language) + return .init( entryId: "\(id)", - title: title.getText(for: language), + title: post.title, textAboveTitle: dateText(in: language), link: linkToPageInFeed(for: language), tags: tags.map { $0.data(in: language) }, text: paragraphs(in: language), - images: images.map { $0.feedEntryImage(for: language) }) - } - - var displayImages: [Image] { - images.map { $0.imageToDisplay } + images: post.images.map { + $0.feedEntryImage(for: language) + }) } } diff --git a/CHDataManagement/Model/Tag.swift b/CHDataManagement/Model/Tag.swift index eb21be4..3a0a094 100644 --- a/CHDataManagement/Model/Tag.swift +++ b/CHDataManagement/Model/Tag.swift @@ -3,14 +3,18 @@ import Foundation final class Tag: ObservableObject { var id: String { - name.getText(for: .english).lowercased().replacingOccurrences(of: " ", with: "-") + english.urlComponent } @Published - var name: LocalizedText + var german: LocalizedTag - init(en: String, de: String) { - self.name = .init(en: en, de: de) + @Published + var english: LocalizedTag + + init(german: LocalizedTag, english: LocalizedTag) { + self.german = german + self.english = english } var linkName: String { @@ -24,22 +28,13 @@ final class Tag: ObservableObject { extension Tag { - func getUrl(for language: ContentLanguage) -> String { - "/\(language.rawValue)/tags/\(id).html" - } - func data(in language: ContentLanguage) -> FeedEntryData.Tag { - .init( - name: name.getText(for: language), - url: getUrl(for: language) - ) - } -} - -extension Tag: ExpressibleByStringLiteral { - - convenience init(stringLiteral value: StringLiteralType) { - self.init(en: value.capitalized, de: value.capitalized) + switch language { + case .english: + return english.data() + case .german: + return german.data() + } } } diff --git a/CHDataManagement/Preview Content/Page+Mock.swift b/CHDataManagement/Preview Content/Page+Mock.swift index 09e0cc9..208c681 100644 --- a/CHDataManagement/Preview Content/Page+Mock.swift +++ b/CHDataManagement/Preview Content/Page+Mock.swift @@ -6,13 +6,32 @@ extension Page { .init( id: "my-id", isDraft: true, - metadata: [ - .init(language: .english, headline: "Title"), - .init(language: .german, headline: "Titel") - ], - externalFiles: [], - requiredFiles: [], - images: []) + createdDate: Date(), + startDate: Date().addingTimeInterval(-86400), + endDate: nil, + german: .german, + english: .english, + tags: [.mock]) } } +extension LocalizedPage { + + static let english = LocalizedPage( + urlString: "my-project", + title: "My First Project", + lastModified: nil, + originalUrl: "projects/electronics/my-first-project/en.html", + files: [], + externalFiles: [], + requiredFiles: []) + + static let german = LocalizedPage( + urlString: "mein-projekt", + title: "Mein Erstes Projekt", + lastModified: nil, + originalUrl: "projects/electronics/my-first-project/de.html", + files: [], + externalFiles: [], + requiredFiles: []) +} diff --git a/CHDataManagement/Preview Content/Post+Mock.swift b/CHDataManagement/Preview Content/Post+Mock.swift index 1db9da3..9caeec6 100644 --- a/CHDataManagement/Preview Content/Post+Mock.swift +++ b/CHDataManagement/Preview Content/Post+Mock.swift @@ -2,52 +2,46 @@ extension Post { static var empty: Post { - .init(id: 0, + .init(id: "empty", isDraft: true, + createdDate: .now, startDate: .now, - title: .init(en: "The title", de: "Der Titel"), - text: .init(en: "", de: ""), + endDate: nil, tags: [], - images: []) + german: .init(content: "Text"), + english: .init(content: "Text"), + linkedPage: nil) } static var mock: Post { Post( - id: 1, + id: "mock", isDraft: false, + createdDate: .now, startDate: .now, endDate: nil, - title: .init(en: "The title", de: "Der Titel"), - text: .init( - en: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.", - de: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend." - ), - tags: [ - Tag(en: "Nature", de: "Natur"), - Tag(en: "Sports", de: "Sport"), - Tag(en: "Hiking", de: "Wandern") - ], - images: [] + tags: [.nature, .sports, .hiking], + german: .init(title: "Der Titel", content: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend."), + english: .init(title: "The title", content: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height") ) } static var fullMock: Post { .init( - id: 2, + id: "full", isDraft: true, + createdDate: .now, startDate: .now.addingTimeInterval(-86400), endDate: .now, - title: .init(en: "A longer title", de: "Ein langer Titel"), - text: .init( - en: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.", - de: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend." - ), - tags: [ - Tag(en: "Nature", de: "Natur"), - Tag(en: "Sports", de: "Sport"), - Tag(en: "Hiking", de: "Wandern"), - Tag(en: "Mountains", de: "Berge") - ], - images: MockImage.images + tags: [.nature, .sports, .hiking, .mountains], + german: .init( + title: "Ein langer Titel", + content: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend.", + images: MockImage.images), + english: .init( + title: "A longer title", + content: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.", + images: MockImage.images) + ) } } diff --git a/CHDataManagement/Preview Content/Tag+Mock.swift b/CHDataManagement/Preview Content/Tag+Mock.swift new file mode 100644 index 0000000..f4a392a --- /dev/null +++ b/CHDataManagement/Preview Content/Tag+Mock.swift @@ -0,0 +1,47 @@ +import Foundation + +extension Tag { + + static let mock = Tag( + german: .german, + english: .english) + + static let nature = Tag( + german: .init(urlComponent: "natur", name: "Natur"), + english: .init(urlComponent: "nature", name: "Nature") + ) + + static let sports = Tag( + german: .init(urlComponent: "sport", name: "Sport"), + english: .init(urlComponent: "sports", name: "Sports") + ) + + static let hiking = Tag( + german: .init(urlComponent: "wandern", name: "Wandern"), + english: .init(urlComponent: "hiking", name: "Hiking") + ) + + static let mountains = Tag( + german: .init(urlComponent: "berge", name: "Berge"), + english: .init(urlComponent: "mountains", name: "Mountains") + ) +} + +extension LocalizedTag { + + static let english = LocalizedTag( + urlComponent: "electronics", + name: "Electronics", + subtitle: "Projects with electronics", + description: "Some description of the tag", + thumbnail: "electronic-thumbnail.jpg", + originalUrl: "projects/electronics") + + static let german = LocalizedTag( + urlComponent: "elektronik", + name: "Elektronik", + subtitle: "Projekte mit Elektronik", + description: "Eine Beschreibung des Tags", + thumbnail: "electronic-thumbnail.jpg", + originalUrl: "projects/electronics") +} diff --git a/CHDataManagement/Storage/PageFile.swift b/CHDataManagement/Storage/PageFile.swift new file mode 100644 index 0000000..1ad4dca --- /dev/null +++ b/CHDataManagement/Storage/PageFile.swift @@ -0,0 +1,62 @@ +import Foundation + +struct PageFile { + + let isDraft: Bool + + let tags: [String] + + let createdDate: Date + + let startDate: Date + + let endDate: Date? + + let german: LocalizedPageFile + + let english: LocalizedPageFile +} + +extension PageFile: Codable { + +} + +/** + The structure to store the metadata of a localized page + */ +struct LocalizedPageFile { + + let url: String + + /** + The files (images, videos, other files) used in the page. + */ + let files: Set + + /** + The additional files required for the page to function correctly, but which are not stored with the content. + */ + 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 + + let title: String + + let linkPreviewImage: String? + + let linkPreviewTitle: String? + + let linkPreviewDescription: String? + + let lastModifiedDate: Date? + + let originalURL: String? +} + +extension LocalizedPageFile: Codable { + +} diff --git a/CHDataManagement/Storage/PostFile.swift b/CHDataManagement/Storage/PostFile.swift new file mode 100644 index 0000000..e234436 --- /dev/null +++ b/CHDataManagement/Storage/PostFile.swift @@ -0,0 +1,42 @@ +import Foundation + +struct PostFile { + + let isDraft: Bool + + let createdDate: Date + + let startDate: Date + + let endDate: Date? + + let tags: [String] + + let german: LocalizedPostFile + + let english: LocalizedPostFile + + let linkedPageId: String? +} + +extension PostFile: Codable { + +} + +/** + The structure to store the metadata of a localized post + */ +struct LocalizedPostFile { + + let images: Set + + let title: String? + + let content: String + + let lastModifiedDate: Date? +} + +extension LocalizedPostFile: Codable { + +} diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift new file mode 100644 index 0000000..cb77a54 --- /dev/null +++ b/CHDataManagement/Storage/Storage.swift @@ -0,0 +1,283 @@ +import Foundation + +/** + A class that handles the storage of the website data. + + BaseFolder + - pages: Contains the markdown files of the localized pages, file name is the url + - images: Contains the raw images + - files: Contains additional files + - videos: Contains raw video files + - posts: Contains the markdown files for localized posts, file name is the post id + - + */ +final class Storage { + + private(set) var baseFolder: URL + + private let encoder = JSONEncoder() + + private let decoder = JSONDecoder() + + private let fm = FileManager.default + + /** + Create the storage. + */ + init(baseFolder: URL) { + self.baseFolder = baseFolder + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + // MARK: Helper + + private func subFolder(_ name: String) -> URL { + baseFolder.appending(path: name, directoryHint: .isDirectory) + } + + private func files(in folder: URL) throws -> [URL] { + do { + return try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey]) + .filter { !$0.hasDirectoryPath } + } catch { + print("Failed to get files in folder \(folder.path): \(error)") + throw error + } + } + + private func fileNames(in folder: URL) throws -> [String] { + try fm.contentsOfDirectory(atPath: folder.path()) + .filter { !$0.hasPrefix(".") } + .sorted() + } + + private func files(in folder: URL, type: String) throws -> [URL] { + try files(in: folder).filter { $0.pathExtension == type } + } + + // MARK: Folders + + func update(baseFolder: URL, moveContent: Bool) throws { + let oldFolder = self.baseFolder + self.baseFolder = baseFolder + try createFolderStructure() + guard moveContent else { + return + } + // TODO: Move all files + } + + private func create(folder: URL) throws { + guard !FileManager.default.fileExists(atPath: folder.path) else { + return + } + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + + func createFolderStructure() throws { + try create(folder: pagesFolder) + try create(folder: imagesFolder) + try create(folder: filesFolder) + try create(folder: videosFolder) + try create(folder: postsFolder) + try create(folder: tagsFolder) + } + + // MARK: 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("pages") } + + private func pageFileUrl(pageId: String) -> URL { + pagesFolder.appending(path: pageId, directoryHint: .notDirectory) + } + + private func pageContentUrl(pageId: String, language: ContentLanguage) -> URL { + pagesFolder.appending(path: "\(pageId)-\(language.rawValue).md", directoryHint: .notDirectory) + } + + private func pageMetadataUrl(pageId: String) -> URL { + pagesFolder.appending(path: pageId + ".json", 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) + } + + @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 loadAllPages() throws -> [String : PageFile] { + try loadAll(in: pagesFolder) + } + + // MARK: Posts + + /// The folder path where the markdown files of the posts are stored (by their unique id/url component) + private var postsFolder: URL { subFolder("posts") } + + private func postFileUrl(postId: String) -> URL { + postsFolder.appending(path: postId, directoryHint: .notDirectory).appendingPathExtension("json") + } + + @discardableResult + func save(post: PostFile, for postId: String) -> Bool { + let contentUrl = postFileUrl(postId: postId) + return write(post, type: "post", id: postId, to: contentUrl) + } + + func loadAllPosts() throws -> [String : PostFile] { + try loadAll(in: postsFolder) + } + + private func post(at url: URL) throws -> PostFile { + try read(at: url) + } + + private func postContent(for postId: String) throws -> PostFile { + let url = postFileUrl(postId: postId) + return try post(at: url) + } + + // MARK: Tags + + /// The folder path where the source images are stored (by their unique name) + private var tagsFolder: URL { subFolder("tags") } + + private func tagFileUrl(tagId: String) -> URL { + tagsFolder.appending(path: tagId, directoryHint: .notDirectory) + } + + private func tagMetadataUrl(tagId: String) -> URL { + tagFileUrl(tagId: tagId).appendingPathExtension("json") + } + + @discardableResult + func save(tagMetadata: TagFile, for tagId: String) -> Bool { + let contentUrl = tagMetadataUrl(tagId: tagId) + return write(tagMetadata, type: "tag", id: tagId, to: contentUrl) + } + + func loadAllTags() throws -> [String : TagFile] { + try loadAll(in: tagsFolder) + } + + // MARK: Images + + /// The folder path where the source images are stored (by their unique name) + private var imagesFolder: URL { subFolder("images") } + + private func imageUrl(image: String) -> URL { + imagesFolder.appending(path: image, directoryHint: .notDirectory) + } + + @discardableResult + func copyImage(at url: URL, imageId: String) -> Bool { + let contentUrl = imageUrl(image: imageId) + return copy(file: url, to: contentUrl, type: "image", id: imageId) + } + + // MARK: Files + + /// The folder path where other files are stored (by their unique name) + private var filesFolder: URL { subFolder("files") } + + private func fileUrl(file: String) -> URL { + filesFolder.appending(path: file, directoryHint: .notDirectory) + } + + @discardableResult + func copyFile(at url: URL, fileId: String) -> Bool { + let contentUrl = fileUrl(file: fileId) + return copy(file: url, to: contentUrl, type: "file", id: fileId) + } + + func loadAllFiles() throws -> [String : URL] { + try files(in: filesFolder).reduce(into: [:]) { files, url in + files[url.lastPathComponent] = url + } + } + + // MARK: Videos + + /// The folder path where source videos are stored (by their unique name) + private var videosFolder: URL { subFolder("videos") } + + private func videoUrl(video: String) -> URL { + videosFolder.appending(path: video, directoryHint: .notDirectory) + } + + @discardableResult + func copyVideo(at url: URL, videoId: String) -> Bool { + let contentUrl = videoUrl(video: videoId) + return copy(file: url, to: contentUrl, type: "video", id: videoId) + } + + func loadAllVideos() throws -> [String] { + try fileNames(in: videosFolder) + } + + // MARK: Writing files + + 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 + } + do { + try content.write(to: file, options: .atomic) + 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 { + do { + try content.write(to: file, atomically: true, encoding: .utf8) + return true + } catch { + print("Failed to save content for \(type) '\(id)': \(error)") + return false + } + } + + 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 + } + } + +} diff --git a/CHDataManagement/Storage/TagFile.swift b/CHDataManagement/Storage/TagFile.swift new file mode 100644 index 0000000..bb64570 --- /dev/null +++ b/CHDataManagement/Storage/TagFile.swift @@ -0,0 +1,39 @@ +import Foundation + +struct TagFile { + + let id: String + + let german: LocalizedTagFile + + let english: LocalizedTagFile + +} + +extension TagFile: Codable { + +} + +struct LocalizedTagFile { + + /// The id of the tag, used also as a url component + let urlComponent: String + + /// A custom name, different from the tag id + let name: String + + let subtitle: String? + + let description: String? + + /// The image id of the thumbnail + let thumbnail: String? + + /// The original url in the previous site layout + let originalURL: String? + +} + +extension LocalizedTagFile: Codable { + +} diff --git a/CHDataManagement/Views/Pages/PageDetailView.swift b/CHDataManagement/Views/Pages/PageDetailView.swift index 0fce300..db8b776 100644 --- a/CHDataManagement/Views/Pages/PageDetailView.swift +++ b/CHDataManagement/Views/Pages/PageDetailView.swift @@ -8,7 +8,7 @@ struct PageDetailView: View { var language: ContentLanguage var body: some View { - Text(page.metadata(for: language)?.headline ?? "No headline") + Text(page.metadata(for: language)?.title ?? "No headline") } } diff --git a/CHDataManagement/Views/Posts/PostList.swift b/CHDataManagement/Views/Posts/PostList.swift index 8196cb0..82c3f9a 100644 --- a/CHDataManagement/Views/Posts/PostList.swift +++ b/CHDataManagement/Views/Posts/PostList.swift @@ -55,17 +55,15 @@ struct PostList: View { } private func addNewPost() { - let largestId = posts.map { $0.id }.max() ?? 0 - let post = Post( - id: largestId + 1, + id: "new", isDraft: true, + createdDate: .now, startDate: .now, endDate: nil, - title: .init(en: "Title", de: "Titel"), - text: .init(en: "Text", de: "Text"), tags: [], - images: []) + german: .init(title: "Titel", content: "Text"), + english: .init(title: "Title", content: "Text")) posts.insert(post, at: 0) } } diff --git a/CHDataManagement/Views/Posts/PostView.swift b/CHDataManagement/Views/Posts/PostView.swift index cf5b462..ce7b937 100644 --- a/CHDataManagement/Views/Posts/PostView.swift +++ b/CHDataManagement/Views/Posts/PostView.swift @@ -13,8 +13,8 @@ struct PostView: View { var body: some View { VStack(alignment: .center) { - if !post.images.isEmpty { - PostImageGalleryView(images: post.displayImages) + if !post.localized(in: language).images.isEmpty { + PostImageGalleryView(images: post.localized(in: language).displayImages) .aspectRatio(1.33, contentMode: .fill) } VStack(alignment: .leading) { @@ -26,17 +26,20 @@ struct PostView: View { Toggle("Draft", isOn: $post.isDraft) } .foregroundStyle(Color(r: 96, g: 186, b: 255)) - TextField("", text: post.title.text(for: language)) + TextField("", text: post.localized(in: language).editableTitle()) .font(.system(size: 24, weight: .bold)) .foregroundStyle(Color.white) .textFieldStyle(.plain) .lineLimit(2) FlowHStack { ForEach(post.tags, id: \.id) { tag in - TagView(tag: tag.name) - .onTapGesture { - remove(tag: tag) - } + TagView(tag: .init( + en: tag.english.name, + de: tag.german.name) + ) + .onTapGesture { + remove(tag: tag) + } } Button(action: showTagList) { SwiftUI.Image(systemSymbol: .plusCircleFill) @@ -49,7 +52,7 @@ struct PostView: View { } .buttonStyle(.plain) } - TextEditor(text: post.text.text(for: language)) + TextEditor(text: post.localized(in: language).editableContent()) .font(.body) .foregroundStyle(Color(r: 221, g: 221, b: 221)) .textEditorStyle(.plain) diff --git a/CHDataManagement/Views/Settings/SettingsView.swift b/CHDataManagement/Views/Settings/SettingsView.swift index daefa0b..4815ad3 100644 --- a/CHDataManagement/Views/Settings/SettingsView.swift +++ b/CHDataManagement/Views/Settings/SettingsView.swift @@ -52,13 +52,19 @@ struct SettingsView: View { private func selectContentFolder() { isSelectingContentFolder = true //showFileImporter = true - savePanelUsingOpenPanel(key: "contentPathBookmark") + guard let url = savePanelUsingOpenPanel(key: "contentPathBookmark") else { + return + } + self.contentPath = url.path() } private func selectOutputFolder() { isSelectingContentFolder = false //showFileImporter = true - savePanelUsingOpenPanel(key: "outputPathBookmark") + guard let url = savePanelUsingOpenPanel(key: "outputPathBookmark") else { + return + } + self.outputPath = url.path() } private func didSelectContentFolder(_ result: Result) { @@ -99,7 +105,7 @@ struct SettingsView: View { content.generateFeed(for: language, bookmarkKey: "outputPathBookmark") } - func savePanelUsingOpenPanel(key: String) { + func savePanelUsingOpenPanel(key: String) -> URL? { let panel = NSOpenPanel() // Sets up so user can only select a single directory panel.canChooseFiles = false @@ -112,12 +118,13 @@ struct SettingsView: View { let response = panel.runModal() guard response == .OK else { - return + return nil } guard let url = panel.url else { - return + return nil } saveSecurityScopedBookmark(url, key: key) + return url } func saveSecurityScopedBookmark(_ url: URL, key: String) {