External files, improve page generation

This commit is contained in:
Christoph Hagen
2024-12-10 15:21:28 +01:00
parent 8183bc4903
commit efc9234917
50 changed files with 1069 additions and 424 deletions

View File

@ -34,6 +34,7 @@ struct AddFileView: View {
HStack {
Button("Cancel", role: .cancel) { dismiss() }
Button("Select more files", action: openFilePanel)
Button("Add placeholder", action: addPlaceholderFile)
Button("Add selected", action: importSelectedFiles)
.disabled(filesToAdd.isEmpty)
}
@ -68,6 +69,11 @@ struct AddFileView: View {
}
}
private func addPlaceholderFile() {
let newFile = FileToAdd(content: content, externalFile: "placeholder")
filesToAdd.append(newFile)
}
private func delete(file: FileToAdd) {
guard let index = filesToAdd.firstIndex(of: file) else {
return
@ -85,16 +91,19 @@ struct AddFileView: View {
print("Skipping existing file \(file.uniqueId)")
continue
}
do {
try content.storage.copyFile(at: file.url, fileId: file.uniqueId)
} catch {
print("Failed to import file '\(file.uniqueId)' at \(file.url.path()): \(error)")
return
if let url = file.url {
do {
try content.storage.copyFile(at: url, fileId: file.uniqueId)
} catch {
print("Failed to import file '\(file.uniqueId)' at \(url.path()): \(error)")
return
}
}
let resource = FileResource(
content: content,
id: file.uniqueId,
isExternallyStored: file.url == nil,
en: "", de: "")
// TODO: Insert at correct index?
content.files.insert(resource, at: 0)

View File

@ -13,44 +13,56 @@ struct FileContentView: View {
var body: some View {
VStack {
switch file.type {
case .image:
file.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
case .model:
if file.isExternallyStored {
VStack {
Image(systemSymbol: .cubeTransparent)
Image(systemSymbol: .squareDashed)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: iconSize)
Text("No preview available")
Text("External file")
.font(.title)
}
.foregroundStyle(.secondary)
case .text, .code:
TextFileContentView(file: file)
.id(file.id)
case .video:
VStack {
Image(systemSymbol: .film)
} else {
switch file.type {
case .image:
file.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: iconSize)
Text("No preview available")
.font(.title)
case .model:
VStack {
Image(systemSymbol: .cubeTransparent)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: iconSize)
Text("No preview available")
.font(.title)
}
.foregroundStyle(.secondary)
case .text, .code:
TextFileContentView(file: file)
.id(file.id)
case .video:
VStack {
Image(systemSymbol: .film)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: iconSize)
Text("No preview available")
.font(.title)
}
.foregroundStyle(.secondary)
case .other:
VStack {
Image(systemSymbol: .docQuestionmark)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: iconSize)
Text("No preview available")
.font(.title)
}
.foregroundStyle(.secondary)
}
.foregroundStyle(.secondary)
case .other:
VStack {
Image(systemSymbol: .docQuestionmark)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: iconSize)
Text("No preview available")
.font(.title)
}
.foregroundStyle(.secondary)
}
}.padding()
}

View File

