2025-02-17 13:38:48 +01:00

410 lines
12 KiB
Swift

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<String>
/// 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<String> = [],
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)
}
}