import SwiftUI @MainActor class BooruManager: ObservableObject { @Published var posts: [BooruPost] = [] @Published var allTags: [BooruTag] = [] @Published var isLoading: Bool = false @Published var currentPage: Int = 1 @Published var searchText = "" @Published var endOfData: Bool = false 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)_tags.json") } #if os(macOS) @Published var selectedPost: BooruPost? #endif init(_ provider: BooruProvider) { self.provider = provider loadCachedTags() fetchAllTags() } 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.provider == .safebooru ? 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(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)) } } } catch { if (error as? URLError)?.code != .cancelled { #if DEBUG print("fetchPosts: \(error)") #endif } } } } 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() } } catch { if (error as? URLError)?.code != .cancelled { #if DEBUG print("fetchAllTags: \(error)") #endif } } } } private func moebooruURL(domain: String, page: Int, limit: Int, tagString: String) -> URL? { URL(string: "https://\(domain)/post.xml?page=\(page)&limit=\(limit)&tags=\(tagString)") } private func gelbooruURL(domain: String, page: Int, limit: Int, tagString: String) -> URL? { URL( string: "https://\(domain)/index.php?page=dapi&s=post&q=index&pid=\(page)&limit=\(limit)&tags=\(tagString)" ) } private func urlForPosts(page: Int, limit: Int, tags: [String]) -> URL? { let tagString = tags.joined(separator: "+") switch provider { case .yandere: return moebooruURL(domain: "yande.re", page: page, limit: limit, tagString: tagString) case .konachan: return moebooruURL(domain: "konachan.com", page: page, limit: limit, tagString: tagString) case .sakugabooru: return moebooruURL(domain: "sakugabooru.com", page: page, limit: limit, tagString: tagString) case .safebooru: return gelbooruURL(domain: "safebooru.org", page: page, limit: limit, tagString: tagString) case .gelbooru: return gelbooruURL(domain: "gelbooru.com", page: page, limit: limit, tagString: tagString) } } private func urlForTags(limit: Int) -> URL? { switch provider { case .yandere: URL(string: "https://yande.re/tag.xml?limit=\(limit)") case .safebooru: URL(string: "https://safebooru.org/index.php?page=dapi&s=tag&q=index&limit=\(limit)") default: nil } } private func saveTagsToCache() { guard let url = tagsCacheFileURL else { return } do { let data = try JSONEncoder().encode(allTags) try data.write(to: url) } catch { #if DEBUG print("saveTagsToCache: \(error)") #endif } } 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 } } catch { #if DEBUG print("loadCachedTags: \(error)") #endif } } deinit { currentTask?.cancel() } }