import AppKit import Foundation /// Downloads and caches Bitable attachments (screenshots) for the detail window. @MainActor @Observable final class AttachmentLoader { static let shared = AttachmentLoader() enum State: Equatable { case loading case loaded case failed } private(set) var states: [String: State] = [:] private var images: [String: NSImage] = [:] private var fileData: [String: Data] = [:] /// Set after the first 400 so later downloads skip the plain attempt. private var needsExtraParam = false /// Field IDs come from BugEditService's shared field-metadata cache. private func screenshotsFieldID(named name: String) async -> String? { await BugEditService.shared.ensureFieldsLoaded() return BugEditService.shared.fieldID(named: name) } private init() {} func image(for attachment: BugAttachment) -> NSImage? { images[attachment.fileToken] } func state(for attachment: BugAttachment) -> State? { states[attachment.fileToken] } /// Downloads the attachment unless already loaded/loading. Safe to call repeatedly. func load(_ attachment: BugAttachment) { guard states[attachment.fileToken] == nil else { return } performLoad(attachment, openAfter: false) } func retry(_ attachment: BugAttachment) { states[attachment.fileToken] = nil performLoad(attachment, openAfter: false) } /// Opens the attachment in the system viewer, downloading first if needed. func loadAndOpen(_ attachment: BugAttachment) { if fileData[attachment.fileToken] != nil { open(attachment) } else { performLoad(attachment, openAfter: true) } } private func performLoad(_ attachment: BugAttachment, openAfter: Bool) { let token = attachment.fileToken states[token] = .loading Task { do { let accessToken = try await TokenManager.shared.getAccessToken() let data = try await download(attachment, accessToken: accessToken) fileData[token] = data images[token] = NSImage(data: data) states[token] = .loaded if openAfter { open(attachment) } } catch { BuggerLog.error("AttachmentLoader: \(attachment.name) failed: \(error.localizedDescription)") states[token] = .failed } } } /// Plain download first; Bitables with advanced permissions answer 400 and /// need an `extra` query param carrying table ID, field ID, record ID. private func download(_ attachment: BugAttachment, accessToken: String) async throws -> Data { let service = FeishuService() if needsExtraParam { return try await service.downloadAttachment( fileToken: attachment.fileToken, accessToken: accessToken, extra: try buildExtra(for: attachment) ) } do { return try await service.downloadAttachment( fileToken: attachment.fileToken, accessToken: accessToken ) } catch FeishuError.apiError(let code, _) where code == 400 { needsExtraParam = true return try await service.downloadAttachment( fileToken: attachment.fileToken, accessToken: accessToken, extra: try buildExtra(for: attachment) ) } } private func buildExtra(for attachment: BugAttachment) async throws -> String { guard let config = AppStateService.shared.config else { throw FeishuError.notConfigured } let fieldName = config.fieldMappings.screenshotsField guard let fieldId = await screenshotsFieldID(named: fieldName) else { throw FeishuError.invalidConfiguration( "Field \"\(fieldName)\" not found in table; check the Screenshots field mapping." ) } let extra: [String: Any] = [ "bitablePerm": [ "tableId": config.tableId, "attachments": [fieldId: [attachment.recordId: [attachment.fileToken]]] ] ] guard let data = try? JSONSerialization.data(withJSONObject: extra), let string = String(data: data, encoding: .utf8) else { throw FeishuError.invalidConfiguration("Could not build attachment extra parameter.") } return string } private func open(_ attachment: BugAttachment) { guard let data = fileData[attachment.fileToken] else { return } let directory = FileManager.default.temporaryDirectory .appendingPathComponent("bugger-attachments", isDirectory: true) // Prefix with the token so same-named attachments don't overwrite each other. let safeName = attachment.name.replacingOccurrences(of: "/", with: "_") let url = directory.appendingPathComponent("\(attachment.fileToken)-\(safeName)") do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try data.write(to: url) NSWorkspace.shared.open(url) } catch { BuggerLog.error("AttachmentLoader: could not write \(url.path): \(error.localizedDescription)") } } }