import SwiftUI @MainActor class BooruManager: ObservableObject { // MARK: - Published Properties @Published var posts: [BooruPost] = [] @Published var allTags: [BooruTag] = [] @Published var isLoading = false @Published var currentPage = 1 @Published var searchText = "" @Published var endOfData = false @Published var cacheSize: String? @Published var selectedPost: BooruPost? @Published var flavor: BooruProviderFlavor @Published var domain: String @Published private(set) var postIndexMap: [String: Int] = [:] @Published var provider: BooruProvider @Published var historyIndex: Int = -1 @Published var searchHistory: [BooruSearchQuery] = [] // MARK: - Private Properties private var currentTask: Task? private let tagsCacheFileURL: URL? = FileManager.default.urls( for: .cachesDirectory, in: .userDomainMask ).first? .appendingPathComponent("\(BooruProvider.safebooru.asFileNameComponent)_tags.json") private let pageCache = NSCache() // swiftlint:disable:this legacy_objc_type private let cacheDuration: TimeInterval = 300 // MARK: - Computed Properties var tags: [String] { searchText.isEmpty ? [] : searchText .split(separator: " ") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } var canGoBackInHistory: Bool { historyIndex > 0 } var canGoForwardInHistory: Bool { historyIndex < searchHistory.count - 1 } // MARK: - Initialisation init(_ provider: BooruProvider) { self.provider = provider self.flavor = BooruProviderFlavor(provider: provider) self.domain = provider.domain pageCache.countLimit = 50 pageCache.totalCostLimit = 50 * 1_024 * 1_024 let rootQuery = BooruSearchQuery( provider: provider, tags: [] ) searchHistory.append(rootQuery) historyIndex = 0 } // MARK: - Public Methods func initializeTags() { loadCachedTags() fetchAllTags() updateTagsCacheSize() } func fetchPosts(page: Int = 1, limit: Int = 100, tags: [String] = [], replace: Bool = false) async { guard !isLoading, let url = urlForPosts(page: flavor == .gelbooru ? page - 1 : page, limit: limit, tags: tags) else { return } let cacheKey = "\(url.absoluteString)_\(replace)" as NSString // swiftlint:disable:this legacy_objc_type if let cachedEntry = pageCache.object(forKey: cacheKey), !cachedEntry.isExpired { isLoading = true defer { isLoading = false } updatePosts(cachedEntry.posts, replace: replace) return } isLoading = true currentTask?.cancel() currentTask = nil currentTask = Task { defer { isLoading = false } do { let (data, _) = try await URLSession.shared.data(from: url) guard !Task.isCancelled else { return } let newPosts = parsePosts(from: data).sorted { $0.id > $1.id } let cacheEntry = BooruPageCacheEntry(posts: newPosts, timestamp: Date()) pageCache.setObject(cacheEntry, forKey: cacheKey) updatePosts(newPosts, replace: replace) } catch { if (error as? URLError)?.code != .cancelled { debugPrint("fetchPosts: \(error)") } } } } func clearPageCache() { pageCache.removeAllObjects() } func performSearch(settings: SettingsManager? = nil) { if searchHistory.last?.tags == tags { return } if historyIndex < searchHistory.count - 1 { searchHistory = Array(searchHistory[0...historyIndex]) } let query = BooruSearchQuery( provider: settings?.preferredBooru ?? provider, tags: tags ) searchHistory.append(query) historyIndex = searchHistory.count - 1 settings?.appendToSearchHistory(query) searchText = tags.joined(separator: " ") currentTask?.cancel() currentTask = nil Task { await fetchPosts(page: 1, tags: tags, replace: true) } } func loadNextPage() { guard !isLoading else { return } Task { await fetchPosts(page: currentPage + 1, tags: tags) currentPage += 1 if historyIndex >= 0 && historyIndex < searchHistory.count { var currentQuery = searchHistory[historyIndex] currentQuery.page = currentPage searchHistory[historyIndex] = currentQuery } } } func goBackInHistory() { guard canGoBackInHistory else { return } historyIndex -= 1 let previousQuery = searchHistory[historyIndex] searchText = previousQuery.tags.joined(separator: " ") currentPage = previousQuery.page posts = [] if tags.isEmpty { clearPageCache() } currentTask?.cancel() currentTask = nil Task { await fetchPosts(page: currentPage, tags: previousQuery.tags, replace: true) } } func goForwardInHistory() { guard canGoForwardInHistory else { return } historyIndex += 1 let nextQuery = searchHistory[historyIndex] searchText = nextQuery.tags.joined(separator: " ") currentPage = nextQuery.page posts = [] clearPageCache() currentTask?.cancel() currentTask = nil Task { await fetchPosts(page: currentPage, tags: nextQuery.tags, replace: true) } } func fetchAllTags(limit: Int = 0) { guard let url = urlForTags(limit: limit) else { return } Task { do { let (data, _) = try await URLSession.shared.data(from: url) guard !Task.isCancelled else { return } allTags = BooruTagXMLParser(data: data).parse().sorted { $0.count > $1.count } saveTagsToCache() } catch { if (error as? URLError)?.code != .cancelled { debugPrint("fetchAllTags: \(error)") } } } } func clearCachedTags() { try? tagsCacheFileURL.map { url in try FileManager.default.removeItem(at: url) updateTagsCacheSize() } } // MARK: - Private Helpers private func urlForPosts(page: Int, limit: Int, tags: [String]) -> URL? { let tagString = tags.joined(separator: "+") switch flavor { case .danbooru: return URL(string: "https://\(domain)/posts.json?page=\(page)&tags=\(tagString)") case .moebooru: return URL(string: "https://\(domain)/post.xml?page=\(page)&limit=\(limit)&tags=\(tagString)") case .gelbooru: return URL( string: "https://\(domain)/index.php?page=dapi&s=post&q=index&pid=\(page)&limit=\(limit)&tags=\(tagString)" ) } } private func urlForTags(limit: Int) -> URL? { switch flavor { case .moebooru: return URL(string: "https://\(domain)/tag.xml?limit=\(limit)") case .gelbooru: return URL(string: "https://\(domain)/index.php?page=dapi&s=tag&q=index&limit=\(limit)") case .danbooru: return nil } } private func parsePosts(from data: Data) -> [BooruPost] { Array( Set( flavor == .danbooru ? DanbooruPostParser(data: data).parse() : BooruPostXMLParser(data: data, provider: provider).parse() ) ) } private func updatePosts(_ newPosts: [BooruPost], replace: Bool) { if replace { posts = [] currentPage = 1 } endOfData = newPosts.isEmpty if !endOfData { posts = (posts + newPosts).sorted { $0.id > $1.id } postIndexMap.merge( Dictionary(uniqueKeysWithValues: newPosts.enumerated().map { ($0.element.id, $0.offset) }) ) { _, new in new } } } private func saveTagsToCache() { try? tagsCacheFileURL.map { url in try JSONEncoder().encode(allTags).write(to: url) updateTagsCacheSize() } } private func loadCachedTags() { guard let url = tagsCacheFileURL else { return } if let data = try? Data(contentsOf: url), let tags = try? JSONDecoder().decode([BooruTag].self, from: data) { allTags = tags updateTagsCacheSize() } } func updateTagsCacheSize() { cacheSize = tagsCacheFileURL.flatMap { url in ByteCountFormatter.string( fromByteCount: Int64( (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 ), countStyle: .file ) } } // MARK: - Deinitialisation deinit { currentTask?.cancel() } }