Compare commits

...

7 Commits

Author SHA1 Message Date
Christoph Hagen
a4710d525b Add button to remove a tag 2025-05-04 11:55:54 +02:00
Christoph Hagen
a8920a4cd2 Remove selection when deleting file 2025-05-04 11:48:58 +02:00
Christoph Hagen
cb041eb6ed Add button to delete page 2025-05-04 11:48:31 +02:00
Christoph Hagen
329519e15b Add button to remove post 2025-05-04 11:48:09 +02:00
Christoph Hagen
d6502fb09c Show source of missing page links 2025-05-04 11:47:20 +02:00
Christoph Hagen
dd720d6646 Show drafts in generation view 2025-05-04 10:13:59 +02:00
Christoph Hagen
e689903f3c Fix image dimension crash 2025-05-04 09:36:37 +02:00
13 changed files with 224 additions and 24 deletions

View File

@@ -207,6 +207,7 @@
E2F3B3982DC54F9400CFA712 /* ChangeObservingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */; };
E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */; };
E2F3B39E2DC55B1C00CFA712 /* LabelCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B39D2DC55B1C00CFA712 /* LabelCreationView.swift */; };
E2F3B3A22DC769C300CFA712 /* DeleteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3A12DC769BF00CFA712 /* DeleteButton.swift */; };
E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D0C2D2DBBA100B48627 /* LinkPreview.swift */; };
E2FD1D192D2DC4F500B48627 /* LoadingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */; };
E2FD1D1B2D2DC63800B48627 /* LinkPreviewDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */; };
@@ -490,6 +491,7 @@
E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeObservingItem.swift; sourceTree = "<group>"; };
E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelEditingView.swift; sourceTree = "<group>"; };
E2F3B39D2DC55B1C00CFA712 /* LabelCreationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelCreationView.swift; sourceTree = "<group>"; };
E2F3B3A12DC769BF00CFA712 /* DeleteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteButton.swift; sourceTree = "<group>"; };
E2FD1D0C2D2DBBA100B48627 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = "<group>"; };
E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingContext.swift; sourceTree = "<group>"; };
E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewDetailView.swift; sourceTree = "<group>"; };
@@ -829,6 +831,7 @@
E2A21C372CB9A4F10060935B /* Generic */ = {
isa = PBXGroup;
children = (
E2F3B3A12DC769BF00CFA712 /* DeleteButton.swift */,
E229902F2D0F75CF009F8D77 /* BoolPropertyView.swift */,
E22990312D0F7678009F8D77 /* DatePropertyView.swift */,
E29D312F2D03A2BD0051B7F4 /* DescriptionField.swift */,
@@ -1449,6 +1452,7 @@
E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */,
E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */,
E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */,
E2F3B3A22DC769C300CFA712 /* DeleteButton.swift in Sources */,
E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */,
E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */,
E2FE0F422D2B4821002963B7 /* OtherCodeBlock.swift in Sources */,

View File

@@ -260,6 +260,18 @@ final class GenerationResults: ObservableObject {
let unused = existingFiles.subtracting(outputFiles)
update { self.unusedFilesInOutput = unused }
}
func sources(forMissingPage page: String) -> [(page: LocalizedItemId, source: String)] {
var all = [(page: LocalizedItemId, source: String)]()
for (id, results) in cache {
guard let sources = results.missingLinkedPages[page]?.sorted() else {
continue
}
let additions = sources.map { (page: id, source: $0) }
all.append(contentsOf: additions)
}
return all
}
}
private extension Dictionary where Value == Set<LocalizedItemId> {

View File

@@ -16,4 +16,28 @@ final class SelectedContent: ObservableObject {
@Published
var file: FileResource?
func remove(_ post: Post) {
if self.post == post {
self.post = nil
}
}
func remove(_ page: Page) {
if self.page == page {
self.page = nil
}
}
func remove(_ tag: Tag) {
if self.tag == tag {
self.tag = nil
}
}
func remove(_ file: FileResource) {
if self.file == file {
self.file = nil
}
}
}

View File

@@ -113,6 +113,8 @@ final class Content: ObservableObject {
loadFromDisk(callback: callback)
}
// MARK: Removing items
func remove(_ file: FileResource) {
files.remove(file)
for post in posts {
@@ -129,6 +131,33 @@ final class Content: ObservableObject {
settings.remove(file)
}
func remove(_ post: Post) {
posts.remove(post)
}
func remove(_ page: Page) {
pages.remove(page)
for post in posts {
if post.linkedPage == page {
post.linkedPage = nil
}
}
// TODO: Check for page links and other references in content
}
func remove(_ tag: Tag) {
tags.remove(tag)
for post in posts {
post.tags.remove(tag)
}
for page in pages {
page.tags.remove(tag)
}
// TODO: Check for tag links and other references is content
}
// MARK: Loading
func file(withOutputPath: String) -> FileResource? {
files.first { $0.absoluteUrl == withOutputPath }
}
@@ -214,11 +243,17 @@ final class Content: ObservableObject {
private var imageDimensions: [String: CGSize] = [:]
private let imageDimensionsQueue = DispatchQueue(label: "imageDimensionsQueue")
func dimensions(of image: String) -> CGSize? {
imageDimensions[image]
imageDimensionsQueue.sync {
imageDimensions[image]
}
}
func cache(dimensions: CGSize?, of image: String) {
imageDimensions[image] = dimensions
imageDimensionsQueue.sync {
imageDimensions[image] = dimensions
}
}
}

View File

@@ -31,3 +31,10 @@ extension LocalizedItemId: Comparable {
return lhs.language < rhs.language
}
}
extension LocalizedItemId: CustomStringConvertible {
var description: String {
"\(itemType) (\(language))"
}
}

View File

@@ -147,6 +147,27 @@ final class Storage: ObservableObject {
return result
}
/**
Completely delete a post file from the content folder
*/
func delete(page pageId: String) -> Bool {
guard let contentScope else { return false }
guard contentScope.deleteFile(at: pageMetadataPath(page: pageId)) else {
return false
}
// Move the existing content files
var result = true
for language in ContentLanguage.allCases {
// Copy as many files as possible, since metadata was already moved
// Don't fail early
if !contentScope.deleteFile(at: pageContentPath(page: pageId, language: language)) {
print("Failed to delete content file \(language) of page \(pageId)")
result = false
}
}
return result
}
// MARK: Posts
private func postFileName(_ postId: String) -> String {
@@ -186,6 +207,14 @@ final class Storage: ObservableObject {
return contentScope.move(postFilePath(post: postId), to: postFilePath(post: newId))
}
/**
Completely delete a post file from the content folder
*/
func delete(post postId: String) -> Bool {
guard let contentScope else { return false }
return contentScope.deleteFile(at: postFilePath(post: postId))
}
// MARK: Tags
private func tagFileName(tagId: String) -> String {
@@ -225,6 +254,11 @@ final class Storage: ObservableObject {
return contentScope.move(tagFilePath(tag: tagId), to: tagFilePath(tag: newId))
}
func delete(tag tagId: String) -> Bool {
guard let contentScope else { return false }
return contentScope.deleteFile(at: tagFilePath(tag: tagId))
}
// MARK: Files
func size(of file: String) -> Int? {

View File

@@ -34,6 +34,9 @@ struct FileDetailView: View {
@EnvironmentObject
private var content: Content
@EnvironmentObject
private var selection: SelectedContent
@Environment(\.language)
private var language
@@ -218,6 +221,7 @@ struct FileDetailView: View {
return
}
content.remove(file)
selection.remove(file)
}
}

View File

@@ -12,6 +12,14 @@ struct GenerationContentView: View {
@Environment(\.dismiss)
private var dismiss
var draftPages: Set<Page> {
Set(content.pages.filter { $0.isDraft })
}
var draftPosts: Set<Post> {
Set(content.posts.filter { $0.isDraft })
}
var body: some View {
VStack(alignment: .leading) {
Text("Website Generation")
@@ -45,7 +53,7 @@ struct GenerationContentView: View {
GenerationStringIssuesView(
text: "output files",
statusWhenNonEmpty: .nominal,
items: $content.results.outputFiles)
items: content.results.outputFiles)
GenerationResultsIssueView(
text: "\(content.results.imagesToGenerate.count) images",
status: .nominal,
@@ -57,54 +65,68 @@ struct GenerationContentView: View {
GenerationStringIssuesView(
text: "external links",
statusWhenNonEmpty: .nominal,
items: $content.results.externalLinks)
items: content.results.externalLinks)
GenerationStringIssuesView(
text: "required files",
statusWhenNonEmpty: .nominal,
items: $content.results.requiredFiles) { $0.id }
items: content.results.requiredFiles) { $0.id }
GenerationStringIssuesView(
text: "external files",
statusWhenNonEmpty: .nominal,
items: $content.results.externalFiles) { $0.id }
items: content.results.externalFiles) { $0.id }
GenerationStringIssuesView(
text: "empty pages",
statusWhenNonEmpty: .warning,
items: $content.results.emptyPages) { "\($0.pageId) (\($0.language))" }
items: content.results.emptyPages) { "\($0.pageId) (\($0.language))" }
GenerationStringIssuesView(
text: "draft pages",
statusWhenNonEmpty: .warning,
items: draftPages) { $0.id }
GenerationStringIssuesView(
text: "draft posts",
statusWhenNonEmpty: .warning,
items: draftPosts) { $0.id }
GenerationStringIssuesView(
text: "additional output files",
statusWhenNonEmpty: .warning,
items: $content.results.unusedFilesInOutput)
items: content.results.unusedFilesInOutput)
GenerationStringIssuesView(
text: "inaccessible files",
items: $content.results.inaccessibleFiles) { $0.id }
items: content.results.inaccessibleFiles) { $0.id }
GenerationStringIssuesView(
text: "unparsable files",
items: $content.results.unparsableFiles) { $0.id }
items: content.results.unparsableFiles) { $0.id }
GenerationStringIssuesView(
text: "unsaved output files",
items: $content.results.unsavedOutputFiles)
items: content.results.unsavedOutputFiles)
GenerationStringIssuesView(
text: "failed image generations",
items: $content.results.failedImages) { $0.outputPath }
items: content.results.failedImages) { $0.outputPath }
GenerationStringIssuesView(
text: "missing files",
items: $content.results.missingFiles)
items: content.results.missingFiles)
GenerationStringIssuesView(
text: "missing tags",
items: $content.results.missingTags)
items: content.results.missingTags)
GenerationStringIssuesView(
text: "missing pages",
items: $content.results.missingPages)
items: content.results.missingPages) { pageId in
let sources = content.results.sources(forMissingPage: pageId)
.map { "\($0.page): \($0.source)"}
.joined(separator: ", ")
return "\(pageId) (\(sources))"
}
GenerationStringIssuesView(
text: "invalid commands",
items: $content.results.invalidCommands)
items: content.results.invalidCommands)
GenerationStringIssuesView(
text: "invalid blocks",
items: $content.results.invalidBlocks)
items: content.results.invalidBlocks)
GenerationStringIssuesView(
text: "warnings",
statusWhenNonEmpty: .warning,
items: $content.results.warnings)
items: content.results.warnings)
HorizontalCenter {
Button(action: { dismiss() }) {
Text("Close")

View File

@@ -6,8 +6,7 @@ struct GenerationStringIssuesView<T>: View where T: Hashable {
let statusWhenNonEmpty: IssueStatus
@Binding
var items: Set<T>
let items: Set<T>
let map: (T) -> String
@@ -18,10 +17,10 @@ struct GenerationStringIssuesView<T>: View where T: Hashable {
items.isEmpty ? .nominal : statusWhenNonEmpty
}
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding<Set<T>>, map: @escaping (T) -> String) {
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Set<T>, map: @escaping (T) -> String) {
self.text = text
self.statusWhenNonEmpty = statusWhenNonEmpty
self._items = items
self.items = items
self.map = map
}
@@ -56,10 +55,10 @@ struct GenerationStringIssuesView<T>: View where T: Hashable {
extension GenerationStringIssuesView where T == String {
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding<Set<String>>) {
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Set<String>) {
self.text = text
self.statusWhenNonEmpty = statusWhenNonEmpty
self._items = items
self.items = items
self.map = { $0 }
}
}

View File

@@ -0,0 +1,20 @@
import SwiftUI
struct DeleteButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
HStack {
Spacer()
Image(systemSymbol: .trash)
Text("Delete")
.padding(.vertical, 8)
Spacer()
}
.foregroundStyle(Color.white)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red))
}.buttonStyle(.plain)
}
}

