142 lines
5.3 KiB
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)")
|
|
}
|
|
}
|
|
}
|