Generate tag overview, add file action

This commit is contained in:
Christoph Hagen 2025-01-05 09:21:21 +01:00
parent 0dca633805
commit 01baf560ee
27 changed files with 501 additions and 137 deletions

View File

@ -48,9 +48,8 @@ final class GenerationResults: ObservableObject {
private(set) var general: PageGenerationResults! private(set) var general: PageGenerationResults!
var resultCount: Int { @Published
cache.count var resultCount: Int = 0
}
// MARK: Life cycle // MARK: Life cycle
@ -59,12 +58,14 @@ final class GenerationResults: ObservableObject {
let general = PageGenerationResults(itemId: id, delegate: self) let general = PageGenerationResults(itemId: id, delegate: self)
self.general = general self.general = general
cache[id] = general cache[id] = general
self.resultCount = 1
} }
func makeResults(_ itemId: ItemId) -> PageGenerationResults { func makeResults(_ itemId: ItemId) -> PageGenerationResults {
guard let result = cache[itemId] else { guard let result = cache[itemId] else {
let result = PageGenerationResults(itemId: itemId, delegate: self) let result = PageGenerationResults(itemId: itemId, delegate: self)
cache[itemId] = result cache[itemId] = result
update { self.resultCount += 1 }
return result return result
} }
return result return result
@ -116,7 +117,6 @@ final class GenerationResults: ObservableObject {
} }
} }
// MARK: Adding entries // MARK: Adding entries
func inaccessibleContent(file: FileResource) { func inaccessibleContent(file: FileResource) {

View File

@ -49,6 +49,7 @@ final class ImageGenerator {
*/ */
func removeVersions(of image: String) { func removeVersions(of image: String) {
generatedImages[image] = nil generatedImages[image] = nil
save()
} }
func recalculateGeneratedImages(by images: Set<String>) { func recalculateGeneratedImages(by images: Set<String>) {
@ -119,6 +120,10 @@ final class ImageGenerator {
} }
if version.type == .avif { 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 // AVIF conversion is very slow, so we save bash commands
// for the conversion instead // for the conversion instead
let baseVersion = ImageVersion( let baseVersion = ImageVersion(

View File

@ -44,7 +44,7 @@ struct ImageSet {
var result = "<picture>" var result = "<picture>"
result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>" result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>"
result += "<source type='image/webp' srcset='\(prefix1x).webm 1x, \(prefix1x).webm 2x'/>" result += "<source type='image/webp' srcset='\(prefix1x).webp 1x, \(prefix1x).webp 2x'/>"
result += "<img srcset='\(prefix2x)\(fileExtension) 2x' src='\(prefix1x)\(fileExtension)' loading='lazy' alt='\(description.htmlEscaped())'/>" result += "<img srcset='\(prefix2x)\(fileExtension) 2x' src='\(prefix1x)\(fileExtension)' loading='lazy' alt='\(description.htmlEscaped())'/>"
result += "</picture>" result += "</picture>"
return result return result

View File

@ -1,4 +1,72 @@
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<HeaderElement>
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 { final class TagOverviewGenerator {
let content: Content let content: Content
@ -13,49 +81,67 @@ final class TagOverviewGenerator {
self.results = results self.results = results
} }
func generatePage(tags: [Tag], overview: TagOverviewPage) { func generatePages(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)
let localized = overview.localized(in: language) let localized = overview.localized(in: language)
let header = TagHeaderContent(
let pageHeader = PageHeader(
language: language, language: language,
title: localized.linkPreviewTitle ?? localized.title, title: localized.linkPreviewTitle ?? localized.title,
description: localized.linkPreviewDescription, description: localized.linkPreviewDescription,
iconUrl: iconUrl, iconUrl: content.settings.navigation.localized(in: language).rootUrl,
languageButton: languageButton,
links: content.navigationBar(in: language), links: content.navigationBar(in: language),
headers: content.defaultPageHeaders, 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..<endIndex]
generatePage(tags: tagsOnPage, header: header, page: pageIndex, totalPages: numberOfPages)
}
}
private func generatePage(tags: ArraySlice<TagData>, header: TagHeaderContent, page pageNumber: Int, totalPages: Int) {
let pageHeader = header.pageHeader(pageNumber: pageNumber)
let page = GenericPage( let page = GenericPage(
header: pageHeader, header: pageHeader,
additionalFooter: "") { content in additionalFooter: "") { content in
content += "<h1>\(localized.title)</h1>" content += "<h1>\(header.title)</h1>"
for tag in tags { for tag in tags {
let localized = tag.localized(in: self.language) let description = tag.localized.description ?? ""
let url = tag.absoluteUrl(in: self.language) let image = self.makePageImage(item: tag.localized)
let title = localized.name
let description = localized.description ?? ""
let image = self.makePageImage(item: localized)
content += RelatedPageLink( content += RelatedPageLink(
title: title, title: tag.title,
description: description, description: description,
url: url, url: tag.url,
image: image) image: image)
.content .content
} }
// if totalPages > 1 { if totalPages > 1 {
// content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content content += PostFeedPageNavigation(
// } linkPrefix: header.baseUrl,
currentPage: pageNumber,
numberOfPages: totalPages).content
}
} }
let fileContent = page.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 { guard content.storage.write(fileContent, to: url) else {
results.unsavedOutput(url, source: .tagOverview) results.unsavedOutput(url, source: .tagOverview)

View File

@ -23,6 +23,8 @@ final class PostListPageGenerator {
func createPages(for posts: [Post]) { func createPages(for posts: [Post]) {
let totalCount = posts.count let totalCount = posts.count
guard totalCount > 0 else { guard totalCount > 0 else {
// Create one empty page
createPostFeedPage(1, pageCount: 1, posts: [])
return return
} }
let postsPerPage = source.postsPerPage let postsPerPage = source.postsPerPage
@ -80,7 +82,7 @@ final class PostListPageGenerator {
pageNumber: pageIndex, pageNumber: pageIndex,
totalPages: pageCount, totalPages: pageCount,
languageButtonUrl: languageButtonUrl, languageButtonUrl: languageButtonUrl,
linkPrefix: "/" + source.pageUrlPrefix(for: language) + "/") linkPrefix: source.pageUrlPrefix(for: language))
let filePath = pageUrl(in: language, pageNumber: pageIndex) + ".html" let filePath = pageUrl(in: language, pageNumber: pageIndex) + ".html"
guard save(fileContent, to: filePath) else { guard save(fileContent, to: filePath) else {
source.results.unsavedOutput(filePath, source: .feed) source.results.unsavedOutput(filePath, source: .feed)

View File

@ -2,7 +2,6 @@ import SwiftUI
import SFSafeSymbols import SFSafeSymbols
#warning("Fix podcast") #warning("Fix podcast")
#warning("Fix CV")
#warning("Fix endeavor basics (image compare)") #warning("Fix endeavor basics (image compare)")
#warning("Fix cap mosaic GIF") #warning("Fix cap mosaic GIF")
@ -23,6 +22,8 @@ import SFSafeSymbols
#warning("Add author to settings and page headers") #warning("Add author to settings and page headers")
#warning("Check for files in output folder not generated by app") #warning("Check for files in output folder not generated by app")
#warning("Fix GIFs: Don't rescale, don't use image set") #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 @main
struct MainView: App { struct MainView: App {
@ -49,9 +50,6 @@ struct MainView: App {
@State @State
private var selectedTag: Tag? private var selectedTag: Tag?
@State
private var selectedImage: ImageResource?
@State @State
private var selectedFile: FileResource? private var selectedFile: FileResource?
@ -122,7 +120,7 @@ struct MainView: App {
case .tags: case .tags:
AddTagView(selected: $selectedTag) AddTagView(selected: $selectedTag)
case .files: case .files:
AddFileView(selectedImage: $selectedImage, selectedFile: $selectedFile) AddFileView(selectedFile: $selectedFile)
case .generation: case .generation:
Text("Not implemented") Text("Not implemented")
} }

View File

@ -11,6 +11,7 @@ extension Content {
self.copyRequiredFiles() self.copyRequiredFiles()
self.generateRequiredImages() self.generateRequiredImages()
self.results.recalculate()
self.status("Generation completed") self.status("Generation completed")
} }
} }
@ -61,9 +62,6 @@ extension Content {
} }
private func generateRequiredImages() { private func generateRequiredImages() {
let imageGenerator = ImageGenerator(
storage: storage,
settings: settings)
let images = results.imagesToGenerate.sorted() let images = results.imagesToGenerate.sorted()
let count = images.count let count = images.count
@ -255,7 +253,7 @@ extension Content {
guard shouldGenerateWebsite else { return } guard shouldGenerateWebsite else { return }
let results = results.makeResults(for: .tagOverview, in: language) let results = results.makeResults(for: .tagOverview, in: language)
let generator = TagOverviewGenerator(content: self, language: language, results: results) let generator = TagOverviewGenerator(content: self, language: language, results: results)
generator.generatePage(tags: tags, overview: tagOverview) generator.generatePages(tags: tags, overview: tagOverview)
} }
} }

View File

@ -10,6 +10,7 @@ extension Content {
subtitle: tag.subtitle, subtitle: tag.subtitle,
description: tag.description, description: tag.description,
thumbnail: tag.thumbnail.map { images[$0] }, thumbnail: tag.thumbnail.map { images[$0] },
linkPreviewTitle: tag.linkPreviewTitle,
originalUrl: tag.originalURL) originalUrl: tag.originalURL)
} }

View File

@ -129,6 +129,7 @@ private extension LocalizedTag {
name: name, name: name,
subtitle: subtitle, subtitle: subtitle,
description: description, description: description,
linkPreviewTitle: linkPreviewTitle,
thumbnail: linkPreviewImage?.id, thumbnail: linkPreviewImage?.id,
originalURL: originalUrl) originalURL: originalUrl)
} }

View File

@ -7,7 +7,7 @@ extension Content {
private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted
func isNewIdForTag(_ id: String) -> Bool { func isNewIdForTag(_ id: String) -> Bool {
!tags.contains { $0.id == id } tagOverview?.id != id && !tags.contains { $0.id == id }
} }
func isNewIdForPage(_ id: String) -> Bool { func isNewIdForPage(_ id: String) -> Bool {

View File

@ -37,6 +37,8 @@ final class Content: ObservableObject {
@Published @Published
private(set) var shouldGenerateWebsite = false private(set) var shouldGenerateWebsite = false
let imageGenerator: ImageGenerator
init(settings: Settings, init(settings: Settings,
posts: [Post], posts: [Post],
pages: [Page], pages: [Page],
@ -53,6 +55,9 @@ final class Content: ObservableObject {
let storage = Storage() let storage = Storage()
self.storage = storage self.storage = storage
self.imageGenerator = ImageGenerator(
storage: storage,
settings: settings)
} }
init() { init() {
@ -67,6 +72,9 @@ final class Content: ObservableObject {
let storage = Storage() let storage = Storage()
self.storage = storage self.storage = storage
self.imageGenerator = ImageGenerator(
storage: storage,
settings: settings)
} }
private func clear() { private func clear() {
@ -112,4 +120,9 @@ final class Content: ObservableObject {
print("Failed to reload content: \(error)") print("Failed to reload content: \(error)")
} }
} }
func remove(_ file: FileResource) {
files.remove(file)
#warning("Remove file from required files, thumbnails, post images, etc.")
}
} }

View File

@ -14,8 +14,13 @@ final class FileResource: Item {
@Published @Published
var english: String var english: String
/// The dimensions of the image
@Published @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) { init(content: Content, id: String, isExternallyStored: Bool, en: String, de: String) {
self.type = FileType(fileExtension: id.fileExtension) self.type = FileType(fileExtension: id.fileExtension)
@ -49,10 +54,13 @@ final class FileResource: Item {
// MARK: Images // MARK: Images
var aspectRatio: CGFloat { var aspectRatio: CGFloat {
guard size.height > 0 else { guard let imageDimensions else {
return 0 return 0
} }
return size.width / size.height guard imageDimensions.height > 0 else {
return 0
}
return imageDimensions.width / imageDimensions.height
} }
var imageToDisplay: Image { var imageToDisplay: Image {
@ -60,18 +68,58 @@ final class FileResource: Item {
print("Failed to load data for image \(id)") print("Failed to load data for image \(id)")
return failureImage return failureImage
} }
if fileSize == nil {
DispatchQueue.main.async {
self.fileSize = imageData.count
}
}
guard let loadedImage = NSImage(data: imageData) else { guard let loadedImage = NSImage(data: imageData) else {
print("Failed to create image \(id)") print("Failed to create image \(id)")
return failureImage return failureImage
} }
if self.size == .zero && loadedImage.size != .zero { if loadedImage.size != imageDimensions {
DispatchQueue.main.async { DispatchQueue.main.async {
self.size = loadedImage.size self.imageDimensions = loadedImage.size
} }
} }
return .init(nsImage: loadedImage) 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 { private var failureImage: Image {
Image(systemSymbol: .exclamationmarkTriangle) Image(systemSymbol: .exclamationmarkTriangle)
} }
@ -105,6 +153,13 @@ final class FileResource: Item {
// MARK: Paths // MARK: Paths
func removeFileFromOutputFolder() {
content.storage.deleteInOutputFolder(path: absoluteUrl)
if type.isImage {
removeGeneratedImages()
}
}
/** /**
Get the url path to a file in the output folder. 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. The result is an absolute path from the output folder for use in HTML.

View File

@ -4,6 +4,10 @@ class Item: ObservableObject, Identifiable {
unowned let content: Content unowned let content: Content
/// A dummy property to force views to update when properties change
@Published
private var changeToggle = false
@Published @Published
var id: String var id: String
@ -12,6 +16,10 @@ class Item: ObservableObject, Identifiable {
self.id = id self.id = id
} }
func didChange() {
changeToggle.toggle()
}
func makeCleanAbsolutePath(_ path: String) -> String { func makeCleanAbsolutePath(_ path: String) -> String {
"/" + makeCleanRelativePath(path) "/" + makeCleanRelativePath(path)
} }

View File

@ -21,6 +21,9 @@ final class LocalizedTag: ObservableObject {
@Published @Published
var linkPreviewImage: FileResource? var linkPreviewImage: FileResource?
@Published
var linkPreviewTitle: String?
/// The original url in the previous site layout /// The original url in the previous site layout
let originalUrl: String? let originalUrl: String?
@ -30,6 +33,7 @@ final class LocalizedTag: ObservableObject {
subtitle: String? = nil, subtitle: String? = nil,
description: String? = nil, description: String? = nil,
thumbnail: FileResource? = nil, thumbnail: FileResource? = nil,
linkPreviewTitle: String? = nil,
originalUrl: String? = nil) { originalUrl: String? = nil) {
self.content = content self.content = content
self.urlComponent = urlComponent self.urlComponent = urlComponent
@ -37,6 +41,7 @@ final class LocalizedTag: ObservableObject {
self.subtitle = subtitle self.subtitle = subtitle
self.description = description self.description = description
self.linkPreviewImage = thumbnail self.linkPreviewImage = thumbnail
self.linkPreviewTitle = linkPreviewTitle
self.originalUrl = originalUrl self.originalUrl = originalUrl
} }
@ -48,10 +53,6 @@ final class LocalizedTag: ObservableObject {
extension LocalizedTag: LinkPreviewItem { extension LocalizedTag: LinkPreviewItem {
var linkPreviewTitle: String? {
self.name
}
var linkPreviewDescription: String? { var linkPreviewDescription: String? {
description description
} }

View File

@ -26,12 +26,19 @@ final class Tag: Item {
super.init(content: content, id: id) super.init(content: content, id: id)
} }
var linkName: String { func isValid(id: String) -> Bool {
id.lowercased().replacingOccurrences(of: " ", with: "-") content.isValidIdForTagOrPageOrPost(id) &&
content.isNewIdForTag(id)
} }
var url: String { @discardableResult
"/tags/\(linkName).html" 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 // MARK: Paths

View File

@ -15,7 +15,7 @@ struct PostFeedPageNavigation {
} }
private func pageLink(_ page: Int) -> String { private func pageLink(_ page: Int) -> String {
"href='\(linkPrefix)\(page)'" "href='/\(linkPrefix)/\(page)'"
} }
private func addPreviousButton(to result: inout String) { private func addPreviousButton(to result: inout String) {

View File

@ -28,6 +28,8 @@ struct LocalizedTagFile {
let description: String? let description: String?
let linkPreviewTitle: String?
/// The image id of the thumbnail /// The image id of the thumbnail
let thumbnail: String? let thumbnail: String?

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import AppKit
struct SecurityBookmark { struct SecurityBookmark {
@ -28,6 +29,14 @@ struct SecurityBookmark {
// MARK: Write // 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 { func fullPath(to relativePath: String) -> URL {
url.appending(path: relativePath, directoryHint: .notDirectory) url.appending(path: relativePath, directoryHint: .notDirectory)
} }
@ -103,6 +112,10 @@ struct SecurityBookmark {
// MARK: Read // MARK: Read
func size(of relativePath: String) -> Int? {
with(relativePath: relativePath) { $0.size }
}
func hasFile(at relativePath: String) -> Bool { func hasFile(at relativePath: String) -> Bool {
with(relativePath: relativePath, perform: exists) with(relativePath: relativePath, perform: exists)
} }
@ -212,6 +225,7 @@ struct SecurityBookmark {
func deleteFile(at relativePath: String) -> Bool { func deleteFile(at relativePath: String) -> Bool {
with(relativePath: relativePath) { file in with(relativePath: relativePath) { file in
guard exists(file) else { guard exists(file) else {
print("Scope: No file to delete at \(file.path())")
return true return true
} }
do { do {

View File

@ -183,13 +183,13 @@ final class Storage: ObservableObject {
tagId + ".json" tagId + ".json"
} }
private func tagFilePath(tagId: String) -> String { private func tagFilePath(tag tagId: String) -> String {
tagsFolderName + "/" + tagFileName(tagId: tagId) tagsFolderName + "/" + tagFileName(tagId: tagId)
} }
func save(tagMetadata: TagFile, for tagId: String) -> Bool { func save(tagMetadata: TagFile, for tagId: String) -> Bool {
guard let contentScope else { return false } guard let contentScope else { return false }
let path = tagFilePath(tagId: tagId) let path = tagFilePath(tag: tagId)
return contentScope.encode(tagMetadata, to: path) return contentScope.encode(tagMetadata, to: path)
} }
@ -211,6 +211,11 @@ final class Storage: ObservableObject {
return true 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 // MARK: File descriptions
func loadFileDescriptions() -> [FileDescriptions]? { func loadFileDescriptions() -> [FileDescriptions]? {
@ -235,6 +240,10 @@ final class Storage: ObservableObject {
// MARK: Files // 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 The full path to a resource file in the content folder
- Parameter file: The filename of the file - Parameter file: The filename of the file
@ -244,6 +253,16 @@ final class Storage: ObservableObject {
contentScope?.fullPath(to: filePath(file: file)) 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 The full file path to a file in the output folder
- Parameter relativePath: The path of the file relative to 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) outputScope?.fullPath(to: relativePath)
} }
func openFinderWindow(withSelectedFile file: String) {
contentScope?.openFinderWindow(relativePath: filePath(file: file))
}
private func filePath(file fileId: String) -> String { private func filePath(file fileId: String) -> String {
filesFolderName + "/" + fileId 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 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)) return contentScope.copy(externalFile: url, to: filePath(file: fileId))
} }
/**
Move (rename) a file resource.
*/
func move(file fileId: String, to newId: String) -> Bool { func move(file fileId: String, to newId: String) -> Bool {
guard let contentScope else { return false } guard let contentScope else { return false }
return contentScope.move(filePath(file: fileId), to: filePath(file: newId)) 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 { func copy(file fileId: String, to relativeOutputPath: String) -> Bool {
guard let contentScope, let outputScope else { return false } guard let contentScope, let outputScope else { return false }
return contentScope.transfer( return contentScope.transfer(

View File

@ -11,15 +11,11 @@ struct AddFileView: View {
@Binding @Binding
var selectedFile: FileResource? var selectedFile: FileResource?
@Binding
var selectedImage: ImageResource?
@State @State
private var filesToAdd: [FileToAdd] = [] private var filesToAdd: [FileToAdd] = []
init(selectedImage: Binding<ImageResource?>, selectedFile: Binding<FileResource?>) { init(selectedFile: Binding<FileResource?>) {
_selectedFile = selectedFile _selectedFile = selectedFile
_selectedImage = selectedImage
} }
var body: some View { var body: some View {
@ -111,6 +107,5 @@ struct AddFileView: View {
} }
#Preview { #Preview {
AddFileView(selectedImage: .constant(nil), AddFileView(selectedFile: .constant(nil))
selectedFile: .constant(nil))
} }

View File

@ -2,15 +2,36 @@ import SwiftUI
struct FileDetailView: View { struct FileDetailView: View {
@EnvironmentObject
private var content: Content
@ObservedObject @ObservedObject
var file: FileResource var file: FileResource
@State
private var showFileSelection = false
@State
private var selectedFile: FileResource?
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
DetailTitle( DetailTitle(
title: "File", title: "File",
text: "A file that can be used in a post or page") 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( IdPropertyView(
id: $file.id, id: $file.id,
title: "Name", title: "Name",
@ -28,15 +49,108 @@ struct FileDetailView: View {
text: $file.english, text: $file.english,
footer: "The description for the file in English. Descriptions are used for images and to explain the content of a file.") 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 { if let imageDimensions = file.imageDimensions {
Text("Image size") GenericPropertyView(title: "Image dimensions") {
.font(.headline) Text("\(Int(imageDimensions.width)) x \(Int(imageDimensions.height)) (\(file.aspectRatio))")
Text("\(Int(file.size.width)) x \(Int(file.size.height)) (\(file.aspectRatio))") }
.foregroundStyle(.secondary)
#warning("Add button to show image versions") #warning("Add button to show image versions")
} }
if let fileSize = file.fileSize {
GenericPropertyView(title: "File size") {
Text(formatBytes(fileSize))
}
}
Spacer() Spacer()
}.padding() }.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)
} }
} }

View File

@ -47,6 +47,7 @@ struct MultiFileSelectionView: View {
} }
var body: some View { var body: some View {
GeometryReader { geo in
HStack { HStack {
VStack { VStack {
Text("Selected files") Text("Selected files")
@ -75,7 +76,7 @@ struct MultiFileSelectionView: View {
dismiss() dismiss()
} }
} }
} }.frame(width: geo.size.width / 2)
VStack { VStack {
Picker("", selection: $selectedFileType) { Picker("", selection: $selectedFileType) {
let all: FileTypeCategory? = nil let all: FileTypeCategory? = nil
@ -84,7 +85,6 @@ struct MultiFileSelectionView: View {
Text(type.text).tag(type) Text(type.text).tag(type)
} }
} }
.pickerStyle(.segmented)
.padding(.trailing, 7) .padding(.trailing, 7)
.disabled(allowedType != nil) .disabled(allowedType != nil)
TextField("", text: $searchString, prompt: Text("Search")) TextField("", text: $searchString, prompt: Text("Search"))
@ -105,10 +105,13 @@ struct MultiFileSelectionView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { select(file: file) } .onTapGesture { select(file: file) }
} }
}.frame(width: geo.size.width / 2)
} }
.frame(minHeight: 500, idealHeight: 600)
} }
.frame(minHeight: 500, idealHeight: 600) .frame(minHeight: 500, idealHeight: 600)
.padding() .padding()
} }
private func deselect(file: FileResource) { private func deselect(file: FileResource) {

View File

@ -4,11 +4,11 @@ struct GenericPropertyView<Content>: View where Content: View {
let title: LocalizedStringKey let title: LocalizedStringKey
let footer: LocalizedStringKey let footer: LocalizedStringKey?
let content: Content 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.title = title
self.footer = footer self.footer = footer
self.content = content() self.content = content()
@ -18,10 +18,16 @@ struct GenericPropertyView<Content>: View where Content: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(title) Text(title)
.font(.headline) .font(.headline)
if let footer {
content content
Text(footer) Text(footer)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.bottom) .padding(.bottom)
} else {
content
.padding(.bottom)
}
} }
} }
} }

View File

@ -24,6 +24,12 @@ struct LocalizedPageDetailView: View {
footer: "Prevent the date and title from being printed on the page") footer: "Prevent the date and title from being printed on the page")
.disabled(isExternalPage) .disabled(isExternalPage)
if let url = page.originalUrl {
GenericPropertyView(title: "Original URL") {
Text(url)
}
}
OptionalStringPropertyView( OptionalStringPropertyView(
title: "Preview Title", title: "Preview Title",
text: $page.linkPreviewTitle, text: $page.linkPreviewTitle,

View File

@ -28,8 +28,8 @@ struct AddTagView: View {
content: content, content: content,
id: "tag", id: "tag",
isVisible: true, isVisible: true,
german: .init(content: .mock, urlComponent: "tag", name: "Neuer Tag"), german: .init(content: content, urlComponent: "tag", name: "Neuer Tag"),
english: .init(content: .mock, urlComponent: "tag-en", name: "New Tag")) english: .init(content: content, urlComponent: "tag-en", name: "New Tag"))
// Add to top of the list, and resort when changing the name // Add to top of the list, and resort when changing the name
content.tags.insert(newTag, at: 0) content.tags.insert(newTag, at: 0)
dismiss() dismiss()

View File

@ -25,18 +25,23 @@ struct LocalizedTagDetailView: View {
validation: tag.isValid, validation: tag.isValid,
update: { tag.urlComponent = $0 }) update: { tag.urlComponent = $0 })
Text("Original url") if let url = tag.originalUrl {
.font(.headline) GenericPropertyView(title: "Original URL") {
Text(tag.originalUrl ?? "-") Text(url)
.foregroundStyle(.secondary) }
.padding(.top, 1) }
.padding(.bottom)
OptionalStringPropertyView( OptionalStringPropertyView(
title: "Subtitle", title: "Subtitle",
text: $tag.subtitle, text: $tag.subtitle,
footer: "The subtitle/tagline to use") 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( OptionalImagePropertyView(
title: "Preview Image", title: "Preview Image",
selectedImage: $tag.linkPreviewImage, selectedImage: $tag.linkPreviewImage,

View File

@ -21,8 +21,17 @@ struct TagDetailView: View {
value: $tag.isVisible, 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.") 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( LocalizedTagDetailView(
tag: tag.localized(in: language)) tag: tag.localized(in: language))
.id(tag.id + language.rawValue)
} }
.padding() .padding()
} }