From f2d78aef938bc121030a4b326e2771259c29e9d9 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Thu, 5 Dec 2024 09:17:33 +0100 Subject: [PATCH] Show file list and contents --- CHDataManagement.xcodeproj/project.pbxproj | 12 ++++ CHDataManagement/Model/FileResource.swift | 19 ++++++ .../Preview Content/File+Mock.swift | 7 ++ CHDataManagement/Storage/Storage.swift | 66 ++++++++++++++---- .../Views/Files/FileContentView.swift | 39 +++++++++++ .../Views/Files/FileDetailView.swift | 26 +++++++ CHDataManagement/Views/Files/FilesView.swift | 68 +++++++++++++++++-- .../Views/Pages/PageListView.swift | 1 + 8 files changed, 220 insertions(+), 18 deletions(-) create mode 100644 CHDataManagement/Preview Content/File+Mock.swift create mode 100644 CHDataManagement/Views/Files/FileContentView.swift create mode 100644 CHDataManagement/Views/Files/FileDetailView.swift diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 0ecc801..2d031d6 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -63,6 +63,9 @@ E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA56C2D00EBC900AEF16D /* NavigationBarSettingsView.swift */; }; E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA56E2D00F99900AEF16D /* PostFeedSettingsView.swift */; }; E25DA5712D01015400AEF16D /* GenerationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5702D01015400AEF16D /* GenerationSettingsView.swift */; }; + E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5722D018AA100AEF16D /* FileContentView.swift */; }; + E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5742D018B6100AEF16D /* FileDetailView.swift */; }; + E25DA5772D018B9900AEF16D /* File+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5762D018B9500AEF16D /* File+Mock.swift */; }; E2A21C012CB16A820060935B /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C002CB16A820060935B /* PostView.swift */; }; E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; }; E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C042CB176670060935B /* LocalizedText.swift */; }; @@ -170,6 +173,9 @@ E25DA56C2D00EBC900AEF16D /* NavigationBarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarSettingsView.swift; sourceTree = ""; }; E25DA56E2D00F99900AEF16D /* PostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedSettingsView.swift; sourceTree = ""; }; E25DA5702D01015400AEF16D /* GenerationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationSettingsView.swift; sourceTree = ""; }; + E25DA5722D018AA100AEF16D /* FileContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileContentView.swift; sourceTree = ""; }; + E25DA5742D018B6100AEF16D /* FileDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDetailView.swift; sourceTree = ""; }; + E25DA5762D018B9500AEF16D /* File+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "File+Mock.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 = ""; }; @@ -336,6 +342,8 @@ isa = PBXGroup; children = ( E2A21C532CBBF87A0060935B /* FilesView.swift */, + E25DA5722D018AA100AEF16D /* FileContentView.swift */, + E25DA5742D018B6100AEF16D /* FileDetailView.swift */, ); path = Files; sourceTree = ""; @@ -496,6 +504,7 @@ E2DD047C2C276F32003BFF1F /* Preview Content */ = { isa = PBXGroup; children = ( + E25DA5762D018B9500AEF16D /* File+Mock.swift */, E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */, E218500A2CEE02FA0090B18B /* Content+Mock.swift */, E2A37D1A2CEA45530000979F /* Tag+Mock.swift */, @@ -589,6 +598,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E25DA5772D018B9900AEF16D /* File+Mock.swift in Sources */, E218502B2CF790B30090B18B /* PostContentView.swift in Sources */, E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */, E2A21C162CB1A3C90060935B /* PostImageGalleryView.swift in Sources */, @@ -644,12 +654,14 @@ E2A21C4D2CBB16B50060935B /* ImagesView.swift in Sources */, E2A21C202CB28ED20060935B /* MockImage.swift in Sources */, E2A21C2C2CB2BB250060935B /* PostList.swift in Sources */, + E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */, E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */, E218500D2CEE07180090B18B /* ColorPalette.swift in Sources */, E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */, E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */, E2B85F412C4294790047CD0C /* PageHead.swift in Sources */, E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */, + E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */, E25DA5432D0094A900AEF16D /* SettingsSidebar.swift in Sources */, E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */, E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */, diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index 88f51bf..9ce1b50 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -14,3 +14,22 @@ final class FileResource: ObservableObject { self.description = description } } + +extension FileResource: Identifiable { + + var id: String { uniqueId } +} + +extension FileResource: Equatable { + + static func == (lhs: FileResource, rhs: FileResource) -> Bool { + lhs.uniqueId == rhs.uniqueId + } +} + +extension FileResource: Hashable { + + func hash(into hasher: inout Hasher) { + hasher.combine(uniqueId) + } +} diff --git a/CHDataManagement/Preview Content/File+Mock.swift b/CHDataManagement/Preview Content/File+Mock.swift new file mode 100644 index 0000000..4aba13a --- /dev/null +++ b/CHDataManagement/Preview Content/File+Mock.swift @@ -0,0 +1,7 @@ + +extension FileResource { + + static var mock: FileResource { + .init(uniqueId: "my-file.txt", description: "Some text file") + } +} diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 73337c1..63813db 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -7,6 +7,30 @@ enum SecurityScopeBookmark: String { case contentPath = "contentPathBookmark" } +enum StorageAccessError: Error { + + case noBookmarkData + + case bookmarkDataCorrupted(Error) + + case folderAccessFailed(URL) + +} + +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())" + } + } +} + /** A class that handles the storage of the website data. @@ -218,6 +242,9 @@ final class Storage { filesFolder.appending(path: file, directoryHint: .notDirectory) } + /** + Copy an external file to the content folder + */ @discardableResult func copyFile(at url: URL, fileId: String) -> Bool { let contentUrl = fileUrl(file: fileId) @@ -234,6 +261,15 @@ final class Storage { try deleteFiles(in: filesFolder, notIn: Set(fileSet)) } + func fileContent(for file: String) throws -> String { + try operate(in: .contentPath) { folder in + let fileUrl = folder + .appending(path: "files", directoryHint: .isDirectory) + .appending(path: file, directoryHint: .notDirectory) + return try String(contentsOf: fileUrl, encoding: .utf8) + } + } + // MARK: Website data private var settingsDataUrl: URL { @@ -284,18 +320,25 @@ final class Storage { } func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool { - guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else { - print("No bookmark data to access folder") + do { + return try operate(in: scope, operation: operation) + } catch { + print(error) return false } + } + + func operate(in scope: SecurityScopeBookmark, operation: (URL) throws -> T) throws -> T { + guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else { + throw StorageAccessError.noBookmarkData + } var isStale = false - let folderURL: URL + let folderUrl: URL do { // Resolve the bookmark to get the folder URL - folderURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) + folderUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) } catch { - print("Failed to resolve bookmark: \(error)") - return false + throw StorageAccessError.bookmarkDataCorrupted(error) } if isStale { @@ -303,14 +346,11 @@ final class Storage { } // Start accessing the security-scoped resource - if folderURL.startAccessingSecurityScopedResource() { - let result = operation(folderURL) - folderURL.stopAccessingSecurityScopedResource() - return result - } else { - print("Failed to access folder: \(folderURL.path)") - return false + guard folderUrl.startAccessingSecurityScopedResource() else { + throw StorageAccessError.folderAccessFailed(folderUrl) } + defer { folderUrl.stopAccessingSecurityScopedResource() } + return try operation(folderUrl) } // MARK: Writing files diff --git a/CHDataManagement/Views/Files/FileContentView.swift b/CHDataManagement/Views/Files/FileContentView.swift new file mode 100644 index 0000000..8fd9bcd --- /dev/null +++ b/CHDataManagement/Views/Files/FileContentView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct FileContentView: View { + + @ObservedObject + var file: FileResource + + @EnvironmentObject + private var content: Content + + @State + private var fileContent: String = "" + + var body: some View { + VStack { + if fileContent != "" { + TextEditor(text: $fileContent) + .font(.body.monospaced()) + .textEditorStyle(.plain) + } else { + Text("The file is not a text file") + .onAppear(perform: loadFileContent) + } + }.padding() + } + + private func loadFileContent() { + do { + fileContent = try content.storage.fileContent(for: file.uniqueId) + } catch { + print(error) + fileContent = "" + } + } +} + +#Preview { + FileContentView(file: .mock) +} diff --git a/CHDataManagement/Views/Files/FileDetailView.swift b/CHDataManagement/Views/Files/FileDetailView.swift new file mode 100644 index 0000000..ced9884 --- /dev/null +++ b/CHDataManagement/Views/Files/FileDetailView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct FileDetailView: View { + + @ObservedObject + var file: FileResource + + var body: some View { + VStack(alignment: .leading) { + Text("File Name") + .font(.headline) + TextField("", text: $file.uniqueId) + .textFieldStyle(.roundedBorder) + .padding(.bottom) + .disabled(true) + Text("Description") + .font(.headline) + TextField("", text: $file.description) + .textFieldStyle(.roundedBorder) + }.padding() + } +} + +#Preview { + FileDetailView(file: .mock) +} diff --git a/CHDataManagement/Views/Files/FilesView.swift b/CHDataManagement/Views/Files/FilesView.swift index d2afa66..352c3ce 100644 --- a/CHDataManagement/Views/Files/FilesView.swift +++ b/CHDataManagement/Views/Files/FilesView.swift @@ -3,18 +3,76 @@ import SwiftUI struct FilesView: View { @EnvironmentObject - var content: Content + private var content: Content + + @State + private var selected: FileResource? = nil var body: some View { - ScrollView { - VStack { - + NavigationSplitView { + List(content.files, selection: $selected) { file in + Text(file.uniqueId) + .tag(file) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: openFilePanel) { + Label("Add file", systemSymbol: .plus) + } + } + } + .navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 250) + } content: { + if let selected { + FileContentView(file: selected) + .id(selected.uniqueId) + } else { + Text("Select a file") + } + } detail: { + if let selected { + FileDetailView(file: selected) + } else { + EmptyView() } } - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) } + + private func openFilePanel() { + let panel = NSOpenPanel() + // Sets up so user can only select a single directory + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.showsHiddenFiles = false + panel.title = "Select files to add" + panel.prompt = "" + + let response = panel.runModal() + guard response == .OK else { + print("Failed to select files to import") + return + } + + for url in panel.urls { + let fileId = url.lastPathComponent + guard !content.files.contains(where: { $0.uniqueId == fileId }) else { + print("A file '\(fileId)' already exists") + continue + } + let file = FileResource(uniqueId: fileId, description: "") + guard content.storage.copyFile(at: url, fileId: fileId) else { + print("Failed to import file '\(fileId)'") + continue + } + content.files.insert(file, at: 0) + } + } + + } #Preview { FilesView() + .environmentObject(Content.mock) } diff --git a/CHDataManagement/Views/Pages/PageListView.swift b/CHDataManagement/Views/Pages/PageListView.swift index 2c21de2..007f515 100644 --- a/CHDataManagement/Views/Pages/PageListView.swift +++ b/CHDataManagement/Views/Pages/PageListView.swift @@ -43,6 +43,7 @@ struct PageListView: View { } content: { if let selected { PageDetailView(page: selected) + .id(selected.id) .layoutPriority(1) } else { // Fallback if no item is selected