Rework path storage, add start screen
This commit is contained in:
parent
849585acc7
commit
9a53e020a7
@ -2,11 +2,11 @@ import Foundation
|
|||||||
|
|
||||||
extension URL {
|
extension URL {
|
||||||
|
|
||||||
func ensureParentFolderExistence() throws {
|
func createParentFolderIfNeeded() throws {
|
||||||
try deletingLastPathComponent().ensureFolderExistence()
|
try deletingLastPathComponent().createIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureFolderExistence() throws {
|
func createIfNeeded() throws {
|
||||||
guard !exists else {
|
guard !exists else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -42,7 +42,7 @@ extension URL {
|
|||||||
if url.exists {
|
if url.exists {
|
||||||
try url.delete()
|
try url.delete()
|
||||||
}
|
}
|
||||||
try url.ensureParentFolderExistence()
|
try url.createParentFolderIfNeeded()
|
||||||
try FileManager.default.copyItem(at: self, to: url)
|
try FileManager.default.copyItem(at: self, to: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,4 +69,14 @@ extension URL {
|
|||||||
}
|
}
|
||||||
return URL(string: components.joined(separator: "/"))
|
return URL(string: components.joined(separator: "/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containedFiles() throws -> [URL] {
|
||||||
|
try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil, options: [])
|
||||||
|
.filter { !$0.hasDirectoryPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
func containedFileNames() throws -> [String] {
|
||||||
|
try FileManager.default.contentsOfDirectory(atPath: path())
|
||||||
|
.filter { !$0.hasPrefix(".") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,7 @@ final class ImageGenerator {
|
|||||||
func prepareForGeneration() -> Bool {
|
func prepareForGeneration() -> Bool {
|
||||||
inOutputImagesFolder { imagesFolder in
|
inOutputImagesFolder { imagesFolder in
|
||||||
do {
|
do {
|
||||||
try imagesFolder.ensureFolderExistence()
|
try imagesFolder.createIfNeeded()
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to create output images folder: \(error)")
|
print("Failed to create output images folder: \(error)")
|
||||||
|
@ -17,11 +17,10 @@ final class LocalizedWebsiteGenerator {
|
|||||||
self.imageGenerator = ImageGenerator(
|
self.imageGenerator = ImageGenerator(
|
||||||
storage: content.storage,
|
storage: content.storage,
|
||||||
settings: content.settings)
|
settings: content.settings)
|
||||||
|
self.outputDirectory = content.storage.outputPath!
|
||||||
}
|
}
|
||||||
|
|
||||||
private var outputDirectory: URL {
|
private let outputDirectory: URL
|
||||||
content.settings.outputDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
private var postsPerPage: Int {
|
private var postsPerPage: Int {
|
||||||
content.settings.posts.postsPerPage
|
content.settings.posts.postsPerPage
|
||||||
|
@ -1,8 +1,73 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct InitialSetupView: View {
|
struct InitialSetupView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject
|
||||||
|
private var content: Content
|
||||||
|
|
||||||
|
@Environment(\.dismiss)
|
||||||
|
private var dismiss
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var message: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
/*@START_MENU_TOKEN@*//*@PLACEHOLDER=Hello, world!@*/Text("Hello, world!")/*@END_MENU_TOKEN@*/
|
VStack {
|
||||||
|
Text("No Database Loaded")
|
||||||
|
.font(.title)
|
||||||
|
.padding()
|
||||||
|
Text("To start editing the content of a website, create a new database or load an existing one. Open a folder with an existing database, or choose an empty folder to create a new project.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Select folder", action: selectContentPath)
|
||||||
|
.padding()
|
||||||
|
if let message {
|
||||||
|
Text(message)
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: 350)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectContentPath() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
// Sets up so user can only select a single directory
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.showsHiddenFiles = false
|
||||||
|
panel.title = "Select the database folder"
|
||||||
|
|
||||||
|
let response = panel.runModal()
|
||||||
|
guard response == .OK else {
|
||||||
|
set(message: "Failed to select a folder: \(response)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let url = panel.url else {
|
||||||
|
set(message: "No folder url found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard content.storage.save(contentPath: url) else {
|
||||||
|
set(message: "Failed to set content path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
print("Selected folder, initializing storage")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
do {
|
||||||
|
print("Loading disk content")
|
||||||
|
try content.loadFromDisk()
|
||||||
|
} catch {
|
||||||
|
set(message: "Failed to load database: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func set(message: String) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -162,18 +162,15 @@ struct MainView: App {
|
|||||||
}.pickerStyle(.segmented)
|
}.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
if content.storageIsInitialized {
|
if content.storage.hasContentFolders {
|
||||||
Button(action: save) {
|
Button(action: save) {
|
||||||
Text("Save")
|
Text("Save")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button(action: showInitialSheet) {
|
||||||
selectedSection = .folders
|
|
||||||
selectedTab = .generation
|
|
||||||
} label: {
|
|
||||||
Text("Setup")
|
Text("Setup")
|
||||||
}
|
}
|
||||||
.foregroundColor(.red)
|
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -206,10 +203,10 @@ struct MainView: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadContent() {
|
private func loadContent() {
|
||||||
guard content.storageIsInitialized else {
|
#warning("Remove")
|
||||||
DispatchQueue.main.async {
|
content.storage.clearContentPath()
|
||||||
self.showInitialSetupSheet = true
|
guard content.storage.hasContentFolders else {
|
||||||
}
|
showInitialSheet()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@ -218,5 +215,13 @@ struct MainView: App {
|
|||||||
print("Failed to load content: \(error.localizedDescription)")
|
print("Failed to load content: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showInitialSheet() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
selectedSection = .folders
|
||||||
|
selectedTab = .generation
|
||||||
|
showInitialSetupSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,16 +81,12 @@ extension Content {
|
|||||||
}
|
}
|
||||||
// TODO: Fix bug where multiple generating operations can be started
|
// TODO: Fix bug where multiple generating operations can be started
|
||||||
// due to dispatch of locking property on main queue
|
// due to dispatch of locking property on main queue
|
||||||
DispatchQueue.main.async {
|
self.set(isGenerating: true)
|
||||||
self.isGeneratingWebsite = true
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func endGenerating() {
|
private func endGenerating() {
|
||||||
DispatchQueue.main.async {
|
set(isGenerating: false)
|
||||||
self.isGeneratingWebsite = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func generateInternal(_ page: Page, in language: ContentLanguage) -> Bool {
|
private func generateInternal(_ page: Page, in language: ContentLanguage) -> Bool {
|
||||||
|
@ -41,12 +41,13 @@ extension Content {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadFromDisk() throws {
|
func loadFromDisk() throws {
|
||||||
guard storageIsInitialized else {
|
guard storage.hasContentFolders else {
|
||||||
print("Storage not initialized, not loading content")
|
print("Storage not initialized, not loading content")
|
||||||
return
|
throw StorageAccessError.noBookmarkData
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = try storage.loadSettings()
|
let settings = try storage.loadSettings() // Uses defaults if missing
|
||||||
|
print("Loaded settings")
|
||||||
let imageDescriptions = try storage.loadFileDescriptions().reduce(into: [:]) { descriptions, description in
|
let imageDescriptions = try storage.loadFileDescriptions().reduce(into: [:]) { descriptions, description in
|
||||||
descriptions[description.fileId] = description
|
descriptions[description.fileId] = description
|
||||||
}
|
}
|
||||||
@ -58,6 +59,16 @@ extension Content {
|
|||||||
let externalFiles = try storage.loadExternalFileList()
|
let externalFiles = try storage.loadExternalFileList()
|
||||||
let tagOverviewData = try storage.loadTagOverview()
|
let tagOverviewData = try storage.loadTagOverview()
|
||||||
|
|
||||||
|
if tagData.isEmpty { print("No tags loaded") }
|
||||||
|
if pagesData.isEmpty { print("No pages loaded") }
|
||||||
|
if postsData.isEmpty { print("No posts loaded") }
|
||||||
|
if fileList.isEmpty { print("No files loaded") }
|
||||||
|
if externalFiles.isEmpty { print("No external files loaded") }
|
||||||
|
if tagOverviewData == nil { print("No tag overview loaded") }
|
||||||
|
|
||||||
|
print("Loaded data from disk, processing...")
|
||||||
|
// All data loaded from storage, start constructing the data model
|
||||||
|
|
||||||
var files: [String : FileResource] = fileList.reduce(into: [:]) { files, fileId in
|
var files: [String : FileResource] = fileList.reduce(into: [:]) { files, fileId in
|
||||||
let descriptions = imageDescriptions[fileId]
|
let descriptions = imageDescriptions[fileId]
|
||||||
files[fileId] = FileResource(
|
files[fileId] = FileResource(
|
||||||
@ -122,6 +133,7 @@ extension Content {
|
|||||||
self.posts = posts.sorted(ascending: false) { $0.startDate }
|
self.posts = posts.sorted(ascending: false) { $0.startDate }
|
||||||
self.tagOverview = tagOverview
|
self.tagOverview = tagOverview
|
||||||
self.settings = makeSettings(settings, tags: tags, pages: pages, files: files)
|
self.settings = makeSettings(settings, tags: tags, pages: pages, files: files)
|
||||||
|
print("Content loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeSettings(_ settings: SettingsFile, tags: [String : Tag], pages: [String : Page], files: [String : FileResource]) -> Settings {
|
private func makeSettings(_ settings: SettingsFile, tags: [String : Tag], pages: [String : Page], files: [String : FileResource]) -> Settings {
|
||||||
|
@ -3,7 +3,7 @@ import Foundation
|
|||||||
extension Content {
|
extension Content {
|
||||||
|
|
||||||
func saveToDisk() throws {
|
func saveToDisk() throws {
|
||||||
guard storageIsInitialized else {
|
guard storage.hasContentFolders else {
|
||||||
print("Storage not initialized, not saving content")
|
print("Storage not initialized, not saving content")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,8 @@ import Combine
|
|||||||
|
|
||||||
final class Content: ObservableObject {
|
final class Content: ObservableObject {
|
||||||
|
|
||||||
let storage = Storage()
|
@ObservedObject
|
||||||
|
var storage = Storage()
|
||||||
@Published
|
|
||||||
var storageIsInitialized = false
|
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var settings: Settings
|
var settings: Settings
|
||||||
@ -28,10 +26,10 @@ final class Content: ObservableObject {
|
|||||||
var tagOverview: TagOverviewPage?
|
var tagOverview: TagOverviewPage?
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var results: [ItemId : PageGenerationResults] = [:]
|
private(set) var results: [ItemId : PageGenerationResults] = [:]
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var isGeneratingWebsite = false
|
private(set) var isGeneratingWebsite = false
|
||||||
|
|
||||||
init(settings: Settings,
|
init(settings: Settings,
|
||||||
posts: [Post],
|
posts: [Post],
|
||||||
@ -45,8 +43,6 @@ final class Content: ObservableObject {
|
|||||||
self.tags = tags
|
self.tags = tags
|
||||||
self.files = files
|
self.files = files
|
||||||
self.tagOverview = tagOverview
|
self.tagOverview = tagOverview
|
||||||
|
|
||||||
initialize()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@ -56,28 +52,25 @@ final class Content: ObservableObject {
|
|||||||
self.tags = []
|
self.tags = []
|
||||||
self.files = []
|
self.files = []
|
||||||
self.tagOverview = nil
|
self.tagOverview = nil
|
||||||
|
|
||||||
initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize() {
|
|
||||||
guard storage.check(contentPath: settings.paths.contentDirectoryPath) == .nominal else {
|
|
||||||
storageIsInitialized = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.check(outputPath: settings.paths.outputDirectoryPath)
|
|
||||||
|
|
||||||
do {
|
|
||||||
try storage.createFolderStructure()
|
|
||||||
storageIsInitialized = true
|
|
||||||
} catch {
|
|
||||||
print("Failed to initialize storage: \(error)")
|
|
||||||
storageIsInitialized = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var images: [FileResource] {
|
var images: [FileResource] {
|
||||||
files.filter { $0.type.isImage }
|
files.filter { $0.type.isImage }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func set(isGenerating: Bool) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isGeneratingWebsite = isGenerating
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ file: FileResource) {
|
||||||
|
// TODO: Insert at correct index?
|
||||||
|
files.insert(file, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ page: Page) {
|
||||||
|
// TODO: Insert at correct index?
|
||||||
|
pages.insert(page, at: 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,12 +2,6 @@ import Foundation
|
|||||||
|
|
||||||
final class PathSettings: ObservableObject {
|
final class PathSettings: ObservableObject {
|
||||||
|
|
||||||
@Published
|
|
||||||
var contentDirectoryPath: String
|
|
||||||
|
|
||||||
@Published
|
|
||||||
var outputDirectoryPath: String
|
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var assetsOutputFolderPath: String
|
var assetsOutputFolderPath: String
|
||||||
|
|
||||||
@ -27,9 +21,7 @@ final class PathSettings: ObservableObject {
|
|||||||
var tagsOutputFolderPath: String
|
var tagsOutputFolderPath: String
|
||||||
|
|
||||||
init(file: PathSettingsFile) {
|
init(file: PathSettingsFile) {
|
||||||
self.contentDirectoryPath = file.contentDirectoryPath
|
|
||||||
self.assetsOutputFolderPath = file.assetsOutputFolderPath
|
self.assetsOutputFolderPath = file.assetsOutputFolderPath
|
||||||
self.outputDirectoryPath = file.outputDirectoryPath
|
|
||||||
self.pagesOutputFolderPath = file.pagesOutputFolderPath
|
self.pagesOutputFolderPath = file.pagesOutputFolderPath
|
||||||
self.imagesOutputFolderPath = file.imagesOutputFolderPath
|
self.imagesOutputFolderPath = file.imagesOutputFolderPath
|
||||||
self.filesOutputFolderPath = file.filesOutputFolderPath
|
self.filesOutputFolderPath = file.filesOutputFolderPath
|
||||||
@ -38,9 +30,7 @@ final class PathSettings: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var file: PathSettingsFile {
|
var file: PathSettingsFile {
|
||||||
.init(contentDirectoryPath: contentDirectoryPath,
|
.init(assetsOutputFolderPath: assetsOutputFolderPath,
|
||||||
outputDirectoryPath: outputDirectoryPath,
|
|
||||||
assetsOutputFolderPath: assetsOutputFolderPath,
|
|
||||||
pagesOutputFolderPath: pagesOutputFolderPath,
|
pagesOutputFolderPath: pagesOutputFolderPath,
|
||||||
imagesOutputFolderPath: imagesOutputFolderPath,
|
imagesOutputFolderPath: imagesOutputFolderPath,
|
||||||
filesOutputFolderPath: filesOutputFolderPath,
|
filesOutputFolderPath: filesOutputFolderPath,
|
||||||
|
@ -36,8 +36,4 @@ final class Settings: ObservableObject {
|
|||||||
case .german: return german
|
case .german: return german
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputDirectory: URL {
|
|
||||||
URL(fileURLWithPath: paths.outputDirectoryPath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
|
|
||||||
struct PathSettingsFile {
|
struct PathSettingsFile {
|
||||||
|
|
||||||
let contentDirectoryPath: String
|
|
||||||
|
|
||||||
let outputDirectoryPath: String
|
|
||||||
|
|
||||||
let assetsOutputFolderPath: String
|
let assetsOutputFolderPath: String
|
||||||
|
|
||||||
let pagesOutputFolderPath: String
|
let pagesOutputFolderPath: String
|
||||||
@ -17,16 +13,12 @@ struct PathSettingsFile {
|
|||||||
|
|
||||||
let tagsOutputFolderPath: String
|
let tagsOutputFolderPath: String
|
||||||
|
|
||||||
init(contentDirectoryPath: String,
|
init(assetsOutputFolderPath: String,
|
||||||
outputDirectoryPath: String,
|
|
||||||
assetsOutputFolderPath: String,
|
|
||||||
pagesOutputFolderPath: String,
|
pagesOutputFolderPath: String,
|
||||||
imagesOutputFolderPath: String,
|
imagesOutputFolderPath: String,
|
||||||
filesOutputFolderPath: String,
|
filesOutputFolderPath: String,
|
||||||
videosOutputFolderPath: String,
|
videosOutputFolderPath: String,
|
||||||
tagsOutputFolderPath: String) {
|
tagsOutputFolderPath: String) {
|
||||||
self.contentDirectoryPath = contentDirectoryPath
|
|
||||||
self.outputDirectoryPath = outputDirectoryPath
|
|
||||||
self.assetsOutputFolderPath = assetsOutputFolderPath
|
self.assetsOutputFolderPath = assetsOutputFolderPath
|
||||||
self.pagesOutputFolderPath = pagesOutputFolderPath
|
self.pagesOutputFolderPath = pagesOutputFolderPath
|
||||||
self.imagesOutputFolderPath = imagesOutputFolderPath
|
self.imagesOutputFolderPath = imagesOutputFolderPath
|
||||||
@ -44,8 +36,6 @@ extension PathSettingsFile {
|
|||||||
|
|
||||||
static var `default`: PathSettingsFile {
|
static var `default`: PathSettingsFile {
|
||||||
PathSettingsFile(
|
PathSettingsFile(
|
||||||
contentDirectoryPath: "",
|
|
||||||
outputDirectoryPath: "build",
|
|
||||||
assetsOutputFolderPath: "asset",
|
assetsOutputFolderPath: "asset",
|
||||||
pagesOutputFolderPath: "page",
|
pagesOutputFolderPath: "page",
|
||||||
imagesOutputFolderPath: "image",
|
imagesOutputFolderPath: "image",
|
||||||
|
@ -12,4 +12,11 @@ enum SecurityScopeStatus {
|
|||||||
case stale
|
case stale
|
||||||
|
|
||||||
case nominal
|
case nominal
|
||||||
|
|
||||||
|
var isUsable: Bool {
|
||||||
|
switch self {
|
||||||
|
case .nominal, .stale: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,28 @@ import Foundation
|
|||||||
*/
|
*/
|
||||||
final class Storage: ObservableObject {
|
final class Storage: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: Content folder structure
|
||||||
|
|
||||||
|
private let filesFolderName = "files"
|
||||||
|
|
||||||
|
private let pagesFolderName = "pages"
|
||||||
|
|
||||||
|
private let postsFolderName = "posts"
|
||||||
|
|
||||||
|
private let tagsFolderName = "tags"
|
||||||
|
|
||||||
|
private let fileDescriptionFilename = "file-descriptions.json"
|
||||||
|
|
||||||
|
private let generatedImagesListName = "generated-images.json"
|
||||||
|
|
||||||
|
private let outputPathFileName = "outputPath.bin"
|
||||||
|
|
||||||
|
private let tagOverviewFileName = "tag-overview.json"
|
||||||
|
|
||||||
|
private let contentPathBookmarkKey = "contentPathBookmark"
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
|
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
@ -21,16 +43,27 @@ final class Storage: ObservableObject {
|
|||||||
private let fm = FileManager.default
|
private let fm = FileManager.default
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var contentFolderStatus: SecurityScopeStatus = .noBookmark
|
var hasContentFolders = false
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var outputFolderStatus: SecurityScopeStatus = .noBookmark
|
var contentPath: URL?
|
||||||
|
|
||||||
|
@Published
|
||||||
|
var outputPath: URL?
|
||||||
|
|
||||||
|
@Published
|
||||||
|
var contentPathUrlIsStale = false
|
||||||
|
|
||||||
|
@Published
|
||||||
|
var outputPathUrlIsStale = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Create the storage.
|
Create the storage.
|
||||||
*/
|
*/
|
||||||
init() {
|
init() {
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
loadContentPath()
|
||||||
|
createFolderStructure()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Helper
|
// MARK: Helper
|
||||||
@ -45,42 +78,30 @@ final class Storage: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fileNames(in folder: URL) throws -> [String] {
|
|
||||||
try fm.contentsOfDirectory(atPath: folder.path())
|
|
||||||
.filter { !$0.hasPrefix(".") }
|
|
||||||
.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func files(in folder: URL, type: String) throws -> [URL] {
|
|
||||||
try files(in: folder).filter { $0.pathExtension == type }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Folders
|
// MARK: Folders
|
||||||
|
|
||||||
func updateBaseFolder() throws {
|
@discardableResult
|
||||||
try createFolderStructure()
|
func createFolderStructure() -> Bool {
|
||||||
}
|
do {
|
||||||
|
try inContentFolder { contentPath in
|
||||||
private func create(folder: URL) throws {
|
try pagesFolder(in: contentPath).createIfNeeded()
|
||||||
guard !FileManager.default.fileExists(atPath: folder.path) else {
|
try filesFolder(in: contentPath).createIfNeeded()
|
||||||
return
|
try postsFolder(in: contentPath).createIfNeeded()
|
||||||
}
|
try tagsFolder(in: contentPath).createIfNeeded()
|
||||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
}
|
||||||
}
|
hasContentFolders = true
|
||||||
|
return true
|
||||||
func createFolderStructure() throws {
|
} catch StorageAccessError.noBookmarkData {
|
||||||
try operate(in: .contentPath) { contentPath in
|
hasContentFolders = false
|
||||||
try create(folder: pagesFolder(in: contentPath))
|
} catch {
|
||||||
try create(folder: filesFolder(in: contentPath))
|
print("Failed to create storage folders: \(error)")
|
||||||
try create(folder: postsFolder(in: contentPath))
|
hasContentFolders = false
|
||||||
try create(folder: tagsFolder(in: contentPath))
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Pages
|
// MARK: Pages
|
||||||
|
|
||||||
private let pagesFolderName = "pages"
|
|
||||||
|
|
||||||
/// The folder path where the markdown and metadata files of the pages are stored (by their id/url component)
|
/// The folder path where the markdown and metadata files of the pages are stored (by their id/url component)
|
||||||
private func pagesFolder(in folder: URL) -> URL {
|
private func pagesFolder(in folder: URL) -> URL {
|
||||||
folder.appending(path: pagesFolderName, directoryHint: .isDirectory)
|
folder.appending(path: pagesFolderName, directoryHint: .isDirectory)
|
||||||
@ -168,8 +189,6 @@ final class Storage: ObservableObject {
|
|||||||
|
|
||||||
// MARK: Posts
|
// MARK: Posts
|
||||||
|
|
||||||
private let postsFolderName = "posts"
|
|
||||||
|
|
||||||
private func postFileName(_ postId: String) -> String {
|
private func postFileName(_ postId: String) -> String {
|
||||||
postId + ".json"
|
postId + ".json"
|
||||||
}
|
}
|
||||||
@ -197,11 +216,6 @@ final class Storage: ObservableObject {
|
|||||||
try decodeAllFromJson(in: postsFolderName)
|
try decodeAllFromJson(in: postsFolderName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func postContent(for postId: String) throws -> PostFile {
|
|
||||||
let path = postFilePath(post: postId)
|
|
||||||
return try read(at: path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Delete all files associated with posts that are not in the given set
|
Delete all files associated with posts that are not in the given set
|
||||||
- Note: This function requires a security scope for the content path
|
- Note: This function requires a security scope for the content path
|
||||||
@ -221,8 +235,6 @@ final class Storage: ObservableObject {
|
|||||||
|
|
||||||
// MARK: Tags
|
// MARK: Tags
|
||||||
|
|
||||||
private let tagsFolderName = "tags"
|
|
||||||
|
|
||||||
private func tagFileName(tagId: String) -> String {
|
private func tagFileName(tagId: String) -> String {
|
||||||
tagId + ".json"
|
tagId + ".json"
|
||||||
}
|
}
|
||||||
@ -256,10 +268,12 @@ final class Storage: ObservableObject {
|
|||||||
|
|
||||||
// MARK: File descriptions
|
// MARK: File descriptions
|
||||||
|
|
||||||
private let fileDescriptionFilename = "file-descriptions.json"
|
|
||||||
|
|
||||||
func loadFileDescriptions() throws -> [FileDescriptions] {
|
func loadFileDescriptions() throws -> [FileDescriptions] {
|
||||||
try read(at: fileDescriptionFilename, defaultValue: [])
|
guard let descriptions: [FileDescriptions] = try read(at: fileDescriptionFilename) else {
|
||||||
|
print("Storage: No file descriptions loaded")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return descriptions
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(fileDescriptions: [FileDescriptions]) throws {
|
func save(fileDescriptions: [FileDescriptions]) throws {
|
||||||
@ -268,8 +282,6 @@ final class Storage: ObservableObject {
|
|||||||
|
|
||||||
// MARK: Tag overview
|
// MARK: Tag overview
|
||||||
|
|
||||||
private let tagOverviewFileName = "tag-overview.json"
|
|
||||||
|
|
||||||
func loadTagOverview() throws -> TagOverviewFile? {
|
func loadTagOverview() throws -> TagOverviewFile? {
|
||||||
try read(at: tagOverviewFileName)
|
try read(at: tagOverviewFileName)
|
||||||
}
|
}
|
||||||
@ -280,8 +292,6 @@ final class Storage: ObservableObject {
|
|||||||
|
|
||||||
// MARK: Files
|
// MARK: Files
|
||||||
|
|
||||||
private let filesFolderName = "files"
|
|
||||||
|
|
||||||
private func filePath(file fileId: String) -> String {
|
private func filePath(file fileId: String) -> String {
|
||||||
filesFolderName + "/" + fileId
|
filesFolderName + "/" + fileId
|
||||||
}
|
}
|
||||||
@ -321,7 +331,7 @@ final class Storage: ObservableObject {
|
|||||||
if output.exists {
|
if output.exists {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try output.ensureParentFolderExistence()
|
try output.createParentFolderIfNeeded()
|
||||||
|
|
||||||
try FileManager.default.copyItem(at: input, to: output)
|
try FileManager.default.copyItem(at: input, to: output)
|
||||||
}
|
}
|
||||||
@ -329,8 +339,7 @@ final class Storage: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadAllFiles() throws -> [String] {
|
func loadAllFiles() throws -> [String] {
|
||||||
try self.existingFiles(in: filesFolderName)
|
try inContentFolder(relativePath: filesFolderName) { try $0.containedFileNames() }
|
||||||
.map { $0.lastPathComponent }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -356,19 +365,27 @@ final class Storage: ObservableObject {
|
|||||||
private let externalFileListName = "external-files.json"
|
private let externalFileListName = "external-files.json"
|
||||||
|
|
||||||
func loadExternalFileList() throws -> [String] {
|
func loadExternalFileList() throws -> [String] {
|
||||||
try read(at: externalFileListName, defaultValue: [])
|
guard let files: [String] = try read(at: externalFileListName) else {
|
||||||
|
print("Storage: No external file list found")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(externalFileList: [String]) throws {
|
func save(externalFileList: [String]) throws {
|
||||||
try writeIfChanged(externalFileList.sorted(), to: externalFileListName)
|
try writeIfChanged(externalFileList.sorted(), to: externalFileListName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Website data
|
// MARK: Settings
|
||||||
|
|
||||||
private let settingsDataFileName: String = "settings.json"
|
private let settingsDataFileName: String = "settings.json"
|
||||||
|
|
||||||
func loadSettings() throws -> SettingsFile {
|
func loadSettings() throws -> SettingsFile {
|
||||||
try read(at: settingsDataFileName, defaultValue: .default)
|
guard let settings: SettingsFile = try read(at: settingsDataFileName) else {
|
||||||
|
print("Storage: Loaded default settings")
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(settings: SettingsFile) throws {
|
func save(settings: SettingsFile) throws {
|
||||||
@ -377,10 +394,12 @@ final class Storage: ObservableObject {
|
|||||||
|
|
||||||
// MARK: Image generation data
|
// MARK: Image generation data
|
||||||
|
|
||||||
private let generatedImagesListName = "generated-images.json"
|
|
||||||
|
|
||||||
func loadListOfGeneratedImages() throws -> [String : [String]] {
|
func loadListOfGeneratedImages() throws -> [String : [String]] {
|
||||||
try read(at: generatedImagesListName, defaultValue: [:])
|
guard let images: [String : [String]] = try read(at: generatedImagesListName) else {
|
||||||
|
print("Storage: No generated images found")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return images
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(listOfGeneratedImages: [String : [String]]) throws {
|
func save(listOfGeneratedImages: [String : [String]]) throws {
|
||||||
@ -395,23 +414,11 @@ final class Storage: ObservableObject {
|
|||||||
|
|
||||||
// MARK: Folder access
|
// MARK: Folder access
|
||||||
|
|
||||||
@discardableResult
|
func create(folder relativePath: String, in scope: SecurityScopeBookmark) -> Bool {
|
||||||
func save(folderUrl url: URL, in bookmark: SecurityScopeBookmark) -> Bool {
|
return write(in: scope) { folder in
|
||||||
do {
|
|
||||||
let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
|
|
||||||
UserDefaults.standard.set(bookmarkData, forKey: bookmark.rawValue)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
print("Failed to create security-scoped bookmark: \(error)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func create(folder relativePath: String, in scopr: SecurityScopeBookmark) -> Bool {
|
|
||||||
return write(in: .outputPath) { folder in
|
|
||||||
let url = folder.appendingPathComponent(relativePath, isDirectory: true)
|
let url = folder.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
do {
|
do {
|
||||||
try url.ensureFolderExistence()
|
try url.createIfNeeded()
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to create folder \(url.path()): \(error)")
|
print("Failed to create folder \(url.path()): \(error)")
|
||||||
@ -470,42 +477,153 @@ final class Storage: ObservableObject {
|
|||||||
return try operation(folderUrl)
|
return try operation(folderUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Security bookmarks
|
||||||
|
|
||||||
|
/**
|
||||||
|
Save the content path url from a folder selection dialog,
|
||||||
|
which contains a security scope.
|
||||||
|
|
||||||
|
The security scope bookmark is saved in UserDefaults under the ``contentPathBookmarkKey`` key
|
||||||
|
- Returns: True, if the bookmark was saved
|
||||||
|
- Note: Updates ``canSave``, ``contentPathUrlIsStale``, and ``contentPath``
|
||||||
|
*/
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func check(contentPath: String) -> SecurityScopeStatus {
|
func save(contentPath: URL) -> Bool {
|
||||||
contentFolderStatus = Storage.ensure(securityScope: .contentPath, matches: contentPath)
|
guard let bookmarkData = encode(url: contentPath) else {
|
||||||
return contentFolderStatus
|
return false
|
||||||
|
}
|
||||||
|
UserDefaults.standard.set(bookmarkData, forKey: contentPathBookmarkKey)
|
||||||
|
guard loadContentPath() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return createFolderStructure()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Attempts to load the content path url from UserDefaults.
|
||||||
|
|
||||||
|
The url is loaded from UserDefaults under the ``contentPathBookmarkKey`` key
|
||||||
|
|
||||||
|
- Note: Updates ``canSave``, ``contentPathUrlIsStale``, and ``contentPath``
|
||||||
|
- Returns: `true`, if the url was loaded.
|
||||||
|
*/
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func check(outputPath: String) -> SecurityScopeStatus {
|
private func loadContentPath() -> Bool {
|
||||||
outputFolderStatus = Storage.ensure(securityScope: .outputPath, matches: outputPath)
|
guard let bookmarkData = UserDefaults.standard.data(forKey: contentPathBookmarkKey) else {
|
||||||
return outputFolderStatus
|
print("No content path bookmark found")
|
||||||
|
contentPath = nil
|
||||||
|
contentPathUrlIsStale = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let (url, isStale) = decode(bookmark: bookmarkData)
|
||||||
|
contentPath = url
|
||||||
|
contentPathUrlIsStale = isStale
|
||||||
|
return url != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func ensure(securityScope: SecurityScopeBookmark, matches path: String) -> SecurityScopeStatus {
|
func clearContentPath() {
|
||||||
guard path != "" else {
|
UserDefaults.standard.removeObject(forKey: contentPathBookmarkKey)
|
||||||
return .noPath
|
contentPath = nil
|
||||||
}
|
contentPathUrlIsStale = false
|
||||||
guard let bookmarkData = UserDefaults.standard.data(forKey: securityScope.rawValue) else {
|
hasContentFolders = false
|
||||||
return .noBookmark
|
outputPath = nil
|
||||||
}
|
outputPathUrlIsStale = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Decode the security scope data to get a url.
|
||||||
|
*/
|
||||||
|
private func decode(bookmark: Data) -> (url: URL?, isStale: Bool) {
|
||||||
do {
|
do {
|
||||||
var isStale = false
|
var isStale = false
|
||||||
let url = try URL(
|
let url = try URL(
|
||||||
resolvingBookmarkData: bookmarkData,
|
resolvingBookmarkData: bookmark,
|
||||||
options: .withSecurityScope,
|
options: .withSecurityScope,
|
||||||
relativeTo: nil,
|
relativeTo: nil,
|
||||||
bookmarkDataIsStale: &isStale)
|
bookmarkDataIsStale: &isStale)
|
||||||
guard !isStale else {
|
return (url, isStale)
|
||||||
return .stale
|
|
||||||
}
|
|
||||||
guard url.path() == path else {
|
|
||||||
return .urlMismatch
|
|
||||||
}
|
|
||||||
return .nominal
|
|
||||||
} catch {
|
} catch {
|
||||||
return .bookmarkCorrupted
|
print("Failed to resolve bookmark: \(error)")
|
||||||
|
return (nil, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encode(url: URL) -> Data? {
|
||||||
|
do {
|
||||||
|
return try url.bookmarkData(
|
||||||
|
options: .withSecurityScope,
|
||||||
|
includingResourceValuesForKeys: nil,
|
||||||
|
relativeTo: nil)
|
||||||
|
} catch {
|
||||||
|
print("Failed to create security-scoped bookmark: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(outputPath: URL) -> Bool {
|
||||||
|
guard let contentPath else { return false }
|
||||||
|
guard let bookmarkData = encode(url: outputPath) else { return false }
|
||||||
|
return write(bookmarkData, to: outputPathFileName, in: contentPath, onlyIfChanged: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Run an operation in the content folder
|
||||||
|
*/
|
||||||
|
func inContentFolder<T>(perform operation: (URL) throws -> T) throws -> T {
|
||||||
|
try inSecurityScope(of: contentPath, perform: operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Run an operation in the output folder
|
||||||
|
*/
|
||||||
|
func inOutputFolder<T>(perform operation: (URL) throws -> T) throws -> T {
|
||||||
|
try inSecurityScope(of: outputPath, perform: operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inContentFolder<T>(relativePath: String, perform operation: (URL) throws -> T) throws -> T {
|
||||||
|
try inContentFolder { url in
|
||||||
|
try operation(url.appendingPathComponent(relativePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Run an operation in the security scope of a url.
|
||||||
|
*/
|
||||||
|
private func inSecurityScope<T>(of url: URL?, perform: (URL) throws -> T) throws -> T {
|
||||||
|
guard let url else {
|
||||||
|
throw StorageAccessError.noBookmarkData
|
||||||
|
}
|
||||||
|
guard url.startAccessingSecurityScopedResource() else {
|
||||||
|
throw StorageAccessError.folderAccessFailed(url)
|
||||||
|
}
|
||||||
|
defer { url.stopAccessingSecurityScopedResource() }
|
||||||
|
return try perform(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeContent(_ data: Data, to relativePath: String, onlyIfChanged: Bool = true) -> Bool {
|
||||||
|
guard let contentPath else { return false }
|
||||||
|
return write(data, to: relativePath, in: contentPath, onlyIfChanged: onlyIfChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func write(_ data: Data, to relativePath: String, in folder: URL, onlyIfChanged: Bool = true) -> Bool {
|
||||||
|
do {
|
||||||
|
try inSecurityScope(of: folder) { url in
|
||||||
|
let file = url.appending(path: relativePath, directoryHint: .notDirectory)
|
||||||
|
|
||||||
|
// Load previous file and compare
|
||||||
|
if onlyIfChanged,
|
||||||
|
fm.fileExists(atPath: file.path()),
|
||||||
|
let oldData = try? Data(contentsOf: file), // Write file again in case of read error
|
||||||
|
oldData == data {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try data.write(to: file)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
print("Failed to write to file: \(error)")
|
||||||
|
#warning("Report error")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -581,7 +699,7 @@ final class Storage: ObservableObject {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print("Writing new file \(url.path())")
|
print("Writing new file \(url.path())")
|
||||||
try url.ensureParentFolderExistence()
|
try url.createParentFolderIfNeeded()
|
||||||
}
|
}
|
||||||
try data.write(to: url)
|
try data.write(to: url)
|
||||||
print("Saved file \(url.path())")
|
print("Saved file \(url.path())")
|
||||||
@ -595,21 +713,12 @@ final class Storage: ObservableObject {
|
|||||||
guard let data = try readData(at: relativePath) else {
|
guard let data = try readData(at: relativePath) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return try decoder.decode(T.self, from: data)
|
do {
|
||||||
}
|
return try decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
/**
|
print("Failed to decode file \(relativePath): \(error)")
|
||||||
|
throw error
|
||||||
- Note: This function requires a security scope for the content path
|
|
||||||
*/
|
|
||||||
private func read<T>(at relativePath: String, defaultValue: T? = nil) throws -> T where T: Decodable {
|
|
||||||
guard let data = try readData(at: relativePath) else {
|
|
||||||
guard let defaultValue else {
|
|
||||||
throw StorageAccessError.fileNotFound(relativePath)
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
}
|
||||||
return try decoder.decode(T.self, from: data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -644,32 +753,45 @@ final class Storage: ObservableObject {
|
|||||||
guard url.exists else {
|
guard url.exists else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return try Data(contentsOf: url)
|
do {
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
print("Storage: Failed to read file \(relativePath): \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getFiles(in folder: URL) throws -> [URL] {
|
|
||||||
try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
|
|
||||||
.filter { !$0.hasDirectoryPath }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func existingFiles(in folder: String) throws -> [URL] {
|
|
||||||
try withScopedContent(folder: folder, getFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
||||||
- Note: This function requires a security scope for the content path
|
- Note: This function requires a security scope for the content path
|
||||||
*/
|
*/
|
||||||
private func decodeAllFromJson<T>(in folder: String) throws -> [String : T] where T: Decodable {
|
private func decodeAllFromJson<T>(in folder: String) throws -> [String : T] where T: Decodable {
|
||||||
try withScopedContent(folder: folder) { folderUrl in
|
try inContentFolder(relativePath: folder) { folderUrl in
|
||||||
try getFiles(in: folderUrl)
|
do {
|
||||||
.filter { $0.pathExtension.lowercased() == "json" }
|
return try folderUrl
|
||||||
.reduce(into: [:]) { items, url in
|
.containedFiles()
|
||||||
let id = url.deletingPathExtension().lastPathComponent
|
.filter { $0.pathExtension.lowercased() == "json" }
|
||||||
let data = try Data(contentsOf: url)
|
.reduce(into: [:]) { items, url in
|
||||||
items[id] = try decoder.decode(T.self, from: data)
|
let id = url.deletingPathExtension().lastPathComponent
|
||||||
}
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
print("Storage: Failed to read file \(url.path()): \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
items[id] = try decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Storage: Failed to decode file \(url.path()): \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Storage: Failed to decode files in \(folder): \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -679,7 +801,7 @@ final class Storage: ObservableObject {
|
|||||||
*/
|
*/
|
||||||
private func copy(file: URL, to relativePath: String) throws {
|
private func copy(file: URL, to relativePath: String) throws {
|
||||||
try withScopedContent(file: relativePath) { destination in
|
try withScopedContent(file: relativePath) { destination in
|
||||||
try destination.ensureParentFolderExistence()
|
try destination.createParentFolderIfNeeded()
|
||||||
try fm.copyItem(at: file, to: destination)
|
try fm.copyItem(at: file, to: destination)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -105,8 +105,7 @@ struct AddFileView: View {
|
|||||||
id: file.uniqueId,
|
id: file.uniqueId,
|
||||||
isExternallyStored: file.url == nil,
|
isExternallyStored: file.url == nil,
|
||||||
en: "", de: "")
|
en: "", de: "")
|
||||||
// TODO: Insert at correct index?
|
content.add(resource)
|
||||||
content.files.insert(resource, at: 0)
|
|
||||||
selectedFile = resource
|
selectedFile = resource
|
||||||
}
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
|
@ -5,13 +5,13 @@ struct FolderOnDiskPropertyView: View {
|
|||||||
let title: LocalizedStringKey
|
let title: LocalizedStringKey
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
var folder: String
|
var folder: URL?
|
||||||
|
|
||||||
let footer: LocalizedStringKey
|
let footer: LocalizedStringKey
|
||||||
|
|
||||||
let update: (URL) -> Void
|
let update: (URL) -> Void
|
||||||
|
|
||||||
init(title: LocalizedStringKey, folder: Binding<String>, footer: LocalizedStringKey, update: @escaping (URL) -> Void) {
|
init(title: LocalizedStringKey, folder: Binding<URL?>, footer: LocalizedStringKey, update: @escaping (URL) -> Void) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self._folder = folder
|
self._folder = folder
|
||||||
self.footer = footer
|
self.footer = footer
|
||||||
@ -21,7 +21,7 @@ struct FolderOnDiskPropertyView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
GenericPropertyView(title: title, footer: footer) {
|
GenericPropertyView(title: title, footer: footer) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
Text(folder)
|
Text(folder?.path() ?? "No folder selected")
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Select") {
|
Button("Select") {
|
||||||
guard let url = openFolderSelectionPanel() else {
|
guard let url = openFolderSelectionPanel() else {
|
||||||
|
@ -81,7 +81,7 @@ struct AddPageView: View {
|
|||||||
urlString: "page",
|
urlString: "page",
|
||||||
title: "A Title"),
|
title: "A Title"),
|
||||||
tags: [])
|
tags: [])
|
||||||
content.pages.insert(page, at: 0)
|
content.add(page)
|
||||||
selectedPage = page
|
selectedPage = page
|
||||||
dismissSheet()
|
dismissSheet()
|
||||||
}
|
}
|
||||||
|
@ -82,11 +82,10 @@ struct PageDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func generate() {
|
private func generate() {
|
||||||
guard content.settings.paths.outputDirectoryPath != "" else {
|
guard let url = content.storage.outputPath else {
|
||||||
print("Invalid output path")
|
print("Invalid output path")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let url = content.settings.outputDirectory
|
|
||||||
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
print("Missing output folder")
|
print("Missing output folder")
|
||||||
|
@ -184,7 +184,7 @@ struct PageIssueView: View {
|
|||||||
isExternallyStored: true,
|
isExternallyStored: true,
|
||||||
en: "",
|
en: "",
|
||||||
de: "")
|
de: "")
|
||||||
content.files.append(file)
|
content.add(file)
|
||||||
|
|
||||||
retryPageCheck()
|
retryPageCheck()
|
||||||
}
|
}
|
||||||
|
@ -59,11 +59,10 @@ struct GenerationContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func generateFeed() {
|
private func generateFeed() {
|
||||||
guard content.settings.paths.outputDirectoryPath != "" else {
|
guard let url = content.storage.outputPath else {
|
||||||
print("Invalid output path")
|
print("Invalid output path")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let url = content.settings.outputDirectory
|
|
||||||
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
print("Missing output folder")
|
print("Missing output folder")
|
||||||
|
@ -17,22 +17,19 @@ struct PathSettingsView: View {
|
|||||||
|
|
||||||
FolderOnDiskPropertyView(
|
FolderOnDiskPropertyView(
|
||||||
title: "Content Folder",
|
title: "Content Folder",
|
||||||
folder: $content.settings.paths.contentDirectoryPath,
|
folder: $content.storage.contentPath,
|
||||||
footer: "The folder where the raw content of the website is stored") { url in
|
footer: "The folder where the raw content of the website is stored") { url in
|
||||||
guard content.storage.save(folderUrl: url, in: .contentPath) else {
|
guard content.storage.save(contentPath: url) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content.settings.paths.contentDirectoryPath = url.path()
|
#warning("Reload database")
|
||||||
}
|
}
|
||||||
|
|
||||||
FolderOnDiskPropertyView(
|
FolderOnDiskPropertyView(
|
||||||
title: "Output Folder",
|
title: "Output Folder",
|
||||||
folder: $content.settings.paths.outputDirectoryPath,
|
folder: $content.storage.outputPath,
|
||||||
footer: "The folder where the generated website is stored") { url in
|
footer: "The folder where the generated website is stored") { url in
|
||||||
guard content.storage.save(folderUrl: url, in: .outputPath) else {
|
content.storage.save(outputPath: url)
|
||||||
return
|
|
||||||
}
|
|
||||||
content.settings.paths.outputDirectoryPath = url.path()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StringPropertyView(
|
StringPropertyView(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user