// swiftlint:disable file_length import Observation @preconcurrency import SwiftUI @MainActor @Observable class SettingsManager { // swiftlint:disable:this type_body_length private enum StorageKey { static let detailViewQuality = "detailViewQuality" static let thumbnailQuality = "thumbnailQuality" static let searchSuggestionsMode = "searchSuggestionsMode" static let thumbnailGridColumns = "thumbnailGridColumns" static let enableShareShortcut = "enableShareShortcut" static let displayDetailsInformationBar = "displayDetailsInformationBar" static let preloadedCarouselImages = "preloadedCarouselImages" static let enableSync = "enableSync" static let alternativeThumbnailGrid = "alternativeThumbnailGrid" static let uniformThumbnailGrid = "uniformThumbnailGrid" static let showHeldMoebooruPosts = "showHeldMoebooruPosts" static let sendBooruUserAgent = "sendBooruUserAgent" static let customBooruUserAgent = "customBooruUserAgent" static let saveTagsToFile = "saveTagsToFile" static let bookmarks = "bookmarks" static let favorites = "favorites" static let displayRatings = "displayRatings" static let blurRatings = "blurRatings" static let searchHistory = "searchHistory" static let preferredBooru = "preferredBooru" static let customProviders = "customProviders" static let folders = "folders" static let providerCredentials = "providerCredentials" } @ObservationIgnored private let userDefaults: UserDefaults // MARK: - Stored Properties var detailViewQuality: BooruPostFileType { didSet { userDefaults.set(detailViewQuality.rawValue, forKey: StorageKey.detailViewQuality) } } var thumbnailQuality: BooruPostFileType { didSet { userDefaults.set(thumbnailQuality.rawValue, forKey: StorageKey.thumbnailQuality) } } var searchSuggestionsMode: SettingsSearchSuggestionsMode { didSet { userDefaults.set( searchSuggestionsMode.rawValue, forKey: StorageKey.searchSuggestionsMode ) } } var thumbnailGridColumns: Int { didSet { userDefaults.set(thumbnailGridColumns, forKey: StorageKey.thumbnailGridColumns) } } var enableShareShortcut: Bool { didSet { userDefaults.set(enableShareShortcut, forKey: StorageKey.enableShareShortcut) } } var displayDetailsInformationBar: Bool { didSet { userDefaults.set( displayDetailsInformationBar, forKey: StorageKey.displayDetailsInformationBar ) } } var preloadedCarouselImages: Int { didSet { userDefaults.set(preloadedCarouselImages, forKey: StorageKey.preloadedCarouselImages) } } var enableSync: Bool { didSet { userDefaults.set(enableSync, forKey: StorageKey.enableSync) } } var alternativeThumbnailGrid: Bool { didSet { userDefaults.set(alternativeThumbnailGrid, forKey: StorageKey.alternativeThumbnailGrid) } } var uniformThumbnailGrid: Bool { didSet { userDefaults.set(uniformThumbnailGrid, forKey: StorageKey.uniformThumbnailGrid) } } var showHeldMoebooruPosts: Bool { didSet { userDefaults.set(showHeldMoebooruPosts, forKey: StorageKey.showHeldMoebooruPosts) } } var sendBooruUserAgent: Bool { didSet { userDefaults.set(sendBooruUserAgent, forKey: StorageKey.sendBooruUserAgent) } } var customBooruUserAgent: String { didSet { userDefaults.set(customBooruUserAgent, forKey: StorageKey.customBooruUserAgent) } } @ObservationIgnored private var syncObservation: NSObjectProtocol? #if os(macOS) var saveTagsToFile: Bool { didSet { userDefaults.set(saveTagsToFile, forKey: StorageKey.saveTagsToFile) } } #endif // MARK: - Private Properties private var bookmarksCache: [SettingsBookmark] private var favoritesCache: [SettingsFavoritePost] private var searchHistoryCache: [BooruSearchQuery] private var blurRatingsCache: [BooruRating] private var displayRatingsCache: [BooruRating] @ObservationIgnored private var isUpdatingCache = false @ObservationIgnored private var pendingSyncKeys: Set = [] @ObservationIgnored private let syncCoordinator = SettingsSyncCoordinator() // MARK: - Codable Properties private var bookmarksData: Data { didSet { userDefaults.set(bookmarksData, forKey: StorageKey.bookmarks) } } private var favoritesData: Data { didSet { userDefaults.set(favoritesData, forKey: StorageKey.favorites) } } private var displayRatingsData: Data { didSet { userDefaults.set(displayRatingsData, forKey: StorageKey.displayRatings) } } private var blurRatingsData: Data { didSet { userDefaults.set(blurRatingsData, forKey: StorageKey.blurRatings) } } private var searchHistoryData: Data { didSet { userDefaults.set(searchHistoryData, forKey: StorageKey.searchHistory) } } private var preferredBooruData: Data { didSet { userDefaults.set(preferredBooruData, forKey: StorageKey.preferredBooru) } } private var customProvidersData: Data { didSet { userDefaults.set(customProvidersData, forKey: StorageKey.customProviders) } } private var foldersData: Data { didSet { userDefaults.set(foldersData, forKey: StorageKey.folders) } } private var providerCredentialsData: Data { didSet { userDefaults.set(providerCredentialsData, forKey: StorageKey.providerCredentials) } } // MARK: - Computed Properties var bookmarks: [SettingsBookmark] { get { bookmarksCache } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let sortedBookmarks = newValue.sorted { $0.date > $1.date } let payload = SettingsCodec.encodeOnce(sortedBookmarks) syncableData( key: "bookmarks", localData: storageBinding(for: \.bookmarksData), newValue: sortedBookmarks, encodedData: payload?.encodedData ) { $0 } bookmarksCache = sortedBookmarks pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } } var favorites: [SettingsFavoritePost] { get { favoritesCache } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let sortedFavorites = newValue.sorted { $0.date > $1.date } let payload = SettingsCodec.encodeOnce(sortedFavorites) syncableData( key: "favorites", localData: storageBinding(for: \.favoritesData), newValue: sortedFavorites, encodedData: payload?.encodedData ) { $0 } favoritesCache = sortedFavorites pendingSyncKeys.insert(.favorites) triggerBatchedSync() } } var displayRatings: [BooruRating] { get { displayRatingsCache } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } displayRatingsData = Self.encode(newValue) ?? displayRatingsData displayRatingsCache = newValue } } var blurRatings: [BooruRating] { get { blurRatingsCache } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } blurRatingsData = Self.encode(newValue) ?? blurRatingsData blurRatingsCache = newValue } } var searchHistory: [BooruSearchQuery] { get { searchHistoryCache } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let sortedSearchHistory = newValue.sorted { $0.date > $1.date } let payload = SettingsCodec.encodeOnce(sortedSearchHistory) syncableData( key: "searchHistory", localData: storageBinding(for: \.searchHistoryData), newValue: sortedSearchHistory, encodedData: payload?.encodedData ) { $0 } searchHistoryCache = sortedSearchHistory pendingSyncKeys.insert(.searchHistory) triggerBatchedSync() } } var preferredBooru: BooruProvider { get { Self.decode(BooruProvider.self, from: preferredBooruData) ?? .safebooru } set { preferredBooruData = Self.encode(newValue) ?? preferredBooruData } } var customProviders: [BooruProviderCustom] { get { syncableData( key: "customProviders", localData: customProvidersData, sort: { $0 }, identifier: { $0.id } ) } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } syncableData( key: "customProviders", localData: storageBinding(for: \.customProvidersData), newValue: newValue, ) { $0 } pendingSyncKeys.insert(.customProviders) triggerBatchedSync() } } var folders: [SettingsFolder] { get { syncableData( key: "folders", localData: foldersData, sort: { $0 }, identifier: { $0.id } ) } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } syncableData( key: "folders", localData: storageBinding(for: \.foldersData), newValue: newValue, ) { $0 } } } // MARK: Provider Credentials var providerCredentials: [BooruProviderCredentials] { get { syncableData( key: "providerAPIKeys", localData: providerCredentialsData, sort: { $0 }, identifier: { $0.id } ) } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let existingCredentials: [BooruProviderCredentials] = Self.decode([BooruProviderCredentials].self, from: providerCredentialsData) ?? [] let rawCredentials = newValue.map { credentials in ( provider: credentials.provider, apiKey: credentials.apiKey, userID: credentials.userID, login: credentials.login ) } let mergedCredentials = BooruProviderCredentials.from( rawCredentials, existingCredentials: existingCredentials ) syncableData( key: "providerAPIKeys", localData: storageBinding(for: \.providerCredentialsData), newValue: mergedCredentials, ) { $0 } } } private func mergedCredentialValues( extract: (BooruProviderCredentials) -> Value, isDefault: (Value) -> Bool ) -> [BooruProvider: Value] { providerCredentials.reduce(into: [BooruProvider: Value]()) { dictionary, credentials in let key = credentials.provider let value = extract(credentials) if let existing = dictionary[key] { if isDefault(existing), !isDefault(value) { dictionary[key] = value } } else { dictionary[key] = value } } } var providerAPIKeys: [BooruProvider: String] { mergedCredentialValues( extract: { $0.apiKey }, isDefault: { $0.isEmpty } ) } var providerUserIDs: [BooruProvider: Int] { mergedCredentialValues( extract: { $0.userID }, isDefault: { $0 == 0 } ) } var providerLogins: [BooruProvider: String] { mergedCredentialValues( extract: { $0.login }, isDefault: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ) } // MARK: - Initialisation init(userDefaults: UserDefaults = .standard) { self.userDefaults = userDefaults detailViewQuality = Self.stringValue( forKey: StorageKey.detailViewQuality, defaultValue: .original, userDefaults: userDefaults ) thumbnailQuality = Self.stringValue( forKey: StorageKey.thumbnailQuality, defaultValue: .preview, userDefaults: userDefaults ) searchSuggestionsMode = Self.stringValue( forKey: StorageKey.searchSuggestionsMode, defaultValue: .disabled, userDefaults: userDefaults ) thumbnailGridColumns = Self.integerValue( forKey: StorageKey.thumbnailGridColumns, defaultValue: 2, userDefaults: userDefaults ) enableShareShortcut = Self.boolValue( forKey: StorageKey.enableShareShortcut, defaultValue: false, userDefaults: userDefaults ) displayDetailsInformationBar = Self.boolValue( forKey: StorageKey.displayDetailsInformationBar, defaultValue: true, userDefaults: userDefaults ) preloadedCarouselImages = Self.integerValue( forKey: StorageKey.preloadedCarouselImages, defaultValue: 3, userDefaults: userDefaults ) enableSync = Self.boolValue( forKey: StorageKey.enableSync, defaultValue: false, userDefaults: userDefaults ) alternativeThumbnailGrid = Self.boolValue( forKey: StorageKey.alternativeThumbnailGrid, defaultValue: false, userDefaults: userDefaults ) uniformThumbnailGrid = Self.boolValue( forKey: StorageKey.uniformThumbnailGrid, defaultValue: false, userDefaults: userDefaults ) showHeldMoebooruPosts = Self.boolValue( forKey: StorageKey.showHeldMoebooruPosts, defaultValue: false, userDefaults: userDefaults ) sendBooruUserAgent = Self.boolValue( forKey: StorageKey.sendBooruUserAgent, defaultValue: true, userDefaults: userDefaults ) customBooruUserAgent = userDefaults.string(forKey: StorageKey.customBooruUserAgent) ?? "" bookmarksCache = [] favoritesCache = [] searchHistoryCache = [] blurRatingsCache = [] displayRatingsCache = [] bookmarksData = userDefaults.data(forKey: StorageKey.bookmarks) ?? Data() favoritesData = userDefaults.data(forKey: StorageKey.favorites) ?? Data() displayRatingsData = userDefaults.data(forKey: StorageKey.displayRatings) ?? Self.encode(BooruRating.allCases) ?? Data() blurRatingsData = userDefaults.data(forKey: StorageKey.blurRatings) ?? Self.encode([.explicit as BooruRating]) ?? Data() searchHistoryData = userDefaults.data(forKey: StorageKey.searchHistory) ?? Data() preferredBooruData = userDefaults.data(forKey: StorageKey.preferredBooru) ?? Data() customProvidersData = userDefaults.data(forKey: StorageKey.customProviders) ?? Data() foldersData = userDefaults.data(forKey: StorageKey.folders) ?? Data() providerCredentialsData = userDefaults.data(forKey: StorageKey.providerCredentials) ?? Data() #if os(macOS) saveTagsToFile = Self.boolValue( forKey: StorageKey.saveTagsToFile, defaultValue: false, userDefaults: userDefaults ) #endif syncObservation = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default, queue: .main ) { [weak self] _ in Task { @MainActor in self?.syncFromCloud() } } loadBookmarksCache() loadFavoritesCache() loadSearchHistoryCache() loadDisplayRatingsCache() loadBlurRatingsCache() } func updateBookmarks(_ newValue: [SettingsBookmark], encodedData: Data? = nil) async { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let sortedBookmarks = newValue.sorted { $0.date > $1.date } let resolvedEncodedData = encodedData ?? SettingsCodec.encode(sortedBookmarks) syncableData( key: "bookmarks", localData: storageBinding(for: \.bookmarksData), newValue: sortedBookmarks, encodedData: resolvedEncodedData ) { $0 } bookmarksCache = sortedBookmarks await backupBookmarks() pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } func updateFavorites(_ newValue: [SettingsFavoritePost], encodedData: Data? = nil) async { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let sortedFavorites = newValue.sorted { $0.date > $1.date } let resolvedEncodedData = encodedData ?? SettingsCodec.encode(sortedFavorites) syncableData( key: "favorites", localData: storageBinding(for: \.favoritesData), newValue: sortedFavorites, encodedData: resolvedEncodedData ) { $0 } favoritesCache = sortedFavorites await backupFavorites() pendingSyncKeys.insert(.favorites) triggerBatchedSync() } func updateSearchHistory(_ newValue: [BooruSearchQuery]) { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let sortedSearchHistory = newValue.sorted { $0.date > $1.date } let payload = SettingsCodec.encodeOnce(sortedSearchHistory) syncableData( key: "searchHistory", localData: storageBinding(for: \.searchHistoryData), newValue: sortedSearchHistory, encodedData: payload?.encodedData ) { $0 } searchHistoryCache = sortedSearchHistory pendingSyncKeys.insert(.searchHistory) triggerBatchedSync() } func updateDisplayRatings(_ newValue: [BooruRating]) { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } displayRatingsData = Self.encode(newValue) ?? displayRatingsData displayRatingsCache = newValue } func updateBlurRatings(_ newValue: [BooruRating]) { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } blurRatingsData = Self.encode(newValue) ?? blurRatingsData blurRatingsCache = newValue } func updateProviderCredentials(_ newValue: [BooruProviderCredentials]) { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let existingCredentials: [BooruProviderCredentials] = Self.decode([BooruProviderCredentials].self, from: providerCredentialsData) ?? [] let rawCredentials = newValue.map { credentials in ( provider: credentials.provider, apiKey: credentials.apiKey, userID: credentials.userID, login: credentials.login ) } let mergedCredentials = BooruProviderCredentials.from( rawCredentials, existingCredentials: existingCredentials ) syncableData( key: "providerAPIKeys", localData: storageBinding(for: \.providerCredentialsData), newValue: mergedCredentials, ) { $0 } } // MARK: - Private Helpers @MainActor private static func encode(_ value: T) -> Data? { SettingsCodec.encode(value) } @MainActor private static func decode(_ type: T.Type, from data: Data) -> T? { SettingsCodec.decode(type, from: data) } @MainActor private static func boolValue( forKey key: String, defaultValue: Bool, userDefaults: UserDefaults ) -> Bool { (userDefaults.object(forKey: key) as? Bool) ?? defaultValue } @MainActor private static func integerValue( forKey key: String, defaultValue: Int, userDefaults: UserDefaults ) -> Int { (userDefaults.object(forKey: key) as? Int) ?? defaultValue } @MainActor private static func stringValue( forKey key: String, defaultValue: Value, userDefaults: UserDefaults ) -> Value where Value.RawValue == String { guard let rawValue = userDefaults.string(forKey: key) else { return defaultValue } return Value(rawValue: rawValue) ?? defaultValue } private func storageBinding(for keyPath: ReferenceWritableKeyPath) -> Binding { Binding( get: { self[keyPath: keyPath] }, set: { self[keyPath: keyPath] = $0 } ) } private func syncableData( key: String, localData: Data, sort: ([T]) -> [T], identifier: (T) -> UUID ) -> [T] { if enableSync { if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: key) { if let iCloudValues = Self.decode([T].self, from: iCloudData) { let localValues = Self.decode([T].self, from: localData) ?? [] var seenValues = Set() let mergedValues = (localValues + iCloudValues).filter { value in seenValues.insert(identifier(value)).inserted } return sort(mergedValues) } } } let localValues = Self.decode([T].self, from: localData) ?? [] return sort(localValues) } private func syncableData( key: String, localData: Binding, newValue: [T], encodedData: Data? = nil, sort: ([T]) -> [T] ) { let sortedValues = sort(newValue) guard let encoded = encodedData ?? SettingsCodec.encode(sortedValues) else { localData.wrappedValue = Data() return } SettingsCodec.applyIfChanged( encodedData: encoded, localData: localData, key: key, enableSync: enableSync ) } private func triggerBatchedSync() { guard !pendingSyncKeys.isEmpty else { return } let keysToSync = pendingSyncKeys pendingSyncKeys.removeAll() Task { @MainActor [weak self] in guard let self else { return } let shouldStartDraining = await syncCoordinator.enqueue(keysToSync) guard shouldStartDraining else { return } while true { let nextBatch = await syncCoordinator.dequeueBatch() guard !nextBatch.isEmpty else { break } performBatchedSync(for: nextBatch) } } } private func performBatchedSync(for keys: Set) { for key in keys { _ = triggerSyncIfNeeded(for: key) } } private func backupBookmarks() async { let backupData = Self.encode(bookmarksCache) await Task.detached { guard let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) .first else { return } let backupDirectory = cachesDirectory.appendingPathComponent("bookmarks_backups") let fileManager = FileManager.default try? fileManager.createDirectory(at: backupDirectory, withIntermediateDirectories: true) let timestamp = Int(Date().timeIntervalSince1970) let backupFile = backupDirectory.appendingPathComponent("bookmarks_backup_\(timestamp).json") if let data = backupData { 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("bookmarks_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 } private func backupFavorites() async { let backupData = Self.encode(favoritesCache) 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 = backupData { 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 cyclomatic_complexity @discardableResult private func triggerSyncIfNeeded(for key: SettingsSyncKey) -> Bool { guard enableSync else { return false } var didChange = false switch key { case .bookmarks: let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") let iCloudBookmarks = iCloudData.flatMap { Self.decode([SettingsBookmark].self, from: $0) } ?? [] let localBookmarks = Self.decode([SettingsBookmark].self, from: bookmarksData) ?? [] let mergedBookmarks = (localBookmarks + iCloudBookmarks) .reduce(into: [UUID: SettingsBookmark]()) { dict, bookmark in if let existing = dict[bookmark.id] { if bookmark.date > existing.date { dict[bookmark.id] = bookmark } } else { dict[bookmark.id] = bookmark } } .values .sorted { lhs, rhs in if lhs.date == rhs.date { return lhs.id.uuidString < rhs.id.uuidString } return lhs.date > rhs.date } bookmarksCache = mergedBookmarks if let encoded = Self.encode(mergedBookmarks) { if iCloudData != encoded { NSUbiquitousKeyValueStore.default.set(encoded, forKey: "bookmarks") didChange = true } if bookmarksData != encoded { bookmarksData = encoded didChange = true } } case .searchHistory: let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") let iCloudHistory = iCloudData.flatMap { Self.decode([BooruSearchQuery].self, from: $0) } ?? [] let localHistory = Self.decode([BooruSearchQuery].self, from: searchHistoryData) ?? [] let mergedHistory = (localHistory + iCloudHistory) .reduce(into: [UUID: BooruSearchQuery]()) { dict, entry in if let existing = dict[entry.id] { if entry.date > existing.date { dict[entry.id] = entry } } else { dict[entry.id] = entry } } .values .sorted { lhs, rhs in if lhs.date == rhs.date { return lhs.id.uuidString < rhs.id.uuidString } return lhs.date > rhs.date } searchHistoryCache = mergedHistory if let encoded = Self.encode(mergedHistory) { if iCloudData != encoded { NSUbiquitousKeyValueStore.default.set(encoded, forKey: "searchHistory") didChange = true } if searchHistoryData != encoded { searchHistoryData = encoded didChange = true } } case .customProviders: let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") let iCloudProviders = iCloudData.flatMap { Self.decode([BooruProviderCustom].self, from: $0) } ?? [] let localProviders = Self.decode([BooruProviderCustom].self, from: customProvidersData) ?? [] let mergedProviders = (localProviders + iCloudProviders) .reduce(into: [UUID: BooruProviderCustom]()) { dict, provider in if dict[provider.id] == nil { dict[provider.id] = provider } } .values .sorted { $0.id.uuidString < $1.id.uuidString } if let encoded = Self.encode(mergedProviders) { if iCloudData != encoded { NSUbiquitousKeyValueStore.default.set(encoded, forKey: "customProviders") didChange = true } if customProvidersData != encoded { customProvidersData = encoded didChange = true } } case .favorites: let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "favorites") let iCloudFavorites = iCloudData.flatMap { Self.decode([SettingsFavoritePost].self, from: $0) } ?? [] let localFavorites = Self.decode([SettingsFavoritePost].self, from: favoritesData) ?? [] let mergedFavorites = (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 } } .values .sorted { lhs, rhs in if lhs.date == rhs.date { return lhs.id.uuidString < rhs.id.uuidString } return lhs.date > rhs.date } favoritesCache = mergedFavorites if let encoded = Self.encode(mergedFavorites) { if iCloudData != encoded { NSUbiquitousKeyValueStore.default.set(encoded, forKey: "favorites") didChange = true } if favoritesData != encoded { favoritesData = encoded didChange = true } } } return didChange } // swiftlint:enable cyclomatic_complexity // MARK: Cache Loaders private func loadCache( from data: Data, sort: ([T]) -> [T], assign: ([T]) -> Void ) { let decoded = Self.decode([T].self, from: data) ?? [] let sorted = sort(decoded) assign(sorted) } private func loadBookmarksCache() { loadCache( from: bookmarksData, sort: { $0.sorted { $0.date > $1.date } }, assign: { [weak self] in self?.bookmarksCache = $0 } ) } 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, sort: { $0.sorted { $0.date > $1.date } }, assign: { [weak self] in self?.searchHistoryCache = $0 } ) } private func loadDisplayRatingsCache() { loadCache( from: displayRatingsData, sort: { $0 }, assign: { [weak self] in self?.displayRatingsCache = $0 } ) } private func loadBlurRatingsCache() { loadCache( from: blurRatingsData, sort: { $0 }, assign: { [weak self] in self?.blurRatingsCache = $0 } ) } // MARK: - Public Methods func appendToSearchHistory(_ query: BooruSearchQuery) { guard !query.tags.isEmpty else { return } var updated = searchHistory updated.append(query) updateSearchHistory(updated) } func resetToDefaults() { detailViewQuality = .original thumbnailQuality = .preview searchSuggestionsMode = .disabled thumbnailGridColumns = 2 preferredBooru = .safebooru enableShareShortcut = false updateDisplayRatings(BooruRating.allCases) updateBlurRatings([.explicit]) displayDetailsInformationBar = true preloadedCarouselImages = 3 #if os(macOS) saveTagsToFile = false #endif } func syncFromCloud() { if enableSync { Task { [weak self] in guard let self else { return } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") { bookmarksData = data } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") { searchHistoryData = data } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") { customProvidersData = data } loadBookmarksCache() loadSearchHistoryCache() } } } func triggerSyncIfNeededForAll() { let keysToSync: [SettingsSyncKey] = [.bookmarks, .favorites, .searchHistory, .customProviders] for keyToSync in keysToSync { _ = triggerSyncIfNeeded(for: keyToSync) } } // MARK: Bookmark Management func addBookmark(provider: BooruProvider, tags: [String], folder: UUID? = nil) { let normalizedTags = tags.map { $0.lowercased() } let existingBookmark = bookmarks.first { bookmark in bookmark.provider == provider && Set(bookmark.tags) == Set(normalizedTags) } if let existingBookmark { if existingBookmark.folder != folder { updateBookmarkFolder(withID: existingBookmark.id, folder: folder) } return } var updatedBookmarks = bookmarks updatedBookmarks.append( SettingsBookmark(provider: provider, tags: normalizedTags, folder: folder) ) let sortedBookmarks = updatedBookmarks.sorted { $0.date > $1.date } if let payload = SettingsCodec.encodeOnce(sortedBookmarks), payload.encodedData.count < 1_000_000 { // 1 MB Task { await updateBookmarks(payload.value, encodedData: payload.encodedData) } } else { debugPrint("SettingsManager.addBookmark: iCloud data limit exceeded") } } func removeBookmark(at offsets: IndexSet) { var updated = bookmarks updated.remove(atOffsets: offsets) Task { await updateBookmarks(updated) } } func removeBookmark(withTags tags: [String]) { let updated = bookmarks.filter { !$0.tags.contains(where: tags.contains) } Task { await updateBookmarks(updated) } } func removeBookmark(withID id: UUID) { let updated = bookmarks.filter { $0.id != id } Task { await updateBookmarks(updated) } } func exportBookmarks() throws -> Data { try JSONEncoder().encode(bookmarks) } func importBookmarks(from data: Data) throws { let importedBookmarks = try JSONDecoder().decode([SettingsBookmark].self, from: data) let existingBookmarkIDs = Set(bookmarks.map(\.id)) let newBookmarks = importedBookmarks.filter { !existingBookmarkIDs.contains($0.id) } var updated = bookmarks updated.append(contentsOf: newBookmarks) Task { await updateBookmarks(updated) } } func updateBookmarkFolder(withID id: UUID, folder: UUID?) { guard let index = bookmarks.firstIndex(where: { $0.id == id }) else { return } var updated = bookmarks updated[index].folder = folder Task { await updateBookmarks(updated) } pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } func updateBookmarkLastVisit(withID id: UUID, date: Date = Date()) { guard let index = bookmarks.firstIndex(where: { $0.id == id }) else { return } var updated = bookmarks updated[index].lastVisit = date Task { await updateBookmarks(updated) } pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } func incrementBookmarkVisitCount(withID id: UUID) { guard let index = bookmarks.firstIndex(where: { $0.id == id }) else { return } var updated = bookmarks updated[index].visitedCount += 1 Task { await updateBookmarks(updated) } pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } // MARK: Favourites Management func addFavorite(post: BooruPost, provider: BooruProvider, folder: UUID? = nil) { let existingFavorite = favorites.first { favorite in favorite.postId == post.id && favorite.provider == provider } guard existingFavorite == nil else { debugPrint("SettingsManager.addFavorite: Favorite already exists") return } var updatedFavorites = favorites updatedFavorites.append( SettingsFavoritePost(post: post, provider: provider, folder: folder) ) let sortedFavorites = updatedFavorites.sorted { $0.date > $1.date } if let payload = SettingsCodec.encodeOnce(sortedFavorites), payload.encodedData.count < 1_000_000 { // 1 MB Task { await updateFavorites(payload.value, encodedData: payload.encodedData) } } 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 isBookmark(provider: BooruProvider, tags: [String]) -> Bool { let normalizedTags = tags.map { $0.lowercased() } return bookmarks.contains { bookmark in bookmark.provider == provider && Set(bookmark.tags) == Set(normalizedTags) } } 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 } func renameFolder(_ folder: SettingsFolder, to newName: String) { var updated = folders guard let index = updated.firstIndex(where: { $0.id == folder.id }) else { return } updated[index].name = newName syncableData( key: "folders", localData: storageBinding(for: \.foldersData), newValue: updated, ) { $0 } } // MARK: Search History Management func removeSearchHistoryEntry(at offsets: IndexSet) { var updated = searchHistory updated.remove(atOffsets: offsets) updateSearchHistory(updated) pendingSyncKeys.insert(.searchHistory) triggerBatchedSync() } func removeSearchHistoryEntry(withID id: UUID) { let updated = searchHistory.filter { $0.id != id } updateSearchHistory(updated) pendingSyncKeys.insert(.searchHistory) triggerBatchedSync() } #if DEBUG // https://stackoverflow.com/a/68926484/14452787 private func randomWord() -> String { var word = "" for _ in 0..<5 { word += String(format: "%c", Int.random(in: 97..<123)) as String } return word } func addSampleBookmarks() { for _ in 0..<10 { let randomTags: [String] = Array(repeating: randomWord(), count: Int.random(in: 1...5)) addBookmark(provider: .safebooru, tags: randomTags) } } func addSampleSearchHistory() { for _ in 0..<10 { let randomTags: [String] = Array(repeating: randomWord(), count: Int.random(in: 1...5)) appendToSearchHistory( BooruSearchQuery(provider: .safebooru, tags: randomTags) ) } } #endif // MARK: - Deinitialisation nonisolated deinit { if let observation = syncObservation { NotificationCenter.default.removeObserver(observation) } } }