@ -2,9 +2,12 @@ import Foundation
final class FileToAdd: ObservableObject {
let id: Int
unowned let content: Content
let url: URL
// The external path to the file, or nil if the file is just a placeholder
let url: URL?
@Published
var uniqueId: String
@ -13,11 +16,19 @@ final class FileToAdd: ObservableObject {
var isSelected: Bool = true
init(content: Content, url: URL) {
self.id = .random()
self.content = content
self.url = url
self.uniqueId = url.lastPathComponent
}
init(content: Content, externalFile: String) {
self.id = .random()
self.content = content
self.url = nil
self.uniqueId = externalFile
}
var idAlreadyExists: Bool {
content.files.contains { $0.id == uniqueId }
}
@ -25,9 +36,6 @@ final class FileToAdd: ObservableObject {
extension FileToAdd: Identifiable {
var id: URL {
url
}
}
extension FileToAdd: Equatable {

View File

@ -30,7 +30,7 @@ struct FileToAddView: View {
.frame(maxWidth: 200)
}
Text(file.url.path())
Text(file.url?.path() ?? "Placeholder file")
.foregroundStyle(.secondary)
}

View File

@ -1,26 +0,0 @@
import SwiftUI
struct ImageContentView: View {
@ObservedObject
var image: FileResource
var body: some View {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
}
}
extension ImageContentView: MainContentView {
init(item: FileResource) {
self.image = item
}
static let itemDescription = "an image"
}
#Preview {
ImageContentView(image: .init(resourceImage: "image1", type: .jpg))
}

View File

@ -73,9 +73,11 @@ struct AddPageView: View {
createdDate: .now,
startDate: .now,
endDate: nil,
german: .init(urlString: "seite",
german: .init(content: content,
urlString: "seite",
title: "Ein Titel"),
english: .init(urlString: "page",
english: .init(content: content,
urlString: "page",
title: "A Title"),
tags: [])
content.pages.insert(page, at: 0)

View File

@ -1,4 +1,5 @@
import SwiftUI
import SFSafeSymbols
import HighlightedTextEditor
struct LocalizedPageContentView: View {
@ -11,17 +12,21 @@ struct LocalizedPageContentView: View {
@Environment(\.language)
private var language
@EnvironmentObject
private var content: Content
@State
private var isGeneratingWebsite = false
@State
private var loadedPageContentLanguage: ContentLanguage?
@State
private var pageContent: String = ""
@State
private var didLoadContent = false
private var pageContentUsedForGeneration: String = ""
@State
private var generationResults = PageGenerationResults()
init(pageId: String, page: LocalizedPage) {
self.pageId = pageId
@ -41,8 +46,12 @@ struct LocalizedPageContentView: View {
Button(action: saveContent) {
Text("Save")
}
Button(action: checkContent) {
Text("Check")
}
Spacer()
}
PageContentResultsView(results: generationResults)
HighlightedTextEditor(
text: $pageContent,
highlightRules: .markdown)
@ -53,32 +62,50 @@ struct LocalizedPageContentView: View {
}
private func loadContent() {
let language = language
do {
let content = try content.storage.pageContent(for: pageId, language: language)
let content = try page.content.storage.pageContent(for: pageId, language: language)
guard content != "" else {
pageContent = "New file"
didLoadContent = false
loadedPageContentLanguage = nil
return
}
pageContent = content
didLoadContent = true
loadedPageContentLanguage = language
checkContent()
} catch {
print("Failed to load page content: \(error)")
pageContent = "Failed to load"
loadedPageContentLanguage = nil
}
}
private func saveContent() {
guard didLoadContent else {
guard let loadedPageContentLanguage else {
return
}
do {
try content.storage.save(pageContent: pageContent, for: pageId, language: language)
try page.content.storage.save(pageContent: pageContent, for: pageId, language: loadedPageContentLanguage)
} catch {
print("Failed to save content: \(error)")
}
}
private func checkContent() {
let content = self.pageContent
guard content != pageContentUsedForGeneration else {
return
}
isGeneratingWebsite = true
DispatchQueue.global(qos: .background).async {
let generator = PageContentParser(content: page.content, language: language)
_ = generator.generatePage(from: content)
DispatchQueue.main.async {
self.generationResults = generator.results
isGeneratingWebsite = false
}
}
}
}

View File

@ -4,22 +4,47 @@ import SFSafeSymbols
struct LocalizedPageDetailView: View {
@ObservedObject
private var item: LocalizedPage
private var page: LocalizedPage
init(page: LocalizedPage, showImagePicker: Bool = false) {
self.item = page
self.page = page
self.showImagePicker = showImagePicker
self.newUrlString = page.urlString
}
@State
private var showImagePicker = false
@State
private var newUrlString: String
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
private var idExists: Bool {
page.content.pages.contains {
$0.german.urlString == newUrlString
|| $0.english.urlString == newUrlString
}
}
private var containsInvalidCharacters: Bool {
newUrlString.rangeOfCharacter(from: allowedCharactersInPostId) != nil
}
var body: some View {
VStack(alignment: .leading) {
HStack {
TextField("", text: $newUrlString)
.textFieldStyle(.roundedBorder)
Button("Update", action: setNewId)
.disabled(newUrlString.isEmpty || containsInvalidCharacters || idExists)
}
.padding(.bottom)
Text("Link Preview Title")
.font(.headline)
OptionalTextField("", text: $item.linkPreviewTitle,
prompt: item.title)
OptionalTextField("", text: $page.linkPreviewTitle,
prompt: page.title)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
@ -35,13 +60,13 @@ struct LocalizedPageDetailView: View {
IconButton(symbol: .trashCircleFill,
size: 22,
color: .red) {
item.linkPreviewImage = nil
}.disabled(item.linkPreviewImage == nil)
page.linkPreviewImage = nil
}.disabled(page.linkPreviewImage == nil)
Spacer()
}
.buttonStyle(.plain)
if let image = item.linkPreviewImage {
if let image = page.linkPreviewImage {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
@ -52,16 +77,20 @@ struct LocalizedPageDetailView: View {
Text("Link Preview Description")
.font(.headline)
.padding(.top)
OptionalDescriptionField(text: $item.linkPreviewDescription)
OptionalDescriptionField(text: $page.linkPreviewDescription)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
item.linkPreviewImage = image
page.linkPreviewImage = image
}
}
}
private func setNewId() {
page.urlString = newUrlString
}
}
#Preview {

View File

@ -0,0 +1,118 @@
import SwiftUI
import SFSafeSymbols
private struct ListPopup: View {
@Environment(\.dismiss)
var dismiss
let items: [String]
var body: some View {
VStack {
List {
ForEach(items, id: \.self) { page in
Text(page)
}
}
.frame(minHeight: min(CGFloat(items.count) * 31, 500))
Button("Dismiss") { dismiss() }
}
.padding(.vertical)
.onTapGesture {
dismiss()
}
}
}
private struct TextWithPopup: View {
let symbol: SFSymbol
let text: LocalizedStringKey
let items: [String]
@State
private var isHovering = false
var body: some View {
HStack {
Image(systemSymbol: symbol)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
Text(text)
}
.contentShape(Rectangle())
.onTapGesture {
if items.count > 0 {
isHovering.toggle()
}
}
.sheet(isPresented: $isHovering) {
ListPopup(items: items)
.onTapGesture {
isHovering.toggle()
}
}
}
}
struct PageContentResultsView: View {
@Environment(\.language)
private var language
@ObservedObject
var results: PageGenerationResults
var body: some View {
HStack {
TextWithPopup(
symbol: .photoOnRectangleAngled,
text: "\(results.files.count + results.missingFiles.count) images and files",
items: results.files.sorted().map { $0.id })
.foregroundStyle(.secondary)
TextWithPopup(
symbol: .docBadgePlus,
text: "\(results.linkedPages.count + results.missingPages.count) page links",
items: results.linkedPages.sorted().map { $0.localized(in: language).title })
.foregroundStyle(.secondary)
if !results.missingPages.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
text: "\(results.missingPages.count) missing pages",
items: results.missingPages.sorted())
.foregroundStyle(.red)
}
if !results.missingFiles.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
text: "\(results.missingFiles.count) missing files",
items: results.missingFiles.sorted())
.foregroundStyle(.red)
}
if !results.warnings.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
text: "\(results.warnings.count) errors",
items: results.warnings.sorted())
.foregroundStyle(.red)
}
if !results.invalidCommandArguments.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
text: "\(results.invalidCommandArguments.count) errors",
items: results.invalidCommandArguments.map {
"\($0.command.rawValue): \($0.arguments.joined(separator: ";"))"
})
.foregroundStyle(.red)
}
}
}
}
#Preview {
PageContentResultsView(results: .init())
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import SFSafeSymbols
struct PageDetailView: View {
@ -17,6 +18,9 @@ struct PageDetailView: View {
@State
private var newId: String
@State
private var didGenerateWebsite: Bool?
init(page: Page) {
self.page = page
self.newId = page.id
@ -35,10 +39,21 @@ struct PageDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Button(action: generate) {
Text("Generate")
HStack {
Button(action: generate) {
Text("Generate")
}
.disabled(isGeneratingWebsite)
if let didGenerateWebsite {
if didGenerateWebsite {
Image(systemSymbol: .checkmarkCircleFill)
.foregroundStyle(.green)
} else {
Image(systemSymbol: .xmarkCircleFill)
.foregroundStyle(.red)
}
}
}
.disabled(isGeneratingWebsite)
HStack {
TextField("", text: $newId)
.textFieldStyle(.roundedBorder)
@ -86,6 +101,7 @@ struct PageDetailView: View {
}
LocalizedPageDetailView(page: page.localized(in: language))
.id(page.id + language.rawValue)
}
.padding()
@ -106,11 +122,13 @@ struct PageDetailView: View {
isGeneratingWebsite = true
print("Generating page")
DispatchQueue.global(qos: .userInitiated).async {
let generator = WebsiteGenerator(
content: content,
language: language)
if !generator.generate(page: page) {
print("Generation failed")
for language in ContentLanguage.allCases {
let generator = LocalizedWebsiteGenerator(
content: content,
language: language)
if !generator.generate(page: page) {
print("Generation failed")
}
}
DispatchQueue.main.async {
isGeneratingWebsite = false

View File

@ -1,6 +1,6 @@
import SwiftUI
struct GenerationSettingsView: View {
struct GenerationContentView: View {
@Environment(\.language)
private var language
@ -37,7 +37,7 @@ struct GenerationSettingsView: View {
Text(generatorText)
Spacer()
}
}
}.padding()
}
}
@ -54,7 +54,7 @@ struct GenerationSettingsView: View {
}
isGeneratingWebsite = true
DispatchQueue.global(qos: .userInitiated).async {
let generator = WebsiteGenerator(
let generator = LocalizedWebsiteGenerator(
content: content,
language: language)
_ = generator.generateWebsite { text in
@ -71,7 +71,7 @@ struct GenerationSettingsView: View {
}
#Preview {
GenerationSettingsView()
GenerationContentView()
.environmentObject(Content.mock)
.padding()
}

View File

@ -0,0 +1,30 @@
import SwiftUI
struct GenerationDetailView: View {
let section: SettingsSection
var body: some View {
Group {
switch section {
//case .generation:
// GenerationSettingsView()
case .folders:
FolderSettingsView()
case .navigationBar:
NavigationBarSettingsView()
case .postFeed:
PostFeedSettingsView()
case .pages:
PageSettingsView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
.navigationTitle("")
}
}
#Preview {
GenerationDetailView(section: .folders)
}

View File

@ -26,7 +26,7 @@ struct NavigationBarSettingsView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Notification Bar Settings")
Text("Navigation Bar")
.font(.largeTitle)
.bold()
Text("Customize the navigation bar for all pages at the top of the website")
@ -37,7 +37,6 @@ struct NavigationBarSettingsView: View {
.font(.headline)
TextField("", text: $content.settings.navigationBar.iconPath)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
Text("Specify the path to the icon file with regard to the final website folder.")
.foregroundStyle(.secondary)
.padding(.bottom, 30)

View File

@ -21,16 +21,22 @@ struct PageSettingsView: View {
.font(.headline)
IntegerField("", number: $content.settings.pages.contentWidth)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 400)
Text("The maximum width of the content in pages (in pixels)")
.foregroundStyle(.secondary)
.padding(.bottom)
Text("Image Width")
.font(.headline)
IntegerField("", number: $content.settings.pages.largeImageWidth)
.textFieldStyle(.roundedBorder)
Text("The maximum width of images that are diplayed fullscreen")
.foregroundStyle(.secondary)
.padding(.bottom)
Text("Page URL Prefix")
.font(.headline)
TextField("", text: $content.settings.pages.pageUrlPrefix)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 400)
Text("The URL prefix used for the links to pages")
.foregroundStyle(.secondary)
.padding(.bottom)

View File

@ -1,49 +0,0 @@
import SwiftUI
struct SectionedSettingsView: View {
@State
private var selectedSection: SettingsSection? = .generation
var body: some View {
NavigationSplitView {
SettingsSidebar(selectedSection: $selectedSection)
.frame(minWidth: 200, idealWidth: 200, maxWidth: 200)
} detail: {
GenerationDetailView(section: selectedSection)
}
}
}
struct GenerationDetailView: View {
let section: SettingsSection?
var body: some View {
Group {
switch section {
case .generation:
GenerationSettingsView()
case .folders:
FolderSettingsView()
case .navigationBar:
NavigationBarSettingsView()
case .postFeed:
PostFeedSettingsView()
case .pages:
PageSettingsView()
case .none:
Text("Select a setting from the sidebar")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
.navigationTitle("")
}
}
#Preview {
SectionedSettingsView()
}

View File

@ -0,0 +1,13 @@
import SwiftUI
struct SettingsListView: View {
@Binding
var selectedSection: SettingsSection
var body: some View {
List(SettingsSection.allCases, selection: $selectedSection) { item in
Label(item.rawValue, systemSymbol: item.icon).tag(item)
}
}
}

View File

@ -2,7 +2,7 @@ import SFSafeSymbols
enum SettingsSection: String {
case generation = "Generation"
//case generation = "Generation"
case folders = "Folders"
@ -18,7 +18,7 @@ extension SettingsSection {
var icon: SFSymbol {
switch self {
case .generation: return .arrowTriangle2Circlepath
//case .generation: return .arrowTriangle2Circlepath
case .folders: return .folder
case .navigationBar: return .menubarRectangle
case .postFeed: return .rectangleGrid1x2

View File

@ -1,15 +0,0 @@
import SwiftUI
import SFSafeSymbols
struct SettingsSidebar: View {
@Binding var selectedSection: SettingsSection?
var body: some View {
List(SettingsSection.allCases, selection: $selectedSection) { item in
Label(item.rawValue, systemSymbol: item.icon)
.tag(item)
}
.navigationTitle("Settings")
}
}

View File

@ -19,50 +19,71 @@ struct LocalizedTagDetailView: View {
VStack(alignment: .leading) {
Toggle("Appears in overviews", isOn: $tagIsVisible)
.toggleStyle(.switch)
.font(.callout)
.foregroundStyle(.secondary)
.font(.headline)
.padding(.bottom)
Text("Name")
.font(.callout)
.foregroundStyle(.secondary)
.font(.headline)
TextField("", text: $tag.name)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
Text("URL String")
.font(.callout)
.foregroundStyle(.secondary)
.font(.headline)
TextField("", text: $tag.urlComponent)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
Text("Original url")
.font(.callout)
.foregroundStyle(.secondary)
.font(.headline)
Text(tag.originalUrl ?? "-")
.padding(.top, 1)
.padding(.bottom)
Text("Subtitle")
.font(.callout)
.foregroundStyle(.secondary)
.font(.headline)
OptionalTextField("", text: $tag.subtitle)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
Text("Description")
.font(.callout)
.foregroundStyle(.secondary)
OptionalTextField("", text: $tag.description)
Text("Link Preview Description")
.font(.headline)
.padding(.top)
OptionalDescriptionField(text: $tag.description)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
Text("Thumbnail")
.font(.callout)
.foregroundStyle(.secondary)
Button(action: { showImagePicker = true }) {
Text(tag.thumbnail?.id ?? "Select")
HStack {
Text("Link Preview Image")
.font(.headline)
IconButton(symbol: .squareAndPencilCircleFill,
size: 22,
color: .blue) {
showImagePicker = true
}
IconButton(symbol: .trashCircleFill,
size: 22,
color: .red) {
tag.linkPreviewImage = nil
}.disabled(tag.linkPreviewImage == nil)
Spacer()
}
.buttonStyle(.plain)
.foregroundStyle(.blue)
if let image = tag.linkPreviewImage {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400, maxHeight: 300)
.cornerRadius(8)
}
}
.padding()
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
tag.thumbnail = image
tag.linkPreviewImage = image
}
}
}