import Foundation import SwiftUI final class FileResource: Item, LocalizedItem { override var itemType: ItemType { .file } let type: FileType /// Indicate if the file content is stored by the app @Published var isExternallyStored: Bool /// The file/image description in German @Published var german: String? /// The file/image description in English @Published var english: String? /// A version string of this resource, mostly for assets @Published var version: String? /// A URL where the resource was copied/downloaded from @Published var sourceUrl: String? /// The list of generated image versions for this image @Published var generatedImageVersions: Set /// A custom file path in the output folder where this file is located @Published var customOutputPath: String? /// The date when the file was added @Published var addedDate: Date /// The date when the file was last modified @Published var modifiedDate: Date /// Identify this file as an asset to be placed in the `asset` folder on generation @Published var isAsset: Bool /// The dimensions of the image @Published var imageDimensions: CGSize? = nil /// The size of the file in bytes @Published var fileSize: Int? = nil var savedData: Data? 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, isAsset: Bool = false) { 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 self.isAsset = isAsset super.init(content: content, id: id) } /** Only for bundle images */ init(content: Content, 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 self.isAsset = false super.init(content: content, id: resourceImage) } // MARK: Text func textContent() -> String { content.storage.fileContent(for: id) ?? "" } func save(textContent: String) -> Bool { content.storage.save(fileContent: textContent, for: id) } func dataContent() -> Foundation.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.removeGeneratedImages() } } var imageToDisplay: Image? { guard let displayImageData else { return nil } update(fileSize: displayImageData.count) guard let loadedImage = NSImage(data: displayImageData) else { print("Failed to create image \(id)") return nil } update(imageDimensions: loadedImage.size) return .init(nsImage: loadedImage) } func determineImageDimensions() { let size = getCurrentImageDimensions() self.update(imageDimensions: size) } func getImageDimensions() -> CGSize? { if let imageDimensions { return imageDimensions } guard let size = getCurrentImageDimensions() else { return nil } self.update(imageDimensions: size) return size } private var displayImageData: Foundation.Data? { if type.isImage { guard let data = content.storage.fileData(for: id) else { print("Failed to load data for image \(id)") return nil } return data } if type.isVideo { return content.storage.getVideoThumbnail(for: id) } return nil } private func getCurrentImageDimensions() -> CGSize? { guard let displayImageData else { return nil } guard let loadedImage = NSImage(data: displayImageData) 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 = [] } /// 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) } func linkPreviewImage(results: PageGenerationResults) -> String { let type: FileType switch self.type { case .jpg, .png, .gif: type = self.type default: type = .jpg } let version = imageVersion( width: content.settings.general.linkPreviewImageWidth, height: content.settings.general.linkPreviewImageHeight, type: type) results.require(image: version) return content.settings.general.url + version.outputPath } // MARK: Video thumbnail func createVideoThumbnail() { guard type.isVideo else { return } guard !content.storage.hasVideoThumbnail(for: id) else { return } Task { if await content.imageGenerator.createVideoThumbnail(for: id) { didChange() } } } // 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, including a leading slash */ 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 isAsset { return content.settings.paths.assetsOutputFolderPath } if type.isImage { return content.settings.paths.imagesOutputFolderPath } if type.isVideo { return content.settings.paths.videosOutputFolderPath } if type.isAudio { return content.settings.paths.audioOutputFolderPath } 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: CustomStringConvertible { var description: String { id } } extension FileResource: StorageItem { convenience init(content: Content, id: String, data: FileResource.Data, isExternalFile: Bool) { self.init( content: content, id: id, isExternallyStored: isExternalFile, english: data.englishDescription, german: data.germanDescription, version: data.version, sourceUrl: data.sourceUrl, generatedImageVersions: Set(data.generatedImages ?? []), customOutputPath: data.customOutputPath, addedDate: data.addedDate, modifiedDate: data.modifiedDate, isAsset: data.isAsset ?? false) savedData = data } var data: Data { .init( englishDescription: english, germanDescription: german, generatedImages: generatedImageVersions.sorted().nonEmpty, customOutputPath: customOutputPath, version: version, sourceUrl: sourceUrl, addedDate: addedDate, modifiedDate: modifiedDate, isAsset: isAsset ? true : nil) } /// This struct holds metadata about a file resource that is stored in the content folder. struct Data: Codable, Equatable { let englishDescription: String? let germanDescription: String? let generatedImages: [String]? let customOutputPath: String? let version: String? let sourceUrl: String? let addedDate: Date let modifiedDate: Date let isAsset: Bool? } func saveToDisk(_ data: Data) -> Bool { content.storage.save(fileResource: data, for: id) } }