2024-12-03 13:19:50 +01:00

339 lines
11 KiB
Swift

import Foundation
/**
A class that handles the storage of the website data.
BaseFolder
- pages: Contains the markdown files of the localized pages, file name is the url
- images: Contains the raw images
- files: Contains additional files
- videos: Contains raw video files
- posts: Contains the markdown files for localized posts, file name is the post id
-
*/
final class Storage {
static let outputPathBookmarkKey = "outputPathBookmark"
static let contentPathBookmarkKey = "contentPathBookmark"
private(set) var baseFolder: URL
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let fm = FileManager.default
/**
Create the storage.
*/
init(baseFolder: URL) {
self.baseFolder = baseFolder
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
}
// MARK: Helper
private func subFolder(_ name: String) -> URL {
baseFolder.appending(path: name, directoryHint: .isDirectory)
}
private func files(in folder: URL) throws -> [URL] {
do {
return try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { !$0.hasDirectoryPath }
} catch {
print("Failed to get files in folder \(folder.path): \(error)")
throw error
}
}
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 update(baseFolder: URL, moveContent: Bool) throws {
let oldFolder = self.baseFolder
self.baseFolder = baseFolder
try createFolderStructure()
guard moveContent else {
return
}
// TODO: Move all files
}
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 create(folder: pagesFolder)
try create(folder: filesFolder)
try create(folder: postsFolder)
try create(folder: tagsFolder)
}
// MARK: Pages
/// The folder path where the markdown and metadata files of the pages are stored (by their id/url component)
private var pagesFolder: URL { subFolder("pages") }
private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String {
"\(id)-\(language.rawValue).md"
}
private func pageFileName(_ id: String) -> String {
id + ".json"
}
private func pageContentUrl(pageId: String, language: ContentLanguage) -> URL {
pagesFolder.appending(path: pageContentFileName(pageId, language), directoryHint: .notDirectory)
}
private func pageMetadataUrl(pageId: String) -> URL {
pagesFolder.appending(path: pageFileName(pageId), directoryHint: .notDirectory)
}
@discardableResult
func save(pageContent: String, for pageId: String, language: ContentLanguage) -> Bool {
let contentUrl = pageContentUrl(pageId: pageId, language: language)
return write(content: pageContent, to: contentUrl, type: "page", id: pageId)
}
@discardableResult
func save(pageMetadata: PageFile, for pageId: String) -> Bool {
let contentUrl = pageMetadataUrl(pageId: pageId)
return write(pageMetadata, type: "page", id: pageId, to: contentUrl)
}
@discardableResult
func copyPageContent(from url: URL, for pageId: String, language: ContentLanguage) -> Bool {
let contentUrl = pageContentUrl(pageId: pageId, language: language)
return copy(file: url, to: contentUrl, type: "page content", id: pageId)
}
func loadAllPages() throws -> [String : PageFile] {
try loadAll(in: pagesFolder)
}
func pageContent(for pageId: String, language: ContentLanguage) -> String {
let contentUrl = pageContentUrl(pageId: pageId, language: language)
guard fm.fileExists(atPath: contentUrl.path()) else {
print("No file at \(contentUrl.path())")
return "New file"
}
do {
return try String(contentsOf: contentUrl, encoding: .utf8)
} catch {
print("Failed to load page content for \(pageId) (\(language)): \(error)")
return error.localizedDescription
}
}
func deletePageFiles(notIn pages: [String]) throws {
var files = Set(pages.map(pageFileName))
for language in ContentLanguage.allCases {
files.formUnion(pages.map { pageContentFileName($0, language) })
}
try deleteFiles(in: pagesFolder, notIn: files)
}
// MARK: Posts
/// The folder path where the markdown files of the posts are stored (by their unique id/url component)
private var postsFolder: URL { subFolder("posts") }
private func postFileUrl(postId: String) -> URL {
postsFolder.appending(path: postId, directoryHint: .notDirectory).appendingPathExtension("json")
}
@discardableResult
func save(post: PostFile, for postId: String) -> Bool {
let contentUrl = postFileUrl(postId: postId)
return write(post, type: "post", id: postId, to: contentUrl)
}
func loadAllPosts() throws -> [String : PostFile] {
try loadAll(in: postsFolder)
}
private func post(at url: URL) throws -> PostFile {
try read(at: url)
}
private func postContent(for postId: String) throws -> PostFile {
let url = postFileUrl(postId: postId)
return try post(at: url)
}
func deletePostFiles(notIn posts: [String]) throws {
let files = Set(posts.map { $0 + ".json" })
try deleteFiles(in: postsFolder, notIn: files)
}
// MARK: Tags
/// The folder path where the source images are stored (by their unique name)
private var tagsFolder: URL { subFolder("tags") }
private func tagFileUrl(tagId: String) -> URL {
tagsFolder.appending(path: tagId, directoryHint: .notDirectory)
}
private func tagMetadataUrl(tagId: String) -> URL {
tagFileUrl(tagId: tagId).appendingPathExtension("json")
}
@discardableResult
func save(tagMetadata: TagFile, for tagId: String) -> Bool {
let contentUrl = tagMetadataUrl(tagId: tagId)
return write(tagMetadata, type: "tag", id: tagId, to: contentUrl)
}
func loadAllTags() throws -> [String : TagFile] {
try loadAll(in: tagsFolder)
}
func deleteTagFiles(notIn tags: [String]) throws {
let files = Set(tags.map { $0 + ".json" })
try deleteFiles(in: tagsFolder, notIn: files)
}
// MARK: Files
/// The folder path where other files are stored (by their unique name)
private var filesFolder: URL { subFolder("files") }
private func fileUrl(file: String) -> URL {
filesFolder.appending(path: file, directoryHint: .notDirectory)
}
@discardableResult
func copyFile(at url: URL, fileId: String) -> Bool {
let contentUrl = fileUrl(file: fileId)
return copy(file: url, to: contentUrl, type: "file", id: fileId)
}
func loadAllFiles() throws -> [String : URL] {
try files(in: filesFolder).reduce(into: [:]) { files, url in
files[url.lastPathComponent] = url
}
}
func deleteFiles(notIn fileSet: [String]) throws {
try deleteFiles(in: filesFolder, notIn: Set(fileSet))
}
// MARK: Website data
private var websiteDataUrl: URL {
baseFolder.appending(path: "website-data.json", directoryHint: .notDirectory)
}
func loadWebsiteData() throws -> WebsiteDataFile {
try read(at: websiteDataUrl)
}
@discardableResult
func save(websiteData: WebsiteDataFile) -> Bool {
write(websiteData, type: "Website Data", id: "-", to: websiteDataUrl)
}
// MARK: Writing files
private func deleteFiles(in folder: URL, notIn fileSet: Set<String>) throws {
let filesToDelete = try files(in: folder)
.filter { !fileSet.contains($0.lastPathComponent) }
for file in filesToDelete {
try fm.removeItem(at: file)
print("Deleted \(file.path())")
}
}
/**
Encode a value and write it to a file, if the content changed
*/
private func write<T>(_ value: T, type: String, id: String, to file: URL) -> Bool where T: Encodable {
let content: Data
do {
content = try encoder.encode(value)
} catch {
print("Failed to encode content of \(type) '\(id)': \(error)")
return false
}
return write(data: content, type: type, id: id, to: file)
}
/**
Write data to a file if the content changed
*/
private func write(data: Data, type: String, id: String, to file: URL) -> Bool {
if fm.fileExists(atPath: file.path()) {
// Check if content is the same, to prevent unnecessary writes
do {
let oldData = try Data(contentsOf: file)
if data == oldData {
// File is the same, don't write
return true
}
} catch {
print("Failed to read file \(file.path()) for equality check: \(error)")
// No check possible, write file
}
} else {
print("Writing new file \(file.path())")
}
do {
try data.write(to: file, options: .atomic)
print("Saved file \(file.path())")
return true
} catch {
print("Failed to save content for \(type) '\(id)': \(error)")
return false
}
}
private func copy(file: URL, to destination: URL, type: String, id: String) -> Bool {
do {
try fm.copyItem(at: file, to: destination)
return true
} catch {
print("Failed to copy content file for \(type) '\(id)': \(error)")
return false
}
}
private func write(content: String, to file: URL, type: String, id: String) -> Bool {
guard let data = content.data(using: .utf8) else {
print("Failed to convert string to data for \(type) '\(id)'")
return false
}
return write(data: data, type: type, id: id, to: file)
}
private func read<T>(at url: URL) throws -> T where T: Decodable {
let data = try Data(contentsOf: url)
return try decoder.decode(T.self, from: data)
}
private func loadAll<T>(in folder: URL) throws -> [String : T] where T: Decodable {
try files(in: folder, type: "json").reduce(into: [:]) { items, url in
let id = url.deletingPathExtension().lastPathComponent
let item: T = try read(at: url)
items[id] = item
}
}
}