Rework path storage, add start screen
This commit is contained in:
@ -1,10 +1,6 @@
|
||||
|
||||
struct PathSettingsFile {
|
||||
|
||||
let contentDirectoryPath: String
|
||||
|
||||
let outputDirectoryPath: String
|
||||
|
||||
let assetsOutputFolderPath: String
|
||||
|
||||
let pagesOutputFolderPath: String
|
||||
@ -17,16 +13,12 @@ struct PathSettingsFile {
|
||||
|
||||
let tagsOutputFolderPath: String
|
||||
|
||||
init(contentDirectoryPath: String,
|
||||
outputDirectoryPath: String,
|
||||
assetsOutputFolderPath: String,
|
||||
init(assetsOutputFolderPath: String,
|
||||
pagesOutputFolderPath: String,
|
||||
imagesOutputFolderPath: String,
|
||||
filesOutputFolderPath: String,
|
||||
videosOutputFolderPath: String,
|
||||
tagsOutputFolderPath: String) {
|
||||
self.contentDirectoryPath = contentDirectoryPath
|
||||
self.outputDirectoryPath = outputDirectoryPath
|
||||
self.assetsOutputFolderPath = assetsOutputFolderPath
|
||||
self.pagesOutputFolderPath = pagesOutputFolderPath
|
||||
self.imagesOutputFolderPath = imagesOutputFolderPath
|
||||
@ -44,8 +36,6 @@ extension PathSettingsFile {
|
||||
|
||||
static var `default`: PathSettingsFile {
|
||||
PathSettingsFile(
|
||||
contentDirectoryPath: "",
|
||||
outputDirectoryPath: "build",
|
||||
assetsOutputFolderPath: "asset",
|
||||
pagesOutputFolderPath: "page",
|
||||
imagesOutputFolderPath: "image",
|
||||
|
@ -12,4 +12,11 @@ enum SecurityScopeStatus {
|
||||
case stale
|
||||
|
||||
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 {
|
||||
|
||||
// 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 decoder = JSONDecoder()
|
||||
@ -21,16 +43,27 @@ final class Storage: ObservableObject {
|
||||
private let fm = FileManager.default
|
||||
|
||||
@Published
|
||||
var contentFolderStatus: SecurityScopeStatus = .noBookmark
|
||||
var hasContentFolders = false
|
||||
|
||||
@Published
|
||||
var outputFolderStatus: SecurityScopeStatus = .noBookmark
|
||||
var contentPath: URL?
|
||||
|
||||
@Published
|
||||
var outputPath: URL?
|
||||
|
||||
@Published
|
||||
var contentPathUrlIsStale = false
|
||||
|
||||
@Published
|
||||
var outputPathUrlIsStale = false
|
||||
|
||||
/**
|
||||
Create the storage.
|
||||
*/
|
||||
init() {
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
loadContentPath()
|
||||
createFolderStructure()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
func updateBaseFolder() throws {
|
||||
try createFolderStructure()
|
||||
}
|
||||
|
||||
private func create(folder: URL) throws {
|
||||
guard !FileManager.default.fileExists(atPath: folder.path) else {
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func createFolderStructure() throws {
|
||||
try operate(in: .contentPath) { contentPath in
|
||||
try create(folder: pagesFolder(in: contentPath))
|
||||
try create(folder: filesFolder(in: contentPath))
|
||||
try create(folder: postsFolder(in: contentPath))
|
||||
try create(folder: tagsFolder(in: contentPath))
|
||||
@discardableResult
|
||||
func createFolderStructure() -> Bool {
|
||||
do {
|
||||
try inContentFolder { contentPath in
|
||||
try pagesFolder(in: contentPath).createIfNeeded()
|
||||
try filesFolder(in: contentPath).createIfNeeded()
|
||||
try postsFolder(in: contentPath).createIfNeeded()
|
||||
try tagsFolder(in: contentPath).createIfNeeded()
|
||||
}
|
||||
hasContentFolders = true
|
||||
return true
|
||||
} catch StorageAccessError.noBookmarkData {
|
||||
hasContentFolders = false
|
||||
} catch {
|
||||
print("Failed to create storage folders: \(error)")
|
||||
hasContentFolders = false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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)
|
||||
private func pagesFolder(in folder: URL) -> URL {
|
||||
folder.appending(path: pagesFolderName, directoryHint: .isDirectory)
|
||||
@ -168,8 +189,6 @@ final class Storage: ObservableObject {
|
||||
|
||||
// MARK: Posts
|
||||
|
||||
private let postsFolderName = "posts"
|
||||
|
||||
private func postFileName(_ postId: String) -> String {
|
||||
postId + ".json"
|
||||
}
|
||||
@ -197,11 +216,6 @@ final class Storage: ObservableObject {
|
||||
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
|
||||
- Note: This function requires a security scope for the content path
|
||||
@ -221,8 +235,6 @@ final class Storage: ObservableObject {
|
||||
|
||||
// MARK: Tags
|
||||
|
||||
private let tagsFolderName = "tags"
|
||||
|
||||
private func tagFileName(tagId: String) -> String {
|
||||
tagId + ".json"
|
||||
}
|
||||
@ -256,10 +268,12 @@ final class Storage: ObservableObject {
|
||||
|
||||
// MARK: File descriptions
|
||||
|
||||
private let fileDescriptionFilename = "file-descriptions.json"
|
||||
|
||||
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 {
|
||||
@ -268,8 +282,6 @@ final class Storage: ObservableObject {
|
||||
|
||||
// MARK: Tag overview
|
||||
|
||||
private let tagOverviewFileName = "tag-overview.json"
|
||||
|
||||
func loadTagOverview() throws -> TagOverviewFile? {
|
||||
try read(at: tagOverviewFileName)
|
||||
}
|
||||
@ -280,8 +292,6 @@ final class Storage: ObservableObject {
|
||||
|
||||
// MARK: Files
|
||||
|
||||
private let filesFolderName = "files"
|
||||
|
||||
private func filePath(file fileId: String) -> String {
|
||||
filesFolderName + "/" + fileId
|
||||
}
|
||||
@ -321,7 +331,7 @@ final class Storage: ObservableObject {
|
||||
if output.exists {
|
||||
return
|
||||
}
|
||||
try output.ensureParentFolderExistence()
|
||||
try output.createParentFolderIfNeeded()
|
||||
|
||||
try FileManager.default.copyItem(at: input, to: output)
|
||||
}
|
||||
@ -329,8 +339,7 @@ final class Storage: ObservableObject {
|
||||
}
|
||||
|
||||
func loadAllFiles() throws -> [String] {
|
||||
try self.existingFiles(in: filesFolderName)
|
||||
.map { $0.lastPathComponent }
|
||||
try inContentFolder(relativePath: filesFolderName) { try $0.containedFileNames() }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -356,19 +365,27 @@ final class Storage: ObservableObject {
|
||||
private let externalFileListName = "external-files.json"
|
||||
|
||||
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 {
|
||||
try writeIfChanged(externalFileList.sorted(), to: externalFileListName)
|
||||
}
|
||||
|
||||
// MARK: Website data
|
||||
// MARK: Settings
|
||||
|
||||
private let settingsDataFileName: String = "settings.json"
|
||||
|
||||
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 {
|
||||
@ -377,10 +394,12 @@ final class Storage: ObservableObject {
|
||||
|
||||
// MARK: Image generation data
|
||||
|
||||
private let generatedImagesListName = "generated-images.json"
|
||||
|
||||
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 {
|
||||
@ -395,23 +414,11 @@ final class Storage: ObservableObject {
|
||||
|
||||
// MARK: Folder access
|
||||
|
||||
@discardableResult
|
||||
func save(folderUrl url: URL, in bookmark: SecurityScopeBookmark) -> Bool {
|
||||
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
|
||||
func create(folder relativePath: String, in scope: SecurityScopeBookmark) -> Bool {
|
||||
return write(in: scope) { folder in
|
||||
let url = folder.appendingPathComponent(relativePath, isDirectory: true)
|
||||
do {
|
||||
try url.ensureFolderExistence()
|
||||
try url.createIfNeeded()
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to create folder \(url.path()): \(error)")
|
||||
@ -470,42 +477,153 @@ final class Storage: ObservableObject {
|
||||
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
|
||||
func check(contentPath: String) -> SecurityScopeStatus {
|
||||
contentFolderStatus = Storage.ensure(securityScope: .contentPath, matches: contentPath)
|
||||
return contentFolderStatus
|
||||
func save(contentPath: URL) -> Bool {
|
||||
guard let bookmarkData = encode(url: contentPath) else {
|
||||
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
|
||||
func check(outputPath: String) -> SecurityScopeStatus {
|
||||
outputFolderStatus = Storage.ensure(securityScope: .outputPath, matches: outputPath)
|
||||
return outputFolderStatus
|
||||
private func loadContentPath() -> Bool {
|
||||
guard let bookmarkData = UserDefaults.standard.data(forKey: contentPathBookmarkKey) else {
|
||||
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 {
|
||||
guard path != "" else {
|
||||
return .noPath
|
||||
}
|
||||
guard let bookmarkData = UserDefaults.standard.data(forKey: securityScope.rawValue) else {
|
||||
return .noBookmark
|
||||
}
|
||||
func clearContentPath() {
|
||||
UserDefaults.standard.removeObject(forKey: contentPathBookmarkKey)
|
||||
contentPath = nil
|
||||
contentPathUrlIsStale = false
|
||||
hasContentFolders = false
|
||||
outputPath = nil
|
||||
outputPathUrlIsStale = false
|
||||
}
|
||||
|
||||
/**
|
||||
Decode the security scope data to get a url.
|
||||
*/
|
||||
private func decode(bookmark: Data) -> (url: URL?, isStale: Bool) {
|
||||
do {
|
||||
var isStale = false
|
||||
let url = try URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
resolvingBookmarkData: bookmark,
|
||||
options: .withSecurityScope,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale)
|
||||
guard !isStale else {
|
||||
return .stale
|
||||
}
|
||||
guard url.path() == path else {
|
||||
return .urlMismatch
|
||||
}
|
||||
return .nominal
|
||||
return (url, isStale)
|
||||
} 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 {
|
||||
print("Writing new file \(url.path())")
|
||||
try url.ensureParentFolderExistence()
|
||||
try url.createParentFolderIfNeeded()
|
||||
}
|
||||
try data.write(to: url)
|
||||
print("Saved file \(url.path())")
|
||||
@ -595,21 +713,12 @@ final class Storage: ObservableObject {
|
||||
guard let data = try readData(at: relativePath) else {
|
||||
return nil
|
||||
}
|
||||
return try decoder.decode(T.self, from: data)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
- 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
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
print("Failed to decode file \(relativePath): \(error)")
|
||||
throw error
|
||||
}
|
||||
return try decoder.decode(T.self, from: data)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -644,32 +753,45 @@ final class Storage: ObservableObject {
|
||||
guard url.exists else {
|
||||
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
|
||||
*/
|
||||
private func decodeAllFromJson<T>(in folder: String) throws -> [String : T] where T: Decodable {
|
||||
try withScopedContent(folder: folder) { folderUrl in
|
||||
try getFiles(in: folderUrl)
|
||||
.filter { $0.pathExtension.lowercased() == "json" }
|
||||
.reduce(into: [:]) { items, url in
|
||||
let id = url.deletingPathExtension().lastPathComponent
|
||||
let data = try Data(contentsOf: url)
|
||||
items[id] = try decoder.decode(T.self, from: data)
|
||||
}
|
||||
try inContentFolder(relativePath: folder) { folderUrl in
|
||||
do {
|
||||
return try folderUrl
|
||||
.containedFiles()
|
||||
.filter { $0.pathExtension.lowercased() == "json" }
|
||||
.reduce(into: [:]) { items, url in
|
||||
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 {
|
||||
try withScopedContent(file: relativePath) { destination in
|
||||
try destination.ensureParentFolderExistence()
|
||||
try destination.createParentFolderIfNeeded()
|
||||
try fm.copyItem(at: file, to: destination)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user