import SwiftUI @MainActor class BooruManager: ObservableObject { @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 var postIndexMap: [String: Int] = [:] private var currentTask: Task? let provider: BooruProvider var tags: [String] { if searchText.isEmpty { return [] } return searchText .split(separator: " ") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } private var tagsCacheFileURL: URL? { guard let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil } return directory .appendingPathComponent("\(provider.asFileNameComponent())_tags.json") } init(_ provider: BooruProvider) { self.provider = provider self.flavor = BooruProviderFlavor(provider: provider) switch provider { case .yandere: domain = "yande.re" case .konachan: domain = "konachan.com" case .sakugabooru: domain = "sakugabooru.com" case .safebooru: domain = "safebooru.org" case .gelbooru: domain = "gelbooru.com" case .danbooru: domain = "danbooru.donmai.us" } } func initializeTags() { loadCachedTags() fetchAllTags() updateTagsCacheSize() } func fetchPosts(page: Int = 1, limit: Int = 100, tags: [String] = [], replace: Bool = false) async { guard !isLoading else { return } currentTask?.cancel() currentTask = Task { isLoading = true defer { isLoading = false } if replace { self.posts = [] self.currentPage = 1 } guard let url = urlForPosts( page: self.flavor == .gelbooru ? page - 1 : page, limit: limit, tags: tags ) else { return } do { let (data, _) = try await URLSession.shared.data(from: url) if Task.isCancelled { return } DispatchQueue.main.async { let newPosts = Array( Set( self.flavor == .danbooru ? DanbooruPostParser(data: data).parse() : BooruPostXMLParser(data: data, provider: self.provider).parse() ) ) .sorted { lhs, rhs in lhs.id > rhs.id } if newPosts.isEmpty { self.endOfData = true } else { self.posts += Array(Set(newPosts)) self.postIndexMap.merge( zip(newPosts.indices, newPosts.map(\.id)).reduce(into: [:]) { result, element in result[element.1] = element.0 } ) { _, new in new } } } } catch { if (error as? URLError)?.code != .cancelled { debugPrint("BooruManager.fetchPosts: \(error)") } } } } func performSearch() { currentTask?.cancel() currentTask = Task { await fetchPosts(page: 1, tags: tags, replace: true) } } func loadNextPage() { guard !isLoading else { return } Task { await fetchPosts(page: currentPage + 1, tags: tags) DispatchQueue.main.async { self.currentPage += 1 } } } func fetchAllTags(limit: Int = 0) { Task { guard let url = urlForTags(limit: limit) else { return } do { let (data, _) = try await URLSession.shared.data(from: url) if Task.isCancelled { return } DispatchQueue.main.async { self.allTags = BooruTagXMLParser(data: data).parse().sorted { $0.count > $1.count } self.saveTagsToCache() self.updateTagsCacheSize() } } catch { if (error as? URLError)?.code != .cancelled { debugPrint("BooruManager.fetchAllTags: \(error)") } } } } 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: URL(string: "https://\(domain)/tag.xml?limit=\(limit)") case .gelbooru: URL(string: "https://\(domain)/index.php?page=dapi&s=tag&q=index&limit=\(limit)") case .danbooru: nil } } private func saveTagsToCache() { guard let url = tagsCacheFileURL else { return } do { let data = try JSONEncoder().encode(allTags) try data.write(to: url) updateTagsCacheSize() } catch { debugPrint("BooruManager.saveTagsToCache: \(error)") } } private func loadCachedTags() { guard let url = tagsCacheFileURL else { return } do { let data = try Data(contentsOf: url) let cachedTags = try JSONDecoder().decode([BooruTag].self, from: data) DispatchQueue.main.async { self.allTags = cachedTags self.updateTagsCacheSize() } } catch { debugPrint("BooruManager.loadCachedTags: \(error)") } } func clearCachedTags() { guard let url = tagsCacheFileURL else { return } do { try FileManager.default.removeItem(at: url) updateTagsCacheSize() } catch { debugPrint("BooruManager.clearCachedTags: \(error)") } } func updateTagsCacheSize() { guard let url = tagsCacheFileURL else { cacheSize = nil return } do { cacheSize = ByteCountFormatter.string( fromByteCount: Int64( (try FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 ), countStyle: .file ) } catch { cacheSize = nil debugPrint("BooruManager.updateCacheSize: \(error)") } } deinit { currentTask?.cancel() } }