Save automatically, improve mocks

This commit is contained in:
Christoph Hagen
2025-02-05 12:24:33 +01:00
parent d41c54d174
commit 5abe6e1a9f
55 changed files with 701 additions and 381 deletions

View File

@ -2,7 +2,47 @@ import Foundation
extension Content {
func saveToDisk() -> Bool {
func needsSave() {
setModificationTimestamp()
if saveState == .isSaved {
update(saveState: saveState)
}
// Wait a few seconds for a save, to allow additional changes
// Reduces the number of saves
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) {
self.saveIfNeeded()
}
}
func saveIfNeeded() {
guard saveState != .isSaved else {
return
}
if Date.now.timeIntervalSince(lastModification) < 5 {
// Additional modification made
// Wait for next scheduled invocation of saveIfNeeded()
// if the overall unsaved time is not too long
if Date.now.timeIntervalSince(lastSave) < 30 {
//print("Waiting while modifying")
return
}
print("Saving after 30 seconds of modifications")
}
saveUnconditionally()
}
func saveUnconditionally() {
guard saveToDisk() else {
update(saveState: .failedToSave)
// TODO: Try to save again
return
}
update(saveState: .isSaved)
setLastSaveTimestamp()
}
private func saveToDisk() -> Bool {
guard didLoadContent else { return false }
guard storage.contentScope != nil else {
print("Storage not initialized, not saving content")
@ -10,12 +50,28 @@ extension Content {
}
var failedSaves = 0
failedSaves += pages.count { !storage.save(pageMetadata: $0.data, for: $0.id) }
failedSaves += posts.count { !storage.save(post: $0.data, for: $0.id) }
failedSaves += tags.count { !storage.save(tagMetadata: $0.data, for: $0.id) }
failedSaves.increment(!storage.save(settings: settings.data(tagOverview: tagOverview)))
failedSaves += files.count { !storage.save(fileInfo: $0.data, for: $0.id) }
var saves = 0
let pageSaves = saveChanged(pages)
failedSaves += pageSaves.unsaved
saves += pageSaves.saved
let postSaves = saveChanged(posts)
failedSaves += postSaves.unsaved
saves += postSaves.saved
let tagSaves = saveChanged(tags)
failedSaves += tagSaves.unsaved
saves += tagSaves.saved
failedSaves.increment(!storage.save(settings: settings.data(tagOverview: tagOverview)))
let fileSaves = saveChanged(files)
failedSaves += fileSaves.unsaved
saves += fileSaves.saved
if saves > 0 {
print("Saved \(saves) changed items")
}
if failedSaves > 0 {
print("Save partially failed with \(failedSaves) errors")
return false
@ -39,4 +95,22 @@ extension Content {
}
return success
}
private func saveChanged<S>(_ items: S) -> (saved: Int, unsaved: Int, unchanged: Int) where S: Sequence, S.Element: StorageItem {
var failed = 0
var saved = 0
var unchanged = 0
for item in items {
guard let wasSaved = item.saveIfNeeded() else {
unchanged += 1
continue
}
if wasSaved {
saved += 1
} else {
failed += 1
}
}
return (saved, failed, unchanged)
}
}

View File

@ -11,7 +11,7 @@ final class Content: ObservableObject {
var storage: Storage
@Published
var settings: Settings
var settings: Settings!
@Published
var posts: [Post]
@ -31,6 +31,9 @@ final class Content: ObservableObject {
@Published
var results: GenerationResults
@Published
var storageErrors: [StorageError] = []
@Published
var generationStatus: String = "Ready to generate"
@ -40,28 +43,12 @@ final class Content: ObservableObject {
@Published
private(set) var shouldGenerateWebsite = false
@Published
private(set) var saveState: SaveState = .isSaved
let imageGenerator: ImageGenerator
init(settings: Settings,
posts: [Post],
pages: [Page],
tags: [Tag],
files: [FileResource],
tagOverview: Tag?) {
self.settings = settings
self.posts = posts
self.pages = pages
self.tags = tags
self.files = files
self.tagOverview = tagOverview
self.results = .init()
let storage = Storage()
self.storage = storage
self.imageGenerator = ImageGenerator(
storage: storage,
settings: settings)
}
var errorCallback: ((StorageError) -> Void)?
init() {
let settings = Settings.default
@ -78,6 +65,10 @@ final class Content: ObservableObject {
self.imageGenerator = ImageGenerator(
storage: storage,
settings: settings)
storage.errorNotification = { [weak self] error in
self?.storageErrors.append(error)
}
settings.content = self
}
private func clear() {
@ -112,7 +103,7 @@ final class Content: ObservableObject {
pages.insert(page, at: 0)
}
func update(contentPath: URL, callback: @escaping ([String]) -> ()) {
func update(contentPath: URL, callback: @escaping () -> ()) {
guard storage.save(contentPath: contentPath) else {
return
}
@ -139,19 +130,15 @@ final class Content: ObservableObject {
files.first { $0.absoluteUrl == withOutputPath }
}
private let errorPrinter = ErrorPrinter()
func loadFromDisk(callback: @escaping (_ errors: [String]) -> ()) {
defer {
storage.contentScope?.delegate = errorPrinter
}
func loadFromDisk(callback: @escaping () -> ()) {
DispatchQueue.global().async {
let loader = ModelLoader(content: self, storage: self.storage)
let result = loader.load()
guard result.errors.isEmpty else {
DispatchQueue.main.async {
self.didLoadContent = false
callback(result.errors.sorted())
self.storageErrors.append(contentsOf: result.errors)
callback()
}
return
}
@ -164,7 +151,7 @@ final class Content: ObservableObject {
self.settings = result.settings
self.tagOverview = result.tagOverview
self.didLoadContent = true
callback([])
callback()
self.generateMissingVideoThumbnails()
}
}
@ -183,4 +170,22 @@ final class Content: ObservableObject {
}
}
}
// MARK: Saving
private(set) var lastSave: Date = .now
private(set) var lastModification: Date = .now
func update(saveState: SaveState) {
self.saveState = saveState
}
func setModificationTimestamp() {
self.lastModification = .now
}
func setLastSaveTimestamp() {
self.lastSave = .now
}
}

View File

@ -42,7 +42,7 @@ extension ContentLabel {
self.init(icon: icon, value: data.value)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let icon: String
let value: String
}

View File

@ -51,6 +51,8 @@ final class FileResource: Item, LocalizedItem {
@Published
var fileSize: Int? = nil
var savedData: Data?
init(content: Content,
id: String,
isExternallyStored: Bool,
@ -78,7 +80,7 @@ final class FileResource: Item, LocalizedItem {
/**
Only for bundle images
*/
init(resourceImage: String, type: FileType) {
init(content: Content, resourceImage: String, type: FileType) {
self.type = type
self.english = "A test image included in the bundle"
self.german = "Ein Testbild aus dem Bundle"
@ -89,7 +91,7 @@ final class FileResource: Item, LocalizedItem {
self.customOutputPath = nil
self.addedDate = Date.now
self.modifiedDate = Date.now
super.init(content: .mock, id: resourceImage) // TODO: Add images to mock
super.init(content: content, id: resourceImage)
}
// MARK: Text
@ -349,7 +351,7 @@ extension FileResource: CustomStringConvertible {
}
}
extension FileResource {
extension FileResource: StorageItem {
convenience init(content: Content, id: String, data: FileResource.Data, isExternalFile: Bool) {
self.init(
@ -364,6 +366,7 @@ extension FileResource {
customOutputPath: data.customOutputPath,
addedDate: data.addedDate,
modifiedDate: data.modifiedDate)
savedData = data
}
var data: Data {
@ -379,7 +382,7 @@ extension FileResource {
}
/// This struct holds metadata about a file resource that is stored in the content folder.
struct Data: Codable {
struct Data: Codable, Equatable {
let englishDescription: String?
let germanDescription: String?
let generatedImages: [String]?
@ -389,4 +392,8 @@ extension FileResource {
let addedDate: Date
let modifiedDate: Date
}
func saveToDisk(_ data: Data) -> Bool {
content.storage.save(fileResource: data, for: id)
}
}

View File

@ -1,6 +1,7 @@
import Foundation
import Combine
class Item: ObservableObject, Identifiable {
class Item: ObservableContentItem, Identifiable {
unowned let content: Content
@ -11,17 +12,25 @@ class Item: ObservableObject, Identifiable {
@Published
var id: String
var cancellables = Set<AnyCancellable>()
init(content: Content, id: String) {
self.content = content
self.id = id
observeChanges()
}
// MARK: Change observation
func didChange() {
DispatchQueue.main.async {
self.changeToggle.toggle()
}
}
// MARK: Paths
func makeCleanAbsolutePath(_ path: String) -> String {
"/" + makeCleanRelativePath(path)
}

View File

@ -9,3 +9,7 @@ struct ItemId {
extension ItemId: Codable {
}
extension ItemId: Equatable {
}

View File

@ -50,7 +50,7 @@ final class LinkPreview: ObservableObject {
extension LinkPreview {
/// The object to serialize a link preview for storage
struct Data: Codable {
struct Data: Codable, Equatable {
let title: String?
let description: String?
let image: String?

View File

@ -29,7 +29,7 @@ final class LoadingContext {
tags: tags.values.sorted(),
files: files.values.sorted { $0.id },
tagOverview: tagOverview,
errors: errors.sorted())
errors: errors.sorted().map { StorageError(message: $0) })
}
func error(_ message: String) {

View File

@ -13,5 +13,5 @@ struct LoadingResult {
let tagOverview: Tag?
let errors: [String]
let errors: [StorageError]
}

View File

@ -1,17 +1,4 @@
final class LoadingErrorHandler: SecurityBookmarkErrorDelegate {
let context: LoadingContext
init(context: LoadingContext) {
self.context = context
}
func securityBookmark(error: String) {
context.error("\(error)")
}
}
final class ModelLoader {
let content: Content
@ -20,14 +7,10 @@ final class ModelLoader {
let context: LoadingContext
let errorHandler: LoadingErrorHandler
init(content: Content, storage: Storage) {
self.content = content
self.storage = storage
self.context = .init(content: content)
self.errorHandler = .init(context: context)
storage.contentScope?.delegate = errorHandler
}
func load() -> LoadingResult {

View File

@ -80,7 +80,7 @@ extension LocalizedPage {
}
/// The structure to store the metadata of a localized page
struct Data: Codable {
struct Data: Codable, Equatable {
let url: String
let title: String
let linkPreview: LinkPreview.Data

View File

@ -108,7 +108,7 @@ extension LocalizedPost {
}
/// The structure to store the metadata of a localized post
struct Data: Codable {
struct Data: Codable, Equatable {
let images: [String]
let labels: [ContentLabel.Data]?
let title: String?

View File

@ -54,7 +54,7 @@ extension LocalizedTag {
originalUrl: data.originalUrl)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let urlComponent: String
let name: String
let linkPreview: LinkPreview.Data

View File

@ -44,6 +44,8 @@ final class Page: Item, DateItem, LocalizedItem {
@Published
var requiredFiles: [FileResource]
var savedData: Data?
init(content: Content,
id: String,
externalLink: String?,
@ -186,7 +188,7 @@ final class Page: Item, DateItem, LocalizedItem {
// MARK: Storage
extension Page {
extension Page: StorageItem {
convenience init(context: LoadingContext, id: String, data: Data) {
self.init(
@ -202,10 +204,11 @@ extension Page {
english: .init(context: context, data: data.english),
tags: data.tags.compactMap(context.tag),
requiredFiles: data.requiredFiles?.compactMap(context.file) ?? [])
savedData = data
}
/// The structure to store the metadata of a page on disk
struct Data: Codable {
struct Data: Codable, Equatable {
let isDraft: Bool
let externalLink: String?
let tags: [String]
@ -232,4 +235,8 @@ extension Page {
english: english.data,
requiredFiles: requiredFiles.nonEmpty?.map { $0.id }.sorted())
}
func saveToDisk(_ data: Data) -> Bool {
content.storage.save(page: data, for: id)
}
}

View File

@ -60,6 +60,10 @@ final class Post: Item, DateItem, LocalizedItem {
super.init(content: content, id: id)
}
// MARK: Storage
var savedData: Data?
// MARK: Tags
func usedTags() -> [Tag] {
@ -173,7 +177,7 @@ final class Post: Item, DateItem, LocalizedItem {
}
}
extension Post {
extension Post: StorageItem {
convenience init(context: LoadingContext, id: String, data: Data) {
self.init(
@ -187,9 +191,10 @@ extension Post {
german: .init(context: context, data: data.german),
english: .init(context: context, data: data.english),
linkedPage: data.linkedPageId.map(context.page))
savedData = data
}
struct Data: Codable {
struct Data: Codable, Equatable {
let isDraft: Bool
let createdDate: Date
let startDate: Date
@ -211,4 +216,8 @@ extension Post {
english: english.data,
linkedPageId: linkedPage?.id)
}
func saveToDisk(_ data: Data) -> Bool {
content.storage.save(post: data, for: id)
}
}

View File

@ -67,7 +67,7 @@ extension AudioPlayerSettings {
english: english.data)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let playlistCoverImageSize: Int
let smallCoverImageSize: Int
let audioPlayerJsFile: String?

View File

@ -34,7 +34,7 @@ extension GeneralSettings {
linkPreviewImageHeight: linkPreviewImageHeight)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let url: String
let linkPreviewImageWidth: Int
let linkPreviewImageHeight: Int

View File

@ -22,7 +22,7 @@ extension LocalizedAudioPlayerSettings {
.init(playlistText: playlistText)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let playlistText: String
}
}

View File

@ -18,7 +18,7 @@ extension LocalizedNavigationSettings {
self.init(rootUrl: data.rootUrl)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let rootUrl: String
}

View File

@ -31,7 +31,7 @@ extension LocalizedPageSettings {
emptyPageText: emptyPageText)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let emptyPageTitle: String
let emptyPageText: String
}

View File

@ -41,7 +41,7 @@ extension LocalizedPostSettings {
linkPreview: linkPreview.data)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let feedUrlPrefix: String
let defaultPageLinkText: String
let linkPreview: LinkPreview.Data

View File

@ -32,7 +32,7 @@ extension NavigationSettings {
english: LocalizedNavigationSettings(data: data.english))
}
struct Data: Codable {
struct Data: Codable, Equatable {
let navigationItems: [ItemId]
let german: LocalizedNavigationSettings.Data
let english: LocalizedNavigationSettings.Data

View File

@ -104,7 +104,7 @@ extension PageSettings {
english: english.data)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let contentWidth: Int
let largeImageWidth: Int
let pageLinkImageSize: Int

View File

@ -64,7 +64,7 @@ extension PathSettings {
tagsOutputFolderPath: tagsOutputFolderPath)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let assetsOutputFolderPath: String
let pagesOutputFolderPath: String
let imagesOutputFolderPath: String

View File

@ -79,7 +79,7 @@ extension PostSettings {
english: english.data)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let postsPerPage: Int
let contentWidth: Int
let swiperCssFile: String?

View File

@ -1,6 +1,7 @@
import Foundation
import Combine
final class Settings: ObservableObject {
final class Settings: ChangeObservableItem {
@Published
var general: GeneralSettings
@ -21,6 +22,10 @@ final class Settings: ObservableObject {
@Published
var audioPlayer: AudioPlayerSettings
weak var content: Content?
var cancellables: Set<AnyCancellable> = []
init(general: GeneralSettings,
paths: PathSettings,
navigation: NavigationSettings,
@ -40,6 +45,10 @@ final class Settings: ObservableObject {
posts.remove(file)
audioPlayer.remove(file)
}
func needsSaving() {
content?.needsSave()
}
}
// MARK: Storage
@ -54,6 +63,7 @@ extension Settings {
posts: .init(context: context, data: data.posts),
pages: .init(context: context, data: data.pages),
audioPlayer: .init(context: context, data: data.audioPlayer))
content = context.content
}
func data(tagOverview: Tag?) -> Data {
@ -67,7 +77,7 @@ extension Settings {
tagOverview: tagOverview?.data)
}
struct Data: Codable {
struct Data: Codable, Equatable {
let general: GeneralSettings.Data
let paths: PathSettings.Data
let navigation: NavigationSettings.Data
@ -76,6 +86,10 @@ extension Settings {
let audioPlayer: AudioPlayerSettings.Data
let tagOverview: Tag.Data?
}
func saveToDisk(_ data: Data) -> Bool {
content?.storage.save(settings: data) ?? false
}
}
extension Settings {

View File

@ -0,0 +1,37 @@
import Foundation
struct StorageError {
let date: Date
let message: String
init(date: Date = .now, message: String) {
self.date = date
self.message = message
}
}
extension StorageError: Identifiable {
var id: String {
date.description + message
}
}
extension StorageError: Comparable {
static func < (lhs: StorageError, rhs: StorageError) -> Bool {
guard lhs.date == rhs.date else {
return lhs.date < rhs.date
}
return lhs.message < rhs.message
}
}
extension StorageError: ExpressibleByStringLiteral {
init(stringLiteral value: StringLiteralType) {
self.init(message: value)
}
}

View File

@ -13,6 +13,8 @@ class Tag: Item, LocalizedItem {
@Published
var english: LocalizedTag
var savedData: Data?
override init(content: Content, id: String) {
self.isVisible = true
self.english = .init(content: content, urlComponent: id, name: id)
@ -77,7 +79,7 @@ class Tag: Item, LocalizedItem {
// MARK: Storage
extension Tag {
extension Tag: StorageItem {
convenience init(context: LoadingContext, id: String, data: Data) {
self.init(
@ -86,9 +88,10 @@ extension Tag {
isVisible: data.isVisible ?? true,
german: .init(context: context, data: data.german),
english: .init(context: context, data: data.english))
savedData = data
}
struct Data: Codable {
struct Data: Codable, Equatable {
// Defaults to true if unset
let isVisible: Bool?
let german: LocalizedTag.Data
@ -101,4 +104,8 @@ extension Tag {
german: german.data,
english: english.data)
}
func saveToDisk(_ data: Data) -> Bool {
content.storage.save(tag: data, for: id)
}
}