summaryrefslogtreecommitdiff
path: root/Sora/Data/Settings
diff options
context:
space:
mode:
authorFuwn <[email protected]>2025-09-05 19:48:59 -0700
committerFuwn <[email protected]>2025-09-05 19:48:59 -0700
commitd5b81fcbc1a8c009204b2482d490e8d8b96c6c26 (patch)
tree3cf3593d4aa9facf028d4fe40d952f848f5b2b3f /Sora/Data/Settings
parentfeat: Development commit (diff)
downloadsora-testing-d5b81fcbc1a8c009204b2482d490e8d8b96c6c26.tar.xz
sora-testing-d5b81fcbc1a8c009204b2482d490e8d8b96c6c26.zip
feat: Development commit
Diffstat (limited to 'Sora/Data/Settings')
-rw-r--r--Sora/Data/Settings/SettingsFavoritePost.swift95
-rw-r--r--Sora/Data/Settings/SettingsManager.swift248
-rw-r--r--Sora/Data/Settings/SettingsSyncKey.swift2
3 files changed, 344 insertions, 1 deletions
diff --git a/Sora/Data/Settings/SettingsFavoritePost.swift b/Sora/Data/Settings/SettingsFavoritePost.swift
new file mode 100644
index 0000000..f0c5126
--- /dev/null
+++ b/Sora/Data/Settings/SettingsFavoritePost.swift
@@ -0,0 +1,95 @@
+import Foundation
+
+struct SettingsFavoritePost: Codable, Identifiable, Hashable {
+ let id: UUID
+ let postId: String
+ let tags: [String]
+ let createdAt: Date
+ let provider: BooruProvider
+ var folder: UUID?
+ var lastVisit: Date
+ var visitedCount: Int
+ let thumbnailUrl: String?
+ let previewUrl: String?
+ let fileUrl: String?
+ let fileType: BooruPostFileType
+ let rating: BooruRating
+ let width: Int
+ let height: Int
+ let fileSize: Int?
+
+ init(
+ post: BooruPost,
+ provider: BooruProvider,
+ folder: UUID? = nil,
+ id: UUID = UUID()
+ ) {
+ self.createdAt = Date()
+ self.lastVisit = Date()
+ self.id = id
+ self.postId = post.id
+ self.tags = post.tags
+ self.provider = provider
+ self.folder = folder
+ self.visitedCount = 0
+ self.thumbnailUrl = post.previewURL.absoluteString
+ self.previewUrl = post.previewURL.absoluteString
+ self.fileUrl = post.fileURL.absoluteString
+ self.fileType = .preview
+ self.rating = post.rating
+ self.width = post.width
+ self.height = post.height
+ self.fileSize = nil
+ }
+
+ enum CodingKeys: String, CodingKey {
+ // swiftlint:disable:next explicit_enum_raw_value
+ case id, postId, tags, createdAt, provider, folder, lastVisit, visitedCount, thumbnailUrl,
+ // swiftlint:disable:next explicit_enum_raw_value
+ previewUrl, fileUrl, fileType, rating, width, height, fileSize
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+ self.postId = try container.decode(String.self, forKey: .postId)
+ self.tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
+ self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
+ self.provider =
+ try container.decodeIfPresent(BooruProvider.self, forKey: .provider) ?? .safebooru
+ self.folder = try container.decodeIfPresent(UUID.self, forKey: .folder)
+ self.lastVisit = try container.decodeIfPresent(Date.self, forKey: .lastVisit) ?? Date()
+ self.visitedCount = try container.decodeIfPresent(Int.self, forKey: .visitedCount) ?? 0
+ self.thumbnailUrl = try container.decodeIfPresent(String.self, forKey: .thumbnailUrl)
+ self.previewUrl = try container.decodeIfPresent(String.self, forKey: .previewUrl)
+ self.fileUrl = try container.decodeIfPresent(String.self, forKey: .fileUrl)
+ self.fileType =
+ try container.decodeIfPresent(BooruPostFileType.self, forKey: .fileType) ?? .preview
+ self.rating = try container.decodeIfPresent(BooruRating.self, forKey: .rating) ?? .questionable
+ self.width = try container.decodeIfPresent(Int.self, forKey: .width) ?? 0
+ self.height = try container.decodeIfPresent(Int.self, forKey: .height) ?? 0
+ self.fileSize = try container.decodeIfPresent(Int.self, forKey: .fileSize)
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ try container.encode(id, forKey: .id)
+ try container.encode(postId, forKey: .postId)
+ try container.encode(tags, forKey: .tags)
+ try container.encode(createdAt, forKey: .createdAt)
+ try container.encode(provider, forKey: .provider)
+ try container.encodeIfPresent(folder, forKey: .folder)
+ try container.encode(lastVisit, forKey: .lastVisit)
+ try container.encode(visitedCount, forKey: .visitedCount)
+ try container.encodeIfPresent(thumbnailUrl, forKey: .thumbnailUrl)
+ try container.encodeIfPresent(previewUrl, forKey: .previewUrl)
+ try container.encodeIfPresent(fileUrl, forKey: .fileUrl)
+ try container.encode(fileType.rawValue, forKey: .fileType)
+ try container.encode(rating, forKey: .rating)
+ try container.encode(width, forKey: .width)
+ try container.encode(height, forKey: .height)
+ try container.encodeIfPresent(fileSize, forKey: .fileSize)
+ }
+}
diff --git a/Sora/Data/Settings/SettingsManager.swift b/Sora/Data/Settings/SettingsManager.swift
index 3fba04e..ffe7057 100644
--- a/Sora/Data/Settings/SettingsManager.swift
+++ b/Sora/Data/Settings/SettingsManager.swift
@@ -44,6 +44,7 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
// MARK: - Private Properties
private var bookmarksCache: [SettingsBookmark] = []
+ private var favoritesCache: [SettingsFavoritePost] = []
private var searchHistoryCache: [BooruSearchQuery] = []
private var blurRatingsCache: [BooruRating] = []
private var displayRatingsCache: [BooruRating] = []
@@ -56,6 +57,9 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
@AppStorage("bookmarks")
private var bookmarksData = Data()
+ @AppStorage("favorites")
+ private var favoritesData = Data()
+
@AppStorage("displayRatings")
private var displayRatingsData = SettingsManager.encode(BooruRating.allCases) ?? Data()
@@ -101,6 +105,29 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
}
}
+ var favorites: [SettingsFavoritePost] {
+ get { favoritesCache }
+
+ set {
+ guard !isUpdatingCache else { return }
+
+ isUpdatingCache = true
+
+ defer { isUpdatingCache = false }
+
+ syncableData(
+ key: "favorites",
+ localData: $favoritesData,
+ newValue: newValue
+ ) { $0.sorted { $0.date > $1.date } }
+
+ favoritesCache = newValue.sorted { $0.date > $1.date }
+
+ pendingSyncKeys.insert(.favorites)
+ triggerBatchedSync()
+ }
+ }
+
var uniformThumbnailGrid: Bool {
get { uniformThumbnailGridCache }
@@ -312,6 +339,7 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
}
loadBookmarksCache()
+ loadFavoritesCache()
loadSearchHistoryCache()
loadDisplayRatingsCache()
loadBlurRatingsCache()
@@ -340,6 +368,26 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
triggerBatchedSync()
}
+ func updateFavorites(_ newValue: [SettingsFavoritePost]) async {
+ guard !isUpdatingCache else { return }
+
+ isUpdatingCache = true
+
+ defer { isUpdatingCache = false }
+
+ syncableData(
+ key: "favorites",
+ localData: $favoritesData,
+ newValue: newValue
+ ) { $0.sorted { $0.date > $1.date } }
+
+ favoritesCache = newValue.sorted { $0.date > $1.date }
+
+ await backupFavorites()
+ pendingSyncKeys.insert(.favorites)
+ triggerBatchedSync()
+ }
+
func updateSearchHistory(_ newValue: [BooruSearchQuery]) {
guard !isUpdatingCache else { return }
@@ -529,6 +577,55 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
}.value
}
+ private func backupFavorites() async {
+ await Task.detached {
+ guard
+ let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
+ .first
+ else { return }
+
+ let backupDirectory = cachesDirectory.appendingPathComponent("favorites_backups")
+ let fileManager = FileManager.default
+
+ try? fileManager.createDirectory(at: backupDirectory, withIntermediateDirectories: true)
+
+ let timestamp = Int(Date().timeIntervalSince1970)
+ let backupFile = backupDirectory.appendingPathComponent("favorites_backup_\(timestamp).json")
+
+ if let data = await Self.encode(self.favoritesCache) {
+ try? data.write(to: backupFile)
+ }
+
+ if let files = try? fileManager.contentsOfDirectory(
+ at: backupDirectory,
+ includingPropertiesForKeys: [.contentModificationDateKey],
+ options: .skipsHiddenFiles
+ ) {
+ let jsonBackups = files.filter { file in
+ file.lastPathComponent.hasPrefix("favorites_backup_") && file.pathExtension == "json"
+ }
+ let sortedBackups = jsonBackups.sorted { firstFile, secondFile in
+ let firstDate =
+ (try? firstFile.resourceValues(forKeys: [.contentModificationDateKey])
+ .contentModificationDate)
+ ?? .distantPast
+ let secondDate =
+ (try? secondFile.resourceValues(forKeys: [.contentModificationDateKey])
+ .contentModificationDate)
+ ?? .distantPast
+
+ return firstDate > secondDate
+ }
+
+ if sortedBackups.count > 10 {
+ for url in sortedBackups[10...] {
+ try? fileManager.removeItem(at: url)
+ }
+ }
+ }
+ }.value
+ }
+
// swiftlint:disable:next cyclomatic_complexity
private func triggerSyncIfNeeded(for key: SettingsSyncKey) {
guard enableSync else { return }
@@ -626,10 +723,43 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
self.customProvidersData = encoded
}
}
+
+ case .favorites:
+ var iCloudFavorites: [SettingsFavoritePost] = []
+
+ if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "favorites") {
+ iCloudFavorites =
+ await Self
+ .decode([SettingsFavoritePost].self, from: iCloudData) ?? []
+ }
+
+ let localFavorites =
+ await Self.decode([SettingsFavoritePost].self, from: favoritesData) ?? []
+ let mergedFavoritesDict = (localFavorites + iCloudFavorites).reduce(
+ into: [UUID: SettingsFavoritePost]()
+ ) { dict, favorite in
+ if let existing = dict[favorite.id] {
+ if favorite.date > existing.date {
+ dict[favorite.id] = favorite
+ }
+ } else {
+ dict[favorite.id] = favorite
+ }
+ }
+ let mergedFavorites = Array(mergedFavoritesDict.values).sorted { $0.date > $1.date }
+
+ if let encoded = await Self.encode(mergedFavorites) {
+ NSUbiquitousKeyValueStore.default.set(encoded, forKey: "favorites")
+
+ await MainActor.run {
+ self.favoritesData = encoded
+ }
+ }
}
await MainActor.run {
self.loadBookmarksCache()
+ self.loadFavoritesCache()
self.loadSearchHistoryCache()
self.objectWillChange.send()
}
@@ -656,6 +786,14 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
)
}
+ private func loadFavoritesCache() {
+ loadCache(
+ from: favoritesData,
+ sort: { $0.sorted { $0.date > $1.date } },
+ assign: { [weak self] in self?.favoritesCache = $0 }
+ )
+ }
+
private func loadSearchHistoryCache() {
loadCache(
from: searchHistoryData,
@@ -742,6 +880,7 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
func triggerSyncIfNeededForAll() {
self.triggerSyncIfNeeded(for: .bookmarks)
+ self.triggerSyncIfNeeded(for: .favorites)
self.triggerSyncIfNeeded(for: .searchHistory)
self.triggerSyncIfNeeded(for: .customProviders)
}
@@ -851,6 +990,115 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
triggerBatchedSync()
}
+ // MARK: Favourites Management
+ func addFavorite(post: BooruPost, provider: BooruProvider, folder: UUID? = nil) {
+ var updatedFavorites = favorites
+
+ updatedFavorites.append(
+ SettingsFavoritePost(post: post, provider: provider, folder: folder)
+ )
+
+ if let data = Self.encode(updatedFavorites), data.count < 1_000_000 { // 1 MB
+ Task {
+ await updateFavorites(updatedFavorites)
+ }
+ } else {
+ debugPrint("SettingsManager.addFavorite: iCloud data limit exceeded")
+ }
+ }
+
+ func removeFavorite(at offsets: IndexSet) {
+ var updated = favorites
+
+ updated.remove(atOffsets: offsets)
+
+ Task {
+ await updateFavorites(updated)
+ }
+ }
+
+ func removeFavorite(withPostId postId: String, provider: BooruProvider) {
+ let updated = favorites.filter { !($0.postId == postId && $0.provider == provider) }
+
+ Task {
+ await updateFavorites(updated)
+ }
+ }
+
+ func removeFavorite(withID id: UUID) {
+ let updated = favorites.filter { $0.id != id }
+
+ Task {
+ await updateFavorites(updated)
+ }
+ }
+
+ func isFavorite(postId: String, provider: BooruProvider) -> Bool {
+ favorites.contains { $0.postId == postId && $0.provider == provider }
+ }
+
+ func exportFavorites() throws -> Data {
+ try JSONEncoder().encode(favorites)
+ }
+
+ func importFavorites(from data: Data) throws {
+ let importedFavorites = try JSONDecoder().decode([SettingsFavoritePost].self, from: data)
+ let existingFavoriteIDs = Set(favorites.map(\.id))
+ let newFavorites = importedFavorites.filter { !existingFavoriteIDs.contains($0.id) }
+ var updated = favorites
+
+ updated.append(contentsOf: newFavorites)
+
+ Task {
+ await updateFavorites(updated)
+ }
+ }
+
+ func updateFavoriteFolder(withID id: UUID, folder: UUID?) {
+ guard let index = favorites.firstIndex(where: { $0.id == id }) else { return }
+
+ var updated = favorites
+
+ updated[index].folder = folder
+
+ Task {
+ await updateFavorites(updated)
+ }
+
+ pendingSyncKeys.insert(.favorites)
+ triggerBatchedSync()
+ }
+
+ func updateFavoriteLastVisit(withID id: UUID, date: Date = Date()) {
+ guard let index = favorites.firstIndex(where: { $0.id == id }) else { return }
+
+ var updated = favorites
+
+ updated[index].lastVisit = date
+
+ Task {
+ await updateFavorites(updated)
+ }
+
+ pendingSyncKeys.insert(.favorites)
+ triggerBatchedSync()
+ }
+
+ func incrementFavoriteVisitCount(withID id: UUID) {
+ guard let index = favorites.firstIndex(where: { $0.id == id }) else { return }
+
+ var updated = favorites
+
+ updated[index].visitedCount += 1
+
+ Task {
+ await updateFavorites(updated)
+ }
+
+ pendingSyncKeys.insert(.favorites)
+ triggerBatchedSync()
+ }
+
func folderName(forID id: UUID) -> String? {
folders.first { $0.id == id }?.name
}
diff --git a/Sora/Data/Settings/SettingsSyncKey.swift b/Sora/Data/Settings/SettingsSyncKey.swift
index 75c6afe..1cfbd1d 100644
--- a/Sora/Data/Settings/SettingsSyncKey.swift
+++ b/Sora/Data/Settings/SettingsSyncKey.swift
@@ -1,3 +1,3 @@
enum SettingsSyncKey {
- case bookmarks, customProviders, searchHistory
+ case bookmarks, customProviders, favorites, searchHistory
}