import Foundation import SwiftUI final class FileResource: Item { let type: FileType @Published var isExternallyStored: Bool @Published var german: String @Published var english: String @Published var version: String? @Published var sourceUrl: String? @Published var generatedImageVersions: Set @Published var customOutputPath: String? @Published var addedDate: Date @Published var modifiedDate: Date /// The dimensions of the image @Published var imageDimensions: CGSize? = nil /// The size of the file in bytes @Published var fileSize: Int? = nil init(content: Content, id: String, isExternallyStored: Bool, english: String?, german: String?, version: String? = nil, sourceUrl: String? = nil, generatedImageVersions: Set = [], customOutputPath: String? = nil, addedDate: Date = .now, modifiedDate: Date = .now) { self.type = FileType(fileExtension: id.fileExtension) self.isExternallyStored = isExternallyStored self.german = german ?? "" self.english = english ?? "" self.version = version self.sourceUrl = sourceUrl self.generatedImageVersions = generatedImageVersions self.customOutputPath = customOutputPath self.addedDate = addedDate self.modifiedDate = modifiedDate super.init(content: content, id: id) } init(content: Content, id: String, file: FileResourceFile, isExternalFile: Bool) { self.type = FileType(fileExtension: id.fileExtension) self.isExternallyStored = isExternalFile self.german = file.germanDescription ?? "" self.english = file.englishDescription ?? "" self.version = file.version self.sourceUrl = file.sourceUrl self.generatedImageVersions = Set(file.generatedImages ?? []) self.customOutputPath = file.customOutputPath self.addedDate = file.addedDate self.modifiedDate = file.modifiedDate super.init(content: content, id: id) } /** Only for bundle images */ init(resourceImage: String, type: FileType) { self.type = type self.english = "A test image included in the bundle" self.german = "Ein Testbild aus dem Bundle" self.isExternallyStored = true self.version = nil self.sourceUrl = nil self.generatedImageVersions = [] self.customOutputPath = nil self.addedDate = Date.now self.modifiedDate = Date.now super.init(content: .mock, id: resourceImage) // TODO: Add images to mock } // MARK: Text func textContent() -> String { content.storage.fileContent(for: id) ?? "" } func dataContent() -> Data? { content.storage.fileData(for: id) } // MARK: Images var aspectRatio: CGFloat { guard let imageDimensions else { return 0 } guard imageDimensions.height > 0 else { return 0 } return imageDimensions.width / imageDimensions.height } private func update(imageDimensions size: CGSize?) { guard let imageDimensions, let size else { // First computation DispatchQueue.main.async { self.imageDimensions = size } return } guard imageDimensions != size else { return } // Image must have changed, so force regeneration DispatchQueue.main.async { self.imageDimensions = size self.didChange() self.removeGeneratedImages() } } var imageToDisplay: Image { guard let imageData = content.storage.fileData(for: id) else { print("Failed to load data for image \(id)") return failureImage } update(fileSize: imageData.count) guard let loadedImage = NSImage(data: imageData) else { print("Failed to create image \(id)") return failureImage } update(imageDimensions: loadedImage.size) return .init(nsImage: loadedImage) } func determineImageDimensions() { let size = getImageDimensions() self.update(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 update(fileSize size: Int?) { guard let fileSize, let size else { // First computation DispatchQueue.main.async { self.fileSize = size } return } guard fileSize != size else { return } // File must have changed DispatchQueue.main.async { self.fileSize = size self.didChange() self.removeGeneratedImages() } } func determineFileSize() { DispatchQueue.global(qos: .userInitiated).async { let size = self.content.storage.size(of: self.id) self.update(fileSize: size) } } func removeGeneratedImages() { guard type.isImage else { return } guard content.storage.deleteInOutputFolder(outputImageFolder) else { return } self.generatedImageVersions = [] } private var failureImage: Image { Image(systemSymbol: .exclamationmarkTriangle) } /// The path to the output folder where image versions are stored (no leading slash) var outputImageFolder: String { "\(content.settings.paths.imagesOutputFolderPath)/\(id.fileNameWithoutExtension)" } func outputPath(width: Int, height: Int, type: FileType?) -> String { let prefix = "/\(outputImageFolder)/\(width)x\(height)" guard let ext = type?.fileExtension else { return prefix } return prefix + "." + ext } func imageSet(width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7, extraAttributes: String? = nil) -> ImageSet { let description = self.localized(in: language) return .init( image: self, maxWidth: width, maxHeight: height, description: description, quality: quality, extraAttributes: extraAttributes) } func imageVersion(width: Int, height: Int, type: FileType) -> ImageVersion { .init(image: self, type: type, maximumWidth: width, maximumHeight: height) } // MARK: Paths func removeFileFromOutputFolder() { content.storage.deleteInOutputFolder(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. */ var absoluteUrl: String { if let customOutputPath { if customOutputPath.hasPrefix("/") { return customOutputPath } else { return "/" + customOutputPath } } let path = pathPrefix + "/" + id return makeCleanAbsolutePath(path) } private var pathPrefix: String { if type.isImage { return content.settings.paths.imagesOutputFolderPath } if type.isVideo { return content.settings.paths.videosOutputFolderPath } if type.isAudio { return content.settings.paths.audioOutputFolderPath } if type.isAsset { return content.settings.paths.assetsOutputFolderPath } return content.settings.paths.filesOutputFolderPath } // MARK: File func isValid(id: String) -> Bool { !id.isEmpty && content.isValidIdForFile(id) && content.isNewIdForFile(id) } @discardableResult func update(id newId: String) -> Bool { guard !isExternallyStored else { id = newId return true } guard content.storage.move(file: id, to: newId) else { print("Failed to move file \(id) to \(newId)") return false } id = newId return true } } extension FileResource { var fileInfo: FileResourceFile { .init( englishDescription: english.nonEmpty, germanDescription: german.nonEmpty, generatedImages: generatedImageVersions.sorted().nonEmpty, customOutputPath: customOutputPath, version: version, sourceUrl: sourceUrl, addedDate: addedDate, modifiedDate: modifiedDate) } } extension FileResource: LocalizedItem { } extension FileResource: CustomStringConvertible { var description: String { id } }