bugger/Sources/Services/Feishu/FeishuModels.swift

277 lines
9.9 KiB
Swift

import Foundation
struct FeishuAPIResponse<T: Decodable>: Decodable {
let code: Int
let msg: String
let data: T?
enum CodingKeys: String, CodingKey {
case code, msg, message, data
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
code = try c.decode(Int.self, forKey: .code)
// Some endpoints use "msg", others use "message"
if let m = try c.decodeIfPresent(String.self, forKey: .msg) {
msg = m
} else {
msg = try c.decode(String.self, forKey: .message)
}
data = try c.decodeIfPresent(T.self, forKey: .data)
}
}
struct RecordListData: Decodable {
let items: [RecordItem]
let hasMore: Bool
let pageToken: String?
let total: Int?
}
struct FieldListData: Decodable {
let items: [FieldItem]
let hasMore: Bool
let pageToken: String?
}
struct FieldItem: Decodable {
let fieldId: String
let fieldName: String
let type: Int?
let uiType: String?
let property: FieldProperty?
}
struct FieldProperty: Decodable {
let options: [SelectOption]?
}
struct SelectOption: Decodable, Equatable, Hashable {
let id: String
let name: String
let color: Int?
}
struct UploadMediaData: Decodable {
// file_token → fileToken via .convertFromSnakeCase
let fileToken: String
}
struct RecordItem: Decodable {
let recordId: String
let fields: [String: JSONValue]
}
struct OAuthTokenData: Decodable {
// Note: decoder uses .convertFromSnakeCase, so JSON keys are auto-mapped:
// access_token→accessToken, token_type→tokenType, expires_in→expiresIn, etc.
let accessToken: String
let tokenType: String?
let expiresIn: Int
let refreshToken: String?
let refreshExpiresIn: Int?
}
struct TenantAccessTokenData: Decodable {
let code: Int
let msg: String
let tenantAccessToken: String
let expire: Int
}
struct UserInfoData: Decodable {
let name: String?
// en_name → enName via .convertFromSnakeCase
let enName: String?
// open_id → openId via .convertFromSnakeCase
// Needed for server-side filtering of Person-type fields on the search endpoint.
let openId: String?
}
enum JSONValue: Decodable {
case string(String)
case number(Double)
case bool(Bool)
case array([JSONValue])
case object([String: JSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([JSONValue].self) {
self = .array(value)
} else if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unsupported JSON value"
)
}
}
var stringValue: String? {
if case let .string(value) = self { return value }
if case let .number(value) = self { return String(value) }
return nil
}
/// Display text for Feishu fields whose values are represented as
/// structured content, such as text fields (`[{"type": "text", "text": "..."}]`).
var textValue: String? {
switch self {
case let .string(value):
return value
case let .number(value):
return String(value)
case let .bool(value):
return String(value)
case let .array(items):
let text = items.compactMap(\.textValue).joined()
return text.isEmpty ? nil : text
case let .object(fields):
return fields["text"]?.textValue ?? fields["name"]?.textValue
case .null:
return nil
}
}
var firstUserName: String? {
guard case let .array(items) = self else { return nil }
for item in items {
if case let .object(fields) = item,
let name = fields["name"]?.stringValue {
return name
}
}
return nil
}
/// Attachment field values are arrays of objects like
/// `[{"file_token": "...", "name": "x.png", "size": 123, "type": "image/png"}]`.
func attachmentsValue(recordId: String) -> [BugAttachment] {
guard case let .array(items) = self else { return [] }
return items.compactMap { item in
guard case let .object(fields) = item,
let token = fields["file_token"]?.stringValue,
let name = fields["name"]?.stringValue else { return nil }
var size = 0
if case let .number(number)? = fields["size"] {
size = Int(number)
} else if let string = fields["size"]?.stringValue {
size = Int(string) ?? 0
}
return BugAttachment(
fileToken: token,
name: name,
size: size,
mimeType: fields["type"]?.stringValue,
recordId: recordId
)
}
}
}
enum BugMapper {
static func map(
_ record: RecordItem,
config: AppConfig
) -> Bug {
let fields = record.fields
let mappings = config.fieldMappings
return Bug(
id: record.recordId,
title: stringValue(in: fields, key: mappings.titleField) ?? "Untitled",
description: description(in: fields, mappings: mappings),
priority: BugPriority(rawValue: stringValue(in: fields, key: mappings.priorityField) ?? "") ?? .unknown,
status: mapStatus(stringValue(in: fields, key: mappings.statusField), mappings: mappings),
statusRaw: stringValue(in: fields, key: mappings.statusField),
assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown",
reporter: userName(in: fields, key: mappings.reporterField),
customer: rawValue(in: fields, key: mappings.customerField).map { "\($0)" },
module: joinedValue(in: fields, key: mappings.moduleField),
source: optionalStringValue(in: fields, key: mappings.sourceField),
flowStatus: optionalStringValue(in: fields, key: mappings.flowStatusField),
fixNotes: optionalStringValue(in: fields, key: mappings.fixNotesField),
screenshots: optionalAttachments(in: fields, key: mappings.screenshotsField, recordId: record.recordId),
createdAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.createdAtField)) ?? .distantPast,
updatedAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.updatedAtField)) ?? .distantPast,
feishuURL: FeishuURLBuilder.recordURL(
baseDomain: config.feishuBaseDomain,
appToken: config.appToken,
tableId: config.tableId,
recordId: record.recordId
)
)
}
private static func rawValue(in fields: [String: JSONValue], key: String) -> Any? {
guard let value = fields[key] else { return nil }
switch value {
case let .string(string): return string
case let .number(number): return number
case let .bool(bool): return bool
case let .array(array):
return array.compactMap(\.textValue)
case let .object(object):
return object["name"]?.textValue ?? object["text"]?.textValue
case .null:
return nil
}
}
private static func stringValue(in fields: [String: JSONValue], key: String) -> String? {
fields[key]?.textValue
}
private static func description(in fields: [String: JSONValue], mappings: AppConfig.FieldMappings) -> String? {
guard !mappings.descriptionField.isEmpty else { return nil }
let text = stringValue(in: fields, key: mappings.descriptionField)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return text?.isEmpty == false ? text : nil
}
/// Like stringValue, but nil when the mapping is empty or the value is blank.
private static func optionalStringValue(in fields: [String: JSONValue], key: String) -> String? {
guard !key.isEmpty else { return nil }
let text = stringValue(in: fields, key: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
return text?.isEmpty == false ? text : nil
}
/// Multi-select fields decode as arrays of option strings; join them for display.
private static func joinedValue(in fields: [String: JSONValue], key: String) -> String? {
guard !key.isEmpty, let value = fields[key] else { return nil }
if case let .array(items) = value {
let parts = items.compactMap(\.textValue)
return parts.isEmpty ? nil : parts.joined(separator: ", ")
}
let text = value.textValue?.trimmingCharacters(in: .whitespacesAndNewlines)
return text?.isEmpty == false ? text : nil
}
private static func optionalAttachments(in fields: [String: JSONValue], key: String, recordId: String) -> [BugAttachment] {
guard !key.isEmpty else { return [] }
return fields[key]?.attachmentsValue(recordId: recordId) ?? []
}
private static func userName(in fields: [String: JSONValue], key: String) -> String? {
fields[key]?.firstUserName ?? fields[key]?.stringValue
}
private static func mapStatus(_ feishuValue: String?, mappings: AppConfig.FieldMappings) -> BugStatus {
guard let value = feishuValue else { return .unknown }
// Look up in the user-configured mapping; fall back to .unknown (shown as active)
return mappings.statusMappings[value] ?? .unknown
}
}