277 lines
9.9 KiB
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
|
|
}
|
|
}
|