View File

@@ -9,6 +9,9 @@ struct PageDetailView: View {
@EnvironmentObject
private var content: Content
@EnvironmentObject
private var selection: SelectedContent
@ObservedObject
private var page: Page
@@ -73,10 +76,20 @@ struct PageDetailView: View {
page: page.localized(in: language),
transferImage: transferImage)
.id(page.id + language.rawValue)
DeleteButton(action: deletePage)
}
.padding()
}
}
private func deletePage() {
guard content.storage.delete(page: page.id) else {
print("Page '\(page.id)': Failed to delete file in content folder")
return
}
content.remove(page)
selection.remove(page)
}
}
extension PageDetailView: MainContentView {

View File

@@ -76,6 +76,7 @@ struct PostDetailView: View {
LocalizedPostDetailView(
post: post.localized(in: language),
transferImage: transferImage)
DeleteButton(action: deletePost)
}
.padding()
}
@@ -90,6 +91,15 @@ struct PostDetailView: View {
selection.tab = .pages
}
}
private func deletePost() {
guard content.storage.delete(post: post.id) else {
print("Post '\(post.id)': Failed to delete file in content folder")
return
}
content.remove(post)
selection.remove(post)
}
}
extension PostDetailView: MainContentView {

View File

@@ -6,6 +6,12 @@ struct TagDetailView: View {
@Environment(\.language)
private var language
@EnvironmentObject
private var content: Content
@EnvironmentObject
private var selection: SelectedContent
@ObservedObject
var tag: Tag
@@ -37,10 +43,20 @@ struct TagDetailView: View {
tag: tag.localized(in: language),
transferImage: transferImage)
.id(tag.id + language.rawValue)
DeleteButton(action: deleteTag)
}
.padding()
}
}
private func deleteTag() {
guard content.storage.delete(tag: tag.id) else {
print("Tag '\(tag.id)': Failed to delete file in content folder")
return
}
content.remove(tag)
selection.remove(tag)
}
}
extension TagDetailView: MainContentView {