// swiftlint:disable file_length import SwiftUI class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_length // MARK: - Stored Properties @AppStorage("detailViewType") var detailViewQuality: BooruPostFileType = .original @AppStorage("thumbnailQuality") 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 private var syncObservation: NSObjectProtocol? #if os(macOS) @AppStorage("saveTagsToFile") var saveTagsToFile = false #endif // MARK: - Codable Properties @AppStorage("bookmarks") private var bookmarksData = 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() // MARK: - Computed Properties var bookmarks: [SettingsBookmark] { get { syncableData( key: "bookmarks", localData: bookmarksData, sort: { $0.sorted { $0.date > $1.date } }, identifier: { $0.id } ) } set { syncableData( key: "bookmarks", localData: $bookmarksData, newValue: newValue, sort: { $0.sorted { $0.date > $1.date } }, identifier: { $0.id } ) } } var displayRatings: [BooruRating] { get { Self.decode([BooruRating].self, from: displayRatingsData) ?? BooruRating.allCases } set { displayRatingsData = Self.encode(newValue) ?? displayRatingsData } } var blurRatings: [BooruRating] { get { Self.decode([BooruRating].self, from: blurRatingsData) ?? [.explicit] } set { blurRatingsData = Self.encode(newValue) ?? blurRatingsData } } var searchHistory: [BooruSearchQuery] { get { syncableData( key: "searchHistory", localData: searchHistoryData, sort: { $0.sorted { $0.date > $1.date } }, identifier: { $0.id } ) } set { syncableData( key: "searchHistory", localData: $searchHistoryData, newValue: newValue, sort: { $0.sorted { $0.date > $1.date } }, identifier: { $0.id } ) } } 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 { syncableData( key: "customProviders", localData: $customProvidersData, newValue: newValue, sort: { $0 }, identifier: { $0.id } ) } } var folders: [SettingsFolder] { get { syncableData( key: "folders", localData: foldersData, sort: { $0 }, identifier: { $0.id } ) } set { syncableData( key: "folders", localData: $foldersData, newValue: newValue, sort: { $0 }, identifier: { $0.id } ) } } // MARK: - Initialisation init() { syncObservation = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default, queue: .main ) { [weak self] _ in self?.syncFromCloud() } } // MARK: - Private Helpers private static func encode(_ value: T) -> Data? { try? JSONEncoder().encode(value) } private static func decode(_ type: T.Type, from data: Data) -> T? { try? JSONDecoder().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) ?? [] let mergedValues = (localValues + iCloudValues) .reduce(into: [T]()) { result, value in if !result.contains(where: { identifier($0) == identifier(value) }) { result.append(value) } } return sort(mergedValues) } } } let localValues = Self.decode([T].self, from: localData) ?? [] return sort(localValues) } private func syncableData( key: String, localData: Binding, newValue: [T], sort: ([T]) -> [T], identifier: (T) -> UUID ) { let sortedValues = sort(newValue) localData.wrappedValue = Self.encode(sortedValues) ?? Data() if enableSync { var iCloudValues: [T] = [] if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: key) { iCloudValues = Self.decode([T].self, from: iCloudData) ?? [] } let filteredICloudValues = iCloudValues.filter { iCloudItem in sortedValues.contains { identifier($0) == identifier(iCloudItem) } } let newLocalItems = sortedValues.filter { localItem in !filteredICloudValues.contains { identifier($0) == identifier(localItem) } } let mergedValues = filteredICloudValues + newLocalItems let sortedMergedValues = sort(mergedValues) NSUbiquitousKeyValueStore.default.set(Self.encode(sortedMergedValues), forKey: key) NSUbiquitousKeyValueStore.default.synchronize() } } // MARK: - Public Methods func appendToSearchHistory(_ query: BooruSearchQuery) { self.searchHistory.append(query) } func resetToDefaults() { detailViewQuality = .original thumbnailQuality = .preview searchSuggestionsMode = .disabled thumbnailGridColumns = 2 preferredBooru = .safebooru enableShareShortcut = false displayRatings = BooruRating.allCases blurRatings = [.explicit] displayDetailsInformationBar = true preloadedCarouselImages = 3 #if os(macOS) saveTagsToFile = false #endif } func syncFromCloud() { if self.enableSync { if let data = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") { self.bookmarksData = data } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") { self.searchHistoryData = data } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") { self.customProvidersData = data } self.objectWillChange.send() } } func syncToCloud() { if enableSync { // Merge bookmarks var iCloudBookmarks: [SettingsBookmark] = [] if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") { iCloudBookmarks = Self.decode([SettingsBookmark].self, from: iCloudData) ?? [] } let localBookmarks = Self.decode([SettingsBookmark].self, from: bookmarksData) ?? [] let mergedBookmarks = (localBookmarks + iCloudBookmarks) .reduce(into: [SettingsBookmark]()) { result, value in if !result.contains(where: { $0.id == value.id }) { result.append(value) } } .sorted { $0.date > $1.date } NSUbiquitousKeyValueStore.default.set(Self.encode(mergedBookmarks), forKey: "bookmarks") bookmarksData = Self.encode(mergedBookmarks) ?? Data() // Merge search history var iCloudHistory: [BooruSearchQuery] = [] if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") { iCloudHistory = Self.decode([BooruSearchQuery].self, from: iCloudData) ?? [] } let localHistory = Self.decode([BooruSearchQuery].self, from: searchHistoryData) ?? [] let mergedHistory = (localHistory + iCloudHistory) .reduce(into: [BooruSearchQuery]()) { result, value in if !result.contains(where: { $0.id == value.id }) { result.append(value) } } .sorted { $0.date > $1.date } NSUbiquitousKeyValueStore.default.set(Self.encode(mergedHistory), forKey: "searchHistory") searchHistoryData = Self.encode(mergedHistory) ?? Data() // Merge custom providers var iCloudProviders: [BooruProviderCustom] = [] if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") { iCloudProviders = Self.decode([BooruProviderCustom].self, from: iCloudData) ?? [] } let localProviders = Self.decode([BooruProviderCustom].self, from: customProvidersData) ?? [] let mergedProviders = (localProviders + iCloudProviders) .reduce(into: [BooruProviderCustom]()) { result, value in if !result.contains(where: { $0.id == value.id }) { result.append(value) } } NSUbiquitousKeyValueStore.default.set(Self.encode(mergedProviders), forKey: "customProviders") customProvidersData = Self.encode(mergedProviders) ?? Data() } } // MARK: Bookmark Management func addBookmark(provider: BooruProvider, tags: [String]) { var updatedBookmarks = bookmarks updatedBookmarks.append( SettingsBookmark(provider: provider, tags: tags.map { $0.lowercased() }) ) if let data = Self.encode(updatedBookmarks), data.count < 1_000_000 { // 1 MB bookmarks = updatedBookmarks } else { debugPrint("SettingsManager.addBookmark: iCloud data limit exceeded") } } func removeBookmark(at offsets: IndexSet) { bookmarks.remove(atOffsets: offsets) } func removeBookmark(withTags tags: [String]) { bookmarks.removeAll { $0.tags.contains(where: tags.contains) } } func removeBookmark(withID id: UUID) { bookmarks.removeAll { $0.id == id } } 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) } bookmarks.append(contentsOf: newBookmarks) } func updateBookmarkFolder(withID id: UUID, folder: UUID?) { guard let index = bookmarks.firstIndex(where: { $0.id == id }) else { return } bookmarks[index].folder = folder self.syncToCloud() } func folderName(forID id: UUID) -> String? { folders.first { $0.id == id }?.name } // MARK: Search History Management func removeSearchHistoryEntry(at offsets: IndexSet) { searchHistory.remove(atOffsets: offsets) } func removeSearchHistoryEntry(withID id: UUID) { searchHistory.removeAll { $0.id == id } } #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 addDummyBookmarks() { for _ in 0..<10 { let randomTags: [String] = Array(repeating: randomWord(), count: Int.random(in: 1...5)) addBookmark(provider: .safebooru, tags: randomTags) } } func addDummySearchHistory() { 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 deinit { if let observation = syncObservation { NotificationCenter.default.removeObserver(observation) } } }