bugger/Sources/Services/AttachmentLoader.swift

142 lines
5.3 KiB
Swift

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)")
}
}
}