148 lines
5.2 KiB
Swift
148 lines
5.2 KiB
Swift
import Foundation
|
|
|
|
enum EditError: Error, LocalizedError {
|
|
case needsReauth
|
|
case notConfigured
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .needsReauth:
|
|
return "Missing write permission. Re-authorize Feishu to enable editing (and make sure you have edit rights on the Bitable)."
|
|
case .notConfigured:
|
|
return "Feishu table not configured. Open Settings."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Writes bug fields back to the Bitable. No optimistic local mutation — after a
|
|
/// successful write it triggers the normal fetch pipeline, which is the source
|
|
/// of truth (and drives change notifications).
|
|
@MainActor
|
|
@Observable
|
|
final class BugEditService {
|
|
static let shared = BugEditService()
|
|
|
|
private(set) var fields: [FieldItem] = []
|
|
private var fieldsLoadedFor: String?
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Field metadata
|
|
|
|
/// Loads table field metadata once per appToken/tableId (status options,
|
|
/// field IDs for attachment downloads).
|
|
func ensureFieldsLoaded() async {
|
|
guard let config = AppStateService.shared.config, config.isConfigured else { return }
|
|
let key = "\(config.appToken)/\(config.tableId)"
|
|
guard fieldsLoadedFor != key else { return }
|
|
do {
|
|
let token = try await TokenManager.shared.getAccessToken()
|
|
fields = try await FeishuService().fetchFields(
|
|
appToken: config.appToken,
|
|
tableId: config.tableId,
|
|
accessToken: token
|
|
)
|
|
fieldsLoadedFor = key
|
|
} catch {
|
|
BuggerLog.error("BugEditService: fetchFields failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func fieldID(named name: String) -> String? {
|
|
fields.first { $0.fieldName == name }?.fieldId
|
|
}
|
|
|
|
var statusOptions: [SelectOption] {
|
|
guard let config = AppStateService.shared.config else { return [] }
|
|
return fields
|
|
.first { $0.fieldName == config.fieldMappings.statusField }?
|
|
.property?.options ?? []
|
|
}
|
|
|
|
// MARK: - Edits
|
|
|
|
func updateStatus(_ bug: Bug, to optionName: String) async throws {
|
|
guard let field = AppStateService.shared.config?.fieldMappings.statusField else {
|
|
throw EditError.notConfigured
|
|
}
|
|
try await update(bug, fields: [field: optionName])
|
|
}
|
|
|
|
func updateFixNotes(_ bug: Bug, text: String) async throws {
|
|
guard let field = AppStateService.shared.config?.fieldMappings.fixNotesField else {
|
|
throw EditError.notConfigured
|
|
}
|
|
try await update(bug, fields: [field: text])
|
|
}
|
|
|
|
/// Uploads each image (sequentially — the API rejects concurrent uploads)
|
|
/// and appends all new file tokens to the attachment field in one update.
|
|
func addScreenshots(_ bug: Bug, images: [(name: String, data: Data, mimeType: String)]) async throws {
|
|
guard let config = AppStateService.shared.config, config.isConfigured else {
|
|
throw EditError.notConfigured
|
|
}
|
|
let maxBytes = 20 * 1024 * 1024
|
|
if let oversized = images.first(where: { $0.data.count > maxBytes }) {
|
|
throw FeishuError.invalidConfiguration(
|
|
"\"\(oversized.name)\" exceeds the 20 MB upload limit."
|
|
)
|
|
}
|
|
|
|
let service = FeishuService()
|
|
let accessToken = try await TokenManager.shared.getAccessToken()
|
|
|
|
var newTokens: [String] = []
|
|
for image in images {
|
|
do {
|
|
let token = try await service.uploadMedia(
|
|
appToken: config.appToken,
|
|
fileName: image.name,
|
|
data: image.data,
|
|
mimeType: image.mimeType,
|
|
accessToken: accessToken
|
|
)
|
|
newTokens.append(token)
|
|
} catch {
|
|
throw Self.mapError(error)
|
|
}
|
|
}
|
|
|
|
// Attachment fields are replaced wholesale, so resend existing tokens.
|
|
let allTokens = (bug.screenshots.map(\.fileToken) + newTokens).map {
|
|
["file_token": $0]
|
|
}
|
|
try await update(bug, fields: [config.fieldMappings.screenshotsField: allTokens])
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func update(_ bug: Bug, fields: [String: Any]) async throws {
|
|
guard let config = AppStateService.shared.config, config.isConfigured else {
|
|
throw EditError.notConfigured
|
|
}
|
|
let accessToken = try await TokenManager.shared.getAccessToken()
|
|
do {
|
|
try await FeishuService().updateRecord(
|
|
appToken: config.appToken,
|
|
tableId: config.tableId,
|
|
recordId: bug.id,
|
|
fields: fields,
|
|
accessToken: accessToken
|
|
)
|
|
} catch {
|
|
throw Self.mapError(error)
|
|
}
|
|
await PollerService.shared.fetchNow()
|
|
}
|
|
|
|
/// Permission failures mean either the token still has the old readonly
|
|
/// scope (re-auth needed) or the user lacks edit rights on the Bitable.
|
|
static func mapError(_ error: Error) -> Error {
|
|
if case let FeishuError.apiError(code, _) = error,
|
|
[1254302, 1254304, 1061004, 99991672, 99991679].contains(code) {
|
|
return EditError.needsReauth
|
|
}
|
|
return error
|
|
}
|
|
}
|