// swiftlint:disable file_length @preconcurrency import SwiftUI @MainActor class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_length // MARK: - Stored Properties @AppStorage("detailViewQuality") var detailViewQuality: BooruPostFileType = .original @AppStorage("thumbnailQuality") private var _thumbnailQuality: BooruPostFileType = .preview @AppStorage("searchSuggestionsMode") var searchSuggestionsMode: SettingsSearchSuggestionsMode = .disabled @AppStorage("thumbnailGridColumns") var thumbnailGridColumns = 2 @AppStorage("enableShareShortcut") var enableShareShortcut = false @AppStorage("displayDetailsInformationBar") var displayDetailsInformationBar = true @AppStorage("preloadedCarouselImages") var preloadedCarouselImages = 3 @AppStorage("enableSync") var enableSync: Bool = false @AppStorage("alternativeThumbnailGrid") var alternativeThumbnailGrid = false @AppStorage("uniformThumbnailGrid") private var _uniformThumbnailGrid: Bool = false @AppStorage("showHeldMoebooruPosts") var showHeldMoebooruPosts = false private var syncObservation: NSObjectProtocol? #if os(macOS) @AppStorage("saveTagsToFile") var saveTagsToFile = false #endif // MARK: - Private Properties private var bookmarksCache: [SettingsBookmark] = [] private var favoritesCache: [SettingsFavoritePost] = [] private var searchHistoryCache: [BooruSearchQuery] = [] private var blurRatingsCache: [BooruRating] = [] private var displayRatingsCache: [BooruRating] = [] private var uniformThumbnailGridCache: Bool = false private var thumbnailQualityCache: BooruPostFileType = .preview private var isUpdatingCache = false private var pendingSyncKeys: Set = [] private let syncCoordinator = SettingsSyncCoordinator() // MARK: - Codable Properties @AppStorage("bookmarks") private var bookmarksData = Data() @AppStorage("favorites") private var favoritesData = Data() @AppStorage("displayRatings") private var displayRatingsData = SettingsManager.encode(BooruRating.allCases) ?? Data() @AppStorage("blurRatings") private var blurRatingsData = SettingsManager.encode([.explicit as BooruRating]) ?? Data() @AppStorage("searchHistory") private var searchHistoryData = Data() @AppStorage("preferredBooru") private var preferredBooruData = Data() @AppStorage("customProviders") private var customProvidersData = Data() @AppStorage("folders") private var foldersData = Data() @AppStorage("providerCredentials") private var providerCredentialsData = Data() // 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: $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: $favoritesData, newValue: sortedFavorites, encodedData: payload?.encodedData ) { $0 } favoritesCache = sortedFavorites pendingSyncKeys.insert(.favorites) triggerBatchedSync() } } var uniformThumbnailGrid: Bool { get { uniformThumbnailGridCache } set { _uniformThumbnailGrid = newValue uniformThumbnailGridCache = newValue } } var thumbnailQuality: BooruPostFileType { get { thumbnailQualityCache } set { _thumbnailQuality = newValue thumbnailQualityCache = newValue } } 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: $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: $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: $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: $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() { 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() uniformThumbnailGridCache = _uniformThumbnailGrid thumbnailQualityCache = _thumbnailQuality } 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: $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: $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: $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: $providerCredentialsData, newValue: mergedCredentials, ) { $0 } } // MARK: - Private Helpers private static func encode(_ value: T) -> Data? { SettingsCodec.encode(value) } private static func decode(_ type: T.Type, from data: Data) -> T? { SettingsCodec.decode(type, from: data) } 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) { var didChange = false for key in keys { didChange = triggerSyncIfNeeded(for: key) || didChange } if didChange { objectWillChange.send() } } private func backupBookmarks() async { 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 = await Self.encode(self.bookmarksCache) { 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 { 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 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 self.enableSync { Task.detached { [weak self] in guard let self else { return } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") { await MainActor.run { self.bookmarksData = data } } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") { await MainActor.run { self.searchHistoryData = data } } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") { await MainActor.run { self.customProvidersData = data } } await MainActor.run { self.loadBookmarksCache() self.loadSearchHistoryCache() self.objectWillChange.send() } } } } func triggerSyncIfNeededForAll() { let keysToSync: [SettingsSyncKey] = [.bookmarks, .favorites, .searchHistory, .customProviders] var didChange = false for keyToSync in keysToSync { didChange = triggerSyncIfNeeded(for: keyToSync) || didChange } if didChange { objectWillChange.send() } } // 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: $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) } } }