172 lines
5.7 KiB
Swift
172 lines
5.7 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 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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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",
|
|
priority: BugPriority(rawValue: stringValue(in: fields, key: mappings.priorityField) ?? "") ?? .unknown,
|
|
status: mapStatus(stringValue(in: fields, key: mappings.statusField), mappings: mappings),
|
|
assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown",
|
|
reporter: userName(in: fields, key: mappings.reporterField),
|
|
customer: rawValue(in: fields, key: mappings.customerField).map { "\($0)" },
|
|
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 { item -> String? in
|
|
if case let .object(fields) = item {
|
|
return fields["name"]?.stringValue
|
|
}
|
|
return item.stringValue
|
|
}
|
|
case let .object(object):
|
|
return object["name"]?.stringValue ?? object["text"]?.stringValue
|
|
case .null:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func stringValue(in fields: [String: JSONValue], key: String) -> String? {
|
|
fields[key]?.stringValue
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|