393 lines
11 KiB
Swift
393 lines
11 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
|
|
|
|
/// 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<String> = [],
|
|
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)
|
|
}
|
|
|
|
/**
|
|
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 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 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: CustomStringConvertible {
|
|
|
|
var description: String {
|
|
id
|
|
}
|
|
}
|
|
|
|
extension FileResource {
|
|
|
|
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)
|
|
}
|
|
|
|
var data: Data {
|
|
.init(
|
|
englishDescription: english,
|
|
germanDescription: german,
|
|
generatedImages: generatedImageVersions.sorted().nonEmpty,
|
|
customOutputPath: customOutputPath,
|
|
version: version,
|
|
sourceUrl: sourceUrl,
|
|
addedDate: addedDate,
|
|
modifiedDate: modifiedDate)
|
|
}
|
|
|
|
/// This struct holds metadata about a file resource that is stored in the content folder.
|
|
struct Data: Codable {
|
|
let englishDescription: String?
|
|
let germanDescription: String?
|
|
let generatedImages: [String]?
|
|
let customOutputPath: String?
|
|
let version: String?
|
|
let sourceUrl: String?
|
|
let addedDate: Date
|
|
let modifiedDate: Date
|
|
}
|
|
}
|