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 // 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") // MARK: - Computed Properties var tags: [String] { searchText.isEmpty ? [] : searchText .split(separator: " ") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } // MARK: - Initialization init(_ provider: BooruProvider) { self.provider = provider self.flavor = BooruProviderFlavor(provider: provider) self.domain = provider.domain } // 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 } isLoading = true currentTask?.cancel() 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 } updatePosts(newPosts, replace: replace) } catch { if (error as? URLError)?.code != .cancelled { debugPrint("fetchPosts: \(error)") } } } } func performSearch(settings: SettingsManager? = nil) { settings?.appendToSearchHistory( BooruSearchQuery(provider: settings!.preferredBooru, tags: tags, searchedAt: Date()) ) currentTask?.cancel() 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 } } 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 ) } } deinit { currentTask?.cancel() } }