From 01baf560eea11e2242902a290009d603fd7ba766 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Sun, 5 Jan 2025 09:21:21 +0100 Subject: [PATCH] Generate tag overview, add file action --- .../Generator/GenerationResults.swift | 8 +- .../Generator/ImageGenerator.swift | 5 + CHDataManagement/Generator/ImageSet.swift | 2 +- .../TagOverviewGenerator.swift | 138 ++++++++++++++---- .../Post Lists/PostListPageGenerator.swift | 4 +- CHDataManagement/Main/MainView.swift | 8 +- .../Model/Content+Generation.swift | 6 +- CHDataManagement/Model/Content+Load.swift | 1 + CHDataManagement/Model/Content+Save.swift | 1 + .../Model/Content+Validation.swift | 2 +- CHDataManagement/Model/Content.swift | 13 ++ CHDataManagement/Model/FileResource.swift | 65 ++++++++- CHDataManagement/Model/Item/Item.swift | 8 + CHDataManagement/Model/LocalizedTag.swift | 9 +- CHDataManagement/Model/Tag.swift | 15 +- .../PostFeedPageNavigation.swift | 2 +- CHDataManagement/Storage/Model/TagFile.swift | 2 + .../Storage/SecurityBookmark.swift | 14 ++ CHDataManagement/Storage/Storage.swift | 39 ++++- .../Views/Files/AddFileView.swift | 9 +- .../Views/Files/FileDetailView.swift | 124 +++++++++++++++- .../Views/Files/MultiFileSelectionView.swift | 105 ++++++------- .../Views/Generic/GenericPropertyView.swift | 18 ++- .../Views/Pages/LocalizedPageDetailView.swift | 6 + CHDataManagement/Views/Tags/AddTagView.swift | 4 +- .../Views/Tags/LocalizedTagDetailView.swift | 21 ++- .../Views/Tags/TagDetailView.swift | 9 ++ 27 files changed, 501 insertions(+), 137 deletions(-) diff --git a/CHDataManagement/Generator/GenerationResults.swift b/CHDataManagement/Generator/GenerationResults.swift index 723c8c2..3631a13 100644 --- a/CHDataManagement/Generator/GenerationResults.swift +++ b/CHDataManagement/Generator/GenerationResults.swift @@ -48,9 +48,8 @@ final class GenerationResults: ObservableObject { private(set) var general: PageGenerationResults! - var resultCount: Int { - cache.count - } + @Published + var resultCount: Int = 0 // MARK: Life cycle @@ -59,12 +58,14 @@ final class GenerationResults: ObservableObject { let general = PageGenerationResults(itemId: id, delegate: self) self.general = general cache[id] = general + self.resultCount = 1 } func makeResults(_ itemId: ItemId) -> PageGenerationResults { guard let result = cache[itemId] else { let result = PageGenerationResults(itemId: itemId, delegate: self) cache[itemId] = result + update { self.resultCount += 1 } return result } return result @@ -116,7 +117,6 @@ final class GenerationResults: ObservableObject { } } - // MARK: Adding entries func inaccessibleContent(file: FileResource) { diff --git a/CHDataManagement/Generator/ImageGenerator.swift b/CHDataManagement/Generator/ImageGenerator.swift index 6082722..b5e1b73 100644 --- a/CHDataManagement/Generator/ImageGenerator.swift +++ b/CHDataManagement/Generator/ImageGenerator.swift @@ -49,6 +49,7 @@ final class ImageGenerator { */ func removeVersions(of image: String) { generatedImages[image] = nil + save() } func recalculateGeneratedImages(by images: Set) { @@ -119,6 +120,10 @@ final class ImageGenerator { } if version.type == .avif { + if version.image.type == .gif { + // Skip GIFs, since they can't be converted by avifenc + return true + } // AVIF conversion is very slow, so we save bash commands // for the conversion instead let baseVersion = ImageVersion( diff --git a/CHDataManagement/Generator/ImageSet.swift b/CHDataManagement/Generator/ImageSet.swift index 32937e7..2df62e7 100644 --- a/CHDataManagement/Generator/ImageSet.swift +++ b/CHDataManagement/Generator/ImageSet.swift @@ -44,7 +44,7 @@ struct ImageSet { var result = "" result += "" - result += "" + result += "" result += "\(description.htmlEscaped())" result += "" return result diff --git a/CHDataManagement/Generator/Page Generators/TagOverviewGenerator.swift b/CHDataManagement/Generator/Page Generators/TagOverviewGenerator.swift index 3963172..7215b2f 100644 --- a/CHDataManagement/Generator/Page Generators/TagOverviewGenerator.swift +++ b/CHDataManagement/Generator/Page Generators/TagOverviewGenerator.swift @@ -1,10 +1,78 @@ +private struct TagData { + + let url: String + + let title: String + + let localized: LocalizedTag + + init(tag: Tag, language: ContentLanguage) { + let localized = tag.localized(in: language) + self.url = tag.absoluteUrl(in: language) + self.title = localized.linkPreviewTitle ?? localized.name + self.localized = localized + } +} + +extension TagData: Comparable { + + static func < (lhs: TagData, rhs: TagData) -> Bool { + lhs.title < rhs.title + } + + static func == (lhs: TagData, rhs: TagData) -> Bool { + lhs.title == rhs.title + } +} + +private struct TagHeaderContent { + + let language: ContentLanguage + + let title: String + + let description: String? + + let iconUrl: String + + let links: [NavigationBar.Link] + + let headers: Set + + let baseUrl: String + + let localizedBaseUrl: String + + private func url(pageNumber: Int) -> String { + baseUrl + "/\(pageNumber)" + } + + func fileUrl(pageNumber: Int) -> String { + url(pageNumber: pageNumber) + ".html" + } + + func pageHeader(pageNumber: Int) -> PageHeader { + .init( + language: language, + title: title, + description: description, + iconUrl: iconUrl, + languageButton: .init( + text: language.next.rawValue, + url: localizedBaseUrl + "/\(pageNumber)"), + links: links, + headers: headers, + icons: []) + } +} + final class TagOverviewGenerator { let content: Content let language: ContentLanguage - + let results: PageGenerationResults init(content: Content, language: ContentLanguage, results: PageGenerationResults) { @@ -13,49 +81,67 @@ final class TagOverviewGenerator { self.results = results } - func generatePage(tags: [Tag], overview: TagOverviewPage) { - let iconUrl = content.settings.navigation.localized(in: language).rootUrl - let languageUrl = overview.absoluteUrl(in: language.next) - let languageButton = NavigationBar.Link( - text: language.next.rawValue, - url: languageUrl) - + func generatePages(tags: [Tag], overview: TagOverviewPage) { let localized = overview.localized(in: language) - - let pageHeader = PageHeader( + let header = TagHeaderContent( language: language, title: localized.linkPreviewTitle ?? localized.title, description: localized.linkPreviewDescription, - iconUrl: iconUrl, - languageButton: languageButton, + iconUrl: content.settings.navigation.localized(in: language).rootUrl, links: content.navigationBar(in: language), headers: content.defaultPageHeaders, - icons: []) + baseUrl: overview.absoluteUrl(in: language), + localizedBaseUrl: overview.absoluteUrl(in: language.next)) + + // Sort tags by title + let tagData = tags.map { + TagData(tag: $0, language: language) + }.sorted { $0.title } + + let totalCount = tagData.count + guard totalCount > 0 else { + // Create one empty page + generatePage(tags: [], header: header, page: 1, totalPages: 1) + return + } + let tagsPerPage = content.settings.posts.postsPerPage + + let numberOfPages = (totalCount + tagsPerPage - 1) / tagsPerPage // Round up + for pageIndex in 1...numberOfPages { + let startIndex = (pageIndex - 1) * tagsPerPage + let endIndex = min(pageIndex * tagsPerPage, totalCount) + let tagsOnPage = tagData[startIndex.., header: TagHeaderContent, page pageNumber: Int, totalPages: Int) { + let pageHeader = header.pageHeader(pageNumber: pageNumber) let page = GenericPage( header: pageHeader, additionalFooter: "") { content in - content += "

\(localized.title)

" + content += "

\(header.title)

" for tag in tags { - let localized = tag.localized(in: self.language) - let url = tag.absoluteUrl(in: self.language) - let title = localized.name - let description = localized.description ?? "" - let image = self.makePageImage(item: localized) + let description = tag.localized.description ?? "" + let image = self.makePageImage(item: tag.localized) content += RelatedPageLink( - title: title, + title: tag.title, description: description, - url: url, + url: tag.url, image: image) - .content + .content + } + if totalPages > 1 { + content += PostFeedPageNavigation( + linkPrefix: header.baseUrl, + currentPage: pageNumber, + numberOfPages: totalPages).content } -// if totalPages > 1 { -// content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content -// } } let fileContent = page.content - let url = overview.absoluteUrl(in: language) + ".html" + let url = header.fileUrl(pageNumber: pageNumber) guard content.storage.write(fileContent, to: url) else { results.unsavedOutput(url, source: .tagOverview) diff --git a/CHDataManagement/Generator/Post Lists/PostListPageGenerator.swift b/CHDataManagement/Generator/Post Lists/PostListPageGenerator.swift index 3c5248d..2dc8726 100644 --- a/CHDataManagement/Generator/Post Lists/PostListPageGenerator.swift +++ b/CHDataManagement/Generator/Post Lists/PostListPageGenerator.swift @@ -23,6 +23,8 @@ final class PostListPageGenerator { func createPages(for posts: [Post]) { let totalCount = posts.count guard totalCount > 0 else { + // Create one empty page + createPostFeedPage(1, pageCount: 1, posts: []) return } let postsPerPage = source.postsPerPage @@ -80,7 +82,7 @@ final class PostListPageGenerator { pageNumber: pageIndex, totalPages: pageCount, languageButtonUrl: languageButtonUrl, - linkPrefix: "/" + source.pageUrlPrefix(for: language) + "/") + linkPrefix: source.pageUrlPrefix(for: language)) let filePath = pageUrl(in: language, pageNumber: pageIndex) + ".html" guard save(fileContent, to: filePath) else { source.results.unsavedOutput(filePath, source: .feed) diff --git a/CHDataManagement/Main/MainView.swift b/CHDataManagement/Main/MainView.swift index b58b2cf..ac908bd 100644 --- a/CHDataManagement/Main/MainView.swift +++ b/CHDataManagement/Main/MainView.swift @@ -2,7 +2,6 @@ import SwiftUI import SFSafeSymbols #warning("Fix podcast") -#warning("Fix CV") #warning("Fix endeavor basics (image compare)") #warning("Fix cap mosaic GIF") @@ -23,6 +22,8 @@ import SFSafeSymbols #warning("Add author to settings and page headers") #warning("Check for files in output folder not generated by app") #warning("Fix GIFs: Don't rescale, don't use image set") +#warning("Add view to browse images") +#warning("Show warnings for empty item properties") @main struct MainView: App { @@ -49,9 +50,6 @@ struct MainView: App { @State private var selectedTag: Tag? - @State - private var selectedImage: ImageResource? - @State private var selectedFile: FileResource? @@ -122,7 +120,7 @@ struct MainView: App { case .tags: AddTagView(selected: $selectedTag) case .files: - AddFileView(selectedImage: $selectedImage, selectedFile: $selectedFile) + AddFileView(selectedFile: $selectedFile) case .generation: Text("Not implemented") } diff --git a/CHDataManagement/Model/Content+Generation.swift b/CHDataManagement/Model/Content+Generation.swift index 37800b2..69bc5cb 100644 --- a/CHDataManagement/Model/Content+Generation.swift +++ b/CHDataManagement/Model/Content+Generation.swift @@ -11,6 +11,7 @@ extension Content { self.copyRequiredFiles() self.generateRequiredImages() + self.results.recalculate() self.status("Generation completed") } } @@ -61,9 +62,6 @@ extension Content { } private func generateRequiredImages() { - let imageGenerator = ImageGenerator( - storage: storage, - settings: settings) let images = results.imagesToGenerate.sorted() let count = images.count @@ -255,7 +253,7 @@ extension Content { guard shouldGenerateWebsite else { return } let results = results.makeResults(for: .tagOverview, in: language) let generator = TagOverviewGenerator(content: self, language: language, results: results) - generator.generatePage(tags: tags, overview: tagOverview) + generator.generatePages(tags: tags, overview: tagOverview) } } diff --git a/CHDataManagement/Model/Content+Load.swift b/CHDataManagement/Model/Content+Load.swift index 76d5071..1ce07e0 100644 --- a/CHDataManagement/Model/Content+Load.swift +++ b/CHDataManagement/Model/Content+Load.swift @@ -10,6 +10,7 @@ extension Content { subtitle: tag.subtitle, description: tag.description, thumbnail: tag.thumbnail.map { images[$0] }, + linkPreviewTitle: tag.linkPreviewTitle, originalUrl: tag.originalURL) } diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index 4e95a7a..07438f6 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -129,6 +129,7 @@ private extension LocalizedTag { name: name, subtitle: subtitle, description: description, + linkPreviewTitle: linkPreviewTitle, thumbnail: linkPreviewImage?.id, originalURL: originalUrl) } diff --git a/CHDataManagement/Model/Content+Validation.swift b/CHDataManagement/Model/Content+Validation.swift index e157716..e5d51da 100644 --- a/CHDataManagement/Model/Content+Validation.swift +++ b/CHDataManagement/Model/Content+Validation.swift @@ -7,7 +7,7 @@ extension Content { private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted func isNewIdForTag(_ id: String) -> Bool { - !tags.contains { $0.id == id } + tagOverview?.id != id && !tags.contains { $0.id == id } } func isNewIdForPage(_ id: String) -> Bool { diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index a329f23..26b61cd 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -37,6 +37,8 @@ final class Content: ObservableObject { @Published private(set) var shouldGenerateWebsite = false + let imageGenerator: ImageGenerator + init(settings: Settings, posts: [Post], pages: [Page], @@ -53,6 +55,9 @@ final class Content: ObservableObject { let storage = Storage() self.storage = storage + self.imageGenerator = ImageGenerator( + storage: storage, + settings: settings) } init() { @@ -67,6 +72,9 @@ final class Content: ObservableObject { let storage = Storage() self.storage = storage + self.imageGenerator = ImageGenerator( + storage: storage, + settings: settings) } private func clear() { @@ -112,4 +120,9 @@ final class Content: ObservableObject { print("Failed to reload content: \(error)") } } + + func remove(_ file: FileResource) { + files.remove(file) + #warning("Remove file from required files, thumbnails, post images, etc.") + } } diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index b19eca8..18fae2b 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -14,8 +14,13 @@ final class FileResource: Item { @Published var english: String + /// The dimensions of the image @Published - var size: CGSize = .zero + var imageDimensions: CGSize? = nil + + /// The size of the file in bytes + @Published + var fileSize: Int? = nil init(content: Content, id: String, isExternallyStored: Bool, en: String, de: String) { self.type = FileType(fileExtension: id.fileExtension) @@ -49,10 +54,13 @@ final class FileResource: Item { // MARK: Images var aspectRatio: CGFloat { - guard size.height > 0 else { + guard let imageDimensions else { return 0 } - return size.width / size.height + guard imageDimensions.height > 0 else { + return 0 + } + return imageDimensions.width / imageDimensions.height } var imageToDisplay: Image { @@ -60,18 +68,58 @@ final class FileResource: Item { print("Failed to load data for image \(id)") return failureImage } + if fileSize == nil { + DispatchQueue.main.async { + self.fileSize = imageData.count + } + } guard let loadedImage = NSImage(data: imageData) else { print("Failed to create image \(id)") return failureImage } - if self.size == .zero && loadedImage.size != .zero { + if loadedImage.size != imageDimensions { DispatchQueue.main.async { - self.size = loadedImage.size + self.imageDimensions = loadedImage.size } } return .init(nsImage: loadedImage) } + func determineImageDimensions() { + let size = getImageDimensions() + + DispatchQueue.main.async { + self.imageDimensions = size + } + } + + private func getImageDimensions() -> CGSize? { + guard type.isImage else { + return nil + } + guard let imageData = content.storage.fileData(for: id) else { + return nil + } + guard let loadedImage = NSImage(data: imageData) else { + return nil + } + return loadedImage.size + } + + func determineFileSize() { + DispatchQueue.global(qos: .userInitiated).async { + let size = self.content.storage.size(of: self.id) + DispatchQueue.main.async { + self.fileSize = size + } + } + } + + func removeGeneratedImages() { + content.imageGenerator.removeVersions(of: id) + content.storage.deleteInOutputFolder(path: outputImageFolder) + } + private var failureImage: Image { Image(systemSymbol: .exclamationmarkTriangle) } @@ -105,6 +153,13 @@ final class FileResource: Item { // MARK: Paths + func removeFileFromOutputFolder() { + content.storage.deleteInOutputFolder(path: absoluteUrl) + if type.isImage { + removeGeneratedImages() + } + } + /** Get the url path to a file in the output folder. The result is an absolute path from the output folder for use in HTML. diff --git a/CHDataManagement/Model/Item/Item.swift b/CHDataManagement/Model/Item/Item.swift index df3b66e..dd70b80 100644 --- a/CHDataManagement/Model/Item/Item.swift +++ b/CHDataManagement/Model/Item/Item.swift @@ -4,6 +4,10 @@ class Item: ObservableObject, Identifiable { unowned let content: Content + /// A dummy property to force views to update when properties change + @Published + private var changeToggle = false + @Published var id: String @@ -12,6 +16,10 @@ class Item: ObservableObject, Identifiable { self.id = id } + func didChange() { + changeToggle.toggle() + } + func makeCleanAbsolutePath(_ path: String) -> String { "/" + makeCleanRelativePath(path) } diff --git a/CHDataManagement/Model/LocalizedTag.swift b/CHDataManagement/Model/LocalizedTag.swift index 4b3e25a..41d22df 100644 --- a/CHDataManagement/Model/LocalizedTag.swift +++ b/CHDataManagement/Model/LocalizedTag.swift @@ -21,6 +21,9 @@ final class LocalizedTag: ObservableObject { @Published var linkPreviewImage: FileResource? + @Published + var linkPreviewTitle: String? + /// The original url in the previous site layout let originalUrl: String? @@ -30,6 +33,7 @@ final class LocalizedTag: ObservableObject { subtitle: String? = nil, description: String? = nil, thumbnail: FileResource? = nil, + linkPreviewTitle: String? = nil, originalUrl: String? = nil) { self.content = content self.urlComponent = urlComponent @@ -37,6 +41,7 @@ final class LocalizedTag: ObservableObject { self.subtitle = subtitle self.description = description self.linkPreviewImage = thumbnail + self.linkPreviewTitle = linkPreviewTitle self.originalUrl = originalUrl } @@ -48,10 +53,6 @@ final class LocalizedTag: ObservableObject { extension LocalizedTag: LinkPreviewItem { - var linkPreviewTitle: String? { - self.name - } - var linkPreviewDescription: String? { description } diff --git a/CHDataManagement/Model/Tag.swift b/CHDataManagement/Model/Tag.swift index ea8f4f5..85e36e9 100644 --- a/CHDataManagement/Model/Tag.swift +++ b/CHDataManagement/Model/Tag.swift @@ -26,12 +26,19 @@ final class Tag: Item { super.init(content: content, id: id) } - var linkName: String { - id.lowercased().replacingOccurrences(of: " ", with: "-") + func isValid(id: String) -> Bool { + content.isValidIdForTagOrPageOrPost(id) && + content.isNewIdForTag(id) } - var url: String { - "/tags/\(linkName).html" + @discardableResult + func update(id newId: String) -> Bool { + guard content.storage.move(tag: id, to: newId) else { + print("Failed to move files of tag \(id)") + return false + } + id = newId + return true } // MARK: Paths diff --git a/CHDataManagement/Page Elements/PostFeedPageNavigation.swift b/CHDataManagement/Page Elements/PostFeedPageNavigation.swift index b5fe99e..37ae413 100644 --- a/CHDataManagement/Page Elements/PostFeedPageNavigation.swift +++ b/CHDataManagement/Page Elements/PostFeedPageNavigation.swift @@ -15,7 +15,7 @@ struct PostFeedPageNavigation { } private func pageLink(_ page: Int) -> String { - "href='\(linkPrefix)\(page)'" + "href='/\(linkPrefix)/\(page)'" } private func addPreviousButton(to result: inout String) { diff --git a/CHDataManagement/Storage/Model/TagFile.swift b/CHDataManagement/Storage/Model/TagFile.swift index 174421c..b108167 100644 --- a/CHDataManagement/Storage/Model/TagFile.swift +++ b/CHDataManagement/Storage/Model/TagFile.swift @@ -28,6 +28,8 @@ struct LocalizedTagFile { let description: String? + let linkPreviewTitle: String? + /// The image id of the thumbnail let thumbnail: String? diff --git a/CHDataManagement/Storage/SecurityBookmark.swift b/CHDataManagement/Storage/SecurityBookmark.swift index b4c53c0..6883f5f 100644 --- a/CHDataManagement/Storage/SecurityBookmark.swift +++ b/CHDataManagement/Storage/SecurityBookmark.swift @@ -1,4 +1,5 @@ import Foundation +import AppKit struct SecurityBookmark { @@ -28,6 +29,14 @@ struct SecurityBookmark { // MARK: Write + func openFinderWindow(relativePath: String) { + with(relativePath: relativePath) { path in + print("Opening file at \(path)") + NSWorkspace.shared.activateFileViewerSelecting([path]) + return + } + } + func fullPath(to relativePath: String) -> URL { url.appending(path: relativePath, directoryHint: .notDirectory) } @@ -103,6 +112,10 @@ struct SecurityBookmark { // MARK: Read + func size(of relativePath: String) -> Int? { + with(relativePath: relativePath) { $0.size } + } + func hasFile(at relativePath: String) -> Bool { with(relativePath: relativePath, perform: exists) } @@ -212,6 +225,7 @@ struct SecurityBookmark { func deleteFile(at relativePath: String) -> Bool { with(relativePath: relativePath) { file in guard exists(file) else { + print("Scope: No file to delete at \(file.path())") return true } do { diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index d00f8df..ffdfb73 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -183,13 +183,13 @@ final class Storage: ObservableObject { tagId + ".json" } - private func tagFilePath(tagId: String) -> String { + private func tagFilePath(tag tagId: String) -> String { tagsFolderName + "/" + tagFileName(tagId: tagId) } func save(tagMetadata: TagFile, for tagId: String) -> Bool { guard let contentScope else { return false } - let path = tagFilePath(tagId: tagId) + let path = tagFilePath(tag: tagId) return contentScope.encode(tagMetadata, to: path) } @@ -211,6 +211,11 @@ final class Storage: ObservableObject { return true } + func move(tag tagId: String, to newId: String) -> Bool { + guard let contentScope else { return false } + return contentScope.move(tagFilePath(tag: tagId), to: tagFilePath(tag: newId)) + } + // MARK: File descriptions func loadFileDescriptions() -> [FileDescriptions]? { @@ -235,6 +240,10 @@ final class Storage: ObservableObject { // MARK: Files + func size(of file: String) -> Int? { + contentScope?.size(of: filePath(file: file)) + } + /** The full path to a resource file in the content folder - Parameter file: The filename of the file @@ -244,6 +253,16 @@ final class Storage: ObservableObject { contentScope?.fullPath(to: filePath(file: file)) } + /** + Delete a file resource from the content folder + */ + func delete(file fileId: String) -> Bool { + guard let contentScope else { + return false + } + return contentScope.deleteFile(at: filePath(file: fileId)) + } + /** The full file path to a file in the output folder - Parameter relativePath: The path of the file relative to the output folder @@ -252,10 +271,20 @@ final class Storage: ObservableObject { outputScope?.fullPath(to: relativePath) } + func openFinderWindow(withSelectedFile file: String) { + contentScope?.openFinderWindow(relativePath: filePath(file: file)) + } + private func filePath(file fileId: String) -> String { filesFolderName + "/" + fileId } + @discardableResult + func deleteInOutputFolder(path: String) -> Bool { + guard let outputScope else { return false } + return outputScope.deleteFile(at: path) + } + /** Copy an external file to the content folder */ @@ -264,11 +293,17 @@ final class Storage: ObservableObject { return contentScope.copy(externalFile: url, to: filePath(file: fileId)) } + /** + Move (rename) a file resource. + */ func move(file fileId: String, to newId: String) -> Bool { guard let contentScope else { return false } return contentScope.move(filePath(file: fileId), to: filePath(file: newId)) } + /** + Copy a file resource to a path relative to the output folder + */ func copy(file fileId: String, to relativeOutputPath: String) -> Bool { guard let contentScope, let outputScope else { return false } return contentScope.transfer( diff --git a/CHDataManagement/Views/Files/AddFileView.swift b/CHDataManagement/Views/Files/AddFileView.swift index 45d4a43..f7d0188 100644 --- a/CHDataManagement/Views/Files/AddFileView.swift +++ b/CHDataManagement/Views/Files/AddFileView.swift @@ -11,15 +11,11 @@ struct AddFileView: View { @Binding var selectedFile: FileResource? - @Binding - var selectedImage: ImageResource? - @State private var filesToAdd: [FileToAdd] = [] - init(selectedImage: Binding, selectedFile: Binding) { + init(selectedFile: Binding) { _selectedFile = selectedFile - _selectedImage = selectedImage } var body: some View { @@ -111,6 +107,5 @@ struct AddFileView: View { } #Preview { - AddFileView(selectedImage: .constant(nil), - selectedFile: .constant(nil)) + AddFileView(selectedFile: .constant(nil)) } diff --git a/CHDataManagement/Views/Files/FileDetailView.swift b/CHDataManagement/Views/Files/FileDetailView.swift index 69f07d8..4a75628 100644 --- a/CHDataManagement/Views/Files/FileDetailView.swift +++ b/CHDataManagement/Views/Files/FileDetailView.swift @@ -2,15 +2,36 @@ import SwiftUI struct FileDetailView: View { + @EnvironmentObject + private var content: Content + @ObservedObject var file: FileResource + @State + private var showFileSelection = false + + @State + private var selectedFile: FileResource? + var body: some View { VStack(alignment: .leading) { DetailTitle( title: "File", text: "A file that can be used in a post or page") + VStack(alignment: .leading) { + Button("Show in Finder", action: showFileInFinder) + Button("Mark as changed", action: markFileAsChanged) + Button("Delete resource", action: deleteFile) + if file.isExternallyStored { + Button("Import file", action: replaceFile) + } else { + Button("Replace file", action: replaceFile) + Button("Make external", action: convertToExternal) + } + } + IdPropertyView( id: $file.id, title: "Name", @@ -28,15 +49,108 @@ struct FileDetailView: View { text: $file.english, footer: "The description for the file in English. Descriptions are used for images and to explain the content of a file.") - if file.type.isImage { - Text("Image size") - .font(.headline) - Text("\(Int(file.size.width)) x \(Int(file.size.height)) (\(file.aspectRatio))") - .foregroundStyle(.secondary) + if let imageDimensions = file.imageDimensions { + GenericPropertyView(title: "Image dimensions") { + Text("\(Int(imageDimensions.width)) x \(Int(imageDimensions.height)) (\(file.aspectRatio))") + } #warning("Add button to show image versions") } + if let fileSize = file.fileSize { + GenericPropertyView(title: "File size") { + Text(formatBytes(fileSize)) + } + } Spacer() }.padding() + .onAppear { + if file.fileSize == nil { + file.determineFileSize() + } + } + } + + private func formatBytes(_ bytes: Int) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useKB, .useBytes] // Customize units if needed + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func showFileInFinder() { + content.storage.openFinderWindow(withSelectedFile: file.id) + } + + private func markFileAsChanged() { + DispatchQueue.main.async { + file.determineImageDimensions() + file.determineFileSize() + // Force regeneration of images and/or file copying + file.removeFileFromOutputFolder() + // Trigger content view update to reload image + file.didChange() + } + } + + private func replaceFile() { + guard let url = openFilePanel() else { + print("File '\(file.id)': No file selected as replacement") + return + } + guard content.storage.importExternalFile(at: url, fileId: file.id) else { + print("File '\(file.id)': Failed to replace file") + return + } + + markFileAsChanged() + if file.isExternallyStored { + DispatchQueue.main.async { + file.isExternallyStored = false + } + } + } + + private func openFilePanel() -> URL? { + let panel = NSOpenPanel() + + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.showsHiddenFiles = false + panel.title = "Select file to replace" + panel.prompt = "" + + let response = panel.runModal() + guard response == .OK else { + print("File '\(file.id)': Failed to select file to replace") + return nil + } + + return panel.url + } + + private func convertToExternal() { + guard !file.isExternallyStored else { + return + } + + guard content.storage.delete(file: file.id) else { + print("File '\(file.id)': Failed to delete file to make it external") + return + } + DispatchQueue.main.async { + file.fileSize = nil + file.isExternallyStored = true + } + } + + private func deleteFile() { + if !file.isExternallyStored { + guard content.storage.delete(file: file.id) else { + print("File '\(file.id)': Failed to delete file in content folder") + return + } + } + content.remove(file) } } diff --git a/CHDataManagement/Views/Files/MultiFileSelectionView.swift b/CHDataManagement/Views/Files/MultiFileSelectionView.swift index 36598d1..8dd96f7 100644 --- a/CHDataManagement/Views/Files/MultiFileSelectionView.swift +++ b/CHDataManagement/Views/Files/MultiFileSelectionView.swift @@ -47,68 +47,71 @@ struct MultiFileSelectionView: View { } var body: some View { - HStack { - VStack { - Text("Selected files") - .font(.title) - List { - ForEach(newSelection) { file in - HStack { - Image(systemSymbol: .minusCircleFill) - .foregroundStyle(.red) - .contentShape(Rectangle()) - .onTapGesture { deselect(file: file) } - Text(file.id) - Spacer() + GeometryReader { geo in + HStack { + VStack { + Text("Selected files") + .font(.title) + List { + ForEach(newSelection) { file in + HStack { + Image(systemSymbol: .minusCircleFill) + .foregroundStyle(.red) + .contentShape(Rectangle()) + .onTapGesture { deselect(file: file) } + Text(file.id) + Spacer() + } } + .onMove(perform: moveSelectedFile) } - .onMove(perform: moveSelectedFile) - } - HStack { - Button("Cancel") { - DispatchQueue.main.async { + HStack { + Button("Cancel") { + DispatchQueue.main.async { + dismiss() + } + } + Button("Save") { + selectedFiles = newSelection dismiss() } } - Button("Save") { - selectedFiles = newSelection - dismiss() - } - } - } - VStack { - Picker("", selection: $selectedFileType) { - let all: FileTypeCategory? = nil - Text("All").tag(all) - ForEach(FileTypeCategory.allCases) { type in - Text(type.text).tag(type) - } - } - .pickerStyle(.segmented) - .padding(.trailing, 7) - .disabled(allowedType != nil) - TextField("", text: $searchString, prompt: Text("Search")) - .textFieldStyle(.roundedBorder) - .padding(.horizontal, 8) - List(filteredFiles) { file in - HStack { - if newSelection.contains(file) { - Image(systemSymbol: .checkmarkCircleFill) - .foregroundStyle(.gray) - } else { - Image(systemSymbol: .plusCircleFill) - .foregroundStyle(.green) + }.frame(width: geo.size.width / 2) + VStack { + Picker("", selection: $selectedFileType) { + let all: FileTypeCategory? = nil + Text("All").tag(all) + ForEach(FileTypeCategory.allCases) { type in + Text(type.text).tag(type) } - Text(file.id) - Spacer() } - .contentShape(Rectangle()) - .onTapGesture { select(file: file) } - } + .padding(.trailing, 7) + .disabled(allowedType != nil) + TextField("", text: $searchString, prompt: Text("Search")) + .textFieldStyle(.roundedBorder) + .padding(.horizontal, 8) + List(filteredFiles) { file in + HStack { + if newSelection.contains(file) { + Image(systemSymbol: .checkmarkCircleFill) + .foregroundStyle(.gray) + } else { + Image(systemSymbol: .plusCircleFill) + .foregroundStyle(.green) + } + Text(file.id) + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { select(file: file) } + } + }.frame(width: geo.size.width / 2) } + .frame(minHeight: 500, idealHeight: 600) } .frame(minHeight: 500, idealHeight: 600) .padding() + } private func deselect(file: FileResource) { diff --git a/CHDataManagement/Views/Generic/GenericPropertyView.swift b/CHDataManagement/Views/Generic/GenericPropertyView.swift index 0487de6..36de8aa 100644 --- a/CHDataManagement/Views/Generic/GenericPropertyView.swift +++ b/CHDataManagement/Views/Generic/GenericPropertyView.swift @@ -4,11 +4,11 @@ struct GenericPropertyView: View where Content: View { let title: LocalizedStringKey - let footer: LocalizedStringKey + let footer: LocalizedStringKey? let content: Content - public init(title: LocalizedStringKey, footer: LocalizedStringKey, @ViewBuilder content: () -> Content) { + public init(title: LocalizedStringKey, footer: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) { self.title = title self.footer = footer self.content = content() @@ -18,10 +18,16 @@ struct GenericPropertyView: View where Content: View { VStack(alignment: .leading) { Text(title) .font(.headline) - content - Text(footer) - .foregroundStyle(.secondary) - .padding(.bottom) + + if let footer { + content + Text(footer) + .foregroundStyle(.secondary) + .padding(.bottom) + } else { + content + .padding(.bottom) + } } } } diff --git a/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift b/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift index 13b65b2..8866da3 100644 --- a/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift +++ b/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift @@ -24,6 +24,12 @@ struct LocalizedPageDetailView: View { footer: "Prevent the date and title from being printed on the page") .disabled(isExternalPage) + if let url = page.originalUrl { + GenericPropertyView(title: "Original URL") { + Text(url) + } + } + OptionalStringPropertyView( title: "Preview Title", text: $page.linkPreviewTitle, diff --git a/CHDataManagement/Views/Tags/AddTagView.swift b/CHDataManagement/Views/Tags/AddTagView.swift index 2e99aa7..faf1aae 100644 --- a/CHDataManagement/Views/Tags/AddTagView.swift +++ b/CHDataManagement/Views/Tags/AddTagView.swift @@ -28,8 +28,8 @@ struct AddTagView: View { content: content, id: "tag", isVisible: true, - german: .init(content: .mock, urlComponent: "tag", name: "Neuer Tag"), - english: .init(content: .mock, urlComponent: "tag-en", name: "New Tag")) + german: .init(content: content, urlComponent: "tag", name: "Neuer Tag"), + english: .init(content: content, urlComponent: "tag-en", name: "New Tag")) // Add to top of the list, and resort when changing the name content.tags.insert(newTag, at: 0) dismiss() diff --git a/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift b/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift index 82d89cd..612a449 100644 --- a/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift +++ b/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift @@ -24,19 +24,24 @@ struct LocalizedTagDetailView: View { footer: "The url component to use in the url for this tag", validation: tag.isValid, update: { tag.urlComponent = $0 }) - - Text("Original url") - .font(.headline) - Text(tag.originalUrl ?? "-") - .foregroundStyle(.secondary) - .padding(.top, 1) - .padding(.bottom) - + + if let url = tag.originalUrl { + GenericPropertyView(title: "Original URL") { + Text(url) + } + } + OptionalStringPropertyView( title: "Subtitle", text: $tag.subtitle, footer: "The subtitle/tagline to use") + OptionalStringPropertyView( + title: "Preview Title", + text: $tag.linkPreviewTitle, + prompt: tag.name, + footer: "The title to use for the tag in previews and on tag pages") + OptionalImagePropertyView( title: "Preview Image", selectedImage: $tag.linkPreviewImage, diff --git a/CHDataManagement/Views/Tags/TagDetailView.swift b/CHDataManagement/Views/Tags/TagDetailView.swift index 5d91d80..f8439fb 100644 --- a/CHDataManagement/Views/Tags/TagDetailView.swift +++ b/CHDataManagement/Views/Tags/TagDetailView.swift @@ -21,8 +21,17 @@ struct TagDetailView: View { value: $tag.isVisible, footer: "Indicate if the tag should appear in the tag list of posts and pages. If the tag is not visible, then it can still be used as a filter.") + IdPropertyView( + id: $tag.id, + title: "Tag id", + footer: "The unique id of the tag for references", + validation: tag.isValid) { + tag.update(id: $0) + } + LocalizedTagDetailView( tag: tag.localized(in: language)) + .id(tag.id + language.rawValue) } .padding() }