diff options
| author | Fuwn <[email protected]> | 2025-09-05 19:48:59 -0700 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2025-09-05 19:48:59 -0700 |
| commit | d5b81fcbc1a8c009204b2482d490e8d8b96c6c26 (patch) | |
| tree | 3cf3593d4aa9facf028d4fe40d952f848f5b2b3f /Sora/Data/Settings | |
| parent | feat: Development commit (diff) | |
| download | sora-testing-d5b81fcbc1a8c009204b2482d490e8d8b96c6c26.tar.xz sora-testing-d5b81fcbc1a8c009204b2482d490e8d8b96c6c26.zip | |
feat: Development commit
Diffstat (limited to 'Sora/Data/Settings')
| -rw-r--r-- | Sora/Data/Settings/SettingsFavoritePost.swift | 95 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsManager.swift | 248 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsSyncKey.swift | 2 |
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 } |