diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 6a2f796..05a598c 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -44,6 +44,11 @@ E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903B2D0F8A74009F8D77 /* OptionalTextFieldPropertyView.swift */; }; E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903D2D0F8EFD009F8D77 /* StringPropertyView.swift */; }; E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */; }; + E22990422D107A95009F8D77 /* ImageJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990412D107A94009F8D77 /* ImageJob.swift */; }; + E22990462D10B7A7009F8D77 /* SecurityScopeStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990452D10B7A6009F8D77 /* SecurityScopeStatus.swift */; }; + E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990472D10B7B7009F8D77 /* StorageAccessError.swift */; }; + E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */; }; + E229904C2D10BE5D009F8D77 /* InitialSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229904B2D10BE59009F8D77 /* InitialSetupView.swift */; }; E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = E24252002C50E0A40029FF16 /* HighlightedTextEditor */; }; E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252092C52C9260029FF16 /* ContentLanguage.swift */; }; E2581DED2C75202400F1F079 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEC2C75202400F1F079 /* Tag.swift */; }; @@ -232,6 +237,11 @@ E229903B2D0F8A74009F8D77 /* OptionalTextFieldPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextFieldPropertyView.swift; sourceTree = ""; }; E229903D2D0F8EFD009F8D77 /* StringPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringPropertyView.swift; sourceTree = ""; }; E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderOnDiskPropertyView.swift; sourceTree = ""; }; + E22990412D107A94009F8D77 /* ImageJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageJob.swift; sourceTree = ""; }; + E22990452D10B7A6009F8D77 /* SecurityScopeStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityScopeStatus.swift; sourceTree = ""; }; + E22990472D10B7B7009F8D77 /* StorageAccessError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageAccessError.swift; sourceTree = ""; }; + E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityScopeBookmark.swift; sourceTree = ""; }; + E229904B2D10BE59009F8D77 /* InitialSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialSetupView.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 = ""; }; E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = ""; }; @@ -454,6 +464,7 @@ E29D318F2D0B34870051B7F4 /* PageContentAnomaly.swift */, E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */, E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */, + E22990412D107A94009F8D77 /* ImageJob.swift */, E218502E2CFAF6990090B18B /* LocalizedWebsiteGenerator.swift */, E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */, E29D316C2D07A4FF0051B7F4 /* PageGenerationResults.swift */, @@ -500,6 +511,7 @@ E29D31372D043EB80051B7F4 /* Main */ = { isa = PBXGroup; children = ( + E229904B2D10BE59009F8D77 /* InitialSetupView.swift */, E29D31422D0488950051B7F4 /* MainContentView.swift */, E2DD04732C276F31003BFF1F /* MainView.swift */, E29D31442D0488CB0051B7F4 /* SelectedContentView.swift */, @@ -638,6 +650,9 @@ E2A37D0F2CE5375E0000979F /* Storage */ = { isa = PBXGroup; children = ( + E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */, + E22990472D10B7B7009F8D77 /* StorageAccessError.swift */, + E22990452D10B7A6009F8D77 /* SecurityScopeStatus.swift */, E25DA5112CFF001900AEF16D /* Model */, E2A37D0D2CE527040000979F /* Storage.swift */, ); @@ -897,6 +912,7 @@ E22990282D0F596C009F8D77 /* IntegerPropertyView.swift in Sources */, E25DA5772D018B9900AEF16D /* File+Mock.swift in Sources */, E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */, + E229904C2D10BE5D009F8D77 /* InitialSetupView.swift in Sources */, E218502B2CF790B30090B18B /* PostContentView.swift in Sources */, E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */, E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */, @@ -923,6 +939,7 @@ E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */, E29D31852D0AE8EE0051B7F4 /* KnownHeaderElement.swift in Sources */, E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */, + E22990422D107A95009F8D77 /* ImageJob.swift in Sources */, E29D317F2D086F4C0051B7F4 /* StatisticsIcons.swift in Sources */, E2A21C082CB17B870060935B /* TagView.swift in Sources */, E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */, @@ -933,6 +950,7 @@ E29D31942D0B7D280051B7F4 /* SvgImage.swift in Sources */, E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */, E25DA5832D01C7A400AEF16D /* VideoFileType.swift in Sources */, + E22990462D10B7A7009F8D77 /* SecurityScopeStatus.swift in Sources */, E29D31512D06168E0051B7F4 /* PostListView.swift in Sources */, E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */, E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */, @@ -1005,6 +1023,7 @@ E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */, E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */, E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */, + E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */, E29D314B2D04FC950051B7F4 /* FileToAdd.swift in Sources */, E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */, E29D31B52D0DA8490051B7F4 /* PageIcon.swift in Sources */, @@ -1034,6 +1053,7 @@ E25DA5292CFFBFBB00AEF16D /* ImageFileType.swift in Sources */, E22990192D0E3546009F8D77 /* ItemType.swift in Sources */, E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */, + E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */, E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */, E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */, E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */, diff --git a/CHDataManagement/Generator/ImageGenerator.swift b/CHDataManagement/Generator/ImageGenerator.swift index 107718e..b7479c4 100644 --- a/CHDataManagement/Generator/ImageGenerator.swift +++ b/CHDataManagement/Generator/ImageGenerator.swift @@ -3,36 +3,23 @@ import AppKit import SDWebImageAVIFCoder import SDWebImageWebPCoder -private struct ImageJob { - - let image: String - - let version: String - - let maximumWidth: CGFloat - - let maximumHeight: CGFloat - - let quality: CGFloat - - let type: ImageFileType -} - final class ImageGenerator { private let storage: Storage - //private let inputImageFolder: URL + private let settings: Settings - private let relativeImageOutputPath: String + private var relativeImageOutputPath: String { + settings.paths.imagesOutputFolderPath + } private var generatedImages: [String : [String]] = [:] - private var jobs: [ImageJob] = [] + private var jobs: [ImageGenerationJob] = [] - init(storage: Storage, relativeImageOutputPath: String) { + init(storage: Storage, settings: Settings) { self.storage = storage - self.relativeImageOutputPath = relativeImageOutputPath + self.settings = settings do { self.generatedImages = try storage.loadListOfGeneratedImages() } catch { @@ -89,29 +76,28 @@ final class ImageGenerator { let width2x = maxWidth * 2 let height2x = maxHeight * 2 - _ = generateVersion(for: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight) - _ = generateVersion(for: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x) + generateVersion(for: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight) + generateVersion(for: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x) - _ = generateVersion(for: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight) - _ = generateVersion(for: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x) + generateVersion(for: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight) + generateVersion(for: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x) - _ = generateVersion(for: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight) - _ = generateVersion(for: image, type: type, maximumWidth: width2x, maximumHeight: height2x) + generateVersion(for: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight) + generateVersion(for: image, type: type, maximumWidth: width2x, maximumHeight: height2x) } - func generateVersion(for image: String, type: ImageFileType, maximumWidth: CGFloat, maximumHeight: CGFloat) -> String { + func generateVersion(for image: String, type: ImageFileType, maximumWidth: CGFloat, maximumHeight: CGFloat) { let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight) - let fullPath = "/" + relativeImageOutputPath + "/" + version if exists(version) { hasNowGenerated(version: version, for: image) - return fullPath + return } if hasPreviouslyGenerated(version: version, for: image), exists(version) { // Don't add job again - return fullPath + return } - let job = ImageJob( + let job = ImageGenerationJob( image: image, version: version, maximumWidth: maximumWidth, @@ -120,7 +106,6 @@ final class ImageGenerator { type: type) jobs.append(job) - return fullPath } private func hasPreviouslyGenerated(version: String, for image: String) -> Bool { @@ -149,7 +134,7 @@ final class ImageGenerator { // MARK: Image operations - private func generate(job: ImageJob) -> Bool { + private func generate(job: ImageGenerationJob) -> Bool { if hasPreviouslyGenerated(version: job.version, for: job.image), exists(job.version), exists(imageVersion: job.version) { return true @@ -168,58 +153,70 @@ final class ImageGenerator { return false } + let representation = create(image: originalImage, width: job.maximumWidth, height: job.maximumHeight) + + guard let data = create(image: representation, type: job.type, quality: job.quality) else { + print("Failed to get data for type \(job.type)") + return false + } + + if job.type == .avif { + return inOutputImagesFolder { folder in + let url = folder.appendingPathComponent(job.version) + let out = url.path() + let input = url.deletingPathExtension().appendingPathExtension(job.image.fileExtension!).path() + print("avifenc -q 70 \(input) \(out)") + hasNowGenerated(version: job.version, for: job.image) + return true + } + } + + guard write(imageData: data, version: job.version) else { + return false + } + hasNowGenerated(version: job.version, for: job.image) + return true + } + + private func create(image originalImage: NSImage, width: CGFloat, height: CGFloat) -> NSBitmapImageRep { let sourceRep = originalImage.representations[0] let sourceSize = NSSize(width: sourceRep.pixelsWide, height: sourceRep.pixelsHigh) - let maximumSize = NSSize(width: job.maximumWidth, height: job.maximumHeight) + let maximumSize = NSSize(width: width, height: height) let destinationSize = sourceSize.scaledToFit(in: maximumSize) // create NSBitmapRep manually, if using cgImage, the resulting size is wrong - let rep = NSBitmapImageRep(bitmapDataPlanes: nil, - pixelsWide: Int(destinationSize.width), - pixelsHigh: Int(destinationSize.height), - bitsPerSample: 8, - samplesPerPixel: 4, - hasAlpha: true, - isPlanar: false, - colorSpaceName: NSColorSpaceName.deviceRGB, - bytesPerRow: Int(destinationSize.width) * 4, - bitsPerPixel: 32)! + let representation = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(destinationSize.width), + pixelsHigh: Int(destinationSize.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: NSColorSpaceName.deviceRGB, + bytesPerRow: Int(destinationSize.width) * 4, + bitsPerPixel: 32)! - let ctx = NSGraphicsContext(bitmapImageRep: rep) + let ctx = NSGraphicsContext(bitmapImageRep: representation) NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = ctx originalImage.draw(in: NSMakeRect(0, 0, destinationSize.width, destinationSize.height)) ctx?.flushGraphics() NSGraphicsContext.restoreGraphicsState() + return representation + } - guard let data = create(image: rep, type: job.type, quality: job.quality) else { - print("Failed to get data for type \(job.type)") - return false - } - - - - let result = inOutputImagesFolder { folder in - let url = folder.appendingPathComponent(job.version) - if job.type == .avif { - let out = url.path() - let input = url.deletingPathExtension().appendingPathExtension(job.image.fileExtension!).path() - print("avifenc -q 70 \(input) \(out)") - return true - } + private func write(imageData data: Data, version: String) -> Bool { + inOutputImagesFolder { folder in + let url = folder.appendingPathComponent(version) do { try data.write(to: url) return true } catch { - print("Failed to write image \(job.version): \(error)") + print("Failed to write image \(version): \(error)") return false } } - guard result else { - return false - } - hasNowGenerated(version: job.version, for: job.image) - return true } private func exists(_ relativePath: String) -> Bool { @@ -258,9 +255,9 @@ final class ImageGenerator { private func createAvif(image: NSBitmapImageRep, quality: CGFloat) -> Data? { return Data() - let newImage = NSImage(size: image.size) - newImage.addRepresentation(image) - return SDImageAVIFCoder.shared.encodedData(with: newImage, format: .AVIF, options: [.encodeCompressionQuality: quality]) +// let newImage = NSImage(size: image.size) +// newImage.addRepresentation(image) +// return SDImageAVIFCoder.shared.encodedData(with: newImage, format: .AVIF, options: [.encodeCompressionQuality: quality]) } private func createWebp(image: NSBitmapImageRep, quality: CGFloat) -> Data? { diff --git a/CHDataManagement/Generator/ImageJob.swift b/CHDataManagement/Generator/ImageJob.swift new file mode 100644 index 0000000..1a12816 --- /dev/null +++ b/CHDataManagement/Generator/ImageJob.swift @@ -0,0 +1,16 @@ +import Foundation + +struct ImageGenerationJob { + + let image: String + + let version: String + + let maximumWidth: CGFloat + + let maximumHeight: CGFloat + + let quality: CGFloat + + let type: ImageFileType +} diff --git a/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift b/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift index 631d2ea..efe6532 100644 --- a/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift +++ b/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift @@ -16,7 +16,7 @@ final class LocalizedWebsiteGenerator { self.localizedPostSettings = content.settings.localized(in: language) self.imageGenerator = ImageGenerator( storage: content.storage, - relativeImageOutputPath: content.settings.paths.imagesOutputFolderPath) + settings: content.settings) } private var outputDirectory: URL { @@ -85,56 +85,6 @@ final class LocalizedWebsiteGenerator { return true } - private func generatePagesFolderIfNeeded() -> Bool { - let relativePath = content.settings.paths.pagesOutputFolderPath - - return content.storage.write(in: .outputPath) { folder in - let outputFile = folder.appendingPathComponent(relativePath, isDirectory: true) - do { - try outputFile.ensureFolderExistence() - return true - } catch { - return false - } - } - } - - func generate(page: Page) -> Bool { - guard generatePagesFolderIfNeeded() else { - print("Failed to generate output folder") - return false - } - let pageGenerator = PageGenerator( - content: content, - imageGenerator: imageGenerator) - - let content: String - let results: PageGenerationResults - do { - (content, results) = try pageGenerator.generate(page: page, language: language) - } catch { - print("Failed to generate page \(page.id) in language \(language): \(error)") - return false - } - guard !content.trimmed.isEmpty else { - #warning("Generate page with placeholder content") - return true - } - - let path = page.absoluteUrl(in: language) + ".html" - guard save(content, to: path) else { - print("Failed to save page") - return false - } - guard imageGenerator.runJobs(callback: { _ in }) else { - return false - } - guard copy(requiredFiles: results.files) else { - return false - } - return true - } - private func copy(requiredFiles: Set) -> Bool { //print("Copying \(requiredVideoFiles.count) files...") for file in requiredFiles { diff --git a/CHDataManagement/Main/InitialSetupView.swift b/CHDataManagement/Main/InitialSetupView.swift new file mode 100644 index 0000000..3c743b9 --- /dev/null +++ b/CHDataManagement/Main/InitialSetupView.swift @@ -0,0 +1,8 @@ +import SwiftUI + +struct InitialSetupView: View { + + var body: some View { + /*@START_MENU_TOKEN@*//*@PLACEHOLDER=Hello, world!@*/Text("Hello, world!")/*@END_MENU_TOKEN@*/ + } +} diff --git a/CHDataManagement/Main/MainView.swift b/CHDataManagement/Main/MainView.swift index 758ce13..1fbca46 100644 --- a/CHDataManagement/Main/MainView.swift +++ b/CHDataManagement/Main/MainView.swift @@ -55,6 +55,9 @@ struct MainView: App { @State private var showAddSheet = false + @State + private var showInitialSetupSheet = false + @ViewBuilder var sidebar: some View { switch selectedTab { @@ -159,8 +162,18 @@ struct MainView: App { }.pickerStyle(.segmented) } ToolbarItem(placement: .primaryAction) { - Button(action: save) { - Text("Save") + if content.storageIsInitialized { + Button(action: save) { + Text("Save") + } + } else { + Button { + selectedSection = .folders + selectedTab = .generation + } label: { + Text("Setup") + } + .foregroundColor(.red) } } } @@ -176,11 +189,15 @@ struct MainView: App { .environment(\.language, language) .environmentObject(content) } + .sheet(isPresented: $showInitialSetupSheet) { + InitialSetupView() + .environment(\.language, language) + .environmentObject(content) + } } } private func save() { - // Save all changed files do { try content.saveToDisk() } catch { @@ -189,6 +206,12 @@ struct MainView: App { } private func loadContent() { + guard content.storageIsInitialized else { + DispatchQueue.main.async { + self.showInitialSetupSheet = true + } + return + } do { try content.loadFromDisk() } catch { diff --git a/CHDataManagement/Model/Content+Generation.swift b/CHDataManagement/Model/Content+Generation.swift index dc20a64..dfc11e9 100644 --- a/CHDataManagement/Model/Content+Generation.swift +++ b/CHDataManagement/Model/Content+Generation.swift @@ -20,41 +20,12 @@ extension Content { return generateInternal(page, in: language) } - private func startGenerating() -> Bool { - guard !isGeneratingWebsite else { - return false - } - // TODO: Fix bug where multiple generating operations can be started - // due to dispatch of locking property on main queue - DispatchQueue.main.async { - self.isGeneratingWebsite = true - } - return true - } - - private func endGenerating() { - DispatchQueue.main.async { - self.isGeneratingWebsite = false - } - } - - private func generateInternal(_ page: Page, in language: ContentLanguage) -> Bool { - let generator = LocalizedWebsiteGenerator( - content: self, - language: language) - if !generator.generate(page: page) { - print("Generation failed") - return false - } - return true - } + // MARK: Paths to items private func makeCleanAbsolutePath(_ path: String) -> String { ("/" + path).replacingOccurrences(of: "//", with: "/") } - // MARK: Paths to items - func absoluteUrlPrefixForTag(_ tag: Tag, language: ContentLanguage) -> String { makeCleanAbsolutePath(settings.paths.tagsOutputFolderPath + "/" + tag.localized(in: language).urlComponent) } @@ -101,4 +72,86 @@ extension Content { } return result } + + // MARK: Generation + + private func startGenerating() -> Bool { + guard !isGeneratingWebsite else { + return false + } + // TODO: Fix bug where multiple generating operations can be started + // due to dispatch of locking property on main queue + DispatchQueue.main.async { + self.isGeneratingWebsite = true + } + return true + } + + private func endGenerating() { + DispatchQueue.main.async { + self.isGeneratingWebsite = false + } + } + + private func generateInternal(_ page: Page, in language: ContentLanguage) -> Bool { + let pagesFolder = settings.paths.pagesOutputFolderPath + guard storage.create(folder: pagesFolder, in: .outputPath) else { + print("Failed to generate output folder") + return false + } + let imageGenerator = ImageGenerator( + storage: storage, + settings: settings) + + let pageGenerator = PageGenerator( + content: self, + imageGenerator: imageGenerator) + + let content: String + let results: PageGenerationResults + do { + (content, results) = try pageGenerator.generate(page: page, language: language) + } catch { + print("Failed to generate page \(page.id) in language \(language): \(error)") + return false + } + guard !content.trimmed.isEmpty else { + #warning("Generate page with placeholder content") + return true + } + + let path = page.absoluteUrl(in: language) + ".html" + do { + try storage.write(content: content, to: path) + } catch { + print("Failed to save page \(page.id): \(error)") + return false + } + guard imageGenerator.runJobs(callback: { _ in }) else { + return false + } + guard copy(requiredFiles: results.files) else { + return false + } + return true + return true + } + + private func copy(requiredFiles: Set) -> Bool { + //print("Copying \(requiredVideoFiles.count) files...") + for file in requiredFiles { + guard !file.isExternallyStored else { + continue + } + + do { + try storage.copy(file: file.id, to: file.absoluteUrl) + } catch { + print("Failed to copy file \(file.id): \(error)") + return false + } + } + return true + } + } diff --git a/CHDataManagement/Model/Content+Load.swift b/CHDataManagement/Model/Content+Load.swift index d26b048..717219f 100644 --- a/CHDataManagement/Model/Content+Load.swift +++ b/CHDataManagement/Model/Content+Load.swift @@ -41,7 +41,10 @@ extension Content { } func loadFromDisk() throws { - let storage = Storage(baseFolder: URL(filePath: contentPath)) + guard storageIsInitialized else { + print("Storage not initialized, not loading content") + return + } let settings = try storage.loadSettings() let imageDescriptions = try storage.loadFileDescriptions().reduce(into: [:]) { descriptions, description in diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index 283ce9e..aa24a66 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -3,6 +3,10 @@ import Foundation extension Content { func saveToDisk() throws { + guard storageIsInitialized else { + print("Storage not initialized, not saving content") + return + } //print("Starting save") for page in pages { try storage.save(pageMetadata: page.pageFile, for: page.id) diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index 6c71852..24f8602 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -4,6 +4,11 @@ import Combine final class Content: ObservableObject { + let storage = Storage() + + @Published + var storageIsInitialized = false + @Published var settings: Settings @@ -28,48 +33,23 @@ final class Content: ObservableObject { @Published var isGeneratingWebsite = false - @AppStorage("contentPath") - private var storedContentPath: String = "" - - @Published - var contentPath: String = "" { - didSet { - storedContentPath = contentPath - } - } - - let storage: Storage - - private var cancellables = Set() - init(settings: Settings, posts: [Post], pages: [Page], tags: [Tag], files: [FileResource], - tagOverview: TagOverviewPage?, - storedContentPath: String) { + tagOverview: TagOverviewPage?) { self.settings = settings self.posts = posts self.pages = pages self.tags = tags self.files = files self.tagOverview = tagOverview - self.storedContentPath = storedContentPath - self.contentPath = storedContentPath - self.storage = Storage(baseFolder: URL(filePath: storedContentPath)) - do { - try storage.createFolderStructure() - } catch { - print(error) - return - } - observeContentPath() + + initialize() } init() { - self.storage = Storage(baseFolder: URL(filePath: "")) - self.settings = .mock self.posts = [] self.pages = [] @@ -77,29 +57,24 @@ final class Content: ObservableObject { self.files = [] self.tagOverview = nil - contentPath = storedContentPath - do { - try storage.createFolderStructure() - } catch { - print(error) + initialize() + } + + private func initialize() { + guard storage.check(contentPath: settings.paths.contentDirectoryPath) == .nominal else { + storageIsInitialized = false return } - try? storage.update(baseFolder: URL(filePath: contentPath)) - observeContentPath() - } + storage.check(outputPath: settings.paths.outputDirectoryPath) - private func observeContentPath() { - $contentPath.sink { newValue in - let url = URL(filePath: newValue) - do { - try self.storage.update(baseFolder: url) - try self.loadFromDisk() - } catch { - print("Failed to switch content path: \(error)") - } + do { + try storage.createFolderStructure() + storageIsInitialized = true + } catch { + print("Failed to initialize storage: \(error)") + storageIsInitialized = false } - .store(in: &cancellables) } var images: [FileResource] { diff --git a/CHDataManagement/Model/Settings/PathSettings.swift b/CHDataManagement/Model/Settings/PathSettings.swift index e8081a1..5452e94 100644 --- a/CHDataManagement/Model/Settings/PathSettings.swift +++ b/CHDataManagement/Model/Settings/PathSettings.swift @@ -2,6 +2,9 @@ import Foundation final class PathSettings: ObservableObject { + @Published + var contentDirectoryPath: String + @Published var outputDirectoryPath: String @@ -24,6 +27,7 @@ final class PathSettings: ObservableObject { var tagsOutputFolderPath: String init(file: PathSettingsFile) { + self.contentDirectoryPath = file.contentDirectoryPath self.assetsOutputFolderPath = file.assetsOutputFolderPath self.outputDirectoryPath = file.outputDirectoryPath self.pagesOutputFolderPath = file.pagesOutputFolderPath @@ -34,7 +38,8 @@ final class PathSettings: ObservableObject { } var file: PathSettingsFile { - .init(outputDirectoryPath: outputDirectoryPath, + .init(contentDirectoryPath: contentDirectoryPath, + outputDirectoryPath: outputDirectoryPath, assetsOutputFolderPath: assetsOutputFolderPath, pagesOutputFolderPath: pagesOutputFolderPath, imagesOutputFolderPath: imagesOutputFolderPath, diff --git a/CHDataManagement/Preview Content/Content+Mock.swift b/CHDataManagement/Preview Content/Content+Mock.swift index b65cd70..b850a83 100644 --- a/CHDataManagement/Preview Content/Content+Mock.swift +++ b/CHDataManagement/Preview Content/Content+Mock.swift @@ -20,6 +20,5 @@ extension Content { pages: [.empty], tags: [.hiking, .mountains, .nature, .sports], files: [], - tagOverview: nil, - storedContentPath: dbPath) + tagOverview: nil) } diff --git a/CHDataManagement/Storage/Model/Settings/PathSettingsFile.swift b/CHDataManagement/Storage/Model/Settings/PathSettingsFile.swift index fc052a4..63a0dde 100644 --- a/CHDataManagement/Storage/Model/Settings/PathSettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/PathSettingsFile.swift @@ -1,6 +1,8 @@ struct PathSettingsFile { + let contentDirectoryPath: String + let outputDirectoryPath: String let assetsOutputFolderPath: String @@ -15,13 +17,15 @@ struct PathSettingsFile { let tagsOutputFolderPath: String - init(outputDirectoryPath: String, + init(contentDirectoryPath: String, + outputDirectoryPath: String, assetsOutputFolderPath: String, pagesOutputFolderPath: String, imagesOutputFolderPath: String, filesOutputFolderPath: String, videosOutputFolderPath: String, tagsOutputFolderPath: String) { + self.contentDirectoryPath = contentDirectoryPath self.outputDirectoryPath = outputDirectoryPath self.assetsOutputFolderPath = assetsOutputFolderPath self.pagesOutputFolderPath = pagesOutputFolderPath @@ -40,6 +44,7 @@ extension PathSettingsFile { static var `default`: PathSettingsFile { PathSettingsFile( + contentDirectoryPath: "", outputDirectoryPath: "build", assetsOutputFolderPath: "asset", pagesOutputFolderPath: "page", diff --git a/CHDataManagement/Storage/SecurityScopeBookmark.swift b/CHDataManagement/Storage/SecurityScopeBookmark.swift new file mode 100644 index 0000000..23148a7 --- /dev/null +++ b/CHDataManagement/Storage/SecurityScopeBookmark.swift @@ -0,0 +1,7 @@ + +enum SecurityScopeBookmark: String { + + case outputPath = "outputPathBookmark" + + case contentPath = "contentPathBookmark" +} diff --git a/CHDataManagement/Storage/SecurityScopeStatus.swift b/CHDataManagement/Storage/SecurityScopeStatus.swift new file mode 100644 index 0000000..112d860 --- /dev/null +++ b/CHDataManagement/Storage/SecurityScopeStatus.swift @@ -0,0 +1,15 @@ + +enum SecurityScopeStatus { + + case noPath + + case urlMismatch + + case noBookmark + + case bookmarkCorrupted + + case stale + + case nominal +} diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index cb048a5..cfc3ab3 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -1,44 +1,5 @@ import Foundation -enum SecurityScopeBookmark: String { - - case outputPath = "outputPathBookmark" - - case contentPath = "contentPathBookmark" -} - -enum StorageAccessError: Error { - - case noBookmarkData - - case bookmarkDataCorrupted(Error) - - case folderAccessFailed(URL) - - case stringConversionFailed - - case fileNotFound(String) - -} - -extension StorageAccessError: CustomStringConvertible { - - var description: String { - switch self { - case .noBookmarkData: - return "No bookmark data to access resources in folder" - case .bookmarkDataCorrupted(let error): - 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)" - } - } -} - /** A class that handles the storage of the website data. @@ -48,11 +9,10 @@ extension StorageAccessError: CustomStringConvertible { - 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 + - Note: The base folder and output folder are stored as security-scoped bookmarks in user defaults. + */ +final class Storage: ObservableObject { private let encoder = JSONEncoder() @@ -60,20 +20,21 @@ final class Storage { private let fm = FileManager.default + @Published + var contentFolderStatus: SecurityScopeStatus = .noBookmark + + @Published + var outputFolderStatus: SecurityScopeStatus = .noBookmark + /** Create the storage. */ - init(baseFolder: URL) { - self.baseFolder = baseFolder + init() { 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]) @@ -96,8 +57,7 @@ final class Storage { // MARK: Folders - func update(baseFolder: URL) throws { - self.baseFolder = baseFolder + func updateBaseFolder() throws { try createFolderStructure() } @@ -447,6 +407,19 @@ final class Storage { } } + func create(folder relativePath: String, in scopr: SecurityScopeBookmark) -> Bool { + return write(in: .outputPath) { folder in + let url = folder.appendingPathComponent(relativePath, isDirectory: true) + do { + try url.ensureFolderExistence() + return true + } catch { + print("Failed to create folder \(url.path()): \(error)") + return false + } + } + } + func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool { do { return try operate(in: scope, operation: operation) @@ -486,6 +459,7 @@ final class Storage { if isStale { print("Bookmark is stale, consider saving a new bookmark.") + #warning("Show warning about stale bookmark") } // Start accessing the security-scoped resource @@ -496,6 +470,45 @@ final class Storage { return try operation(folderUrl) } + @discardableResult + func check(contentPath: String) -> SecurityScopeStatus { + contentFolderStatus = Storage.ensure(securityScope: .contentPath, matches: contentPath) + return contentFolderStatus + } + + @discardableResult + func check(outputPath: String) -> SecurityScopeStatus { + outputFolderStatus = Storage.ensure(securityScope: .outputPath, matches: outputPath) + return outputFolderStatus + } + + private static func ensure(securityScope: SecurityScopeBookmark, matches path: String) -> SecurityScopeStatus { + guard path != "" else { + return .noPath + } + guard let bookmarkData = UserDefaults.standard.data(forKey: securityScope.rawValue) else { + return .noBookmark + } + + do { + var isStale = false + let url = try URL( + resolvingBookmarkData: bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale) + guard !isStale else { + return .stale + } + guard url.path() == path else { + return .urlMismatch + } + return .nominal + } catch { + return .bookmarkCorrupted + } + } + // MARK: Writing files /** diff --git a/CHDataManagement/Storage/StorageAccessError.swift b/CHDataManagement/Storage/StorageAccessError.swift new file mode 100644 index 0000000..555c906 --- /dev/null +++ b/CHDataManagement/Storage/StorageAccessError.swift @@ -0,0 +1,33 @@ +import Foundation + +enum StorageAccessError: Error { + + case noBookmarkData + + case bookmarkDataCorrupted(Error) + + case folderAccessFailed(URL) + + case stringConversionFailed + + case fileNotFound(String) + +} + +extension StorageAccessError: CustomStringConvertible { + + var description: String { + switch self { + case .noBookmarkData: + return "No bookmark data to access resources in folder" + case .bookmarkDataCorrupted(let error): + 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)" + } + } +} diff --git a/CHDataManagement/Views/Pages/PageDetailView.swift b/CHDataManagement/Views/Pages/PageDetailView.swift index 2c8e2ec..610cbaf 100644 --- a/CHDataManagement/Views/Pages/PageDetailView.swift +++ b/CHDataManagement/Views/Pages/PageDetailView.swift @@ -30,14 +30,16 @@ struct PageDetailView: View { Text("Generate") } .disabled(content.isGeneratingWebsite) - if let didGenerateWebsite { - if didGenerateWebsite { - Image(systemSymbol: .checkmarkCircleFill) - .foregroundStyle(.green) - } else { - Image(systemSymbol: .xmarkCircleFill) - .foregroundStyle(.red) - } + switch didGenerateWebsite { + case .none: + Image(systemSymbol: .questionmarkCircleFill) + .foregroundStyle(.gray) + case .some(true): + Image(systemSymbol: .checkmarkCircleFill) + .foregroundStyle(.green) + case .some(false): + Image(systemSymbol: .xmarkCircleFill) + .foregroundStyle(.red) } } IdPropertyView( diff --git a/CHDataManagement/Views/Settings/PathSettingsView.swift b/CHDataManagement/Views/Settings/PathSettingsView.swift index a871573..3fa2d54 100644 --- a/CHDataManagement/Views/Settings/PathSettingsView.swift +++ b/CHDataManagement/Views/Settings/PathSettingsView.swift @@ -5,9 +5,6 @@ struct PathSettingsView: View { @Environment(\.language) private var language - @AppStorage("contentPath") - private var contentPath: String = "" - @EnvironmentObject private var content: Content @@ -20,12 +17,12 @@ struct PathSettingsView: View { FolderOnDiskPropertyView( title: "Content Folder", - folder: $contentPath, + folder: $content.settings.paths.contentDirectoryPath, footer: "The folder where the raw content of the website is stored") { url in guard content.storage.save(folderUrl: url, in: .contentPath) else { return } - contentPath = url.path() + content.settings.paths.contentDirectoryPath = url.path() } FolderOnDiskPropertyView(