import Foundation struct FeishuAPIResponse: 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 } }